@cloudbitmaps/core 0.3.0 → 0.4.1

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.cjs CHANGED
@@ -91,6 +91,14 @@ function applyRemove(delta, remainder) {
91
91
  delta.removes.add(remainder);
92
92
  delta.adds.remove(remainder);
93
93
  }
94
+ function applyAddAll(delta, ids) {
95
+ delta.adds.orInPlace(ids);
96
+ delta.removes.andNotInPlace(ids);
97
+ }
98
+ function applyRemoveAll(delta, ids) {
99
+ delta.removes.orInPlace(ids);
100
+ delta.adds.andNotInPlace(ids);
101
+ }
94
102
  function effective(cold, delta) {
95
103
  const out = cold.clone();
96
104
  out.orInPlace(delta.adds);
@@ -214,6 +222,21 @@ async function mapWithConcurrency(items, limit, fn) {
214
222
  return results;
215
223
  }
216
224
 
225
+ // src/core/cooperative.ts
226
+ var YIELD_EVERY = 1024;
227
+ function yieldEvery(clock, everyN = YIELD_EVERY) {
228
+ if (clock === void 0) return () => null;
229
+ const sleep = clock.sleep;
230
+ const pause = clock.yieldNow !== void 0 ? () => clock.yieldNow() : sleep !== void 0 ? () => sleep.call(clock, 1) : void 0;
231
+ if (pause === void 0) return () => null;
232
+ let n = 0;
233
+ return () => {
234
+ if (++n < everyN) return null;
235
+ n = 0;
236
+ return pause();
237
+ };
238
+ }
239
+
217
240
  // src/core/keys.ts
218
241
  var FIELD = " ";
219
242
  var NO_NAMESPACE = "/";
@@ -396,6 +419,8 @@ async function withRetry(op, policy, deps) {
396
419
  var DEFAULT_MAX_BITMAP_BYTES = 1 << 20;
397
420
  var DEFAULT_MAX_RETRIES = 16;
398
421
  var DEFAULT_INTERSECT_CONCURRENCY = 8;
422
+ var MUTATE_STAGE_IDS = 1 << 20;
423
+ var YIELD_EVERY_IDS = 1 << 14;
399
424
  var InstantClock = class {
400
425
  now() {
401
426
  return 0;
@@ -403,6 +428,17 @@ var InstantClock = class {
403
428
  sleep() {
404
429
  return Promise.resolve();
405
430
  }
431
+ /**
432
+ * An explicit no-op, so the intent is stated rather than inferred.
433
+ *
434
+ * Without it, `yieldEvery` would fall back to `sleep(1)` — and this clock's `sleep` ignores its argument and
435
+ * resolves on a microtask, so the "yield" would silently do nothing while every doc comment claimed it did
436
+ * something. This wiring has no real event loop to be courteous to (it is the clock used when nobody injects
437
+ * one), so no-op is the right behaviour; it just has to be a decision rather than an accident.
438
+ */
439
+ yieldNow() {
440
+ return Promise.resolve();
441
+ }
406
442
  };
407
443
  var ZeroRng = class {
408
444
  next() {
@@ -410,6 +446,7 @@ var ZeroRng = class {
410
446
  }
411
447
  };
412
448
  var DEFAULT_MAX_WARM_SCAN_BYTES = 64 * 1024 * 1024;
449
+ var DEFAULT_WRITE_CONCURRENCY = 4;
413
450
  var SegmentEngine = class {
414
451
  warm;
415
452
  cold;
@@ -439,7 +476,7 @@ var SegmentEngine = class {
439
476
  this.rng = deps.rng ?? new ZeroRng();
440
477
  this.occBackoff = deps.occBackoff ?? DEFAULT_OCC_BACKOFF;
441
478
  this.readOpts = { consistent: (deps.warmReadConsistency ?? "strong") !== "eventual" };
442
- this.writeConcurrency = deps.writeConcurrency ?? 1;
479
+ this.writeConcurrency = deps.writeConcurrency ?? DEFAULT_WRITE_CONCURRENCY;
443
480
  this.maxWarmScanBytes = deps.maxWarmScanBytes ?? DEFAULT_MAX_WARM_SCAN_BYTES;
444
481
  if (!Number.isFinite(this.maxWarmScanBytes) || this.maxWarmScanBytes < 1) {
445
482
  throw new ValidationError(
@@ -459,10 +496,10 @@ var SegmentEngine = class {
459
496
  return this.readModifyWrite(seg, chunkKey, (d) => applyRemove(d, remainder));
460
497
  }
461
498
  addMany(seg, ids) {
462
- return this.mutateMany(seg, ids, applyAdd);
499
+ return this.mutateMany(seg, ids, applyAddAll);
463
500
  }
464
501
  removeMany(seg, ids) {
465
- return this.mutateMany(seg, ids, applyRemove);
502
+ return this.mutateMany(seg, ids, applyRemoveAll);
466
503
  }
467
504
  async has(seg, id, opts) {
468
505
  const { chunkKey, remainder } = splitId(id);
@@ -519,6 +556,17 @@ var SegmentEngine = class {
519
556
  }
520
557
  }
521
558
  /**
559
+ * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
560
+ *
561
+ * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
562
+ * afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
563
+ * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
564
+ * operand is read **only at the keys that survived the intersection** — the cost is bounded by the
565
+ * audience's surviving key count and **never scales with the size of the suppression list**. Subtracting a
566
+ * 61,000-chunk global opt-out list costs at most one read per surviving key, not 61,000.
567
+ *
568
+ * ---
569
+ *
522
570
  * Chunk-skipping intersection. Computes
523
571
  * `seg[0] ∩ seg[1] ∩ …` and streams the result ids ascending. It aligns each segment's *effective*
524
572
  * chunk-key set (Warm ∪ Cold), keeps only keys present in **all** operands (a key missing from any
@@ -543,9 +591,35 @@ var SegmentEngine = class {
543
591
  * re-resolves fresh (bypassing the TTL), which can hop generations even sub-TTL — still whole/immutable per
544
592
  * read, never torn. Full intra-op snapshot isolation is deferred (gap #4).
545
593
  */
546
- async *intersect(segs, options) {
594
+ intersect(segs, options) {
595
+ return this.combine(segs, options?.exclude ?? [], "all", "intersect", options);
596
+ }
597
+ /**
598
+ * `segs[0] ∪ segs[1] ∪ …`, minus every id in `options.exclude` — streamed ascending.
599
+ *
600
+ * **Union is the one composite read with no chunk-skipping**, and that is a property of the operation rather
601
+ * than of this implementation: an id in *any* operand belongs to the result, so every chunk of every operand
602
+ * has to be read. `intersect` can prune a key absent from any operand; union cannot prune anything. It is
603
+ * budgeted exactly like `intersect` so a wide union is refused rather than quietly billed.
604
+ */
605
+ union(segs, options) {
606
+ return this.combine(segs, options?.exclude ?? [], "any", "union", options);
607
+ }
608
+ /**
609
+ * `seg \ (excludes[0] ∪ excludes[1] ∪ …)` — streamed ascending. Suppression on its own.
610
+ *
611
+ * Reads every chunk of `seg` (any of them may survive) but each exclude **only where it overlaps `seg`**, so
612
+ * the cost scales with the segment being filtered rather than with the size of the suppression list.
613
+ */
614
+ andNot(seg, excludes, options) {
615
+ return this.combine([seg], excludes, "all", "andNot", options);
616
+ }
617
+ async *combine(segs, excludeSegs, mode, op, options) {
547
618
  if (segs.length === 0) {
548
- throw new ValidationError("intersect requires at least one segment");
619
+ throw new ValidationError(`${op} requires at least one segment`);
620
+ }
621
+ if (op === "andNot" && excludeSegs.length === 0) {
622
+ throw new ValidationError("andNot requires at least one segment to subtract");
549
623
  }
550
624
  const limit = options?.concurrency ?? DEFAULT_INTERSECT_CONCURRENCY;
551
625
  if (!Number.isInteger(limit) || limit < 1) {
@@ -554,34 +628,52 @@ var SegmentEngine = class {
554
628
  );
555
629
  }
556
630
  const budget = resolvePerOpBudget(options?.budget, this.budget);
557
- const operands = await Promise.all(
558
- segs.map(async (seg) => {
559
- const warmRows = await this.collectWarm(seg, "intersect", null);
560
- const keys = await this.chunkKeys(seg, warmRows);
561
- const gen = await this.currentGen(seg);
562
- return { seg, warmRows, keys: new Set(keys), gen };
563
- })
564
- );
565
- const pivot = operands.reduce((a, b) => a.keys.size <= b.keys.size ? a : b);
566
- const common = [];
567
- for (const k of pivot.keys) {
568
- if (operands.every((op) => op.keys.has(k))) common.push(k);
569
- }
570
- common.sort((a, b) => a - b);
571
- checkBudget(budget, common.length * operands.length, "intersect");
631
+ const extract = async (seg) => {
632
+ const warmRows = await this.collectWarm(seg, op, null);
633
+ const keys = await this.chunkKeys(seg, warmRows);
634
+ const gen = await this.currentGen(seg);
635
+ return { seg, warmRows, keys: new Set(keys), gen };
636
+ };
637
+ const [operands, excludes] = await Promise.all([
638
+ Promise.all(segs.map(extract)),
639
+ Promise.all(excludeSegs.map(extract))
640
+ ]);
641
+ const candidates = [];
642
+ if (mode === "all") {
643
+ const pivot = operands.reduce((a, b) => a.keys.size <= b.keys.size ? a : b);
644
+ for (const k of pivot.keys) {
645
+ if (operands.every((o) => o.keys.has(k))) candidates.push(k);
646
+ }
647
+ } else {
648
+ const seen = /* @__PURE__ */ new Set();
649
+ for (const o of operands) for (const k of o.keys) seen.add(k);
650
+ for (const k of seen) candidates.push(k);
651
+ }
652
+ candidates.sort((a, b) => a - b);
653
+ const common = candidates;
654
+ let units = 0;
655
+ for (const key of common) {
656
+ for (const o of operands) if (mode === "all" || o.keys.has(key)) units += 1;
657
+ for (const e of excludes) if (e.keys.has(key)) units += 1;
658
+ }
659
+ checkBudget(budget, units, op);
572
660
  if (this.metricsOn) {
573
661
  const distinctKeys = /* @__PURE__ */ new Set();
574
- for (const op of operands) {
575
- for (const k of op.keys) distinctKeys.add(k);
662
+ for (const op2 of operands) {
663
+ for (const k of op2.keys) distinctKeys.add(k);
576
664
  }
665
+ for (const e of excludes) for (const k of e.keys) distinctKeys.add(k);
577
666
  this.metrics.onEvent({
578
667
  kind: "intersect",
579
- operands: segs.length,
668
+ op,
669
+ operands: segs.length + excludes.length,
580
670
  fetchedChunks: common.length,
671
+ // Never negative: under 'any' every candidate key came from some operand, so `distinctKeys` is a
672
+ // superset of `common`; excludes only ever add keys here.
581
673
  skippedChunks: distinctKeys.size - common.length
582
674
  });
583
675
  }
584
- const startAt = (key) => this.intersectChunk(operands, key).then(
676
+ const startAt = (key) => this.combineChunk(operands, excludes, mode, key).then(
585
677
  (result) => ({ key, result }),
586
678
  (error) => ({ key, result: null, error })
587
679
  );
@@ -603,16 +695,33 @@ var SegmentEngine = class {
603
695
  * first operand's effective chunk and mutates it in place — safe because `effectiveChunk` always returns
604
696
  * a FRESH bitmap (never a cached/shared Cold instance), so this can't poison the HOT cache.
605
697
  */
606
- async intersectChunk(operands, chunkKey) {
698
+ async combineChunk(operands, excludes, mode, chunkKey) {
699
+ const present = mode === "all" ? operands : operands.filter((o) => o.keys.has(chunkKey));
700
+ if (present.length === 0) return null;
607
701
  const chunks = await Promise.all(
608
- operands.map((op) => this.effectiveChunk(op.seg, chunkKey, op.warmRows, op.gen))
702
+ present.map((op) => this.effectiveChunk(op.seg, chunkKey, op.warmRows, op.gen))
609
703
  );
610
704
  const acc = chunks[0];
611
705
  for (let i = 1; i < chunks.length; i++) {
612
- acc.andInPlace(chunks[i]);
613
- if (acc.isEmpty) return null;
706
+ if (mode === "all") {
707
+ acc.andInPlace(chunks[i]);
708
+ if (acc.isEmpty) return null;
709
+ } else {
710
+ acc.orInPlace(chunks[i]);
711
+ }
614
712
  }
615
- return acc.isEmpty ? null : acc;
713
+ if (acc.isEmpty) return null;
714
+ const relevant = excludes.filter((e) => e.keys.has(chunkKey));
715
+ if (relevant.length > 0) {
716
+ const cuts = await Promise.all(
717
+ relevant.map((e) => this.effectiveChunk(e.seg, chunkKey, e.warmRows, e.gen))
718
+ );
719
+ for (const cut of cuts) {
720
+ acc.andNotInPlace(cut);
721
+ if (acc.isEmpty) return null;
722
+ }
723
+ }
724
+ return acc;
616
725
  }
617
726
  /**
618
727
  * Materialize `seg[0] ∩ seg[1] ∩ …` into `dest` via batched `addMany` (streaming, bounded memory).
@@ -620,9 +729,30 @@ var SegmentEngine = class {
620
729
  * `dest` is added to, not replaced.
621
730
  */
622
731
  async intersectInto(dest, segs, options) {
623
- const batchSize = Math.max(1, options?.batchSize ?? 4096);
732
+ await this.drainInto(dest, this.intersect(segs, options), options?.batchSize);
733
+ }
734
+ /**
735
+ * Materialize `segs[0] ∪ segs[1] ∪ …` (minus `options.exclude`) into `dest`. **Not atomic**, like
736
+ * {@link intersectInto}. See {@link union} for why this one cannot skip any chunk.
737
+ */
738
+ async unionInto(dest, segs, options) {
739
+ await this.drainInto(dest, this.union(segs, options), options?.batchSize);
740
+ }
741
+ /** Materialize `seg \ (excludes…)` into `dest`. **Not atomic**, like {@link intersectInto}. */
742
+ async andNotInto(dest, seg, excludes, options) {
743
+ await this.drainInto(dest, this.andNot(seg, excludes, options), options?.batchSize);
744
+ }
745
+ /**
746
+ * Stream a combine's results into `dest` in bounded batches.
747
+ *
748
+ * `dest` is **added to, not replaced**, and this is **not atomic** — ids land chunk-by-chunk, so a failure
749
+ * mid-stream leaves a partial result, exactly as `addMany` does. Batching keeps memory bounded no matter how
750
+ * large the result is.
751
+ */
752
+ async drainInto(dest, source, batchSizeOption) {
753
+ const batchSize = Math.max(1, batchSizeOption ?? 4096);
624
754
  let batch = [];
625
- for await (const id of this.intersect(segs, options)) {
755
+ for await (const id of source) {
626
756
  batch.push(id);
627
757
  if (batch.length >= batchSize) {
628
758
  await this.addMany(dest, batch);
@@ -631,19 +761,77 @@ var SegmentEngine = class {
631
761
  }
632
762
  if (batch.length > 0) await this.addMany(dest, batch);
633
763
  }
764
+ /**
765
+ * Group ids by chunk, then apply each chunk's whole batch in a single OCC read-modify-write.
766
+ *
767
+ * Accepts a **sync or async** iterable, so an Athena/BigQuery cursor streams straight in without the caller
768
+ * hand-batching `page → addMany(page)`.
769
+ *
770
+ * **The pending ids are held as BITMAPS, not as JS numbers, and that is the load-bearing choice.** Streaming
771
+ * input is unbounded by definition, so something has to give. The obvious answer — buffer N ids, flush them to
772
+ * the backend, repeat — bounds memory and is quietly catastrophic on cost: with ids arriving in arbitrary
773
+ * order, every flush touches nearly every chunk again, so an 11M-id stream at a 1M-id buffer would issue
774
+ * **11x** the OCC round-trips of a single pass. In a library whose headline claim is honest cost, silently
775
+ * multiplying someone's write bill by the size of their input is not a bounded-memory strategy; it is a
776
+ * different bug.
777
+ *
778
+ * So the staging buffer is capped, but it folds into per-chunk **compressed bitmaps** rather than being
779
+ * written out. Peak memory is the roaring representation of the ids seen so far — which is what the segment
780
+ * itself costs, and the floor for any implementation that writes each chunk exactly once — while the backend
781
+ * still sees exactly one read-modify-write per distinct chunk regardless of stream length. Above the staging cap
782
+ * this holds less than the previous sync-only version, which kept every remainder as an uncompressed JS
783
+ * number; below it — the common `addMany` — nothing is compacted until the end and the two are comparable.
784
+ * The guarantee is that a stream of *any* length stays bounded, not that every call got smaller.
785
+ *
786
+ * There is no way to do better without a sorted stream: a chunk can only be written once it is known that no
787
+ * further ids for it will arrive, and unsorted input never gives that guarantee. Callers streaming *very*
788
+ * large sets want `bulkLoadCrbmGeneration` instead — it writes one immutable Cold object rather than tens of
789
+ * thousands of warm rows. The crossover is documented in the guide.
790
+ */
634
791
  async mutateMany(seg, ids, apply) {
635
792
  const byChunk = /* @__PURE__ */ new Map();
636
- for (const id of ids) {
793
+ const staged = /* @__PURE__ */ new Map();
794
+ let pending = 0;
795
+ const tickId = yieldEvery(this.clock, YIELD_EVERY_IDS);
796
+ const compact = () => {
797
+ for (const [chunkKey, rems] of staged) {
798
+ if (rems.length === 0) continue;
799
+ const existing = byChunk.get(chunkKey);
800
+ if (existing === void 0) byChunk.set(chunkKey, this.codec.fromValues(rems));
801
+ else existing.addMany(rems);
802
+ rems.length = 0;
803
+ }
804
+ pending = 0;
805
+ };
806
+ const stage = (id) => {
637
807
  const { chunkKey, remainder } = splitId(id);
638
- const rems = byChunk.get(chunkKey) ?? [];
808
+ let rems = staged.get(chunkKey);
809
+ if (rems === void 0) {
810
+ rems = [];
811
+ staged.set(chunkKey, rems);
812
+ }
639
813
  rems.push(remainder);
640
- byChunk.set(chunkKey, rems);
814
+ pending += 1;
815
+ };
816
+ if (Symbol.asyncIterator in ids) {
817
+ for await (const id of ids) {
818
+ stage(id);
819
+ if (pending >= MUTATE_STAGE_IDS) compact();
820
+ const pause = tickId();
821
+ if (pause !== null) await pause;
822
+ }
823
+ } else {
824
+ for (const id of ids) {
825
+ stage(id);
826
+ if (pending >= MUTATE_STAGE_IDS) compact();
827
+ }
641
828
  }
829
+ compact();
642
830
  await mapWithConcurrency(
643
831
  [...byChunk],
644
832
  this.writeConcurrency,
645
- ([chunkKey, rems]) => this.readModifyWrite(seg, chunkKey, (d) => {
646
- for (const r of rems) apply(d, r);
833
+ ([chunkKey, bitmap]) => this.readModifyWrite(seg, chunkKey, (d) => {
834
+ apply(d, bitmap);
647
835
  })
648
836
  );
649
837
  }
@@ -1772,6 +1960,7 @@ function coldBlobReader(driver, key) {
1772
1960
  }
1773
1961
  var DEFAULT_CURRENT_GEN_TTL_MS = 2e3;
1774
1962
  var BULK_FLUSH_IDS = 1 << 20;
1963
+ var YIELD_EVERY_IDS2 = 1 << 14;
1775
1964
  var DEFAULT_MAX_OPEN_SEGMENTS = 1024;
1776
1965
  var DEFAULT_MAX_OPEN_INDEX_BYTES = 64 * 1024 * 1024;
1777
1966
  var CrbmColdChunkSource = class {
@@ -1990,11 +2179,14 @@ var CrbmColdChunkSource = class {
1990
2179
  };
1991
2180
  function writeCrbmGeneration(driver, key, chunks, options = {}) {
1992
2181
  const sorted = [...chunks].sort((a, b) => a.chunkKey - b.chunkKey);
2182
+ const tick = yieldEvery(options.clock);
1993
2183
  return driver.putImmutable(key, async (sink) => {
1994
2184
  const writer = new CrbmWriter(sink, { generation: key.generation, crypto: options.crypto });
1995
2185
  for (const { chunkKey, bitmap } of sorted) {
1996
2186
  if (bitmap.isEmpty) continue;
1997
2187
  await writer.addChunk(chunkKey, bitmap.serialize(), bitmap.size);
2188
+ const pause = tick();
2189
+ if (pause !== null) await pause;
1998
2190
  }
1999
2191
  await writer.finish();
2000
2192
  });
@@ -2002,6 +2194,7 @@ function writeCrbmGeneration(driver, key, chunks, options = {}) {
2002
2194
  async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
2003
2195
  const chunkKeys = [];
2004
2196
  let cardinality = 0;
2197
+ const tick = yieldEvery(options.clock);
2005
2198
  const { size, sha256 } = await driver.putImmutable(key, async (sink) => {
2006
2199
  const writer = new CrbmWriter(sink, { generation: key.generation, crypto: options.crypto });
2007
2200
  for await (const { chunkKey, bitmap } of chunks) {
@@ -2009,6 +2202,8 @@ async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
2009
2202
  await writer.addChunk(chunkKey, bitmap.serialize(), bitmap.size);
2010
2203
  chunkKeys.push(chunkKey);
2011
2204
  cardinality += bitmap.size;
2205
+ const pause = tick();
2206
+ if (pause !== null) await pause;
2012
2207
  }
2013
2208
  await writer.finish();
2014
2209
  });
@@ -2051,17 +2246,21 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
2051
2246
  const byChunk = /* @__PURE__ */ new Map();
2052
2247
  const pendingByChunk = /* @__PURE__ */ new Map();
2053
2248
  let pending = 0;
2054
- const flushPending = () => {
2249
+ const tickChunk = yieldEvery(options.clock);
2250
+ const tickId = yieldEvery(options.clock, YIELD_EVERY_IDS2);
2251
+ const flushPending = async () => {
2055
2252
  for (const [chunkKey, rems] of pendingByChunk) {
2056
2253
  if (rems.length === 0) continue;
2057
2254
  const existingBitmap = byChunk.get(chunkKey);
2058
2255
  if (existingBitmap === void 0) byChunk.set(chunkKey, codec.fromValues(rems));
2059
2256
  else existingBitmap.addMany(rems);
2060
2257
  rems.length = 0;
2258
+ const pause = tickChunk();
2259
+ if (pause !== null) await pause;
2061
2260
  }
2062
2261
  pending = 0;
2063
2262
  };
2064
- for await (const id of toAsyncIterable(ids)) {
2263
+ const ingest = (id) => {
2065
2264
  const { chunkKey, remainder } = splitId(id);
2066
2265
  let bucket = pendingByChunk.get(chunkKey);
2067
2266
  if (bucket === void 0) {
@@ -2069,14 +2268,29 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
2069
2268
  pendingByChunk.set(chunkKey, bucket);
2070
2269
  }
2071
2270
  bucket.push(remainder);
2072
- if (++pending >= BULK_FLUSH_IDS) flushPending();
2271
+ return ++pending >= BULK_FLUSH_IDS;
2272
+ };
2273
+ if (Symbol.asyncIterator in ids) {
2274
+ for await (const id of ids) {
2275
+ if (ingest(id)) await flushPending();
2276
+ const pause = tickId();
2277
+ if (pause !== null) await pause;
2278
+ }
2279
+ } else {
2280
+ for (const id of ids) {
2281
+ if (ingest(id)) await flushPending();
2282
+ const pause = tickId();
2283
+ if (pause !== null) await pause;
2284
+ }
2073
2285
  }
2074
- flushPending();
2286
+ await flushPending();
2075
2287
  let cardinality = 0;
2076
2288
  const chunks = [];
2077
2289
  for (const [chunkKey, bitmap] of byChunk) {
2078
2290
  cardinality += bitmap.size;
2079
2291
  chunks.push({ chunkKey, bitmap });
2292
+ const pause = tickChunk();
2293
+ if (pause !== null) await pause;
2080
2294
  }
2081
2295
  if (options.keystore !== void 0 && options.registry === void 0) {
2082
2296
  throw new ValidationError("bulk-load encryption requires a registry to store the wrapped DEK");
@@ -2099,7 +2313,10 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
2099
2313
  crypto = { aead: minted.aead, aadFor: (scope) => aadFor(key, key.generation, scope) };
2100
2314
  }
2101
2315
  }
2102
- const { size, sha256 } = await writeCrbmGeneration(driver, key, chunks, { crypto });
2316
+ const { size, sha256 } = await writeCrbmGeneration(driver, key, chunks, {
2317
+ crypto,
2318
+ clock: options.clock
2319
+ });
2103
2320
  if (options.registry !== void 0) {
2104
2321
  const becameCurrent = await publishGeneration(options.registry, key, {
2105
2322
  wrappedDeks: newWrapped
@@ -2115,13 +2332,6 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
2115
2332
  }
2116
2333
  return { size, sha256, chunkCount: chunks.length, cardinality };
2117
2334
  }
2118
- async function* toAsyncIterable(src) {
2119
- if (Symbol.asyncIterator in src) {
2120
- yield* src;
2121
- } else {
2122
- yield* src;
2123
- }
2124
- }
2125
2335
 
2126
2336
  // src/core/compaction.ts
2127
2337
  var DEFAULT_MAX_BITMAP_BYTES2 = 1 << 20;
@@ -2227,6 +2437,8 @@ async function compactSegmentInner(ref, deps, options) {
2227
2437
  let tally;
2228
2438
  try {
2229
2439
  tally = await writeCrbmGenerationStream(deps.cold, gen0, stream, {
2440
+ clock: deps.clock,
2441
+ // cooperative if the caller's clock can yield; a no-op if it only tells the time
2230
2442
  crypto: cryptoAt(aead, 0)
2231
2443
  });
2232
2444
  } catch (err) {
@@ -2354,7 +2566,8 @@ async function compactSegmentInner(ref, deps, options) {
2354
2566
  deps.cold,
2355
2567
  { namespace: ref.namespace, segment: ref.segment, generation: newGen },
2356
2568
  stream,
2357
- cryptoAt(aead, newGen)
2569
+ cryptoAt(aead, newGen),
2570
+ deps.clock
2358
2571
  );
2359
2572
  try {
2360
2573
  await deps.registry.compareAndSwap(ref, leaseToken, {
@@ -2451,8 +2664,8 @@ async function* mergeChunksStream(ref, g, pinned, deps, maxBytes, readCrypto) {
2451
2664
  if (!merged.isEmpty) yield { chunkKey, bitmap: merged };
2452
2665
  }
2453
2666
  }
2454
- async function stageGeneration(cold, key, chunks, crypto) {
2455
- const tally = await writeCrbmGenerationStream(cold, key, chunks, { crypto });
2667
+ async function stageGeneration(cold, key, chunks, crypto, clock) {
2668
+ const tally = await writeCrbmGenerationStream(cold, key, chunks, { crypto, clock });
2456
2669
  await verifyGeneration(cold, key, tally, crypto);
2457
2670
  }
2458
2671
  async function verifyGeneration(cold, key, expected, crypto) {
@@ -3950,17 +4163,43 @@ var RetryingWarmDriver = class {
3950
4163
  deleteConditional(ref, expected) {
3951
4164
  return withRetry(() => this.inner.deleteConditional(ref, expected), this.policy, this.deps);
3952
4165
  }
4166
+ /**
4167
+ * Stream the inner driver's rows, retrying only until the first one arrives.
4168
+ *
4169
+ * **This used to buffer the whole scan** — `for await (…) out.push(row)` inside `withRetry`, then
4170
+ * `yield* out` — which made the retry trivially safe and quietly defeated every bound above it. The engine's
4171
+ * `collectWarm` refuses *during* enumeration precisely so resident memory is `O(ceiling)` rather than
4172
+ * `O(segment)`; with the buffering wrapper in place (and it is wired by default) it saw its first row only
4173
+ * after the entire segment was already in memory. Measured on a 500-row segment under
4174
+ * `budget: { maxRequests: 3 }`: **500 rows materialised with the default wiring, 4 with `retry: false`**.
4175
+ * Both paths threw the same `BudgetExceededError`, which is why no test caught it — the error was never the
4176
+ * distinguishing observable, the row count was.
4177
+ *
4178
+ * **The trade this makes.** A mid-stream fault now propagates instead of being retried. It cannot be retried
4179
+ * honestly: rows have already been handed to the consumer, so restarting the iterator would re-yield them,
4180
+ * and the driver interface exposes no resumption token to continue from. Retry is kept where it pays —
4181
+ * establishing the scan, which is where transient connect/throttle failures actually land — and bounded
4182
+ * memory wins the conflict, because invariant 6 ("bounded memory & cost, always") is a hard invariant and
4183
+ * mid-scan retry is not. A caller who needs whole-scan retry can wrap the operation at their own level,
4184
+ * where re-reading from the start is safe.
4185
+ */
3953
4186
  async *listChunks(ref, opts) {
3954
- const rows = await withRetry(
4187
+ const started = await withRetry(
3955
4188
  async () => {
3956
- const out = [];
3957
- for await (const row of this.inner.listChunks(ref, opts)) out.push(row);
3958
- return out;
4189
+ const iterator = this.inner.listChunks(ref, opts)[Symbol.asyncIterator]();
4190
+ const first = await iterator.next();
4191
+ return { iterator, first };
3959
4192
  },
3960
4193
  this.policy,
3961
4194
  this.deps
3962
4195
  );
3963
- yield* rows;
4196
+ if (started.first.done === true) return;
4197
+ yield started.first.value;
4198
+ for (; ; ) {
4199
+ const next = await started.iterator.next();
4200
+ if (next.done === true) return;
4201
+ yield next.value;
4202
+ }
3964
4203
  }
3965
4204
  };
3966
4205
  var RetryingColdChunkSource = class {
@@ -4090,6 +4329,7 @@ exports.DEFAULT_MAX_WARM_SCAN_BYTES = DEFAULT_MAX_WARM_SCAN_BYTES;
4090
4329
  exports.DEFAULT_OCC_BACKOFF = DEFAULT_OCC_BACKOFF;
4091
4330
  exports.DEFAULT_PRICING = DEFAULT_PRICING;
4092
4331
  exports.DEFAULT_RETRY_POLICY = DEFAULT_RETRY_POLICY;
4332
+ exports.DEFAULT_WRITE_CONCURRENCY = DEFAULT_WRITE_CONCURRENCY;
4093
4333
  exports.InProcessKeystore = InProcessKeystore;
4094
4334
  exports.IntegrityError = IntegrityError;
4095
4335
  exports.KeyUnavailableError = KeyUnavailableError;