@cloudbitmaps/core 0.2.0 → 0.4.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.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);
@@ -171,6 +179,18 @@ function checkBudget(budget, projectedRequests, op) {
171
179
  );
172
180
  }
173
181
  }
182
+ async function collectWithinBudget(source, budget, op) {
183
+ const out = [];
184
+ for await (const item of source) {
185
+ out.push(item);
186
+ if (budget !== null && out.length > budget.maxRequests) {
187
+ throw new BudgetExceededError(
188
+ `${op} would fan out to more than ${budget.maxRequests} units, over the per-op budget \u2014 the scan was abandoned at that point rather than completed, so no exact total is available. Raise \`budget.maxRequests\`, override it per-op, or set \`budget: false\``
189
+ );
190
+ }
191
+ }
192
+ return out;
193
+ }
174
194
 
175
195
  // src/core/concurrency.ts
176
196
  async function mapWithConcurrency(items, limit, fn) {
@@ -202,6 +222,19 @@ async function mapWithConcurrency(items, limit, fn) {
202
222
  return results;
203
223
  }
204
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 pause = clock.yieldNow !== void 0 ? () => clock.yieldNow() : () => clock.sleep(1);
230
+ let n = 0;
231
+ return () => {
232
+ if (++n < everyN) return null;
233
+ n = 0;
234
+ return pause();
235
+ };
236
+ }
237
+
205
238
  // src/core/keys.ts
206
239
  var FIELD = " ";
207
240
  var NO_NAMESPACE = "/";
