import type { BudgetMember, SharedBudget } from './SharedBudget.ts'; export interface SharedReadCacheOptions { /** * Performs the read. It is handed the *shared* signal, which fires only once * every caller waiting on this key has aborted — never one caller's own. * * Optional, because a caller whose read differs per key — a closure over the * thing being read, rather than a function of the key — can pass it to * {@link SharedReadCache.get} instead. One of the two must be present. */ fill?: (key: K, signal: AbortSignal) => Promise; /** * Budget, in whatever unit {@link sizeOf} returns. Defaults to `Infinity`: * this package does not prescribe a limit, because what a sensible one is * depends entirely on what is being cached. * * Note what a budget does and does not do. It bounds *retained* memory, not * request size: a value larger than the whole budget is still kept, reads in * flight are never evicted, and eviction only ever discards a value already * returned once. So nothing is refused for being too large, and the worst a * budget can cost is a re-read. * * Unbounded is therefore the permissive default, not the safe one. A cache * with no budget grows for the life of the object — @gmod/tabix measured 2GB * RSS panning a dense VCF before it bounded this. Pass one if the values are * large or the object is long-lived. * * Settable later: lowering it evicts immediately rather than waiting for the * next read, which is what a consumer shedding memory under pressure needs. */ maxSize?: number; /** * Weighs a settled value against `maxSize`. Defaults to 1, making the budget * an entry count. * * This is the parameter the whole package exists for. Every hand-rolled copy * of this cache across the gmod repos was identical except here: @gmod/bam * and @gmod/tabix weigh decompressed bytes, @gmod/bbi weighs entries, and * @gmod/cram weighs decoded records. An entry cannot be weighed until its * read settles, so a cache that does this has to own its entries — which is * exactly why those four could not share a plain-LRU-backed package and each * wrote their own. */ sizeOf?: (value: V) => number; /** Maps a key to its cache key. Defaults to `String(key)`. */ cacheKey?: (key: K) => string; /** * When to evict. Defaults to `'lru'`. * * `'lru'` evicts as each read settles, so the budget is a hard ceiling. * * `'batch'` waits until no reads are in flight and then spares everything * that batch touched. The case for it: a single request that starts many * reads at once and holds all of their values until it returns: evicting one * mid-request frees nothing, because the caller is still holding it, but it * does guarantee the next identical request re-reads it. * * **Try a bigger {@link maxSize} first.** @gmod/cram adopted `'batch'` on a * 117ms-against-12ms measurement and then dropped it again, and the sequence * is the useful part. That measurement was taken with a budget 2.75x *below* * the request's working set. Raising the budget above the working set made * the two policies measurably identical — same re-read counts, times inside * noise — because a request that fits has nothing to evict mid-flight * whichever policy is in force. @gmod/bam measured the same thing from the * other side: on a pan workload over an undersized budget, `'batch'` did not * rescue it at all, matching `'lru'` re-read for re-read while retaining 1.7x * the memory. * * So `'batch'` only changes anything when a batch exceeds the budget, and * what it does there is exceed the budget: cram measured it holding 420,000 * records against a stated limit of 20,000. That is the documented trade — a * batch touching more than the whole budget leaves the cache over it until * the next batch lands — and it is worth being clear that it is the whole * mechanism, not a side effect. A consumer lowering the budget to constrain * memory will not get what it asked for. * * Reach for it when a too-small budget is genuinely forced on you and the * workload is repeated identical requests. Otherwise size the budget above * one request and leave this alone. */ evictionPolicy?: 'lru' | 'batch'; /** * A budget shared with other caches, evicted globally least-recently-used * across all of them. Defaults to none. * * Composes with {@link maxSize} rather than replacing it: the per-cache * ceiling still applies, and a cache that passes only a budget is unbounded * on its own and bounded in aggregate — usually what you want, since the * point of sharing is to let one busy member use most of the total. * * Reach for it when the number of caches is a property of the workload * rather than of the code. A per-cache ceiling sized so that one cache never * thrashes is, by construction, not a bound on N of them; see * {@link SharedBudget} for what that measured. */ budget?: SharedBudget; /** * Drop an entry once nothing has asked for it for this many milliseconds. * Defaults to no idle eviction. * * This is the only reclamation that happens while a consumer sits still. * {@link maxSize} is enforced when a read settles, so an idle cache stays at * whatever it reached and never gives it back — fine for a short-lived * object, expensive for one that lives as long as its UI does. A genome * browser parked on a region holds its whole last view indefinitely, times * every open track. * * The two compose and answer different questions. `maxSize` is the ceiling * under load, and wants to be generous: set below one request's working set * it does not cache less, it caches *nothing*, evicting each value before the * next request can reuse it while still retaining the ones in flight. * `idleTimeoutMs` is what makes a generous ceiling affordable, by making it a * peak rather than a resting level. * * Measured from the last **read** of an entry, or from its fill settling if * nothing has read it since: something fetched once and used every second is * not idle, and an absolute expiry would throw it away mid-use for no reason. * The clock never starts before the value exists, so however long a read * takes it still gets the full timeout to be reused in. * * Reads still in flight are never swept, on the same grounds as eviction — * they have no weight to reclaim and dropping one would lose the * de-duplication every caller joined to it is relying on. */ idleTimeoutMs?: number; } /** * One read per key, shared by every caller that asks for it while it is in * flight, with a bounded cache of the results. * * ## Why not a memoized promise * * Memoizing a bare promise built from the *first* caller's signal makes that * caller's abort reject everyone else awaiting it. In a genome browser, panning * away from one block then fails its still-wanted siblings. Here the read runs * under a controller of its own, and a caller's abort is reported to that * caller alone. * * ## The cancellation rule * * A read is cancelled only once **every** caller waiting on it has given up. A * caller with no signal cannot give up, so it pins the read — the honest * reading of a caller that never asked to be cancellable, and the reason one * signal-free consumer makes a read uncancellable for everyone joined to it. A * duck-typed signal with no `addEventListener` pins it for the same reason — * nothing here can learn when such a caller gives up — and is still told about * its own cancellation once the read settles. * * A rejection is dropped rather than cached, so one transient failure does not * poison the key for the life of the cache. * * ## On "LRU" * * With no {@link SharedReadCacheOptions.maxSize} nothing is ever evicted, so * this is a shared-read memo and not an LRU at all — least-recently-used is an * *eviction order*, and there is no eviction to order. Recency is still tracked * while unbounded, cheaply, so that imposing a budget later evicts the right * entries rather than the oldest-inserted ones. */ export declare class SharedReadCache implements BudgetMember { private entries; private total; /** * How many of {@link entries} have settled. Maintained rather than counted, * because {@link evict} needs it on every settle and a cache sitting at its * ceiling is over the limit on every settle — so the O(entries) count it * replaces was the steady-state cost of having a budget at all. That count * measured 8.6us per read over 100 entries and 134us over 20,000; maintaining * it instead holds 7.4us and 14us across the same range. */ private settledCount; private limit; private budget?; /** how this cache reports its weight to {@link budget}; see SharedBudget */ private membership?; private fill?; private sizeOf; private toCacheKey; private evictionPolicy; /** reads still in flight, so the batch policy knows when the batch is done */ private pending; /** the batch in flight; see {@link Entry.batch} */ private batch; private idleTimeoutMs?; private sweepTimer?; constructor({ fill, maxSize, sizeOf, cacheKey, evictionPolicy, idleTimeoutMs, budget, }?: SharedReadCacheOptions); /** Number of entries held, including reads still in flight. */ get size(): number; /** Sum of {@link SharedReadCacheOptions.sizeOf} over the settled entries. */ get totalSize(): number; get maxSize(): number; /** * Accessor rather than a plain field so lowering the budget frees memory now. * As a field it did nothing until the next read happened to run the eviction * loop, which on an idle consumer is never. */ set maxSize(maxSize: number); /** * How many caller signals the entry under `key` is still holding. Exposed for * tests: an entry that has leaked a thousand stale signals answers every read * exactly like one that has not, so nothing else would notice. */ waiterCount(key: K): number; get(key: K, signal?: AbortSignal, fill?: (signal: AbortSignal) => Promise): Promise; /** * The shared read, but a caller that gives up while it is still running is * released now rather than whenever that read lands. * * Awaiting `entry.promise` alone made abort() a request rather than an * answer. The read is shared, so one caller aborting deliberately does not * stop it — which left that caller pending until every *other* waiter was * done: two callers on a 30s read, one aborts, and it waits the full 30s to * be told about a cancellation it asked for itself. A fill that ignores the * signal — a stalled fetch — never released it at all. Panning a genome * browser is exactly this shape, since the abandoned blocks are the ones * whose siblings are still wanted. * * Only for a read still in flight, and only for a signal that can be * subscribed to. A settled entry has nothing left to wait for, and nothing * here can learn when a duck-typed signal fires; both fall back to the * post-await {@link throwIfAborted} in {@link get}, which is all either ever * needed. */ private settleFor; /** * The promise cached under `key`, or `undefined` if there is none. * * Marks the entry most-recently-used, exactly as {@link get} does: this is a * lookup that happens not to start a read, not an inspection. Use * {@link has} if you need to ask without touching the LRU order. * * The promise is the shared one, so awaiting it does not register the caller * as a waiter and its rejection is not re-reported per caller. Callers that * want either should use {@link get}. * * `undefined` too for a read every caller has already abandoned, which is not * a cached value but a rejection that has not landed yet. */ getIfCached(key: K): Promise | undefined; has(key: K): boolean; delete(key: K): void; clear(): void; /** * Mark an entry most-recently-used, in both orders that word has here: its * position in {@link entries}, which {@link evict} and {@link lruSpare} walk, * and its {@link Entry.seq}, which {@link SharedBudget} compares across * caches. * * The two have to move together, and this is the only place either moves * after {@link start} places a new entry at the front with a matching seq — * which is the point of it being one function. {@link lruSpare} takes an * entry from map order and reports *its* seq, so the budget's claim to evict * the globally least-recently-used entry holds only while the two orders * agree. They did not: settling stamped a fresh seq without moving the entry, * so any read that settled out of the order it was started in left its cache * offering the budget a seq belonging to some other entry. */ private touch; /** * This entry's place in the recency order {@link SharedBudget} evicts by. * Only a budget compares these, so a cache without one has no order to keep. */ private stamp; /** In flight, but every caller waiting on it has already given up. */ private isDoomed; /** * Evict entries nothing has read for {@link SharedReadCacheOptions.idleTimeoutMs}. * * Exposed so a consumer can reclaim on its own schedule — a browser tab going * hidden, say — rather than only on the interval. A no-op when no idle * timeout is configured. */ sweepIdle(): void; private startSweep; private stopSweep; private start; /** * {@link SharedReadCacheOptions.sizeOf}, checked — `undefined` when it does * not answer with a weight, which drops the entry rather than keeping one the * budget cannot see. * * It has to be checked because it is consumer code over consumer values. * `v => v.byteLength` throws on a null value and returns `undefined` on a * value without the field, and arithmetic turns the latter into `NaN` rather * than an error. `NaN` in `total` is permanent: `total <= limit` is false * forever after, so every settle evicts down to the last entry and the cache * silently stops caching. Measured at five entries against a `maxSize` of 100 * collapsing to one. * * Swallowed rather than rethrown, which is the part worth defending. Thrown * from here it would reject a promise nothing holds — `unhandledRejection`, * and so the end of the process. Carried into the entry's own promise it * would fail the read for its callers, and that was tried: it made * `getIfCached` hand back a chained promise rather than the one the fill * returned, and pushed this bookkeeping a microtask later than the fill's own * promise, so a consumer awaiting that and reading `totalSize` saw the last * read missing. @gmod/cram caught both. Neither is a price worth paying to * report a bug in a caller's `sizeOf`, when the read itself succeeded and the * caller already has its value — so the value is served and simply not kept. */ private weigh; private join; private deleteKey; /** * Stop counting a read among the {@link pending} ones the batch policy waits * for. Idempotent, because a read can leave that count either by settling or * by the cache giving up on it first, and both can happen to the same read. * * The read itself is untouched: a caller still awaiting one the cache has * dropped gets its value as normal. What ends is only its claim on the batch, * which it has no business holding open once nothing will use the result. */ private detach; private charge; /** * @internal — {@link SharedBudget} asks; nothing else should. * * The least-recently-used settled entry, or `undefined` if this cache holds * at most one. Iteration order is least-recently-used first, so this returns * on the first settled entry it sees rather than walking the map. */ lruSpare(): { cacheKey: string; seq: number; } | undefined; /** @internal — {@link SharedBudget} evicting on this cache's behalf. */ release(cacheKey: string): void; /** * Evict from the least-recently-used end. * * Reads still in flight are skipped: they are not results yet, they have no * weight to reclaim, and dropping one would lose the de-duplication every * caller joined to it is relying on. * * The last settled entry is kept whatever the budget. A single value larger * than the whole budget is still worth holding — the caller needs it for the * request in flight, so dropping it only buys an immediate re-read. */ private maybeEvict; private evict; }