@cloudbitmaps/core 0.4.0 → 0.5.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.
package/dist/index.d.cts CHANGED
@@ -7,7 +7,7 @@ export { o as BufferReader, p as BufferSink, q as GovernanceMeta, r as NO_ROW, s
7
7
  * `core/` is **codec-agnostic**: `SegmentEngine`, compaction, and the `.crbm` read/write helpers only ever
8
8
  * construct and combine bitmaps through the {@link CodecInterface} factory + the {@link CodecBitmap} value type
9
9
  * defined here — never a concrete implementation. The flagship codec is roaring (`roaringCodec`, today in
10
- * `core/bitmap.ts`; it moves to `@cloudbitmaps/roaring` when the package split lands); `@cloudbitmaps/bitmap`
10
+ * `core/bitmap.ts`; it moves to `@cloudbitmaps/roaring` when the package split lands); `@cloudbitmaps/bitset`
11
11
  * (plain bitset) and `@cloudbitmaps/soaring` plug in behind the same seam with zero engine or driver changes.
12
12
  *
13
13
  * **Homogeneity contract:** a single store uses a single codec, so every {@link CodecBitmap} an operation sees
@@ -18,7 +18,7 @@ export { o as BufferReader, p as BufferSink, q as GovernanceMeta, r as NO_ROW, s
18
18
  * mints the cache per store, so this cannot arise in normal use.)
19
19
  *
20
20
  * **Interface surface = exactly what the engine needs**, kept general enough for the known fast-follow codecs
