@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.js CHANGED
@@ -37,6 +37,14 @@ function applyRemove(delta, remainder) {
37
37
  delta.removes.add(remainder);
38
38
  delta.adds.remove(remainder);
39
39
  }
40
+ function applyAddAll(delta, ids) {
41
+ delta.adds.orInPlace(ids);
42
+ delta.removes.andNotInPlace(ids);
43
+ }
44
+ function applyRemoveAll(delta, ids) {
45
+ delta.removes.orInPlace(ids);
46
+ delta.adds.andNotInPlace(ids);
47
+ }
40
48
  function effective(cold, delta) {
41
49
  const out = cold.clone();
42
50
  out.orInPlace(delta.adds);
@@ -130,6 +138,21 @@ async function collectWithinBudget(source, budget, op) {
130
138
  return out;
131
139
  }
132
140
 
141
+ // src/core/cooperative.ts
142
+ var YIELD_EVERY = 1024;
143
+ function yieldEvery(clock, everyN = YIELD_EVERY) {
144
+ if (clock === void 0) return () => null;
145
+ const sleep = clock.sleep;
146
+ const pause = clock.yieldNow !== void 0 ? () => clock.yieldNow() : sleep !== void 0 ? () => sleep.call(clock, 1) : void 0;
147
+ if (pause === void 0) return () => null;
148
+ let n = 0;
149
+ return () => {
150
+ if (++n < everyN) return null;
151
+ n = 0;
152
+ return pause();
153
+ };
154
+ }
155
+
133
156
  // src/core/keys.ts
134
157
  var FIELD = " ";
135
158
  var NO_NAMESPACE = "/";
@@ -309,6 +332,8 @@ async function withRetry(op, policy, deps) {
309
332
  var DEFAULT_MAX_BITMAP_BYTES = 1 << 20;
310
333
  var DEFAULT_MAX_RETRIES = 16;
311
334
  var DEFAULT_INTERSECT_CONCURRENCY = 8;
335
+ var MUTATE_STAGE_IDS = 1 << 20;
336
+ var YIELD_EVERY_IDS = 1 << 14;
312
337
  var InstantClock = class {
313
338
  now() {
314
339
  return 0;
@@ -316,6 +341,17 @@ var InstantClock = class {
316
341
  sleep() {
317
342
  return Promise.resolve();
318
343
  }
344
+ /**
345
+ * An explicit no-op, so the intent is stated rather than inferred.
346
+ *
347
+ * Without it, `yieldEvery` would fall back to `sleep(1)` — and this clock's `sleep` ignores its argument and
348
+ * resolves on a microtask, so the "yield" would silently do nothing while every doc comment claimed it did
349
+ * something. This wiring has no real event loop to be courteous to (it is the clock used when nobody injects
350
+ * one), so no-op is the right behaviour; it just has to be a decision rather than an accident.
351
+ */
352
+ yieldNow() {
353
+ return Promise.resolve();
354
+ }
319
355
  };
320
356
  var ZeroRng = class {
321
357
  next() {
@@ -323,6 +359,7 @@ var ZeroRng = class {
323
359
  }
324
360
  };
325
361
  var DEFAULT_MAX_WARM_SCAN_BYTES = 64 * 1024 * 1024;
362
+ var DEFAULT_WRITE_CONCURRENCY = 4;
326
363
  var SegmentEngine = class {
327
364
  warm;
328
365
  cold;
@@ -352,7 +389,7 @@ var SegmentEngine = class {
352
389
  this.rng = deps.rng ?? new ZeroRng();
353
390
  this.occBackoff = deps.occBackoff ?? DEFAULT_OCC_BACKOFF;
354
391
  this.readOpts = { consistent: (deps.warmReadConsistency ?? "strong") !== "eventual" };
355
- this.writeConcurrency = deps.writeConcurrency ?? 1;
392
+ this.writeConcurrency = deps.writeConcurrency ?? DEFAULT_WRITE_CONCURRENCY;
356
393
  this.maxWarmScanBytes = deps.maxWarmScanBytes ?? DEFAULT_MAX_WARM_SCAN_BYTES;
357
394
  if (!Number.isFinite(this.maxWarmScanBytes) || this.maxWarmScanBytes < 1) {
358
395
  throw new ValidationError(
@@ -372,10 +409,10 @@ var SegmentEngine = class {
372
409
  return this.readModifyWrite(seg, chunkKey, (d) => applyRemove(d, remainder));
373
410
  }
374
411
  addMany(seg, ids) {
375
- return this.mutateMany(seg, ids, applyAdd);
412
+ return this.mutateMany(seg, ids, applyAddAll);
376
413
  }
377
414
  removeMany(seg, ids) {
378
- return this.mutateMany(seg, ids, applyRemove);
415
+ return this.mutateMany(seg, ids, applyRemoveAll);
379
416
  }
380
417
  async has(seg, id, opts) {
381
418
  const { chunkKey, remainder } = splitId(id);
@@ -432,6 +469,17 @@ var SegmentEngine = class {
432
469
  }
433
470
  }
434
471
  /**
472
+ * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
473
+ *
474
+ * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
475
+ * afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
476
+ * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
477
+ * operand is read **only at the keys that survived the intersection** — the cost is bounded by the
478
+ * audience's surviving key count and **never scales with the size of the suppression list**. Subtracting a
479
+ * 61,000-chunk global opt-out list costs at most one read per surviving key, not 61,000.
480
+ *
481
+ * ---
482
+ *
435
483
  * Chunk-skipping intersection. Computes
436
484
  * `seg[0] ∩ seg[1] ∩ …` and streams the result ids ascending. It aligns each segment's *effective*
437
485
  * chunk-key set (Warm ∪ Cold), keeps only keys present in **all** operands (a key missing from any
@@ -456,9 +504,35 @@ var SegmentEngine = class {
456
504
  * re-resolves fresh (bypassing the TTL), which can hop generations even sub-TTL — still whole/immutable per
457
505
  * read, never torn. Full intra-op snapshot isolation is deferred (gap #4).
458
506
  */
459
- async *intersect(segs, options) {
507
+ intersect(segs, options) {
508
+ return this.combine(segs, options?.exclude ?? [], "all", "intersect", options);
509
+ }
510
+ /**
511
+ * `segs[0] ∪ segs[1] ∪ …`, minus every id in `options.exclude` — streamed ascending.
512
+ *
513
+ * **Union is the one composite read with no chunk-skipping**, and that is a property of the operation rather
514
+ * than of this implementation: an id in *any* operand belongs to the result, so every chunk of every operand
515
+ * has to be read. `intersect` can prune a key absent from any operand; union cannot prune anything. It is
516
+ * budgeted exactly like `intersect` so a wide union is refused rather than quietly billed.
517
+ */
518
+ union(segs, options) {
519
+ return this.combine(segs, options?.exclude ?? [], "any", "union", options);
520
+ }
521
+ /**
522
+ * `seg \ (excludes[0] ∪ excludes[1] ∪ …)` — streamed ascending. Suppression on its own.
523
+ *
524
+ * Reads every chunk of `seg` (any of them may survive) but each exclude **only where it overlaps `seg`**, so
525
+ * the cost scales with the segment being filtered rather than with the size of the suppression list.
526
+ */
527
+ andNot(seg, excludes, options) {
528
+ return this.combine([seg], excludes, "all", "andNot", options);
529
+ }
530
+ async *combine(segs, excludeSegs, mode, op, options) {
460
531
  if (segs.length === 0) {
461
- throw new ValidationError("intersect requires at least one segment");
532
+ throw new ValidationError(`${op} requires at least one segment`);
533
+ }
534
+ if (op === "andNot" && excludeSegs.length === 0) {
535
+ throw new ValidationError("andNot requires at least one segment to subtract");
462
536
  }
463
537
  const limit = options?.concurrency ?? DEFAULT_INTERSECT_CONCURRENCY;
464
538
  if (!Number.isInteger(limit) || limit < 1) {
@@ -467,34 +541,52 @@ var SegmentEngine = class {
467
541
  );
468
542
  }
469
543
  const budget = resolvePerOpBudget(options?.budget, this.budget);
470
- const operands = await Promise.all(
471
- segs.map(async (seg) => {
472
- const warmRows = await this.collectWarm(seg, "intersect", null);
473
- const keys = await this.chunkKeys(seg, warmRows);
474
- const gen = await this.currentGen(seg);
475
- return { seg, warmRows, keys: new Set(keys), gen };
476
- })
477
- );
478
- const pivot = operands.reduce((a, b) => a.keys.size <= b.keys.size ? a : b);
479
- const common = [];
480
- for (const k of pivot.keys) {
481
- if (operands.every((op) => op.keys.has(k))) common.push(k);
482
- }
483
- common.sort((a, b) => a - b);
484
- checkBudget(budget, common.length * operands.length, "intersect");
544
+ const extract = async (seg) => {
545
+ const warmRows = await this.collectWarm(seg, op, null);
546
+ const keys = await this.chunkKeys(seg, warmRows);
547
+ const gen = await this.currentGen(seg);
548
+ return { seg, warmRows, keys: new Set(keys), gen };
549
+ };
550
+ const [operands, excludes] = await Promise.all([
551
+ Promise.all(segs.map(extract)),
552
+ Promise.all(excludeSegs.map(extract))
553
+ ]);
554
+ const candidates = [];
555
+ if (mode === "all") {
556
+ const pivot = operands.reduce((a, b) => a.keys.size <= b.keys.size ? a : b);
557
+ for (const k of pivot.keys) {
558
+ if (operands.every((o) => o.keys.has(k))) candidates.push(k);
559
+ }
560
+ } else {
561
+ const seen = /* @__PURE__ */ new Set();
562
+ for (const o of operands) for (const k of o.keys) seen.add(k);
563
+ for (const k of seen) candidates.push(k);
564
+ }
565
+ candidates.sort((a, b) => a - b);
566
+ const common = candidates;
567
+ let units = 0;
568
+ for (const key of common) {
569
+ for (const o of operands) if (mode === "all" || o.keys.has(key)) units += 1;
570
+ for (const e of excludes) if (e.keys.has(key)) units += 1;
571
+ }
572
+ checkBudget(budget, units, op);
485
573
  if (this.metricsOn) {
486
574
  const distinctKeys = /* @__PURE__ */ new Set();
487
- for (const op of operands) {
488
- for (const k of op.keys) distinctKeys.add(k);
575
+ for (const op2 of operands) {
576
+ for (const k of op2.keys) distinctKeys.add(k);
489
577
  }
578
+ for (const e of excludes) for (const k of e.keys) distinctKeys.add(k);
490
579
  this.metrics.onEvent({
491
580
  kind: "intersect",
492
- operands: segs.length,
581
+ op,
582
+ operands: segs.length + excludes.length,
493
583
  fetchedChunks: common.length,
584
+ // Never negative: under 'any' every candidate key came from some operand, so `distinctKeys` is a
585
+ // superset of `common`; excludes only ever add keys here.
494
586
  skippedChunks: distinctKeys.size - common.length
495
587
  });
496
588
  }
497
- const startAt = (key) => this.intersectChunk(operands, key).then(
589
+ const startAt = (key) => this.combineChunk(operands, excludes, mode, key).then(
498
590
  (result) => ({ key, result }),
499
591
  (error) => ({ key, result: null, error })
500
592
  );
@@ -516,16 +608,33 @@ var SegmentEngine = class {
516
608
  * first operand's effective chunk and mutates it in place — safe because `effectiveChunk` always returns
517
609
  * a FRESH bitmap (never a cached/shared Cold instance), so this can't poison the HOT cache.
518
610
  */
519
- async intersectChunk(operands, chunkKey) {
611
+ async combineChunk(operands, excludes, mode, chunkKey) {
612
+ const present = mode === "all" ? operands : operands.filter((o) => o.keys.has(chunkKey));
613
+ if (present.length === 0) return null;
520
614
  const chunks = await Promise.all(
521
- operands.map((op) => this.effectiveChunk(op.seg, chunkKey, op.warmRows, op.gen))
615
+ present.map((op) => this.effectiveChunk(op.seg, chunkKey, op.warmRows, op.gen))
522
616
  );
523
617
  const acc = chunks[0];
524
618
  for (let i = 1; i < chunks.length; i++) {
525
- acc.andInPlace(chunks[i]);
526
- if (acc.isEmpty) return null;
619
+ if (mode === "all") {
620
+ acc.andInPlace(chunks[i]);
621
+ if (acc.isEmpty) return null;
622
+ } else {
623
+ acc.orInPlace(chunks[i]);
624
+ }
527
625
  }
528
- return acc.isEmpty ? null : acc;
626
+ if (acc.isEmpty) return null;
627
+ const relevant = excludes.filter((e) => e.keys.has(chunkKey));
628
+ if (relevant.length > 0) {
629
+ const cuts = await Promise.all(
630
+ relevant.map((e) => this.effectiveChunk(e.seg, chunkKey, e.warmRows, e.gen))
631
+ );
632
+ for (const cut of cuts) {
633
+ acc.andNotInPlace(cut);
634
+ if (acc.isEmpty) return null;
635
+ }
636
+ }
637
+ return acc;
529
638
  }
530
639
  /**
531
640
  * Materialize `seg[0] ∩ seg[1] ∩ …` into `dest` via batched `addMany` (streaming, bounded memory).
@@ -533,9 +642,30 @@ var SegmentEngine = class {
533
642
  * `dest` is added to, not replaced.
534
643
  */
535
644
  async intersectInto(dest, segs, options) {
536
- const batchSize = Math.max(1, options?.batchSize ?? 4096);
645
+ await this.drainInto(dest, this.intersect(segs, options), options?.batchSize);
646
+ }
647
+ /**
648
+ * Materialize `segs[0] ∪ segs[1] ∪ …` (minus `options.exclude`) into `dest`. **Not atomic**, like
649
+ * {@link intersectInto}. See {@link union} for why this one cannot skip any chunk.
650
+ */
651
+ async unionInto(dest, segs, options) {
652
+ await this.drainInto(dest, this.union(segs, options), options?.batchSize);
653
+ }
654
+ /** Materialize `seg \ (excludes…)` into `dest`. **Not atomic**, like {@link intersectInto}. */
655
+ async andNotInto(dest, seg, excludes, options) {
656
+ await this.drainInto(dest, this.andNot(seg, excludes, options), options?.batchSize);
657
+ }
658
+ /**
659
+ * Stream a combine's results into `dest` in bounded batches.
660
+ *
661
+ * `dest` is **added to, not replaced**, and this is **not atomic** — ids land chunk-by-chunk, so a failure
662
+ * mid-stream leaves a partial result, exactly as `addMany` does. Batching keeps memory bounded no matter how
663
+ * large the result is.
664
+ */
665
+ async drainInto(dest, source, batchSizeOption) {
666
+ const batchSize = Math.max(1, batchSizeOption ?? 4096);
537
667
  let batch = [];
538
- for await (const id of this.intersect(segs, options)) {
668
+ for await (const id of source) {
539
669
  batch.push(id);
540
670
  if (batch.length >= batchSize) {
541
671
  await this.addMany(dest, batch);
@@ -544,19 +674,77 @@ var SegmentEngine = class {
544
674
  }
545
675
  if (batch.length > 0) await this.addMany(dest, batch);
546
676
  }
677
+ /**
678
+ * Group ids by chunk, then apply each chunk's whole batch in a single OCC read-modify-write.
679
+ *
680
+ * Accepts a **sync or async** iterable, so an Athena/BigQuery cursor streams straight in without the caller
681
+ * hand-batching `page → addMany(page)`.
682
+ *
683
+ * **The pending ids are held as BITMAPS, not as JS numbers, and that is the load-bearing choice.** Streaming
684
+ * input is unbounded by definition, so something has to give. The obvious answer — buffer N ids, flush them to
685
+ * the backend, repeat — bounds memory and is quietly catastrophic on cost: with ids arriving in arbitrary
686
+ * order, every flush touches nearly every chunk again, so an 11M-id stream at a 1M-id buffer would issue
687
+ * **11x** the OCC round-trips of a single pass. In a library whose headline claim is honest cost, silently
688
+ * multiplying someone's write bill by the size of their input is not a bounded-memory strategy; it is a
689
+ * different bug.
690
+ *
691
+ * So the staging buffer is capped, but it folds into per-chunk **compressed bitmaps** rather than being
692
+ * written out. Peak memory is the roaring representation of the ids seen so far — which is what the segment
693
+ * itself costs, and the floor for any implementation that writes each chunk exactly once — while the backend
694
+ * still sees exactly one read-modify-write per distinct chunk regardless of stream length. Above the staging cap
695
+ * this holds less than the previous sync-only version, which kept every remainder as an uncompressed JS
696
+ * number; below it — the common `addMany` — nothing is compacted until the end and the two are comparable.
697
+ * The guarantee is that a stream of *any* length stays bounded, not that every call got smaller.
698
+ *
699
+ * There is no way to do better without a sorted stream: a chunk can only be written once it is known that no
700
+ * further ids for it will arrive, and unsorted input never gives that guarantee. Callers streaming *very*
701
+ * large sets want `bulkLoadCrbmGeneration` instead — it writes one immutable Cold object rather than tens of
702
+ * thousands of warm rows. The crossover is documented in the guide.
703
+ */
547
704
  async mutateMany(seg, ids, apply) {
548
705
  const byChunk = /* @__PURE__ */ new Map();
549
- for (const id of ids) {
706
+ const staged = /* @__PURE__ */ new Map();
707
+ let pending = 0;
708
+ const tickId = yieldEvery(this.clock, YIELD_EVERY_IDS);
709
+ const compact = () => {
710
+ for (const [chunkKey, rems] of staged) {
711
+ if (rems.length === 0) continue;
712
+ const existing = byChunk.get(chunkKey);
713
+ if (existing === void 0) byChunk.set(chunkKey, this.codec.fromValues(rems));
714
+ else existing.addMany(rems);
715
+ rems.length = 0;
716
+ }
717
+ pending = 0;
718
+ };
719
+ const stage = (id) => {
550
720
  const { chunkKey, remainder } = splitId(id);
551
- const rems = byChunk.get(chunkKey) ?? [];
721
+ let rems = staged.get(chunkKey);
722
+ if (rems === void 0) {
723
+ rems = [];
724
+ staged.set(chunkKey, rems);
725
+ }
552
726
  rems.push(remainder);
553
- byChunk.set(chunkKey, rems);
727
+ pending += 1;
728
+ };
729
+ if (Symbol.asyncIterator in ids) {
730
+ for await (const id of ids) {
731
+ stage(id);
732
+ if (pending >= MUTATE_STAGE_IDS) compact();
733
+ const pause = tickId();
734
+ if (pause !== null) await pause;
735
+ }
736
+ } else {
737
+ for (const id of ids) {
738
+ stage(id);
739
+ if (pending >= MUTATE_STAGE_IDS) compact();
740
+ }
554
741
  }
742
+ compact();
555
743
  await mapWithConcurrency(
556
744
  [...byChunk],
557
745
  this.writeConcurrency,
558
- ([chunkKey, rems]) => this.readModifyWrite(seg, chunkKey, (d) => {
559
- for (const r of rems) apply(d, r);
746
+ ([chunkKey, bitmap]) => this.readModifyWrite(seg, chunkKey, (d) => {
747
+ apply(d, bitmap);
560
748
  })
561
749
  );
562
750
  }
@@ -1662,6 +1850,7 @@ function coldBlobReader(driver, key) {
1662
1850
  }
1663
1851
  var DEFAULT_CURRENT_GEN_TTL_MS = 2e3;
1664
1852
  var BULK_FLUSH_IDS = 1 << 20;
1853
+ var YIELD_EVERY_IDS2 = 1 << 14;
1665
1854
  var DEFAULT_MAX_OPEN_SEGMENTS = 1024;
1666
1855
  var DEFAULT_MAX_OPEN_INDEX_BYTES = 64 * 1024 * 1024;
1667
1856
  var CrbmColdChunkSource = class {
@@ -1880,11 +2069,14 @@ var CrbmColdChunkSource = class {
1880
2069
  };
1881
2070
  function writeCrbmGeneration(driver, key, chunks, options = {}) {
1882
2071
  const sorted = [...chunks].sort((a, b) => a.chunkKey - b.chunkKey);
2072
+ const tick = yieldEvery(options.clock);
1883
2073
  return driver.putImmutable(key, async (sink) => {
1884
2074
  const writer = new CrbmWriter(sink, { generation: key.generation, crypto: options.crypto });
1885
2075
  for (const { chunkKey, bitmap } of sorted) {
1886
2076
  if (bitmap.isEmpty) continue;
1887
2077
  await writer.addChunk(chunkKey, bitmap.serialize(), bitmap.size);
2078
+ const pause = tick();
2079
+ if (pause !== null) await pause;
1888
2080
  }
1889
2081
  await writer.finish();
1890
2082
  });
@@ -1892,6 +2084,7 @@ function writeCrbmGeneration(driver, key, chunks, options = {}) {
1892
2084
  async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
1893
2085
  const chunkKeys = [];
1894
2086
  let cardinality = 0;
2087
+ const tick = yieldEvery(options.clock);
1895
2088
  const { size, sha256 } = await driver.putImmutable(key, async (sink) => {
1896
2089
  const writer = new CrbmWriter(sink, { generation: key.generation, crypto: options.crypto });
1897
2090
  for await (const { chunkKey, bitmap } of chunks) {
@@ -1899,6 +2092,8 @@ async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
1899
2092
  await writer.addChunk(chunkKey, bitmap.serialize(), bitmap.size);
1900
2093
  chunkKeys.push(chunkKey);
1901
2094
  cardinality += bitmap.size;
2095
+ const pause = tick();
2096
+ if (pause !== null) await pause;
1902
2097
  }
1903
2098
  await writer.finish();
1904
2099
  });
@@ -1941,17 +2136,21 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
1941
2136
  const byChunk = /* @__PURE__ */ new Map();
1942
2137
  const pendingByChunk = /* @__PURE__ */ new Map();
1943
2138
  let pending = 0;
1944
- const flushPending = () => {
2139
+ const tickChunk = yieldEvery(options.clock);
2140
+ const tickId = yieldEvery(options.clock, YIELD_EVERY_IDS2);
2141
+ const flushPending = async () => {
1945
2142
  for (const [chunkKey, rems] of pendingByChunk) {
1946
2143
  if (rems.length === 0) continue;
1947
2144
  const existingBitmap = byChunk.get(chunkKey);
1948
2145
  if (existingBitmap === void 0) byChunk.set(chunkKey, codec.fromValues(rems));
1949
2146
  else existingBitmap.addMany(rems);
1950
2147
  rems.length = 0;
2148
+ const pause = tickChunk();
2149
+ if (pause !== null) await pause;
1951
2150
  }
1952
2151
  pending = 0;
1953
2152
  };
1954
- for await (const id of toAsyncIterable(ids)) {
2153
+ const ingest = (id) => {
1955
2154
  const { chunkKey, remainder } = splitId(id);
1956
2155
  let bucket = pendingByChunk.get(chunkKey);
1957
2156
  if (bucket === void 0) {
@@ -1959,14 +2158,29 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
1959
2158
  pendingByChunk.set(chunkKey, bucket);
1960
2159
  }
1961
2160
  bucket.push(remainder);
1962
- if (++pending >= BULK_FLUSH_IDS) flushPending();
2161
+ return ++pending >= BULK_FLUSH_IDS;
2162
+ };
2163
+ if (Symbol.asyncIterator in ids) {
2164
+ for await (const id of ids) {
2165
+ if (ingest(id)) await flushPending();
2166
+ const pause = tickId();
2167
+ if (pause !== null) await pause;
2168
+ }
2169
+ } else {
2170
+ for (const id of ids) {
2171
+ if (ingest(id)) await flushPending();
2172
+ const pause = tickId();
2173
+ if (pause !== null) await pause;
2174
+ }
1963
2175
  }
1964
- flushPending();
2176
+ await flushPending();
1965
2177
  let cardinality = 0;
1966
2178
  const chunks = [];
1967
2179
  for (const [chunkKey, bitmap] of byChunk) {
1968
2180
  cardinality += bitmap.size;
1969
2181
  chunks.push({ chunkKey, bitmap });
2182
+ const pause = tickChunk();
2183
+ if (pause !== null) await pause;
1970
2184
  }
1971
2185
  if (options.keystore !== void 0 && options.registry === void 0) {
1972
2186
  throw new ValidationError("bulk-load encryption requires a registry to store the wrapped DEK");
@@ -1989,7 +2203,10 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
1989
2203
  crypto = { aead: minted.aead, aadFor: (scope) => aadFor(key, key.generation, scope) };
1990
2204
  }
1991
2205
  }
1992
- const { size, sha256 } = await writeCrbmGeneration(driver, key, chunks, { crypto });
2206
+ const { size, sha256 } = await writeCrbmGeneration(driver, key, chunks, {
2207
+ crypto,
2208
+ clock: options.clock
2209
+ });
1993
2210
  if (options.registry !== void 0) {
1994
2211
  const becameCurrent = await publishGeneration(options.registry, key, {
1995
2212
  wrappedDeks: newWrapped
@@ -2005,13 +2222,6 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
2005
2222
  }
2006
2223
  return { size, sha256, chunkCount: chunks.length, cardinality };
2007
2224
  }
2008
- async function* toAsyncIterable(src) {
2009
- if (Symbol.asyncIterator in src) {
2010
- yield* src;
2011
- } else {
2012
- yield* src;
2013
- }
2014
- }
2015
2225
 
2016
2226
  // src/core/compaction.ts
2017
2227
  var DEFAULT_MAX_BITMAP_BYTES2 = 1 << 20;
@@ -2117,6 +2327,8 @@ async function compactSegmentInner(ref, deps, options) {
2117
2327
  let tally;
2118
2328
  try {
2119
2329
  tally = await writeCrbmGenerationStream(deps.cold, gen0, stream, {
2330
+ clock: deps.clock,
2331
+ // cooperative if the caller's clock can yield; a no-op if it only tells the time
2120
2332
  crypto: cryptoAt(aead, 0)
2121
2333
  });
2122
2334
  } catch (err) {
@@ -2244,7 +2456,8 @@ async function compactSegmentInner(ref, deps, options) {
2244
2456
  deps.cold,
2245
2457
  { namespace: ref.namespace, segment: ref.segment, generation: newGen },
2246
2458
  stream,
2247
- cryptoAt(aead, newGen)
2459
+ cryptoAt(aead, newGen),
2460
+ deps.clock
2248
2461
  );
2249
2462
  try {
2250
2463
  await deps.registry.compareAndSwap(ref, leaseToken, {
@@ -2341,8 +2554,8 @@ async function* mergeChunksStream(ref, g, pinned, deps, maxBytes, readCrypto) {
2341
2554
  if (!merged.isEmpty) yield { chunkKey, bitmap: merged };
2342
2555
  }
2343
2556
  }
2344
- async function stageGeneration(cold, key, chunks, crypto) {
2345
- const tally = await writeCrbmGenerationStream(cold, key, chunks, { crypto });
2557
+ async function stageGeneration(cold, key, chunks, crypto, clock) {
2558
+ const tally = await writeCrbmGenerationStream(cold, key, chunks, { crypto, clock });
2346
2559
  await verifyGeneration(cold, key, tally, crypto);
2347
2560
  }
2348
2561
  async function verifyGeneration(cold, key, expected, crypto) {
@@ -3634,17 +3847,43 @@ var RetryingWarmDriver = class {
3634
3847
  deleteConditional(ref, expected) {
3635
3848
  return withRetry(() => this.inner.deleteConditional(ref, expected), this.policy, this.deps);
3636
3849
  }
3850
+ /**
3851
+ * Stream the inner driver's rows, retrying only until the first one arrives.
3852
+ *
3853
+ * **This used to buffer the whole scan** — `for await (…) out.push(row)` inside `withRetry`, then
3854
+ * `yield* out` — which made the retry trivially safe and quietly defeated every bound above it. The engine's
3855
+ * `collectWarm` refuses *during* enumeration precisely so resident memory is `O(ceiling)` rather than
3856
+ * `O(segment)`; with the buffering wrapper in place (and it is wired by default) it saw its first row only
3857
+ * after the entire segment was already in memory. Measured on a 500-row segment under
3858
+ * `budget: { maxRequests: 3 }`: **500 rows materialised with the default wiring, 4 with `retry: false`**.
3859
+ * Both paths threw the same `BudgetExceededError`, which is why no test caught it — the error was never the
3860
+ * distinguishing observable, the row count was.
3861
+ *
3862
+ * **The trade this makes.** A mid-stream fault now propagates instead of being retried. It cannot be retried
3863
+ * honestly: rows have already been handed to the consumer, so restarting the iterator would re-yield them,
3864
+ * and the driver interface exposes no resumption token to continue from. Retry is kept where it pays —
3865
+ * establishing the scan, which is where transient connect/throttle failures actually land — and bounded
3866
+ * memory wins the conflict, because invariant 6 ("bounded memory & cost, always") is a hard invariant and
3867
+ * mid-scan retry is not. A caller who needs whole-scan retry can wrap the operation at their own level,
3868
+ * where re-reading from the start is safe.
3869
+ */
3637
3870
  async *listChunks(ref, opts) {
3638
- const rows = await withRetry(
3871
+ const started = await withRetry(
3639
3872
  async () => {
3640
- const out = [];
3641
- for await (const row of this.inner.listChunks(ref, opts)) out.push(row);
3642
- return out;
3873
+ const iterator = this.inner.listChunks(ref, opts)[Symbol.asyncIterator]();
3874
+ const first = await iterator.next();
3875
+ return { iterator, first };
3643
3876
  },
3644
3877
  this.policy,
3645
3878
  this.deps
3646
3879
  );
3647
- yield* rows;
3880
+ if (started.first.done === true) return;
3881
+ yield started.first.value;
3882
+ for (; ; ) {
3883
+ const next = await started.iterator.next();
3884
+ if (next.done === true) return;
3885
+ yield next.value;
3886
+ }
3648
3887
  }
3649
3888
  };
3650
3889
  var RetryingColdChunkSource = class {
@@ -3757,6 +3996,6 @@ var RetryingRegistryDriver = class {
3757
3996
  }
3758
3997
  };
3759
3998
 
3760
- export { AWS_US_EAST_1_ONDEMAND, BoundedLru, BufferReader, BufferSink, CountingMetricsSink, CrbmColdChunkSource, CrbmReader, CrbmWriter, DEFAULT_BUDGET, DEFAULT_MAX_SCAN_SEGMENTS, DEFAULT_MAX_WARM_SCAN_BYTES, DEFAULT_OCC_BACKOFF, DEFAULT_PRICING, DEFAULT_RETRY_POLICY, InProcessKeystore, LocalFsColdDriver, LocalFsRegistryDriver, LocalFsWarmDriver, MemoryColdChunkSource, MemoryColdDriver, MemoryRegistryDriver, MemoryWarmDriver, NOOP_AUDIT, NOOP_METRICS, NodeAead, RecordingAuditSink, RetryingColdChunkSource, RetryingColdDriver, RetryingRegistryDriver, RetryingWarmDriver, SegmentEngine, aadFor, bulkLoadCrbmGeneration, checkBudget, chunkRefKey, collectWithinBudget, compactSegment, destroySegment, eraseNamespace, estimateCost, findCompactable, gcOrphanGenerations, groundedReport, isTransient, joinId, publishGeneration, resolveBudget, resolvePerOpBudget, runCompactionCycle, runConsistencyCheck, runExport, safeMetrics, segmentKey, splitId, validateCompactionOptions, withRetry, writeCrbmGeneration };
3999
+ export { AWS_US_EAST_1_ONDEMAND, BoundedLru, BufferReader, BufferSink, CountingMetricsSink, CrbmColdChunkSource, CrbmReader, CrbmWriter, DEFAULT_BUDGET, DEFAULT_MAX_SCAN_SEGMENTS, DEFAULT_MAX_WARM_SCAN_BYTES, DEFAULT_OCC_BACKOFF, DEFAULT_PRICING, DEFAULT_RETRY_POLICY, DEFAULT_WRITE_CONCURRENCY, InProcessKeystore, LocalFsColdDriver, LocalFsRegistryDriver, LocalFsWarmDriver, MemoryColdChunkSource, MemoryColdDriver, MemoryRegistryDriver, MemoryWarmDriver, NOOP_AUDIT, NOOP_METRICS, NodeAead, RecordingAuditSink, RetryingColdChunkSource, RetryingColdDriver, RetryingRegistryDriver, RetryingWarmDriver, SegmentEngine, aadFor, bulkLoadCrbmGeneration, checkBudget, chunkRefKey, collectWithinBudget, compactSegment, destroySegment, eraseNamespace, estimateCost, findCompactable, gcOrphanGenerations, groundedReport, isTransient, joinId, publishGeneration, resolveBudget, resolvePerOpBudget, runCompactionCycle, runConsistencyCheck, runExport, safeMetrics, segmentKey, splitId, validateCompactionOptions, withRetry, writeCrbmGeneration };
3761
4000
  //# sourceMappingURL=index.js.map
3762
4001
  //# sourceMappingURL=index.js.map