@cloudbitmaps/core 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import type { BlobReader } from '../blob.js';
2
2
  import type { CrbmCrypto } from '../crypto.js';
3
- import type { GenerationMetadata } from '../ports.js';
3
+ import type { ChunkRead, GenerationMetadata, ReadChunksOptions } from '../ports.js';
4
4
  /**
5
5
  * A parsed index as parallel typed arrays, one slot per entry, ascending by key. Typed arrays hold each field at
6
6
  * its own width with no per-entry object, so the retained heap is the arrays' byte length and nothing else.
@@ -58,6 +58,11 @@ export declare const fingerprintFor: (size: number, footerCrc: number) => string
58
58
  * checking that its target is what the row says.
59
59
  */
60
60
  export declare function footerSaysEncrypted(blob: BlobReader): Promise<boolean>;
61
+ /**
62
+ * {@link CrbmReader.open} for a reader that keeps a copy of the chunk region when the tail read returned the whole
63
+ * object and that region is at most `keepChunkBytesUpTo` bytes. Not exported from a package entry.
64
+ */
65
+ export declare function openCrbmReaderKeeping(blob: BlobReader, options: CrbmReaderOptions, keepChunkBytesUpTo: number | undefined): Promise<CrbmReader>;
61
66
  export declare class CrbmReader {
62
67
  private readonly blob;
63
68
  private readonly objectSize;
@@ -82,6 +87,14 @@ export declare class CrbmReader {
82
87
  readonly metadata: GenerationMetadata | undefined;
83
88
  /** What that metadata adds to {@link retainedBytes}; 0 when there is none. */
84
89
  private readonly metadataWeight;
90
+ /** Where the chunk payloads end: the extension block's start when there is one, else the index's. */
91
+ private readonly payloadEnd;
92
+ /**
93
+ * A copy of the chunk region, `[PAYLOAD_START, payloadEnd)`, when `open()` kept one (see
94
+ * {@link openCrbmReaderKeeping}); `undefined` otherwise. Never handed out: a read of it is a copy or is
95
+ * consumed before it returns.
96
+ */
97
+ private readonly kept;
85
98
  private constructor();
86
99
  /**
87
100
  * Which object this reader opened, as its size and its footer's CRC. The footer covers the index's CRC, so two
@@ -100,8 +113,9 @@ export declare class CrbmReader {
100
113
  /**
101
114
  * What this reader holds, the weight the storage reader cache bounds on: its parsed index
102
115
  * ({@link retainedIndexBytes}), plus a weight for the metadata it decoded, from its canonical length and its number
103
- * of keys. Equal to {@link retainedIndexBytes} for a generation with no metadata. A cache heuristic, not a contract:
104
- * how the metadata is weighed may change in any release.
116
+ * of keys, plus the chunk bytes kept from the open, when it kept any. Equal to {@link retainedIndexBytes} for a
117
+ * generation with no metadata and nothing kept. A cache heuristic, not a contract: how the metadata is weighed may
118
+ * change in any release.
105
119
  */
106
120
  get retainedBytes(): number;
107
121
  /** Per-chunk cardinality (`chunkKey → count`) from the parsed index — no payload reads. */
@@ -130,7 +144,74 @@ export declare class CrbmReader {
130
144
  * not mutate it.
131
145
  */
132
146
  getChunk(chunkKey: number): Promise<Uint8Array | null>;
147
+ /**
148
+ * `[offset, offset + length)` of the object from the kept chunk region, or `undefined` when it is not wholly in it
149
+ * (or nothing was kept). The bounds checks are defence on untrusted bytes: the index's validation at open keeps every
150
+ * extent inside the region, so they do not fire for a reader that opened.
151
+ *
152
+ * The range is returned as a copy, so nothing a caller or an AEAD does with it changes the next read.
153
+ */
154
+ private keptRange;
155
+ /**
156
+ * Check one chunk's stored bytes against the index, and open them: the CRC32C first, then, on an encrypted object,
157
+ * the AEAD with the chunk's own associated data. The one check every read of a chunk goes through, whether the
158
+ * bytes came from a range of their own or from a slice of a larger one.
159
+ */
160
+ private openChunk;
161
+ /**
162
+ * The next needed chunk of the range in `cur`, checked and opened, as a stream hands it out; the range is let go of
163
+ * when this is its last chunk.
164
+ */
165
+ private nextChunk;
166
+ /**
167
+ * Read several chunks as a stream, one storage request per merged range ({@link planChunkReads}) rather than one per
168
+ * chunk. `chunkKeys` are in ascending order, a key may repeat, and anything else is a {@link ValidationError}. One
169
+ * item comes out per position of `chunkKeys`, in order: the chunk's bytes, or `null` where this generation has none,
170
+ * as {@link getChunk} answers for an absent key. A key given twice is read once and answered at both positions with
171
+ * the same view.
172
+ *
173
+ * The whole key list is planned once, up front, against this one generation's index. The ranges then run through a
174
+ * window of `options.concurrency` reads ({@link MAX_RANGES_IN_FLIGHT} by default) that counts a range until the
175
+ * consumer has taken it, so a stream never holds more than that many ranges, in flight or landed, however many keys
176
+ * it was given or how slowly it is read; with `options.ramp` the window opens 1, 2, 4 … wide. A range is at most 1 MiB
177
+ * unless it is one chunk, which is at most the payload cap, so the bound is that many times the per-chunk cap
178
+ * (1 MiB, and 28 bytes more for an encrypted chunk). Nothing is read until the first item is asked for, and a
179
+ * consumer that stops launches no more: ranges already in flight finish and are dropped, and a failure of one of
180
+ * those is never raised.
181
+ *
182
+ * Every chunk is held to the check a read of it alone gets: its slice of the range must match its index entry's
183
+ * CRC32C, and on an encrypted object it opens under its own associated data, so a chunk moved to another place in
184
+ * the object, or another key's bytes, is refused. The bytes in a gap between needed chunks are never parsed. A
185
+ * range that comes back shorter than asked is an {@link IntegrityError}, and a chunk that fails its check ends the
186
+ * stream with that error when its turn comes, as a read of it alone would fail.
187
+ *
188
+ * `options.readRange`, when given, runs each range read, so a caller can retry one request without repeating the
189
+ * others; `options.now` times each request for `options.onRequest`, which hears of every range request that was sent, once, when
190
+ * it settles: taken, abandoned after the consumer stopped, or failed (a reader has no clock of its own, so `ms` is 0
191
+ * without one).
192
+ * A plain chunk is a writable view into the range it was read in: copy a chunk to keep it.
193
+ *
194
+ * A stream that fails (a range that errors, a chunk that fails its check) raises at once and sends nothing further:
195
+ * the ranges it still had in flight finish in the background, are not retried and never raise. Its `settled()`
196
+ * resolves when those have finished, for a caller that is about to read again and does not want them beside its next
197
+ * window (the source awaits it before a heal, and only then). It waits as long as the slowest of them takes to
198
+ * answer, which only the store's own read timeout bounds, so a read that never answers delays a heal and never an
199
+ * error.
200
+ */
201
+ readChunks(chunkKeys: readonly number[], options?: {
202
+ readonly concurrency?: number;
203
+ readonly ramp?: boolean | number;
204
+ readonly readRange?: <T>(read: () => Promise<T>) => Promise<T>;
205
+ readonly now?: () => number;
206
+ readonly onRequest?: ReadChunksOptions['onRequest'];
207
+ }): ChunkStream;
208
+ private streamChunks;
133
209
  }
210
+ /** What {@link CrbmReader.readChunks} returns: the stream, and a way to wait for the reads it left in flight. */
211
+ export type ChunkStream = AsyncGenerator<Omit<ChunkRead, 'version'>> & {
212
+ /** Resolves, never rejects, once every range the stream launched and did not hand out has finished. */
213
+ settled(): Promise<void>;
214
+ };
134
215
  /**
135
216
  * Read the sections of an extension block, its bytes before the trailer, once their CRC has passed. Each section is
136
217
  * `u8 type ‖ u32 length ‖ bytes`, in strictly ascending type order, and together they fill the region exactly. Type 1
@@ -1,8 +1,8 @@
1
1
  import { type IAuditSink } from './audit.js';
2
2
  import type { Yielder } from './cooperative.js';
3
3
  import type { Clock, Rng } from './determinism.js';
4
- import type { CrbmCrypto, IKeystore, WrappedDek } from './crypto.js';
5
- import type { ChunkRef, StorageChunkSource, GenerationMetadata, GenerationSummary, GenKey, IStorageDriver, IRegistryDriver, RegistryRecord, RegistrySummary, SegmentRef, SegmentSize, Token } from './ports.js';
4
+ import type { Aead, CrbmCrypto, IKeystore, WrappedDek } from './crypto.js';
5
+ import type { ChunkRef, ChunkRead, ReadChunksOptions, StorageChunkSource, GenerationMetadata, GenerationSummary, GenKey, IStorageDriver, IRegistryDriver, RegistryRecord, RegistrySummary, SegmentRef, SegmentSize, Token } from './ports.js';
6
6
  import { CrbmReader } from './crbm/reader.js';
7
7
  import type { CrbmReaderOptions } from './crbm/reader.js';
8
8
  import type { CodecBitmap, CodecInterface } from './codec.js';
@@ -55,12 +55,16 @@ export interface CrbmStorageChunkSourceOptions extends CrbmReaderOptions {
55
55
  */
56
56
  readonly maxOpenSegments?: number;
57
57
  /**
58
- * Aggregate byte ceiling on the parsed `.crbm` indices resident in the reader cache (default 64 MiB) — the
58
+ * Aggregate byte ceiling on what the reader cache holds (default 64 MiB): the parsed `.crbm` indices, and the chunk bytes a
59
+ * reader keeps when its first read returned a whole small object (see below) — the
59
60
  * **second half of that memory bound**. `maxOpenSegments` alone bounds by *count*, but a wide/dense segment's
60
61
  * parsed index can reach about 1.3 MB, so 1024 wide indices could pin over a GB and blow a small heap (e.g. a 128 MB
61
62
  * Lambda) while the count is nominally "in bounds". This caps the summed {@link CrbmReader.retainedBytes} (the
62
- * parsed index, and any metadata a generation carries) across cached readers; the least-recently-used reader is evicted once the total would exceed it — whichever
63
- * of the count/byte bounds binds first. Lower it for memory-tight deployments with wide segments; a single
63
+ * parsed index, any metadata a generation carries, and any kept chunk bytes) across cached readers; the least-recently-used reader is evicted once the total would exceed it — whichever
64
+ * of the count/byte bounds binds first. A reader whose open read the whole object keeps a copy of its chunk region when
65
+ * that region is at most this ceiling divided by {@link maxOpenSegments} (64 KiB by default) — its fair share — so a small generation's
66
+ * chunks are served from memory with no further request, and a bigger one is read by range. Only a source with a timed
67
+ * pointer refresh ({@link currentGenTtlMs} above 0, a registry and a clock) keeps chunk bytes. Lower it for memory-tight deployments with wide segments; a single
64
68
  * segment whose index alone exceeds the budget is still cached (it can't be shrunk) but nothing else alongside.
65
69
  */
66
70
  readonly maxOpenIndexBytes?: number;
@@ -122,6 +126,8 @@ export declare class CrbmStorageChunkSource implements StorageChunkSource {
122
126
  private readonly keystore;
123
127
  private readonly requireEncryption;
124
128
  private readonly readerOptions;
129
+ /** The most chunk bytes a reader keeps from its open: the reader cache's byte bound over its count bound, per reader; 0 without a timed pointer refresh. */
130
+ private readonly keepChunkBytesUpTo;
125
131
  private readonly clock;
126
132
  private readonly currentGenTtlMs;
127
133
  constructor(driver: IStorageDriver, options?: CrbmStorageChunkSourceOptions);
@@ -317,6 +323,22 @@ export declare class CrbmStorageChunkSource implements StorageChunkSource {
317
323
  /** Build the per-generation decryption context for an encrypted segment (undefined for cleartext). */
318
324
  private cryptoForRead;
319
325
  getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
326
+ /**
327
+ * Several chunks as a stream, in as few storage requests as {@link CrbmReader.readChunks} can make them (see
328
+ * {@link StorageChunkSource.getChunks}). The stream is served from one resolved generation, and every chunk says
329
+ * which version it came from. A generation swept, or an object replaced under its number, while the stream runs
330
+ * re-resolves the segment and carries on with the keys not yet yielded from whatever is current then, so each chunk
331
+ * is whole and from one generation and the stream may describe two; a second failure with no chunk yielded in between
332
+ * propagates. `options.retry` runs the resolution, each range request and the check that a failed read's object was
333
+ * replaced, so a transient fault repeats that step alone. Nothing is resolved until the first chunk is asked for.
334
+ */
335
+ getChunks(ref: SegmentRef, keys: readonly number[], options?: ReadChunksOptions): AsyncGenerator<ChunkRead>;
336
+ /**
337
+ * {@link getChunks} of a specific generation, the pinned shape read. It yields the chunks without a version: the
338
+ * caller that pinned the generation knows it. It never moves to another generation; `held` as for
339
+ * {@link getChunkAt}.
340
+ */
341
+ getChunksAt(ref: SegmentRef, generation: number, keys: readonly number[], held?: PinnedObject, options?: ReadChunksOptions): AsyncGenerator<Omit<ChunkRead, 'version'>>;
320
342
  listChunkKeys(ref: SegmentRef): Promise<number[]>;
321
343
  sizeOf(ref: SegmentRef): Promise<SegmentSize | null>;
322
344
  cardinalities(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
@@ -681,6 +703,28 @@ export declare function bulkLoadCrbmGeneration(driver: IStorageDriver, key: GenK
681
703
  */
682
704
  row?: RegistryRecord | null;
683
705
  }): Promise<BulkLoadResult>;
706
+ /**
707
+ * What a load has already started when it calls {@link bulkLoadAhead}: its number, and the segment's data key, each
708
+ * still in flight so their round trips overlap the encoding. Both are joined where the write needs them, and neither
709
+ * may be left to reject unobserved.
710
+ */
711
+ export interface LoadAhead {
712
+ /** The number the generation takes (see {@link nextLoadGeneration}); joined just before the object is written. */
713
+ readonly generation: PromiseLike<number>;
714
+ /**
715
+ * The key a caller already asked the keystore to unwrap, with the wrappings it asked about. Used only when the
716
+ * row the write reads after the ids carries exactly these wrappings; otherwise the write unwraps for itself.
717
+ */
718
+ readonly unwrapped?: {
719
+ readonly wrapped: readonly WrappedDek[];
720
+ readonly aead: PromiseLike<Aead>;
721
+ };
722
+ }
723
+ /**
724
+ * {@link bulkLoadCrbmGeneration} for a load whose number, and perhaps its key, are still being fetched: the same
725
+ * options, with the same meaning, and the same writes. The number is joined only where the object is written.
726
+ */
727
+ export declare function bulkLoadAhead(driver: IStorageDriver, ref: SegmentRef, ahead: LoadAhead, ids: Iterable<number> | AsyncIterable<number> | DecodedLoadInput, options?: NonNullable<Parameters<typeof bulkLoadCrbmGeneration>[3]>): Promise<BulkLoadResult>;
684
728
  /**
685
729
  * Open a {@link CrbmReader} on one generation over the storage driver's range/tail reads (decrypting if `crypto`).
686
730
  * The reader the write paths use to re-read what they wrote, and the erasure rewrite uses to stream the old
@@ -4,6 +4,13 @@ import type { Clock } from './determinism.js';
4
4
  import type { BoundedLru } from './lru.js';
5
5
  import type { IMetricsSink } from './metrics.js';
6
6
  import type { GenerationMetadata, StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
7
+ /**
8
+ * Range requests a stream holds ahead of a combine or `iterate` by default (chunk keys, on a source that reads chunk by
9
+ * chunk, and for `count`'s fallback) — bounds memory and concurrent reads (invariant 6). A read that does not fit
10
+ * one range of a source's stream takes about one sequential round trip per this many ranges, so this, not the network,
11
+ * is what sets how long a long read takes.
12
+ */
13
+ export declare const DEFAULT_INTERSECT_CONCURRENCY = 32;
7
14
  export interface EngineDeps {
8
15
  readonly storage: StorageChunkSource;
9
16
  /** Optional cache of decoded (immutable) Storage chunks. */
@@ -49,11 +56,15 @@ export interface IdRange {
49
56
  /** Options common to the chunk-aligned combines. */
50
57
  export interface CombineOptions extends IdRange {
51
58
  /**
52
- * Max chunk keys resolved concurrently — bounds the Storage footprint (about `concurrency × operands` chunks in
53
- * flight). A positive integer; default 32. The window opens 8 keys wide (or `concurrency`, if lower) and widens
54
- * as keys are taken, so a read that stops early fetches no more than a few keys ahead; one that runs to the end
55
- * spends nearly all of its round trips at the full width. A read that stops early has requested up to
56
- * `concurrency` keys per operand past the last one it used.
59
+ * How far ahead each operand is read: a positive integer, default 32. The library's own stores read each operand as
60
+ * one stream of coalesced ranges (chunks that sit near each other are one request), and `concurrency` is how many
61
+ * range requests a stream holds ahead of the read, in flight or landed and not yet used. So about
62
+ * `concurrency × operands` requests are in flight at most, and, since a range is at most 1 MiB (one chunk, when a chunk
63
+ * is larger), about `concurrency × operands` MiB are held, whatever the segment's size. The stream opens 4 ranges wide
64
+ * (or `concurrency`, if lower) and widens as ranges are taken, so a read that stops early has asked for little past
65
+ * where it stopped, and a read that fits one range makes one request however many chunks it needs. A source that
66
+ * reads chunk by chunk (a custom one) is read as before: `concurrency` chunk keys at once, opening 8 wide. A running
67
+ * stream holds its operand's reader outside the reader cache's bounds until it ends or the segment moves on.
57
68
  */
58
69
  readonly concurrency?: number;
59
70
  /** Override the store's per-op budget for this call (`false` lifts it). */
@@ -83,6 +94,11 @@ export declare class SegmentEngine {
83
94
  * promise per distinct key in flight.
84
95
  */
85
96
  private readonly openReads;
97
+ /**
98
+ * The chunk streams open right now. An {@link invalidate} of a segment marks its streams, which then cache no chunk
99
+ * they deliver, as a read whose entry an invalidation dropped does not (streams hold no `openReads` entry).
100
+ */
101
+ private readonly openStreams;
86
102
  constructor(deps: EngineDeps);
87
103
  /** Membership: one chunk lookup — the cache, else one Storage fetch of that chunk. */
88
104
  has(seg: SegmentRef, id: number): Promise<boolean>;
@@ -117,9 +133,17 @@ export declare class SegmentEngine {
117
133
  /** The chunks of `seg` at `chunkKeys`, read through a window of {@link DEFAULT_INTERSECT_CONCURRENCY}. */
118
134
  private chunkWindow;
119
135
  /**
120
- * Every id, ascending, reading ahead through a window of up to 32 chunk fetches that opens 1, 2, 4 … 32 wide, so a
121
- * read that stops early has fetched only a handful of chunks past the one it stopped in; with `range`, only the ids in `(after, through]`, fetching only the
122
- * chunks the range overlaps (see {@link IdRange}).
136
+ * The chunks of `seg` at `chunkKeys`, taken in ascending key order by a read that may stop at any point: one stream of
137
+ * coalesced ranges when the source has `getChunks`, opening 1, 2, 4 … ranges wide up to {@link DEFAULT_INTERSECT_CONCURRENCY},
138
+ * else the chunk-by-chunk window of that many reads. `close` stops what is left of it.
139
+ */
140
+ private chunkSequence;
141
+ /**
142
+ * Every id, ascending, reading the chunks as one stream of coalesced ranges (chunks that sit near each other are one
143
+ * request) that opens 1, 2, 4 … 32 ranges wide, so a read that stops early has asked for only a few ranges past the
144
+ * one it stopped in (each at most 1 MiB); with `range`, only the ids in `(after, through]`, fetching only the
145
+ * chunks the range overlaps (see {@link IdRange}). A source that reads chunk by chunk is read through a window of up to
146
+ * 32 chunk fetches that opens 1, 2, 4 … 32 wide.
123
147
  *
124
148
  * Two generators, not one with a branch: a second `yield` site in the full read's generator grows its frame, and
125
149
  * every id of a read that asked for no range paid for it (measured at about 5% per id). The full read keeps the
@@ -128,6 +152,13 @@ export declare class SegmentEngine {
128
152
  iterate(seg: SegmentRef, range?: IdRange): AsyncGenerator<number>;
129
153
  private iterateAll;
130
154
  private iterateRange;
155
+ /**
156
+ * {@link iterate}, one `Uint32Array` per chunk instead of one id at a time: the same chunks, in the same order, with
157
+ * the same ids (a range cut at its edges the same way), and no array for a chunk that holds none of them. The
158
+ * reads, the budget charge and the window are the per-id read's. Each array is at most 65,536 ids (256 KiB) and is
159
+ * the caller's to keep.
160
+ */
161
+ iterateBatches(seg: SegmentRef, range?: IdRange): AsyncGenerator<Uint32Array>;
131
162
  /**
132
163
  * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
133
164
  *
@@ -142,11 +173,11 @@ export declare class SegmentEngine {
142
173
  *
143
174
  * Chunk-skipping intersection. Aligns each segment's chunk-key set (read from the `.crbm` index, no payload),
144
175
  * keeps only keys present in **all** operands (a key missing from any operand can't contribute → its Storage
145
- * chunks are never fetched — the core saving), then for each surviving key fetches the operands' chunks in
146
- * parallel and hands them to the codec for the AND, streaming results through a bounded in-flight window.
176
+ * chunks are never fetched — the core saving), then reads each operand's surviving chunks as one stream of coalesced
177
+ * ranges and hands them, key by key, to the codec for the AND, streaming results through a bounded window.
147
178
  *
148
- * **Memory:** the Storage payload footprint is bounded by the window (`concurrency × operands × chunk`), not by
149
- * segment size — that's the Lambda-friendly property.
179
+ * **Memory:** the Storage payload footprint is bounded by the window (`concurrency × operands × chunk`, a range being
180
+ * at most a chunk's cap of 1 MiB), not by segment size — that's the Lambda-friendly property.
150
181
  *
151
182
  * Generation-consistent within the call (normal case): each operand's current generation is resolved **once**
152
183
  * up front (before the fan-out) and threaded into every chunk read, so a concurrent load can't corrupt or tear
@@ -157,7 +188,9 @@ export declare class SegmentEngine {
157
188
  * waiting for the TTL, so a shorter call can meet them too: **the reader cache evicting an operand mid-call**
158
189
  * (`maxOpenSegments`), whose re-read re-resolves fresh; a sweep deleting the generation it was reading, which
159
190
  * heals the read forward; and an invalidation, which this store's own `load`, `rollback` and `eraseSubject` make
160
- * and `invalidate()` makes on request. Still whole/immutable per read, never torn.
191
+ * and `invalidate()` makes on request. Still whole/immutable per read, never torn. An operand's stream resolves its
192
+ * segment again before it serves each chunk, so each of those moves a running read as it would a read of one chunk, and
193
+ * the ranges it had requested of the earlier generation are dropped, not served.
161
194
  */
162
195
  intersect(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<number>;
163
196
  /**
@@ -176,7 +209,20 @@ export declare class SegmentEngine {
176
209
  * the cost scales with the segment being filtered rather than with the size of the suppression list.
177
210
  */
178
211
  andNot(seg: SegmentRef, excludes: readonly SegmentRef[], options?: Omit<CombineOptions, 'exclude'>): AsyncGenerator<number>;
212
+ /** {@link intersect}, one `Uint32Array` per chunk — see {@link iterateBatches}. */
213
+ intersectBatches(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<Uint32Array>;
214
+ /** {@link union}, one `Uint32Array` per chunk — see {@link iterateBatches}. */
215
+ unionBatches(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<Uint32Array>;
216
+ /** {@link andNot}, one `Uint32Array` per chunk — see {@link iterateBatches}. */
217
+ andNotBatches(seg: SegmentRef, excludes: readonly SegmentRef[], options?: Omit<CombineOptions, 'exclude'>): AsyncGenerator<Uint32Array>;
218
+ /**
219
+ * Everything a combine decides before it reads a chunk: validation, each operand's keys and generation, the
220
+ * absent-operand refusal, key alignment, the budget and the metric. `null` when the range is empty. The per-id
221
+ * read and the batch read share it, so the two cannot disagree about what is read or charged.
222
+ */
223
+ private combinePlan;
179
224
  private combine;
225
+ private combineBatches;
180
226
  /**
181
227
  * The AND (or OR) of one chunk key across all operands, minus the excludes that hold it, or `null` if empty.
182
228
  * Operand chunks are fetched **in parallel** (the spec's parallel byte-range reads).
@@ -187,6 +233,32 @@ export declare class SegmentEngine {
187
233
  * used as-is — one clone per key, not one per operand.
188
234
  */
189
235
  private combineChunk;
236
+ /**
237
+ * One operand's chunk at `key`: from its stream when it has one, else a read of that chunk on its own. Not `async`,
238
+ * so a stream's take is made before anything is awaited: concurrent takes of one stream must be made in key order.
239
+ */
240
+ private operandChunk;
241
+ /**
242
+ * Open `keys` of `seg` as a stream of coalesced ranges, or `undefined` when the read is per key: a source with no
243
+ * `getChunks`, a segment with no generation, or nothing to read. The chunks already decoded in the cache are taken out
244
+ * of the key list first and served from there, so a repeat read asks the source for nothing it holds. Nothing is
245
+ * requested until the first chunk is asked for.
246
+ */
247
+ private openStreamed;
248
+ private closeStreamed;
249
+ /**
250
+ * The decoded chunk at `chunkKey` of a streamed read: the next chunk of its stream, or, for a chunk the cache held
251
+ * when the read opened, the cached instance while it is still there, and a read of that chunk alone once it is not.
252
+ */
253
+ private streamedChunk;
254
+ /**
255
+ * The version a streamed chunk is cached under, or `null` for one not to cache: the version its read reports when the
256
+ * source names versions as its lookups do (`currentVersion`), and otherwise the one the read planned under, since a
257
+ * lookup by generation number would never find a chunk cached under a stream's version.
258
+ */
259
+ private streamedVersion;
260
+ /** The cache key of a chunk: by the version it was read under, or by segment and key alone for a source with none. */
261
+ private chunkCacheKey;
190
262
  /** The segment's chunk keys, ascending — a shape read off the index, no payload. Keys are untrusted (invariant 5). */
191
263
  private chunkKeys;
192
264
  /**
@@ -277,6 +349,16 @@ export declare class SegmentEngine {
277
349
  * one): callers read it or clone it, never mutate it.
278
350
  */
279
351
  private storageChunk;
280
- /** One request for one chunk, decoded, range-checked and cached. */
352
+ /**
353
+ * A chunk's stored bytes decoded under the size cap (invariant 5) and range-checked: the one place a chunk becomes a
354
+ * bitmap, whether its bytes came from a request of its own or from a stream of coalesced ranges.
355
+ */
356
+ private decodeChunk;
357
+ /**
358
+ * One chunk, decoded, range-checked and cached. A source with `getChunks` is read through it, as a one-key stream, so
359
+ * a `storage.get` is reported only for a request that was sent (a chunk the source holds in memory sends none);
360
+ * the chunk is cached under the version it came from, as a stream's chunk is. A source without it is read by
361
+ * `getChunk`, one event a call.
362
+ */
281
363
  private fetchChunk;
282
364
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pulls the items of an async iterator one at a time so that the code relaying them never holds the one it just
3
+ * handed on. A binding of a generator's body (`for await (const item of …)`, `yield*`) is kept in the generator's
4
+ * frame across its next `yield`, so the relay would hold the previous item, and whatever buffer it is a view into,
5
+ * for as long as it waits for the next. Here the item sits in the pull between {@link advance} and {@link take}, and
6
+ * `take` clears it before it returns.
7
+ *
8
+ * ```ts
9
+ * const pull = new ItemPull(source);
10
+ * try {
11
+ * while (await pull.advance()) yield pull.take((item) => wrap(item));
12
+ * } finally {
13
+ * await pull.close();
14
+ * }
15
+ * ```
16
+ *
17
+ * Internal to core: not exported from any entry point.
18
+ */
19
+ export declare class ItemPull<A> {
20
+ private readonly iterator;
21
+ private item;
22
+ constructor(source: AsyncIterable<A>);
23
+ /** Waits for the next item and keeps it inside; `false` once the source has ended. A failure of the source throws. */
24
+ advance(): Promise<boolean>;
25
+ /** Hands over the item {@link advance} fetched, through `map`, and forgets it. */
26
+ take<B>(map: (item: A) => B): B;
27
+ /** Ends the source early (a consumer that stopped), as leaving a `for await` does. */
28
+ close(): Promise<void>;
29
+ }
@@ -26,19 +26,26 @@ export type MetricEvent = {
26
26
  readonly kind: 'storage.get';
27
27
  readonly namespace?: string;
28
28
  readonly segment: string;
29
- /** One event per request, however many callers were waiting on it. Bytes returned (0 if the chunk was absent — a GET still happened). */
29
+ /**
30
+ * One event per request for chunks: a range request of a combine or `iterate`, which carries every chunk the read
31
+ * needs from a stretch of the object, or the one chunk of a read made on its own, however many callers were waiting
32
+ * on it. Bytes returned: the range's, the gaps between its chunks included (0 if a single chunk was absent — a GET
33
+ * still happened).
34
+ */
30
35
  readonly bytes: number;
31
36
  /**
32
- * Elapsed wall time of the read — includes any transient-retry backoff on the storage call. From the
33
- * injected clock, clamped to ≥ 0 (0 under the no-wait test clock).
37
+ * Elapsed wall time of the request — includes any transient-retry backoff on the storage call. From the
38
+ * injected clock, clamped to ≥ 0 (0 under the no-wait test clock). For a range of a stream, the time from the
39
+ * range being asked for to its answer, which the source measures.
34
40
  */
35
41
  readonly ms: number;
36
42
  } | {
37
43
  /**
38
44
  * One decoded-chunk cache lookup (emitted only when a cache is configured). `hit: false` means the lookup found
39
- * no cached chunk. A caller that missed while another caller's read of the same chunk was open waits on that
40
- * read and adds no `storage.get`, so the misses can outnumber the `storage.get` events; the `storage.get`
41
- * count is the number of requests.
45
+ * no cached chunk. A combine or `iterate` looks up every chunk it needs when it opens its streams. A caller that
46
+ * missed while another caller's read of the same chunk was open waits on that read and adds no `storage.get`, and a
47
+ * range carries several chunks, so the misses can outnumber the `storage.get` events; the `storage.get` count is
48
+ * the number of requests for chunks.
42
49
  */
43
50
  readonly kind: 'cache';
44
51
  readonly hit: boolean;
@@ -21,7 +21,7 @@
21
21
  * on some other handle. That is why the routing is by segment identity here rather than by which object the
22
22
  * caller happened to start from.
23
23
  */
24
- import type { ChunkRef, GenerationSummary, StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
24
+ import type { ChunkRef, ChunkRead, ReadChunksOptions, GenerationSummary, StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
25
25
  import type { CrbmStorageChunkSource } from './crbm-storage-source.js';
26
26
  /**
27
27
  * What a pin holds for one segment: the generation, the version identifying those exact bytes, and the pinned
@@ -64,6 +64,12 @@ export declare class PinnedStorageChunkSource implements StorageChunkSource {
64
64
  */
65
65
  private pinFor;
66
66
  getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
67
+ /**
68
+ * A pinned segment's chunks come from the pinned generation alone, under the version a pinned read keys by
69
+ * ({@link currentVersion}); another segment's go to the live source, which answers with its own.
70
+ */
71
+ getChunks(ref: SegmentRef, keys: readonly number[], options?: ReadChunksOptions): AsyncIterable<ChunkRead>;
72
+ private pinnedChunks;
67
73
  listChunkKeys(ref: SegmentRef): Promise<number[]>;
68
74
  sizeOf(ref: SegmentRef): Promise<SegmentSize | null>;
69
75
  cardinalities(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
@@ -40,11 +40,80 @@ export interface GenKey extends SegmentRef {
40
40
  export interface SegmentSize {
41
41
  readonly sizeBytes: number;
42
42
  }
43
+ /**
44
+ * One chunk of a {@link StorageChunkSource.getChunks} stream: the key asked for, its bytes (`null` where the generation
45
+ * holds none), and the version of the generation the bytes were read from, or `null` when the segment has no
46
+ * generation. The version is what {@link StorageChunkSource.currentVersion} reports for that generation, so a caller
47
+ * can key what it keeps by the generation the bytes came from rather than the one it resolved earlier.
48
+ */
49
+ export interface ChunkRead {
50
+ readonly key: number;
51
+ readonly bytes: Uint8Array | null;
52
+ readonly version: string | null;
53
+ }
54
+ /** Options of {@link StorageChunkSource.getChunks}. */
55
+ export interface ReadChunksOptions {
56
+ /**
57
+ * Runs one storage request, and may run it again if it fails. A source passes each request it makes through
58
+ * this, so a caller that retries transient faults repeats the one request that failed, not the ones that landed.
59
+ * Absent, each request runs once.
60
+ */
61
+ readonly retry?: <T>(request: () => Promise<T>) => Promise<T>;
62
+ /**
63
+ * How many range requests the stream holds ahead of its consumer, in flight or landed and not yet taken: a positive
64
+ * integer, 32 by default. It bounds the requests in flight, and the memory the stream holds, at this many times the
65
+ * largest range (1 MiB, 1 MiB + 28 B for one encrypted chunk).
66
+ */
67
+ readonly concurrency?: number;
68
+ /**
69
+ * Open the window 1, 2, 4 … ranges wide up to `concurrency` instead of at full width, so a reader that stops early
70
+ * has asked for little; a number starts it that wide instead of at 1 (a positive integer), and it doubles from there.
71
+ */
72
+ readonly ramp?: boolean | number;
73
+ /**
74
+ * Called once for every range request the stream sends, when it settles: a request the consumer took, one that was
75
+ * still in flight when the consumer stopped, and one that failed. `bytes` is what the request moved, the gaps between
76
+ * the chunks it carried included (0 when it failed), and `ms` how long it took, retries included (0 when the source
77
+ * has no clock). It is how a caller counts what it is billed for, since several chunks can share a request and
78
+ * a stopped stream leaves some in flight. A throw from it is ignored.
79
+ */
80
+ readonly onRequest?: (request: {
81
+ readonly bytes: number;
82
+ readonly ms: number;
83
+ }) => void;
84
+ }
43
85
  /** Per-chunk read view of the immutable Storage tier (implemented by the `.crbm` reader). */
44
86
  export interface StorageChunkSource {
45
87
  /** Read-only bytes for the chunk, or `null` if absent. Callers must not mutate the buffer. */
46
88
  getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
47
89
  listChunkKeys(ref: SegmentRef): Promise<number[]>;
90
+ /**
91
+ * Optional: several chunks of one segment as a stream, read **from one generation** with fewer storage requests than
92
+ * one per chunk where the chunks sit near each other in the object. `keys` are chunk keys in ascending order (a key
93
+ * may repeat; any other order is a `ValidationError`). The stream yields one {@link ChunkRead} per position of
94
+ * `keys`, in that order, as the requests that carry them land; a key the generation does not hold yields `null`
95
+ * bytes, as {@link StorageChunkSource.getChunk} answers. Each chunk is checked as a read of it alone is.
96
+ *
97
+ * **Nothing is resolved or read until the first chunk is asked for**, and the source holds at most
98
+ * {@link ReadChunksOptions.concurrency} ranges at a time (in flight, or landed and not yet taken), however many keys
99
+ * there are: a consumer that is slow holds the stream back, and one that stops early (`break`, or `return()`) stops
100
+ * the reads, so the requests it had already started (at most that many) finish and are dropped, and are not retried.
101
+ * A stream that fails raises at once and sends nothing further, and the requests it left in flight finish in the
102
+ * background and never raise. A plain chunk may be a view into a buffer of up to 1 MiB that it shares with its
103
+ * neighbours (and the same view at two positions for a key asked twice), so do not write to it, and copy a chunk to
104
+ * keep it.
105
+ *
106
+ * One stream reads one generation. If that generation is swept, or its object replaced, while the stream runs, a
107
+ * source may re-resolve the segment and continue with the keys not yet yielded from the generation that is current
108
+ * then; each chunk says which version it came from, and each is whole and verified either way. Before it does, it
109
+ * waits for the requests the failed stream left in flight, so a heal never opens a second window beside them: a
110
+ * request that never answers delays a heal, as it would hang a read of one chunk, until the driver's read timeout (if
111
+ * it has one) ends it, and never delays an error. A source that reads a pinned generation never heals.
112
+ *
113
+ * A source that cannot read a range of an object omits this, and a caller reads chunk by chunk; the `.crbm`
114
+ * source, which is what the stores the library ships read through, implements it.
115
+ */
116
+ getChunks?(ref: SegmentRef, keys: readonly number[], options?: ReadChunksOptions): AsyncIterable<ChunkRead>;
48
117
  /**
49
118
  * Optional: the current generation's grounded size, cheaply (from the already-parsed `.crbm` index — no
50
119
  * payload reads), or `null` if the segment has no Storage generation. Powers the grounded `costReport()`.
@@ -42,5 +42,10 @@ export declare function isReservedNamespace(namespace: string | undefined): bool
42
42
  export declare function validateUserNamespace(namespace: string): void;
43
43
  /** Validate a segment ref an application hands in: the name rules, and a namespace outside the reserved prefix. */
44
44
  export declare function validateUserRef(ref: SegmentRef): void;
45
+ /**
46
+ * Validate the keys of a `getChunks` stream: in ascending order, a key may repeat. Checked before anything is
47
+ * resolved or read, so a caller's mistake costs no request.
48
+ */
49
+ export declare function validateChunkKeyOrder(keys: readonly number[]): void;
45
50
  /** Validate a chunk ref: the segment/namespace rules plus `chunkKey` ∈ `[0, 65535]` (a u16). A driver-boundary check. */
46
51
  export declare function validateChunkRef(ref: ChunkRef): void;
@@ -28,7 +28,7 @@ import {
28
28
  validateRegistryPatch,
29
29
  validateSegmentRef,
30
30
  webCryptoEntropy
31
- } from "./chunk-5W3XS2IU.js";
31
+ } from "./chunk-B7IGS3W6.js";
32
32
 
33
33
  // src/drivers/_shared/object-registry-keys.ts
34
34
  var REGISTRY_SUFFIX = ".reg";
@@ -104,7 +104,7 @@ function parseRegistryKey(prefix, objectKey) {
104
104
  var MAX_ROW_BYTES = 1 * 1024 * 1024;
105
105
  var MAX_DELETE_ATTEMPTS = 8;
106
106
  var MAX_READ_ATTEMPTS = 8;
107
- var LIST_READ_CONCURRENCY = 16;
107
+ var LIST_READ_CONCURRENCY = 48;
108
108
  var ObjectVersionRaced = class extends Error {
109
109
  constructor(objectKey) {
110
110
  super(`registry object was overwritten mid-read: ${objectKey}`);