21
- * (positional/rank-select access + raw-bitset interop that `@cloudbitmaps/bitmap` will add live on that
21
+ * (positional/rank-select access + raw-bitset interop that `@cloudbitmaps/bitset` will add live on that
22
22
  * package's own extended value type, not here — the engine never calls them). The one codec-linked concern
23
23
  * this seam intentionally leaves in the format layer is the `.crbm` **serialization id** (`ROARING_PORTABLE_ID`
24
24
  * in `crbm/format.ts`): it is stamped in the footer and validated on read, and generalizes to a per-codec id
@@ -314,14 +314,22 @@ type MetricEvent = {
314
314
  * Which chunk-aligned combine this was. **Optional for backward compatibility** — absent means
315
315
  * `'intersect'`, which is all this event reported before `union`/`andNot` existed.
316
316
  *
317
- * `skippedChunks` is only ever non-zero for `'intersect'` and `'andNot'`: union has to read every chunk
318
- * of every operand, so there is nothing it can prune. A steady stream of `op: 'union'` events with
319
- * `skippedChunks: 0` is the expected shape, not a regression.
317
+ * For `'union'` over its *include* operands alone, `skippedChunks` is 0 by construction — union reads
318
+ * every chunk of every operand, so there is nothing to prune. It can still be non-zero when a union
319
+ * carries `exclude` operands, because a suppression list may hold keys no include does and those are
320
+ * legitimately never fetched. So: 0 is the expected shape for a plain union, not a guarantee.
320
321
  */
321
322
  readonly op?: 'intersect' | 'union' | 'andNot';
322
323
  /** Operand count, including any `exclude` (suppression) operands. */
323
324
  readonly operands: number;
324
- /** Distinct chunk-keys selected to fetch (for `'intersect'`, those present in every operand). */
325
+ /**
326
+ * Distinct chunk **keys** selected to fetch — for `'intersect'`, those present in every operand.
327
+ *
328
+ * **Keys, not requests.** The actual cold GETs are roughly `fetchedChunks × operands`, plus one per
329
+ * `exclude` that holds a given key, which is why this number is smaller than what the per-op budget
330
+ * charges for the same call. The two are different units by design; if you are reconciling a bill, the
331
+ * budget's accounting is the one that models requests.
332
+ */
325
333
  readonly fetchedChunks: number;
326
334
  /**
327
335
  * **Distinct** chunk-keys pruned — never fetched (the chunk-skipping saving). Counts distinct keys,
@@ -498,8 +506,8 @@ declare const DEFAULT_MAX_WARM_SCAN_BYTES: number;
498
506
  * write here is a read-modify-write, so 4 in flight is up to 8 concurrent requests per `addMany` call — and a
499
507
  * server handling many concurrent calls multiplies that again. Provisioned-capacity backends answer a burst
500
508
  * with throttling, which is free only while retries can absorb it (4 attempts, exponential backoff, full
501
- * jitter). Swept against a backend that throttles on concurrent requests in flight, 64 chunks, 5 trials each,
502
- * counting a lost id or a surfaced error as a failure:
509
+ * jitter). Swept against a **modelled** backend — an in-process fake that throttles on concurrent requests in
510
+ * flight, not a real DynamoDB — 64 chunks, 5 trials each, counting a lost id or a surfaced error as a failure:
503
511
  *
504
512
  * ```text
505
513
  * backend capacity: 1 2 4 8
@@ -609,6 +617,17 @@ declare class SegmentEngine {
609
617
  segmentSize(seg: SegmentRef): Promise<SegmentSize | null>;
610
618
  iterate(seg: SegmentRef): AsyncGenerator<number>;
611
619
  /**
620
+ * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
621
+ *
622
+ * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
623
+ * afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
624
+ * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
625
+ * operand is read **only at the keys that survived the intersection** — the cost is bounded by the
626
+ * audience's surviving key count and **never scales with the size of the suppression list**. Subtracting a
627
+ * 61,000-chunk global opt-out list costs at most one read per surviving key, not 61,000.
628
+ *
629
+ * ---
630
+ *
612
631
  * Chunk-skipping intersection. Computes
613
632
  * `seg[0] ∩ seg[1] ∩ …` and streams the result ids ascending. It aligns each segment's *effective*
614
633
  * chunk-key set (Warm ∪ Cold), keeps only keys present in **all** operands (a key missing from any
@@ -633,15 +652,6 @@ declare class SegmentEngine {
633
652
  * re-resolves fresh (bypassing the TTL), which can hop generations even sub-TTL — still whole/immutable per
634
653
  * read, never torn. Full intra-op snapshot isolation is deferred (gap #4).
635
654
  */
636
- /**
637
- * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
638
- *
639
- * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
640
- * afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
641
- * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
642
- * operand is read **only at the keys that survived the intersection**, so a 61,000-chunk global opt-out list
643
- * intersected against a narrow audience costs a handful of reads, not 61,000.
644
- */
645
655
  intersect(segs: readonly SegmentRef[], options?: {
646
656
  concurrency?: number;
647
657
  budget?: BudgetOption;
@@ -730,8 +740,10 @@ declare class SegmentEngine {
730
740
  * So the staging buffer is capped, but it folds into per-chunk **compressed bitmaps** rather than being
731
741
  * written out. Peak memory is the roaring representation of the ids seen so far — which is what the segment
732
742
  * itself costs, and the floor for any implementation that writes each chunk exactly once — while the backend
733
- * still sees exactly one read-modify-write per distinct chunk regardless of stream length. This is strictly
734
- * less memory than the previous sync-only version, which held every remainder as an uncompressed JS number.
743
+ * still sees exactly one read-modify-write per distinct chunk regardless of stream length. Above the staging cap
744
+ * this holds less than the previous sync-only version, which kept every remainder as an uncompressed JS
745
+ * number; below it — the common `addMany` — nothing is compacted until the end and the two are comparable.
746
+ * The guarantee is that a stream of *any* length stays bounded, not that every call got smaller.
735
747
  *
736
748
  * There is no way to do better without a sorted stream: a chunk can only be written once it is known that no
737
749
  * further ids for it will arrive, and unsorted input never gives that guarantee. Callers streaming *very*
@@ -1055,8 +1067,16 @@ interface CompactionDeps {
1055
1067
  readonly cold: IColdDriver;
1056
1068
  readonly warm: IWarmDriver;
1057
1069
  readonly registry: IRegistryDriver;
1058
- /** Injected time source for lease expiry (the determinism seam — `core/` never reads ambient time). */
1059
- readonly clock: Pick<Clock, 'now'>;
1070
+ /**
1071
+ * Injected time source for lease expiry (the determinism seam — `core/` never reads ambient time).
1072
+ *
1073
+ * `now` is required; `sleep`/`yieldNow` are **optional and used only for cooperative yielding** while writing
1074
+ * a generation. Supplying a full {@link Clock} (the facade does) makes compaction hand the event loop back
1075
+ * periodically instead of blocking it for the whole write; supplying a bare `{ now }` keeps the previous
1076
+ * uninterrupted behaviour. Optional rather than required because every existing caller passes `{ now }`, and
1077
+ * requiring more would be a breaking change — the reason the yield below shipped unreachable the first time.
1078
+ */
1079
+ readonly clock: Pick<Clock, 'now'> & Partial<Pick<Clock, 'sleep' | 'yieldNow'>>;
1060
1080
  readonly maxBitmapBytes?: number;
1061
1081
  /**
1062
1082
  * Bitmap codec — compaction only merges bitmaps through it. Optional in the type so this
@@ -1515,6 +1535,56 @@ declare class LocalFsRegistryDriver implements IRegistryDriver {
1515
1535
  private withRowLock;
1516
1536
  }
1517
1537
 
1538
+ /**
1539
+ * Cooperative yielding for the long CPU-bound loops.
1540
+ *
1541
+ * Everything this library does on the hot read path is small. Bulk-load is the exception: it is the one place
1542
+ * where a single call legitimately occupies the CPU for hundreds of milliseconds, and Node has exactly one thread
1543
+ * to occupy. Measured on a 1M-id load, 62,455 chunks: **442 ms wall, and 450 ms during which the event loop did
1544
+ * not turn at all**. Wire that to a request handler and every other in-flight request on the instance — health
1545
+ * checks included — waits out the whole load.
1546
+ *
1547
+ * The fix is not to make the work asynchronous. That was measured too, and it is a 7x *regression*: handing each
1548
+ * chunk's insert to the threadpool costs ~9 µs of dispatch against ~1.5 µs of actual work once ids are spread
1549
+ * across ~62,000 chunks (636 ms versus 92 ms). The work is genuinely CPU-bound and genuinely small per chunk;
1550
+ * the only thing wrong with it is that it never stops to let anyone else go. So the fix is to keep the work
1551
+ * synchronous and periodically hand the loop back.
1552
+ *
1553
+ * TWO TRAPS, both of which make a plausible implementation measure as no change at all:
1554
+ *
1555
+ * 1. **`await Promise.resolve()` — and equally `Clock.sleep(0)` — do nothing.** Both resolve on a microtask, and
1556
+ * the microtask queue drains to empty before the event loop advances a single phase. A loop awaiting them
1557
+ * yields to other *microtasks* and to nothing else; no pending I/O callback runs. Measured: 555 ms of
1558
+ * starvation against a 568 ms unyielded baseline **on the synthetic yield-primitive benchmark below** —
1559
+ * a different, smaller workload than the 442 ms end-to-end load above, which is why the two baselines
1560
+ * differ. The yield must be a **macrotask** — hence
1561
+ * `Clock.yieldNow`, backed by `setImmediate` in production wiring.
1562
+ * 2. **Yielding per unit of work costs more than the work.** The yield has to be periodic, and `everyN` has to be
1563
+ * large enough that its cost disappears into the batch it interrupts.
1564
+ *
1565
+ * At `everyN = 1024` over that same synthetic workload the trade is close to free: 569 ms wall against a 568 ms
1566
+ * baseline, with the worst event-loop gap down from 568 ms to 13.8 ms.
1567
+ *
1568
+ * **Three experiments, three sets of numbers — do not mix them.** (a) The end-to-end 1M-id bulk load:
1569
+ * 442 ms wall / 450 ms blocked before, 256 ms / 19 ms after. (b) This synthetic yield-primitive comparison:
1570
+ * 568 ms / 568 ms unyielded, 569 ms / 13.8 ms with `setImmediate`. (c) A per-chunk insert microbenchmark:
1571
+ * 92 ms sync versus 636 ms via the threadpool. Quoting a baseline from one against a result from another is
1572
+ * how this file previously published a 92 ms operation with 819 ms of starvation inside it.
1573
+ */
1574
+ /**
1575
+ * The only part of a `Clock` that yielding actually needs.
1576
+ *
1577
+ * Deliberately looser than `Clock` so the compaction path can opt in: `CompactionDeps.clock` is declared as
1578
+ * `Pick<Clock, 'now'>` plus optional yield members, because its callers (including the `compact-segments` CLI)
1579
+ * have always supplied a bare `{ now }`. Requiring a full `Clock` there would have been a breaking change for
1580
+ * every one of them — which is exactly why the compaction yield shipped as dead code: the type could not carry
1581
+ * a clock capable of yielding.
1582
+ */
1583
+ interface Yielder {
1584
+ yieldNow?(): Promise<void>;
1585
+ sleep?(ms: number): Promise<void>;
1586
+ }
1587
+
1518
1588
  interface CrbmReaderOptions {
1519
1589
  /** Speculative tail size in bytes (default 256 KB; clamped up to at least the footer size). */
1520
1590
  readonly tailBytes?: number;
@@ -1704,7 +1774,7 @@ declare function writeCrbmGeneration(driver: IColdDriver, key: GenKey, chunks: I
1704
1774
  bitmap: CodecBitmap;
1705
1775
  }>, options?: {
1706
1776
  crypto?: CrbmCrypto;
1707
- clock?: Clock;
1777
+ clock?: Yielder;
1708
1778
  }): Promise<{
1709
1779
  size: number;
1710
1780
  sha256: string;
@@ -2061,11 +2131,23 @@ declare function runConsistencyCheck(deps: {
2061
2131
  * I/O of their own); the wrapped driver is responsible for *classifying* its transient faults (raising
2062
2132
  * {@link TransientError}); these decorators decide *whether and when* to retry.
2063
2133
  *
2064
- * **Streaming methods** (`listChunks`, `list`) are retried by **re-enumerating from the start**, buffering
2065
- * the result — a partially-consumed async iterator can't be safely resumed mid-stream (it would re-yield
2066
- * earlier items). Their consumers in the engine already collect fully (the Warm dirty-set is small under
2067
- * Topology-A; the cold `list` is a discovery scan), so buffering preserves current semantics. Point methods
2068
- * are retried in place with no buffering.
2134
+ * **Streaming methods split into two shapes**, and the difference is deliberate — a partially-consumed async
2135
+ * iterator cannot be resumed mid-stream (it would re-yield earlier items), so each method picks between
2136
+ * buffering and bounded memory:
2137
+ *
2138
+ * · `RetryingColdDriver.list` / `RetryingRegistryDriver.list` **buffer**, inside `withRetry`, so the whole
2139
+ * enumeration is retried by re-running it from the start. Both are discovery scans over a generation or
2140
+ * namespace listing, where the result set is small and whole-scan retry is worth the memory.
2141
+ * · `RetryingWarmDriver.listChunks` does **not** buffer. It retries only until the first row arrives and
2142
+ * then streams live, because buffering it defeated the engine's resident-memory bound outright — see the
2143
+ * measured account on the method itself. A mid-stream fault therefore propagates rather than being
2144
+ * retried, which is the documented trade there.
2145
+ *
2146
+ * Point methods are retried in place, with nothing to buffer.
2147
+ *
2148
+ * (This paragraph previously said streaming methods buffer, full stop. That described `listChunks` as it was
2149
+ * before the bound was fixed, and the stale wording had also been copied into the Postgres and MySQL warm
2150
+ * drivers — so three comments promised a mid-stream resilience that the hot read path does not have.)
2069
2151
  *
2070
2152
  * What is **not** retried here: {@link WriteConflictError} (OCC — the engine's read-modify-write loop owns
2071
2153
  * that; a blind replay would re-apply against a stale token), and every deterministic error
@@ -2097,6 +2179,26 @@ declare class RetryingWarmDriver implements IWarmDriver {
2097
2179
  token: Token;
2098
2180
  }>;
2099
2181
  deleteConditional(ref: ChunkRef, expected: Token): Promise<void>;
2182
+ /**
2183
+ * Stream the inner driver's rows, retrying only until the first one arrives.
2184
+ *
2185
+ * **This used to buffer the whole scan** — `for await (…) out.push(row)` inside `withRetry`, then
2186
+ * `yield* out` — which made the retry trivially safe and quietly defeated every bound above it. The engine's
2187
+ * `collectWarm` refuses *during* enumeration precisely so resident memory is `O(ceiling)` rather than
2188
+ * `O(segment)`; with the buffering wrapper in place (and it is wired by default) it saw its first row only
2189
+ * after the entire segment was already in memory. Measured on a 500-row segment under
2190
+ * `budget: { maxRequests: 3 }`: **500 rows materialised with the default wiring, 4 with `retry: false`**.
2191
+ * Both paths threw the same `BudgetExceededError`, which is why no test caught it — the error was never the
2192
+ * distinguishing observable, the row count was.
2193
+ *
2194
+ * **The trade this makes.** A mid-stream fault now propagates instead of being retried. It cannot be retried
2195
+ * honestly: rows have already been handed to the consumer, so restarting the iterator would re-yield them,
2196
+ * and the driver interface exposes no resumption token to continue from. Retry is kept where it pays —
2197
+ * establishing the scan, which is where transient connect/throttle failures actually land — and bounded
2198
+ * memory wins the conflict, because invariant 6 ("bounded memory & cost, always") is a hard invariant and
2199
+ * mid-scan retry is not. A caller who needs whole-scan retry can wrap the operation at their own level,
2200
+ * where re-reading from the start is safe.
2201
+ */
2100
2202
  listChunks(ref: SegmentRef, opts?: WarmReadOptions): AsyncIterable<{
2101
2203
  chunkKey: number;
2102
2204
  } & WarmRow>;
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export { o as BufferReader, p as BufferSink, q as GovernanceMeta, r as NO_ROW, s
7
7
  * `core/` is **codec-agnostic**: `SegmentEngine`, compaction, and the `.crbm` read/write helpers only ever
8
8
  * construct and combine bitmaps through the {@link CodecInterface} factory + the {@link CodecBitmap} value type
9
9
  * defined here — never a concrete implementation. The flagship codec is roaring (`roaringCodec`, today in
10
- * `core/bitmap.ts`; it moves to `@cloudbitmaps/roaring` when the package split lands); `@cloudbitmaps/bitmap`
10
+ * `core/bitmap.ts`; it moves to `@cloudbitmaps/roaring` when the package split lands); `@cloudbitmaps/bitset`
11
11
  * (plain bitset) and `@cloudbitmaps/soaring` plug in behind the same seam with zero engine or driver changes.
12
12
  *
13
13
  * **Homogeneity contract:** a single store uses a single codec, so every {@link CodecBitmap} an operation sees
@@ -18,7 +18,7 @@ export { o as BufferReader, p as BufferSink, q as GovernanceMeta, r as NO_ROW, s
18
18
  * mints the cache per store, so this cannot arise in normal use.)
19
19
  *
20
20
  * **Interface surface = exactly what the engine needs**, kept general enough for the known fast-follow codecs
21
- * (positional/rank-select access + raw-bitset interop that `@cloudbitmaps/bitmap` will add live on that
21
+ * (positional/rank-select access + raw-bitset interop that `@cloudbitmaps/bitset` will add live on that
22
22
  * package's own extended value type, not here — the engine never calls them). The one codec-linked concern
23
23
  * this seam intentionally leaves in the format layer is the `.crbm` **serialization id** (`ROARING_PORTABLE_ID`
24
24
  * in `crbm/format.ts`): it is stamped in the footer and validated on read, and generalizes to a per-codec id
@@ -314,14 +314,22 @@ type MetricEvent = {
314
314
  * Which chunk-aligned combine this was. **Optional for backward compatibility** — absent means
315
315
  * `'intersect'`, which is all this event reported before `union`/`andNot` existed.
316
316
  *
317
- * `skippedChunks` is only ever non-zero for `'intersect'` and `'andNot'`: union has to read every chunk
318
- * of every operand, so there is nothing it can prune. A steady stream of `op: 'union'` events with
319
- * `skippedChunks: 0` is the expected shape, not a regression.
317
+ * For `'union'` over its *include* operands alone, `skippedChunks` is 0 by construction — union reads
318
+ * every chunk of every operand, so there is nothing to prune. It can still be non-zero when a union
319
+ * carries `exclude` operands, because a suppression list may hold keys no include does and those are
320
+ * legitimately never fetched. So: 0 is the expected shape for a plain union, not a guarantee.
320
321
  */
321
322
  readonly op?: 'intersect' | 'union' | 'andNot';
322
323
  /** Operand count, including any `exclude` (suppression) operands. */
323
324
  readonly operands: number;
324
- /** Distinct chunk-keys selected to fetch (for `'intersect'`, those present in every operand). */
325
+ /**
326
+ * Distinct chunk **keys** selected to fetch — for `'intersect'`, those present in every operand.
327
+ *
328
+ * **Keys, not requests.** The actual cold GETs are roughly `fetchedChunks × operands`, plus one per
329
+ * `exclude` that holds a given key, which is why this number is smaller than what the per-op budget
330
+ * charges for the same call. The two are different units by design; if you are reconciling a bill, the
331
+ * budget's accounting is the one that models requests.
332
+ */
325
333
  readonly fetchedChunks: number;
326
334
  /**
327
335
  * **Distinct** chunk-keys pruned — never fetched (the chunk-skipping saving). Counts distinct keys,
@@ -498,8 +506,8 @@ declare const DEFAULT_MAX_WARM_SCAN_BYTES: number;
498
506
  * write here is a read-modify-write, so 4 in flight is up to 8 concurrent requests per `addMany` call — and a
499
507
  * server handling many concurrent calls multiplies that again. Provisioned-capacity backends answer a burst
500
508
  * with throttling, which is free only while retries can absorb it (4 attempts, exponential backoff, full
501
- * jitter). Swept against a backend that throttles on concurrent requests in flight, 64 chunks, 5 trials each,
502
- * counting a lost id or a surfaced error as a failure:
509
+ * jitter). Swept against a **modelled** backend — an in-process fake that throttles on concurrent requests in
510
+ * flight, not a real DynamoDB — 64 chunks, 5 trials each, counting a lost id or a surfaced error as a failure:
503
511
  *
504
512
  * ```text
505
513
  * backend capacity: 1 2 4 8
@@ -609,6 +617,17 @@ declare class SegmentEngine {
609
617
  segmentSize(seg: SegmentRef): Promise<SegmentSize | null>;
610
618
  iterate(seg: SegmentRef): AsyncGenerator<number>;
611
619
  /**
620
+ * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
621
+ *
622
+ * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
623
+ * afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
624
+ * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
625
+ * operand is read **only at the keys that survived the intersection** — the cost is bounded by the
626
+ * audience's surviving key count and **never scales with the size of the suppression list**. Subtracting a
627
+ * 61,000-chunk global opt-out list costs at most one read per surviving key, not 61,000.
628
+ *
629
+ * ---
630
+ *
612
631
  * Chunk-skipping intersection. Computes
613
632
  * `seg[0] ∩ seg[1] ∩ …` and streams the result ids ascending. It aligns each segment's *effective*
614
633
  * chunk-key set (Warm ∪ Cold), keeps only keys present in **all** operands (a key missing from any
@@ -633,15 +652,6 @@ declare class SegmentEngine {
633
652
  * re-resolves fresh (bypassing the TTL), which can hop generations even sub-TTL — still whole/immutable per
634
653
  * read, never torn. Full intra-op snapshot isolation is deferred (gap #4).
635
654
  */
636
- /**
637
- * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
638
- *
639
- * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
640
- * afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
641
- * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
642
- * operand is read **only at the keys that survived the intersection**, so a 61,000-chunk global opt-out list
643
- * intersected against a narrow audience costs a handful of reads, not 61,000.
644
- */
645
655
  intersect(segs: readonly SegmentRef[], options?: {
646
656
  concurrency?: number;
647
657
  budget?: BudgetOption;
@@ -730,8 +740,10 @@ declare class SegmentEngine {
730
740
  * So the staging buffer is capped, but it folds into per-chunk **compressed bitmaps** rather than being
731
741
  * written out. Peak memory is the roaring representation of the ids seen so far — which is what the segment
732
742
  * itself costs, and the floor for any implementation that writes each chunk exactly once — while the backend
733
- * still sees exactly one read-modify-write per distinct chunk regardless of stream length. This is strictly
734
- * less memory than the previous sync-only version, which held every remainder as an uncompressed JS number.
743
+ * still sees exactly one read-modify-write per distinct chunk regardless of stream length. Above the staging cap
744
+ * this holds less than the previous sync-only version, which kept every remainder as an uncompressed JS
745
+ * number; below it — the common `addMany` — nothing is compacted until the end and the two are comparable.
746
+ * The guarantee is that a stream of *any* length stays bounded, not that every call got smaller.
735
747
  *
736
748
  * There is no way to do better without a sorted stream: a chunk can only be written once it is known that no
737
749
  * further ids for it will arrive, and unsorted input never gives that guarantee. Callers streaming *very*
@@ -1055,8 +1067,16 @@ interface CompactionDeps {
1055
1067
  readonly cold: IColdDriver;
1056
1068
  readonly warm: IWarmDriver;
1057
1069
  readonly registry: IRegistryDriver;
1058
- /** Injected time source for lease expiry (the determinism seam — `core/` never reads ambient time). */
1059
- readonly clock: Pick<Clock, 'now'>;
1070
+ /**
1071
+ * Injected time source for lease expiry (the determinism seam — `core/` never reads ambient time).
1072
+ *
1073
+ * `now` is required; `sleep`/`yieldNow` are **optional and used only for cooperative yielding** while writing
1074
+ * a generation. Supplying a full {@link Clock} (the facade does) makes compaction hand the event loop back
1075
+ * periodically instead of blocking it for the whole write; supplying a bare `{ now }` keeps the previous
1076
+ * uninterrupted behaviour. Optional rather than required because every existing caller passes `{ now }`, and
1077
+ * requiring more would be a breaking change — the reason the yield below shipped unreachable the first time.
1078
+ */
1079
+ readonly clock: Pick<Clock, 'now'> & Partial<Pick<Clock, 'sleep' | 'yieldNow'>>;
1060
1080
  readonly maxBitmapBytes?: number;
1061
1081
  /**
1062
1082
  * Bitmap codec — compaction only merges bitmaps through it. Optional in the type so this
@@ -1515,6 +1535,56 @@ declare class LocalFsRegistryDriver implements IRegistryDriver {
1515
1535
  private withRowLock;
1516
1536
  }
1517
1537
 
1538
+ /**
1539
+ * Cooperative yielding for the long CPU-bound loops.
1540
+ *
1541
+ * Everything this library does on the hot read path is small. Bulk-load is the exception: it is the one place
1542
+ * where a single call legitimately occupies the CPU for hundreds of milliseconds, and Node has exactly one thread
1543
+ * to occupy. Measured on a 1M-id load, 62,455 chunks: **442 ms wall, and 450 ms during which the event loop did
1544
+ * not turn at all**. Wire that to a request handler and every other in-flight request on the instance — health
1545
+ * checks included — waits out the whole load.
1546
+ *
1547
+ * The fix is not to make the work asynchronous. That was measured too, and it is a 7x *regression*: handing each
1548
+ * chunk's insert to the threadpool costs ~9 µs of dispatch against ~1.5 µs of actual work once ids are spread
1549
+ * across ~62,000 chunks (636 ms versus 92 ms). The work is genuinely CPU-bound and genuinely small per chunk;
1550
+ * the only thing wrong with it is that it never stops to let anyone else go. So the fix is to keep the work
1551
+ * synchronous and periodically hand the loop back.
1552
+ *
1553
+ * TWO TRAPS, both of which make a plausible implementation measure as no change at all:
1554
+ *
1555
+ * 1. **`await Promise.resolve()` — and equally `Clock.sleep(0)` — do nothing.** Both resolve on a microtask, and
1556
+ * the microtask queue drains to empty before the event loop advances a single phase. A loop awaiting them
1557
+ * yields to other *microtasks* and to nothing else; no pending I/O callback runs. Measured: 555 ms of
1558
+ * starvation against a 568 ms unyielded baseline **on the synthetic yield-primitive benchmark below** —
1559
+ * a different, smaller workload than the 442 ms end-to-end load above, which is why the two baselines
1560
+ * differ. The yield must be a **macrotask** — hence
1561
+ * `Clock.yieldNow`, backed by `setImmediate` in production wiring.
1562
+ * 2. **Yielding per unit of work costs more than the work.** The yield has to be periodic, and `everyN` has to be
1563
+ * large enough that its cost disappears into the batch it interrupts.
1564
+ *
1565
+ * At `everyN = 1024` over that same synthetic workload the trade is close to free: 569 ms wall against a 568 ms
1566
+ * baseline, with the worst event-loop gap down from 568 ms to 13.8 ms.
1567
+ *
1568
+ * **Three experiments, three sets of numbers — do not mix them.** (a) The end-to-end 1M-id bulk load:
1569
+ * 442 ms wall / 450 ms blocked before, 256 ms / 19 ms after. (b) This synthetic yield-primitive comparison:
1570
+ * 568 ms / 568 ms unyielded, 569 ms / 13.8 ms with `setImmediate`. (c) A per-chunk insert microbenchmark:
1571
+ * 92 ms sync versus 636 ms via the threadpool. Quoting a baseline from one against a result from another is
1572
+ * how this file previously published a 92 ms operation with 819 ms of starvation inside it.
1573
+ */
1574
+ /**
1575
+ * The only part of a `Clock` that yielding actually needs.
1576
+ *
1577
+ * Deliberately looser than `Clock` so the compaction path can opt in: `CompactionDeps.clock` is declared as
1578
+ * `Pick<Clock, 'now'>` plus optional yield members, because its callers (including the `compact-segments` CLI)
1579
+ * have always supplied a bare `{ now }`. Requiring a full `Clock` there would have been a breaking change for
1580
+ * every one of them — which is exactly why the compaction yield shipped as dead code: the type could not carry
1581
+ * a clock capable of yielding.
1582
+ */
1583
+ interface Yielder {
1584
+ yieldNow?(): Promise<void>;
1585
+ sleep?(ms: number): Promise<void>;
1586
+ }
1587
+
1518
1588
  interface CrbmReaderOptions {
1519
1589
  /** Speculative tail size in bytes (default 256 KB; clamped up to at least the footer size). */
1520
1590
  readonly tailBytes?: number;
@@ -1704,7 +1774,7 @@ declare function writeCrbmGeneration(driver: IColdDriver, key: GenKey, chunks: I
1704
1774
  bitmap: CodecBitmap;
1705
1775
  }>, options?: {
1706
1776
  crypto?: CrbmCrypto;
1707
- clock?: Clock;
1777
+ clock?: Yielder;
1708
1778
  }): Promise<{
1709
1779
  size: number;
1710
1780
  sha256: string;
@@ -2061,11 +2131,23 @@ declare function runConsistencyCheck(deps: {
2061
2131
  * I/O of their own); the wrapped driver is responsible for *classifying* its transient faults (raising
2062
2132
  * {@link TransientError}); these decorators decide *whether and when* to retry.
2063
2133
  *
2064
- * **Streaming methods** (`listChunks`, `list`) are retried by **re-enumerating from the start**, buffering
2065
- * the result — a partially-consumed async iterator can't be safely resumed mid-stream (it would re-yield
2066
- * earlier items). Their consumers in the engine already collect fully (the Warm dirty-set is small under
2067
- * Topology-A; the cold `list` is a discovery scan), so buffering preserves current semantics. Point methods
2068
- * are retried in place with no buffering.
2134
+ * **Streaming methods split into two shapes**, and the difference is deliberate — a partially-consumed async
2135
+ * iterator cannot be resumed mid-stream (it would re-yield earlier items), so each method picks between
2136
+ * buffering and bounded memory:
2137
+ *
2138
+ * · `RetryingColdDriver.list` / `RetryingRegistryDriver.list` **buffer**, inside `withRetry`, so the whole
2139
+ * enumeration is retried by re-running it from the start. Both are discovery scans over a generation or
2140
+ * namespace listing, where the result set is small and whole-scan retry is worth the memory.
2141
+ * · `RetryingWarmDriver.listChunks` does **not** buffer. It retries only until the first row arrives and
2142
+ * then streams live, because buffering it defeated the engine's resident-memory bound outright — see the
2143
+ * measured account on the method itself. A mid-stream fault therefore propagates rather than being
2144
+ * retried, which is the documented trade there.
2145
+ *
2146
+ * Point methods are retried in place, with nothing to buffer.
2147
+ *
2148
+ * (This paragraph previously said streaming methods buffer, full stop. That described `listChunks` as it was
2149
+ * before the bound was fixed, and the stale wording had also been copied into the Postgres and MySQL warm
2150
+ * drivers — so three comments promised a mid-stream resilience that the hot read path does not have.)
2069
2151
  *
2070
2152
  * What is **not** retried here: {@link WriteConflictError} (OCC — the engine's read-modify-write loop owns
2071
2153
  * that; a blind replay would re-apply against a stale token), and every deterministic error
@@ -2097,6 +2179,26 @@ declare class RetryingWarmDriver implements IWarmDriver {
2097
2179
  token: Token;
2098
2180
  }>;
2099
2181
  deleteConditional(ref: ChunkRef, expected: Token): Promise<void>;
2182
+ /**
2183
+ * Stream the inner driver's rows, retrying only until the first one arrives.
2184
+ *
2185
+ * **This used to buffer the whole scan** — `for await (…) out.push(row)` inside `withRetry`, then
2186
+ * `yield* out` — which made the retry trivially safe and quietly defeated every bound above it. The engine's
2187
+ * `collectWarm` refuses *during* enumeration precisely so resident memory is `O(ceiling)` rather than
2188
+ * `O(segment)`; with the buffering wrapper in place (and it is wired by default) it saw its first row only
2189
+ * after the entire segment was already in memory. Measured on a 500-row segment under
2190
+ * `budget: { maxRequests: 3 }`: **500 rows materialised with the default wiring, 4 with `retry: false`**.
2191
+ * Both paths threw the same `BudgetExceededError`, which is why no test caught it — the error was never the
2192
+ * distinguishing observable, the row count was.
2193
+ *
2194
+ * **The trade this makes.** A mid-stream fault now propagates instead of being retried. It cannot be retried
2195
+ * honestly: rows have already been handed to the consumer, so restarting the iterator would re-yield them,
2196
+ * and the driver interface exposes no resumption token to continue from. Retry is kept where it pays —
2197
+ * establishing the scan, which is where transient connect/throttle failures actually land — and bounded
2198
+ * memory wins the conflict, because invariant 6 ("bounded memory & cost, always") is a hard invariant and
2199
+ * mid-scan retry is not. A caller who needs whole-scan retry can wrap the operation at their own level,
2200
+ * where re-reading from the start is safe.
2201
+ */
2100
2202
  listChunks(ref: SegmentRef, opts?: WarmReadOptions): AsyncIterable<{
2101
2203
  chunkKey: number;
2102
2204
  } & WarmRow>;