@@ -384,6 +417,8 @@ async function withRetry(op, policy, deps) {
384
417
  var DEFAULT_MAX_BITMAP_BYTES = 1 << 20;
385
418
  var DEFAULT_MAX_RETRIES = 16;
386
419
  var DEFAULT_INTERSECT_CONCURRENCY = 8;
420
+ var MUTATE_STAGE_IDS = 1 << 20;
421
+ var YIELD_EVERY_IDS = 1 << 14;
387
422
  var InstantClock = class {
388
423
  now() {
389
424
  return 0;
@@ -397,6 +432,8 @@ var ZeroRng = class {
397
432
  return 0;
398
433
  }
399
434
  };
435
+ var DEFAULT_MAX_WARM_SCAN_BYTES = 64 * 1024 * 1024;
436
+ var DEFAULT_WRITE_CONCURRENCY = 4;
400
437
  var SegmentEngine = class {
401
438
  warm;
402
439
  cold;
@@ -412,6 +449,7 @@ var SegmentEngine = class {
412
449
  /** Consistency for READ-path Warm fetches (precomputed; OCC RMW passes strong explicitly, see `warmGet`). */
413
450
  readOpts;
414
451
  writeConcurrency;
452
+ maxWarmScanBytes;
415
453
  /** Resolved per-op budget (null = disabled); undefined deps ⇒ the generous default. See {@link checkBudget}. */
416
454
  budget;
417
455
  constructor(deps) {
@@ -425,7 +463,13 @@ var SegmentEngine = class {
425
463
  this.rng = deps.rng ?? new ZeroRng();
426
464
  this.occBackoff = deps.occBackoff ?? DEFAULT_OCC_BACKOFF;
427
465
  this.readOpts = { consistent: (deps.warmReadConsistency ?? "strong") !== "eventual" };
428
- this.writeConcurrency = deps.writeConcurrency ?? 1;
466
+ this.writeConcurrency = deps.writeConcurrency ?? DEFAULT_WRITE_CONCURRENCY;
467
+ this.maxWarmScanBytes = deps.maxWarmScanBytes ?? DEFAULT_MAX_WARM_SCAN_BYTES;
468
+ if (!Number.isFinite(this.maxWarmScanBytes) || this.maxWarmScanBytes < 1) {
469
+ throw new ValidationError(
470
+ `maxWarmScanBytes must be a finite number >= 1 (got ${String(deps.maxWarmScanBytes)})`
471
+ );
472
+ }
429
473
  this.budget = deps.budget === void 0 ? DEFAULT_BUDGET : deps.budget;
430
474
  this.metrics = safeMetrics(deps.metrics ?? NOOP_METRICS);
431
475
  this.metricsOn = this.metrics !== NOOP_METRICS;
@@ -439,10 +483,10 @@ var SegmentEngine = class {
439
483
  return this.readModifyWrite(seg, chunkKey, (d) => applyRemove(d, remainder));
440
484
  }
441
485
  addMany(seg, ids) {
442
- return this.mutateMany(seg, ids, applyAdd);
486
+ return this.mutateMany(seg, ids, applyAddAll);
443
487
  }
444
488
  removeMany(seg, ids) {
445
- return this.mutateMany(seg, ids, applyRemove);
489
+ return this.mutateMany(seg, ids, applyRemoveAll);
446
490
  }
447
491
  async has(seg, id, opts) {
448
492
  const { chunkKey, remainder } = splitId(id);
@@ -457,7 +501,7 @@ var SegmentEngine = class {
457
501
  return cold ? cold.has(remainder) : false;
458
502
  }
459
503
  async count(seg) {
460
- const warmRows = await this.collectWarm(seg);
504
+ const warmRows = await this.collectWarm(seg, "count", this.budget?.maxRequests ?? null);
461
505
  const cardinalities = this.cold.cardinalities ? await this.cold.cardinalities(seg) : null;
462
506
  if (!cardinalities) {
463
507
  const chunkKeys = await this.chunkKeys(seg, warmRows);
@@ -489,7 +533,7 @@ var SegmentEngine = class {
489
533
  return this.cold.sizeOf ? this.cold.sizeOf(seg) : Promise.resolve(null);
490
534
  }
491
535
  async *iterate(seg) {
492
- const warmRows = await this.collectWarm(seg);
536
+ const warmRows = await this.collectWarm(seg, "iterate", this.budget?.maxRequests ?? null);
493
537
  const chunkKeys = await this.chunkKeys(seg, warmRows);
494
538
  checkBudget(this.budget, chunkKeys.length, "iterate");
495
539
  const gen = await this.currentGen(seg);
@@ -523,9 +567,44 @@ var SegmentEngine = class {
523
567
  * re-resolves fresh (bypassing the TTL), which can hop generations even sub-TTL — still whole/immutable per
524
568
  * read, never torn. Full intra-op snapshot isolation is deferred (gap #4).
525
569
  */
526
- async *intersect(segs, options) {
570
+ /**
571
+ * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
572
+ *
573
+ * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
574
+ * afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
575
+ * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
576
+ * operand is read **only at the keys that survived the intersection**, so a 61,000-chunk global opt-out list
577
+ * intersected against a narrow audience costs a handful of reads, not 61,000.
578
+ */
579
+ intersect(segs, options) {
580
+ return this.combine(segs, options?.exclude ?? [], "all", "intersect", options);
581
+ }
582
+ /**
583
+ * `segs[0] ∪ segs[1] ∪ …`, minus every id in `options.exclude` — streamed ascending.
584
+ *
585
+ * **Union is the one composite read with no chunk-skipping**, and that is a property of the operation rather
586
+ * than of this implementation: an id in *any* operand belongs to the result, so every chunk of every operand
587
+ * has to be read. `intersect` can prune a key absent from any operand; union cannot prune anything. It is
588
+ * budgeted exactly like `intersect` so a wide union is refused rather than quietly billed.
589
+ */
590
+ union(segs, options) {
591
+ return this.combine(segs, options?.exclude ?? [], "any", "union", options);
592
+ }
593
+ /**
594
+ * `seg \ (excludes[0] ∪ excludes[1] ∪ …)` — streamed ascending. Suppression on its own.
595
+ *
596
+ * Reads every chunk of `seg` (any of them may survive) but each exclude **only where it overlaps `seg`**, so
597
+ * the cost scales with the segment being filtered rather than with the size of the suppression list.
598
+ */
599
+ andNot(seg, excludes, options) {
600
+ return this.combine([seg], excludes, "all", "andNot", options);
601
+ }
602
+ async *combine(segs, excludeSegs, mode, op, options) {
527
603
  if (segs.length === 0) {
528
- throw new ValidationError("intersect requires at least one segment");
604
+ throw new ValidationError(`${op} requires at least one segment`);
605
+ }
606
+ if (op === "andNot" && excludeSegs.length === 0) {
607
+ throw new ValidationError("andNot requires at least one segment to subtract");
529
608
  }
530
609
  const limit = options?.concurrency ?? DEFAULT_INTERSECT_CONCURRENCY;
531
610
  if (!Number.isInteger(limit) || limit < 1) {
@@ -534,34 +613,52 @@ var SegmentEngine = class {
534
613
  );
535
614
  }
536
615
  const budget = resolvePerOpBudget(options?.budget, this.budget);
537
- const operands = await Promise.all(
538
- segs.map(async (seg) => {
539
- const warmRows = await this.collectWarm(seg);
540
- const keys = await this.chunkKeys(seg, warmRows);
541
- const gen = await this.currentGen(seg);
542
- return { seg, warmRows, keys: new Set(keys), gen };
543
- })
544
- );
545
- const pivot = operands.reduce((a, b) => a.keys.size <= b.keys.size ? a : b);
546
- const common = [];
547
- for (const k of pivot.keys) {
548
- if (operands.every((op) => op.keys.has(k))) common.push(k);
549
- }
550
- common.sort((a, b) => a - b);
551
- checkBudget(budget, common.length * operands.length, "intersect");
616
+ const extract = async (seg) => {
617
+ const warmRows = await this.collectWarm(seg, op, null);
618
+ const keys = await this.chunkKeys(seg, warmRows);
619
+ const gen = await this.currentGen(seg);
620
+ return { seg, warmRows, keys: new Set(keys), gen };
621
+ };
622
+ const [operands, excludes] = await Promise.all([
623
+ Promise.all(segs.map(extract)),
624
+ Promise.all(excludeSegs.map(extract))
625
+ ]);
626
+ const candidates = [];
627
+ if (mode === "all") {
628
+ const pivot = operands.reduce((a, b) => a.keys.size <= b.keys.size ? a : b);
629
+ for (const k of pivot.keys) {
630
+ if (operands.every((o) => o.keys.has(k))) candidates.push(k);
631
+ }
632
+ } else {
633
+ const seen = /* @__PURE__ */ new Set();
634
+ for (const o of operands) for (const k of o.keys) seen.add(k);
635
+ for (const k of seen) candidates.push(k);
636
+ }
637
+ candidates.sort((a, b) => a - b);
638
+ const common = candidates;
639
+ let units = 0;
640
+ for (const key of common) {
641
+ for (const o of operands) if (mode === "all" || o.keys.has(key)) units += 1;
642
+ for (const e of excludes) if (e.keys.has(key)) units += 1;
643
+ }
644
+ checkBudget(budget, units, op);
552
645
  if (this.metricsOn) {
553
646
  const distinctKeys = /* @__PURE__ */ new Set();
554
- for (const op of operands) {
555
- for (const k of op.keys) distinctKeys.add(k);
647
+ for (const op2 of operands) {
648
+ for (const k of op2.keys) distinctKeys.add(k);
556
649
  }
650
+ for (const e of excludes) for (const k of e.keys) distinctKeys.add(k);
557
651
  this.metrics.onEvent({
558
652
  kind: "intersect",
559
- operands: segs.length,
653
+ op,
654
+ operands: segs.length + excludes.length,
560
655
  fetchedChunks: common.length,
656
+ // Never negative: under 'any' every candidate key came from some operand, so `distinctKeys` is a
657
+ // superset of `common`; excludes only ever add keys here.
561
658
  skippedChunks: distinctKeys.size - common.length
562
659
  });
563
660
  }
564
- const startAt = (key) => this.intersectChunk(operands, key).then(
661
+ const startAt = (key) => this.combineChunk(operands, excludes, mode, key).then(
565
662
  (result) => ({ key, result }),
566
663
  (error) => ({ key, result: null, error })
567
664
  );
@@ -583,16 +680,33 @@ var SegmentEngine = class {
583
680
  * first operand's effective chunk and mutates it in place — safe because `effectiveChunk` always returns
584
681
  * a FRESH bitmap (never a cached/shared Cold instance), so this can't poison the HOT cache.
585
682
  */
586
- async intersectChunk(operands, chunkKey) {
683
+ async combineChunk(operands, excludes, mode, chunkKey) {
684
+ const present = mode === "all" ? operands : operands.filter((o) => o.keys.has(chunkKey));
685
+ if (present.length === 0) return null;
587
686
  const chunks = await Promise.all(
588
- operands.map((op) => this.effectiveChunk(op.seg, chunkKey, op.warmRows, op.gen))
687
+ present.map((op) => this.effectiveChunk(op.seg, chunkKey, op.warmRows, op.gen))
589
688
  );
590
689
  const acc = chunks[0];
591
690
  for (let i = 1; i < chunks.length; i++) {
592
- acc.andInPlace(chunks[i]);
593
- if (acc.isEmpty) return null;
691
+ if (mode === "all") {
692
+ acc.andInPlace(chunks[i]);
693
+ if (acc.isEmpty) return null;
694
+ } else {
695
+ acc.orInPlace(chunks[i]);
696
+ }
697
+ }
698
+ if (acc.isEmpty) return null;
699
+ const relevant = excludes.filter((e) => e.keys.has(chunkKey));
700
+ if (relevant.length > 0) {
701
+ const cuts = await Promise.all(
702
+ relevant.map((e) => this.effectiveChunk(e.seg, chunkKey, e.warmRows, e.gen))
703
+ );
704
+ for (const cut of cuts) {
705
+ acc.andNotInPlace(cut);
706
+ if (acc.isEmpty) return null;
707
+ }
594
708
  }
595
- return acc.isEmpty ? null : acc;
709
+ return acc;
596
710
  }
597
711
  /**
598
712
  * Materialize `seg[0] ∩ seg[1] ∩ …` into `dest` via batched `addMany` (streaming, bounded memory).
@@ -600,9 +714,30 @@ var SegmentEngine = class {
600
714
  * `dest` is added to, not replaced.
601
715
  */
602
716
  async intersectInto(dest, segs, options) {
603
- const batchSize = Math.max(1, options?.batchSize ?? 4096);
717
+ await this.drainInto(dest, this.intersect(segs, options), options?.batchSize);
718
+ }
719
+ /**
720
+ * Materialize `segs[0] ∪ segs[1] ∪ …` (minus `options.exclude`) into `dest`. **Not atomic**, like
721
+ * {@link intersectInto}. See {@link union} for why this one cannot skip any chunk.
722
+ */
723
+ async unionInto(dest, segs, options) {
724
+ await this.drainInto(dest, this.union(segs, options), options?.batchSize);
725
+ }
726
+ /** Materialize `seg \ (excludes…)` into `dest`. **Not atomic**, like {@link intersectInto}. */
727
+ async andNotInto(dest, seg, excludes, options) {
728
+ await this.drainInto(dest, this.andNot(seg, excludes, options), options?.batchSize);
729
+ }
730
+ /**
731
+ * Stream a combine's results into `dest` in bounded batches.
732
+ *
733
+ * `dest` is **added to, not replaced**, and this is **not atomic** — ids land chunk-by-chunk, so a failure
734
+ * mid-stream leaves a partial result, exactly as `addMany` does. Batching keeps memory bounded no matter how
735
+ * large the result is.
736
+ */
737
+ async drainInto(dest, source, batchSizeOption) {
738
+ const batchSize = Math.max(1, batchSizeOption ?? 4096);
604
739
  let batch = [];
605
- for await (const id of this.intersect(segs, options)) {
740
+ for await (const id of source) {
606
741
  batch.push(id);
607
742
  if (batch.length >= batchSize) {
608
743
  await this.addMany(dest, batch);
@@ -611,19 +746,77 @@ var SegmentEngine = class {
611
746
  }
612
747
  if (batch.length > 0) await this.addMany(dest, batch);
613
748
  }
749
+ /**
750
+ * Group ids by chunk, then apply each chunk's whole batch in a single OCC read-modify-write.
751
+ *
752
+ * Accepts a **sync or async** iterable, so an Athena/BigQuery cursor streams straight in without the caller
753
+ * hand-batching `page → addMany(page)`.
754
+ *
755
+ * **The pending ids are held as BITMAPS, not as JS numbers, and that is the load-bearing choice.** Streaming
756
+ * input is unbounded by definition, so something has to give. The obvious answer — buffer N ids, flush them to
757
+ * the backend, repeat — bounds memory and is quietly catastrophic on cost: with ids arriving in arbitrary
758
+ * order, every flush touches nearly every chunk again, so an 11M-id stream at a 1M-id buffer would issue
759
+ * **11x** the OCC round-trips of a single pass. In a library whose headline claim is honest cost, silently
760
+ * multiplying someone's write bill by the size of their input is not a bounded-memory strategy; it is a
761
+ * different bug.
762
+ *
763
+ * So the staging buffer is capped, but it folds into per-chunk **compressed bitmaps** rather than being
764
+ * written out. Peak memory is the roaring representation of the ids seen so far — which is what the segment
765
+ * itself costs, and the floor for any implementation that writes each chunk exactly once — while the backend
766
+ * still sees exactly one read-modify-write per distinct chunk regardless of stream length. This is strictly
767
+ * less memory than the previous sync-only version, which held every remainder as an uncompressed JS number.
768
+ *
769
+ * There is no way to do better without a sorted stream: a chunk can only be written once it is known that no
770
+ * further ids for it will arrive, and unsorted input never gives that guarantee. Callers streaming *very*
771
+ * large sets want `bulkLoadCrbmGeneration` instead — it writes one immutable Cold object rather than tens of
772
+ * thousands of warm rows. The crossover is documented in the guide.
773
+ */
614
774
  async mutateMany(seg, ids, apply) {
615
775
  const byChunk = /* @__PURE__ */ new Map();
616
- for (const id of ids) {
776
+ const staged = /* @__PURE__ */ new Map();
777
+ let pending = 0;
778
+ const tickId = yieldEvery(this.clock, YIELD_EVERY_IDS);
779
+ const compact = () => {
780
+ for (const [chunkKey, rems] of staged) {
781
+ if (rems.length === 0) continue;
782
+ const existing = byChunk.get(chunkKey);
783
+ if (existing === void 0) byChunk.set(chunkKey, this.codec.fromValues(rems));
784
+ else existing.addMany(rems);
785
+ rems.length = 0;
786
+ }
787
+ pending = 0;
788
+ };
789
+ const stage = (id) => {
617
790
  const { chunkKey, remainder } = splitId(id);
618
- const rems = byChunk.get(chunkKey) ?? [];
791
+ let rems = staged.get(chunkKey);
792
+ if (rems === void 0) {
793
+ rems = [];
794
+ staged.set(chunkKey, rems);
795
+ }
619
796
  rems.push(remainder);
620
- byChunk.set(chunkKey, rems);
797
+ pending += 1;
798
+ };
799
+ if (Symbol.asyncIterator in ids) {
800
+ for await (const id of ids) {
801
+ stage(id);
802
+ if (pending >= MUTATE_STAGE_IDS) compact();
803
+ const pause = tickId();
804
+ if (pause !== null) await pause;
805
+ }
806
+ } else {
807
+ for (const id of ids) {
808
+ stage(id);
809
+ if (pending >= MUTATE_STAGE_IDS) compact();
810
+ const pause = tickId();
811
+ if (pause !== null) await pause;
812
+ }
621
813
  }
814
+ compact();
622
815
  await mapWithConcurrency(
623
816
  [...byChunk],
624
817
  this.writeConcurrency,
625
- ([chunkKey, rems]) => this.readModifyWrite(seg, chunkKey, (d) => {
626
- for (const r of rems) apply(d, r);
818
+ ([chunkKey, bitmap]) => this.readModifyWrite(seg, chunkKey, (d) => {
819
+ apply(d, bitmap);
627
820
  })
628
821
  );
629
822
  }
@@ -677,12 +870,22 @@ var SegmentEngine = class {
677
870
  }
678
871
  return row;
679
872
  }
680
- async collectWarm(seg) {
873
+ async collectWarm(seg, op, rowCeiling) {
681
874
  const rows = /* @__PURE__ */ new Map();
682
875
  let bytes = 0;
683
876
  for await (const row of this.warm.listChunks(seg, this.readOpts)) {
684
877
  rows.set(row.chunkKey, row.bytes);
685
878
  bytes += row.bytes.length;
879
+ if (bytes > this.maxWarmScanBytes) {
880
+ throw new BudgetExceededError(
881
+ `${op} would hold more than ${this.maxWarmScanBytes} bytes of warm deltas resident for one segment \u2014 the scan was abandoned there rather than completed. Raise \`maxWarmScanBytes\`, narrow the segment, or compact it so fewer chunks carry warm deltas. (This ceiling is independent of \`budget\` and stays in force when \`budget: false\`.)`
882
+ );
883
+ }
884
+ if (rowCeiling !== null && rows.size > rowCeiling) {
885
+ throw new BudgetExceededError(
886
+ `${op} would read more than ${rowCeiling} warm chunk rows, over the per-op budget \u2014 the scan was abandoned at that point rather than completed, so no exact total is available. Raise \`budget.maxRequests\`, override it per-op, or set \`budget: false\``
887
+ );
888
+ }
686
889
  }
687
890
  if (this.metricsOn) {
688
891
  this.metrics.onEvent({
@@ -1193,22 +1396,44 @@ function validateChunkRef(ref) {
1193
1396
  }
1194
1397
 
1195
1398
  // src/core/crbm/crc32c.ts
1196
- var TABLE = (() => {
1399
+ var TABLES = (() => {
1197
1400
  const POLY = 2197175160;
1198
- const table = new Uint32Array(256);
1401
+ const t0 = new Uint32Array(256);
1199
1402
  for (let n = 0; n < 256; n++) {
1200
1403
  let c = n;
1201
- for (let k = 0; k < 8; k++) {
1202
- c = c & 1 ? POLY ^ c >>> 1 : c >>> 1;
1404
+ for (let k = 0; k < 8; k++) c = c & 1 ? POLY ^ c >>> 1 : c >>> 1;
1405
+ t0[n] = c >>> 0;
1406
+ }
1407
+ const tables = [t0];
1408
+ for (let k = 1; k < 8; k++) {
1409
+ const prev = tables[k - 1];
1410
+ const next = new Uint32Array(256);
1411
+ for (let n = 0; n < 256; n++) {
1412
+ const v = prev[n];
1413
+ next[n] = (v >>> 8 ^ t0[v & 255]) >>> 0;
1203
1414
  }
1204
- table[n] = c >>> 0;
1415
+ tables.push(next);
1205
1416
  }
1206
- return table;
1417
+ return tables;
1207
1418
  })();
1419
+ var T0 = TABLES[0];
1420
+ var T1 = TABLES[1];
1421
+ var T2 = TABLES[2];
1422
+ var T3 = TABLES[3];
1423
+ var T4 = TABLES[4];
1424
+ var T5 = TABLES[5];
1425
+ var T6 = TABLES[6];
1426
+ var T7 = TABLES[7];
1208
1427
  function crc32c(bytes, seed = 0) {
1209
1428
  let crc = (seed ^ 4294967295) >>> 0;
1210
- for (let i = 0; i < bytes.length; i++) {
1211
- crc = (TABLE[(crc ^ bytes[i]) & 255] ^ crc >>> 8) >>> 0;
1429
+ let i = 0;
1430
+ const n8 = bytes.length - bytes.length % 8;
1431
+ for (; i < n8; i += 8) {
1432
+ crc ^= bytes[i] | bytes[i + 1] << 8 | bytes[i + 2] << 16 | bytes[i + 3] << 24;
1433
+ crc = (T7[crc & 255] ^ T6[crc >>> 8 & 255] ^ T5[crc >>> 16 & 255] ^ T4[crc >>> 24 & 255] ^ T3[bytes[i + 4]] ^ T2[bytes[i + 5]] ^ T1[bytes[i + 6]] ^ T0[bytes[i + 7]]) >>> 0;
1434
+ }
1435
+ for (; i < bytes.length; i++) {
1436
+ crc = (T0[(crc ^ bytes[i]) & 255] ^ crc >>> 8) >>> 0;
1212
1437
  }
1213
1438
  return (crc ^ 4294967295) >>> 0;
1214
1439
  }
@@ -1719,6 +1944,8 @@ function coldBlobReader(driver, key) {
1719
1944
  };
1720
1945
  }
1721
1946
  var DEFAULT_CURRENT_GEN_TTL_MS = 2e3;
1947
+ var BULK_FLUSH_IDS = 1 << 20;
1948
+ var YIELD_EVERY_IDS2 = 1 << 14;
1722
1949
  var DEFAULT_MAX_OPEN_SEGMENTS = 1024;
1723
1950
  var DEFAULT_MAX_OPEN_INDEX_BYTES = 64 * 1024 * 1024;
1724
1951
  var CrbmColdChunkSource = class {
@@ -1937,11 +2164,14 @@ var CrbmColdChunkSource = class {
1937
2164
  };
1938
2165
  function writeCrbmGeneration(driver, key, chunks, options = {}) {
1939
2166
  const sorted = [...chunks].sort((a, b) => a.chunkKey - b.chunkKey);
2167
+ const tick = yieldEvery(options.clock);
1940
2168
  return driver.putImmutable(key, async (sink) => {
1941
2169
  const writer = new CrbmWriter(sink, { generation: key.generation, crypto: options.crypto });
1942
2170
  for (const { chunkKey, bitmap } of sorted) {
1943
2171
  if (bitmap.isEmpty) continue;
1944
2172
  await writer.addChunk(chunkKey, bitmap.serialize(), bitmap.size);
2173
+ const pause = tick();
2174
+ if (pause !== null) await pause;
1945
2175
  }
1946
2176
  await writer.finish();
1947
2177
  });
@@ -1949,6 +2179,7 @@ function writeCrbmGeneration(driver, key, chunks, options = {}) {
1949
2179
  async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
1950
2180
  const chunkKeys = [];
1951
2181
  let cardinality = 0;
2182
+ const tick = yieldEvery(options.clock);
1952
2183
  const { size, sha256 } = await driver.putImmutable(key, async (sink) => {
1953
2184
  const writer = new CrbmWriter(sink, { generation: key.generation, crypto: options.crypto });
1954
2185
  for await (const { chunkKey, bitmap } of chunks) {
@@ -1956,6 +2187,8 @@ async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
1956
2187
  await writer.addChunk(chunkKey, bitmap.serialize(), bitmap.size);
1957
2188
  chunkKeys.push(chunkKey);
1958
2189
  cardinality += bitmap.size;
2190
+ const pause = tick();
2191
+ if (pause !== null) await pause;
1959
2192
  }
1960
2193
  await writer.finish();
1961
2194
  });
@@ -1996,20 +2229,53 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
1996
2229
  }
1997
2230
  const codec = requireCodec(options.codec, "bulkLoadCrbmGeneration");
1998
2231
  const byChunk = /* @__PURE__ */ new Map();
1999
- for await (const id of toAsyncIterable(ids)) {
2232
+ const pendingByChunk = /* @__PURE__ */ new Map();
2233
+ let pending = 0;
2234
+ const tickChunk = yieldEvery(options.clock);
2235
+ const tickId = yieldEvery(options.clock, YIELD_EVERY_IDS2);
2236
+ const flushPending = async () => {
2237
+ for (const [chunkKey, rems] of pendingByChunk) {
2238
+ if (rems.length === 0) continue;
2239
+ const existingBitmap = byChunk.get(chunkKey);
2240
+ if (existingBitmap === void 0) byChunk.set(chunkKey, codec.fromValues(rems));
2241
+ else existingBitmap.addMany(rems);
2242
+ rems.length = 0;
2243
+ const pause = tickChunk();
2244
+ if (pause !== null) await pause;
2245
+ }
2246
+ pending = 0;
2247
+ };
2248
+ const ingest = (id) => {
2000
2249
  const { chunkKey, remainder } = splitId(id);
2001
- let bitmap = byChunk.get(chunkKey);
2002
- if (bitmap === void 0) {
2003
- bitmap = codec.empty();
2004
- byChunk.set(chunkKey, bitmap);
2250
+ let bucket = pendingByChunk.get(chunkKey);
2251
+ if (bucket === void 0) {
2252
+ bucket = [];
2253
+ pendingByChunk.set(chunkKey, bucket);
2254
+ }
2255
+ bucket.push(remainder);
2256
+ return ++pending >= BULK_FLUSH_IDS;
2257
+ };
2258
+ if (Symbol.asyncIterator in ids) {
2259
+ for await (const id of ids) {
2260
+ if (ingest(id)) await flushPending();
2261
+ const pause = tickId();
2262
+ if (pause !== null) await pause;
2263
+ }
2264
+ } else {
2265
+ for (const id of ids) {
2266
+ if (ingest(id)) await flushPending();
2267
+ const pause = tickId();
2268
+ if (pause !== null) await pause;
2005
2269
  }
2006
- bitmap.add(remainder);
2007
2270
  }
2271
+ await flushPending();
2008
2272
  let cardinality = 0;
2009
2273
  const chunks = [];
2010
2274
  for (const [chunkKey, bitmap] of byChunk) {
2011
2275
  cardinality += bitmap.size;
2012
2276
  chunks.push({ chunkKey, bitmap });
2277
+ const pause = tickChunk();
2278
+ if (pause !== null) await pause;
2013
2279
  }
2014
2280
  if (options.keystore !== void 0 && options.registry === void 0) {
2015
2281
  throw new ValidationError("bulk-load encryption requires a registry to store the wrapped DEK");
@@ -2032,7 +2298,10 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
2032
2298
  crypto = { aead: minted.aead, aadFor: (scope) => aadFor(key, key.generation, scope) };
2033
2299
  }
2034
2300
  }
2035
- const { size, sha256 } = await writeCrbmGeneration(driver, key, chunks, { crypto });
2301
+ const { size, sha256 } = await writeCrbmGeneration(driver, key, chunks, {
2302
+ crypto,
2303
+ clock: options.clock
2304
+ });
2036
2305
  if (options.registry !== void 0) {
2037
2306
  const becameCurrent = await publishGeneration(options.registry, key, {
2038
2307
  wrappedDeks: newWrapped
@@ -2048,13 +2317,6 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
2048
2317
  }
2049
2318
  return { size, sha256, chunkCount: chunks.length, cardinality };
2050
2319
  }
2051
- async function* toAsyncIterable(src) {
2052
- if (Symbol.asyncIterator in src) {
2053
- yield* src;
2054
- } else {
2055
- yield* src;
2056
- }
2057
- }
2058
2320
 
2059
2321
  // src/core/compaction.ts
2060
2322
  var DEFAULT_MAX_BITMAP_BYTES2 = 1 << 20;
@@ -3788,6 +4050,7 @@ async function eraseWarm(warm, ref) {
3788
4050
  }
3789
4051
 
3790
4052
  // src/core/consistency.ts
4053
+ var DEFAULT_MAX_SCAN_SEGMENTS = 25e4;
3791
4054
  var DEFAULT_CHECK_CONCURRENCY = 8;
3792
4055
  async function listGenerations(cold, ref) {
3793
4056
  const present = /* @__PURE__ */ new Set();
@@ -3799,8 +4062,21 @@ async function runConsistencyCheck(deps, options = {}) {
3799
4062
  if (!Number.isInteger(concurrency) || concurrency < 1) {
3800
4063
  throw new ValidationError(`concurrency must be a positive integer; got ${concurrency}`);
3801
4064
  }
4065
+ const maxScanSegments = options.maxScanSegments ?? DEFAULT_MAX_SCAN_SEGMENTS;
4066
+ if (!Number.isFinite(maxScanSegments) || maxScanSegments < 1) {
4067
+ throw new ValidationError(
4068
+ `maxScanSegments must be a finite number >= 1; got ${String(options.maxScanSegments)}`
4069
+ );
4070
+ }
3802
4071
  const recs = [];
3803
- for await (const rec of deps.registry.list(options.namespace)) recs.push(rec);
4072
+ for await (const rec of deps.registry.list(options.namespace)) {
4073
+ recs.push(rec);
4074
+ if (recs.length > maxScanSegments) {
4075
+ throw new BudgetExceededError(
4076
+ `checkConsistency would enumerate more than ${maxScanSegments} segments \u2014 the scan was abandoned there rather than completed. Narrow it with \`namespace\`, or raise \`maxScanSegments\` if the fleet really is that large and the memory is available.`
4077
+ );
4078
+ }
4079
+ }
3804
4080
  const results = await mapWithConcurrency(recs, concurrency, async (rec) => {
3805
4081
  if (rec.status === "destroyed") return { kind: "ok" };
3806
4082
  const ref = { segment: rec.segment, namespace: rec.namespace };
@@ -4004,9 +4280,12 @@ exports.CrbmColdChunkSource = CrbmColdChunkSource;
4004
4280
  exports.CrbmReader = CrbmReader;
4005
4281
  exports.CrbmWriter = CrbmWriter;
4006
4282
  exports.DEFAULT_BUDGET = DEFAULT_BUDGET;
4283
+ exports.DEFAULT_MAX_SCAN_SEGMENTS = DEFAULT_MAX_SCAN_SEGMENTS;
4284
+ exports.DEFAULT_MAX_WARM_SCAN_BYTES = DEFAULT_MAX_WARM_SCAN_BYTES;
4007
4285
  exports.DEFAULT_OCC_BACKOFF = DEFAULT_OCC_BACKOFF;
4008
4286
  exports.DEFAULT_PRICING = DEFAULT_PRICING;
4009
4287
  exports.DEFAULT_RETRY_POLICY = DEFAULT_RETRY_POLICY;
4288
+ exports.DEFAULT_WRITE_CONCURRENCY = DEFAULT_WRITE_CONCURRENCY;
4010
4289
  exports.InProcessKeystore = InProcessKeystore;
4011
4290
  exports.IntegrityError = IntegrityError;
4012
4291
  exports.KeyUnavailableError = KeyUnavailableError;
@@ -4037,6 +4316,7 @@ exports.aadFor = aadFor;
4037
4316
  exports.bulkLoadCrbmGeneration = bulkLoadCrbmGeneration;
4038
4317
  exports.checkBudget = checkBudget;
4039
4318
  exports.chunkRefKey = chunkRefKey;
4319
+ exports.collectWithinBudget = collectWithinBudget;
4040
4320
  exports.compactSegment = compactSegment;
4041
4321
  exports.destroySegment = destroySegment;
4042
4322
  exports.eraseNamespace = eraseNamespace;