@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.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);
@@ -117,6 +125,31 @@ function checkBudget(budget, projectedRequests, op) {
117
125
  );
118
126
  }
119
127
  }
128
+ async function collectWithinBudget(source, budget, op) {
129
+ const out = [];
130
+ for await (const item of source) {
131
+ out.push(item);
132
+ if (budget !== null && out.length > budget.maxRequests) {
133
+ throw new BudgetExceededError(
134
+ `${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\``
135
+ );
136
+ }
137
+ }
138
+ return out;
139
+ }
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 pause = clock.yieldNow !== void 0 ? () => clock.yieldNow() : () => clock.sleep(1);
146
+ let n = 0;
147
+ return () => {
148
+ if (++n < everyN) return null;
149
+ n = 0;
150
+ return pause();
151
+ };
152
+ }
120
153
 
121
154
  // src/core/keys.ts
122
155
  var FIELD = " ";
@@ -297,6 +330,8 @@ async function withRetry(op, policy, deps) {
297
330
  var DEFAULT_MAX_BITMAP_BYTES = 1 << 20;
298
331
  var DEFAULT_MAX_RETRIES = 16;
299
332
  var DEFAULT_INTERSECT_CONCURRENCY = 8;
333
+ var MUTATE_STAGE_IDS = 1 << 20;
334
+ var YIELD_EVERY_IDS = 1 << 14;
300
335
  var InstantClock = class {
301
336
  now() {
302
337
  return 0;
@@ -310,6 +345,8 @@ var ZeroRng = class {
310
345
  return 0;
311
346
  }
312
347
  };
348
+ var DEFAULT_MAX_WARM_SCAN_BYTES = 64 * 1024 * 1024;
349
+ var DEFAULT_WRITE_CONCURRENCY = 4;
313
350
  var SegmentEngine = class {
314
351
  warm;
315
352
  cold;
@@ -325,6 +362,7 @@ var SegmentEngine = class {
325
362
  /** Consistency for READ-path Warm fetches (precomputed; OCC RMW passes strong explicitly, see `warmGet`). */
326
363
  readOpts;
327
364
  writeConcurrency;
365
+ maxWarmScanBytes;
328
366
  /** Resolved per-op budget (null = disabled); undefined deps ⇒ the generous default. See {@link checkBudget}. */
329
367
  budget;
330
368
  constructor(deps) {
@@ -338,7 +376,13 @@ var SegmentEngine = class {
338
376
  this.rng = deps.rng ?? new ZeroRng();
339
377
  this.occBackoff = deps.occBackoff ?? DEFAULT_OCC_BACKOFF;
340
378
  this.readOpts = { consistent: (deps.warmReadConsistency ?? "strong") !== "eventual" };
341
- this.writeConcurrency = deps.writeConcurrency ?? 1;
379
+ this.writeConcurrency = deps.writeConcurrency ?? DEFAULT_WRITE_CONCURRENCY;
380
+ this.maxWarmScanBytes = deps.maxWarmScanBytes ?? DEFAULT_MAX_WARM_SCAN_BYTES;
381
+ if (!Number.isFinite(this.maxWarmScanBytes) || this.maxWarmScanBytes < 1) {
382
+ throw new ValidationError(
383
+ `maxWarmScanBytes must be a finite number >= 1 (got ${String(deps.maxWarmScanBytes)})`
384
+ );
385
+ }
342
386
  this.budget = deps.budget === void 0 ? DEFAULT_BUDGET : deps.budget;
343
387
  this.metrics = safeMetrics(deps.metrics ?? NOOP_METRICS);
344
388
  this.metricsOn = this.metrics !== NOOP_METRICS;
@@ -352,10 +396,10 @@ var SegmentEngine = class {
352
396
  return this.readModifyWrite(seg, chunkKey, (d) => applyRemove(d, remainder));
353
397
  }
354
398
  addMany(seg, ids) {
355
- return this.mutateMany(seg, ids, applyAdd);
399
+ return this.mutateMany(seg, ids, applyAddAll);
356
400
  }
357
401
  removeMany(seg, ids) {
358
- return this.mutateMany(seg, ids, applyRemove);
402
+ return this.mutateMany(seg, ids, applyRemoveAll);
359
403
  }
360
404
  async has(seg, id, opts) {
361
405
  const { chunkKey, remainder } = splitId(id);
@@ -370,7 +414,7 @@ var SegmentEngine = class {
370
414
  return cold ? cold.has(remainder) : false;
371
415
  }
372
416
  async count(seg) {
373
- const warmRows = await this.collectWarm(seg);
417
+ const warmRows = await this.collectWarm(seg, "count", this.budget?.maxRequests ?? null);
374
418
  const cardinalities = this.cold.cardinalities ? await this.cold.cardinalities(seg) : null;
375
419
  if (!cardinalities) {
376
420
  const chunkKeys = await this.chunkKeys(seg, warmRows);
@@ -402,7 +446,7 @@ var SegmentEngine = class {
402
446
  return this.cold.sizeOf ? this.cold.sizeOf(seg) : Promise.resolve(null);
403
447
  }
404
448
  async *iterate(seg) {
405
- const warmRows = await this.collectWarm(seg);
449
+ const warmRows = await this.collectWarm(seg, "iterate", this.budget?.maxRequests ?? null);
406
450
  const chunkKeys = await this.chunkKeys(seg, warmRows);
407
451
  checkBudget(this.budget, chunkKeys.length, "iterate");
408
452
  const gen = await this.currentGen(seg);
@@ -436,9 +480,44 @@ var SegmentEngine = class {
436
480
  * re-resolves fresh (bypassing the TTL), which can hop generations even sub-TTL — still whole/immutable per
437
481
  * read, never torn. Full intra-op snapshot isolation is deferred (gap #4).
438
482
  */
439
- async *intersect(segs, options) {
483
+ /**
484
+ * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
485
+ *
486
+ * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
487
+ * afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
488
+ * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
489
+ * operand is read **only at the keys that survived the intersection**, so a 61,000-chunk global opt-out list
490
+ * intersected against a narrow audience costs a handful of reads, not 61,000.
491
+ */
492
+ intersect(segs, options) {
493
+ return this.combine(segs, options?.exclude ?? [], "all", "intersect", options);
494
+ }
495
+ /**
496
+ * `segs[0] ∪ segs[1] ∪ …`, minus every id in `options.exclude` — streamed ascending.
497
+ *
498
+ * **Union is the one composite read with no chunk-skipping**, and that is a property of the operation rather
499
+ * than of this implementation: an id in *any* operand belongs to the result, so every chunk of every operand
500
+ * has to be read. `intersect` can prune a key absent from any operand; union cannot prune anything. It is
501
+ * budgeted exactly like `intersect` so a wide union is refused rather than quietly billed.
502
+ */
503
+ union(segs, options) {
504
+ return this.combine(segs, options?.exclude ?? [], "any", "union", options);
505
+ }
506
+ /**
507
+ * `seg \ (excludes[0] ∪ excludes[1] ∪ …)` — streamed ascending. Suppression on its own.
508
+ *
509
+ * Reads every chunk of `seg` (any of them may survive) but each exclude **only where it overlaps `seg`**, so
510
+ * the cost scales with the segment being filtered rather than with the size of the suppression list.
511
+ */
512
+ andNot(seg, excludes, options) {
513
+ return this.combine([seg], excludes, "all", "andNot", options);
514
+ }
515
+ async *combine(segs, excludeSegs, mode, op, options) {
440
516
  if (segs.length === 0) {
441
- throw new ValidationError("intersect requires at least one segment");
517
+ throw new ValidationError(`${op} requires at least one segment`);
518
+ }
519
+ if (op === "andNot" && excludeSegs.length === 0) {
520
+ throw new ValidationError("andNot requires at least one segment to subtract");
442
521
  }
443
522
  const limit = options?.concurrency ?? DEFAULT_INTERSECT_CONCURRENCY;
444
523
  if (!Number.isInteger(limit) || limit < 1) {
@@ -447,34 +526,52 @@ var SegmentEngine = class {
447
526
  );
448
527
  }
449
528
  const budget = resolvePerOpBudget(options?.budget, this.budget);
450
- const operands = await Promise.all(
451
- segs.map(async (seg) => {
452
- const warmRows = await this.collectWarm(seg);
453
- const keys = await this.chunkKeys(seg, warmRows);
454
- const gen = await this.currentGen(seg);
455
- return { seg, warmRows, keys: new Set(keys), gen };
456
- })
457
- );
458
- const pivot = operands.reduce((a, b) => a.keys.size <= b.keys.size ? a : b);
459
- const common = [];
460
- for (const k of pivot.keys) {
461
- if (operands.every((op) => op.keys.has(k))) common.push(k);
462
- }
463
- common.sort((a, b) => a - b);
464
- checkBudget(budget, common.length * operands.length, "intersect");
529
+ const extract = async (seg) => {
530
+ const warmRows = await this.collectWarm(seg, op, null);
531
+ const keys = await this.chunkKeys(seg, warmRows);
532
+ const gen = await this.currentGen(seg);
533
+ return { seg, warmRows, keys: new Set(keys), gen };
534
+ };
535
+ const [operands, excludes] = await Promise.all([
536
+ Promise.all(segs.map(extract)),
537
+ Promise.all(excludeSegs.map(extract))
538
+ ]);
539
+ const candidates = [];
540
+ if (mode === "all") {
541
+ const pivot = operands.reduce((a, b) => a.keys.size <= b.keys.size ? a : b);
542
+ for (const k of pivot.keys) {
543
+ if (operands.every((o) => o.keys.has(k))) candidates.push(k);
544
+ }
545
+ } else {
546
+ const seen = /* @__PURE__ */ new Set();
547
+ for (const o of operands) for (const k of o.keys) seen.add(k);
548
+ for (const k of seen) candidates.push(k);
549
+ }
550
+ candidates.sort((a, b) => a - b);
551
+ const common = candidates;
552
+ let units = 0;
553
+ for (const key of common) {
554
+ for (const o of operands) if (mode === "all" || o.keys.has(key)) units += 1;
555
+ for (const e of excludes) if (e.keys.has(key)) units += 1;
556
+ }
557
+ checkBudget(budget, units, op);
465
558
  if (this.metricsOn) {
466
559
  const distinctKeys = /* @__PURE__ */ new Set();
467
- for (const op of operands) {
468
- for (const k of op.keys) distinctKeys.add(k);
560
+ for (const op2 of operands) {
561
+ for (const k of op2.keys) distinctKeys.add(k);
469
562
  }
563
+ for (const e of excludes) for (const k of e.keys) distinctKeys.add(k);
470
564
  this.metrics.onEvent({
471
565
  kind: "intersect",
472
- operands: segs.length,
566
+ op,
567
+ operands: segs.length + excludes.length,
473
568
  fetchedChunks: common.length,
569
+ // Never negative: under 'any' every candidate key came from some operand, so `distinctKeys` is a
570
+ // superset of `common`; excludes only ever add keys here.
474
571
  skippedChunks: distinctKeys.size - common.length
475
572
  });
476
573
  }
477
- const startAt = (key) => this.intersectChunk(operands, key).then(
574
+ const startAt = (key) => this.combineChunk(operands, excludes, mode, key).then(
478
575
  (result) => ({ key, result }),
479
576
  (error) => ({ key, result: null, error })
480
577
  );
@@ -496,16 +593,33 @@ var SegmentEngine = class {
496
593
  * first operand's effective chunk and mutates it in place — safe because `effectiveChunk` always returns
497
594
  * a FRESH bitmap (never a cached/shared Cold instance), so this can't poison the HOT cache.
498
595
  */
499
- async intersectChunk(operands, chunkKey) {
596
+ async combineChunk(operands, excludes, mode, chunkKey) {
597
+ const present = mode === "all" ? operands : operands.filter((o) => o.keys.has(chunkKey));
598
+ if (present.length === 0) return null;
500
599
  const chunks = await Promise.all(
501
- operands.map((op) => this.effectiveChunk(op.seg, chunkKey, op.warmRows, op.gen))
600
+ present.map((op) => this.effectiveChunk(op.seg, chunkKey, op.warmRows, op.gen))
502
601
  );
503
602
  const acc = chunks[0];
504
603
  for (let i = 1; i < chunks.length; i++) {
505
- acc.andInPlace(chunks[i]);
506
- if (acc.isEmpty) return null;
604
+ if (mode === "all") {
605
+ acc.andInPlace(chunks[i]);
606
+ if (acc.isEmpty) return null;
607
+ } else {
608
+ acc.orInPlace(chunks[i]);
609
+ }
610
+ }
611
+ if (acc.isEmpty) return null;
612
+ const relevant = excludes.filter((e) => e.keys.has(chunkKey));
613
+ if (relevant.length > 0) {
614
+ const cuts = await Promise.all(
615
+ relevant.map((e) => this.effectiveChunk(e.seg, chunkKey, e.warmRows, e.gen))
616
+ );
617
+ for (const cut of cuts) {
618
+ acc.andNotInPlace(cut);
619
+ if (acc.isEmpty) return null;
620
+ }
507
621
  }
508
- return acc.isEmpty ? null : acc;
622
+ return acc;
509
623
  }
510
624
  /**
511
625
  * Materialize `seg[0] ∩ seg[1] ∩ …` into `dest` via batched `addMany` (streaming, bounded memory).
@@ -513,9 +627,30 @@ var SegmentEngine = class {
513
627
  * `dest` is added to, not replaced.
514
628
  */
515
629
  async intersectInto(dest, segs, options) {
516
- const batchSize = Math.max(1, options?.batchSize ?? 4096);
630
+ await this.drainInto(dest, this.intersect(segs, options), options?.batchSize);
631
+ }
632
+ /**
633
+ * Materialize `segs[0] ∪ segs[1] ∪ …` (minus `options.exclude`) into `dest`. **Not atomic**, like
634
+ * {@link intersectInto}. See {@link union} for why this one cannot skip any chunk.
635
+ */
636
+ async unionInto(dest, segs, options) {
637
+ await this.drainInto(dest, this.union(segs, options), options?.batchSize);
638
+ }
639
+ /** Materialize `seg \ (excludes…)` into `dest`. **Not atomic**, like {@link intersectInto}. */
640
+ async andNotInto(dest, seg, excludes, options) {
641
+ await this.drainInto(dest, this.andNot(seg, excludes, options), options?.batchSize);
642
+ }
643
+ /**
644
+ * Stream a combine's results into `dest` in bounded batches.
645
+ *
646
+ * `dest` is **added to, not replaced**, and this is **not atomic** — ids land chunk-by-chunk, so a failure
647
+ * mid-stream leaves a partial result, exactly as `addMany` does. Batching keeps memory bounded no matter how
648
+ * large the result is.
649
+ */
650
+ async drainInto(dest, source, batchSizeOption) {
651
+ const batchSize = Math.max(1, batchSizeOption ?? 4096);
517
652
  let batch = [];
518
- for await (const id of this.intersect(segs, options)) {
653
+ for await (const id of source) {
519
654
  batch.push(id);
520
655
  if (batch.length >= batchSize) {
521
656
  await this.addMany(dest, batch);
@@ -524,19 +659,77 @@ var SegmentEngine = class {
524
659
  }
525
660
  if (batch.length > 0) await this.addMany(dest, batch);
526
661
  }
662
+ /**
663
+ * Group ids by chunk, then apply each chunk's whole batch in a single OCC read-modify-write.
664
+ *
665
+ * Accepts a **sync or async** iterable, so an Athena/BigQuery cursor streams straight in without the caller
666
+ * hand-batching `page → addMany(page)`.
667
+ *
668
+ * **The pending ids are held as BITMAPS, not as JS numbers, and that is the load-bearing choice.** Streaming
669
+ * input is unbounded by definition, so something has to give. The obvious answer — buffer N ids, flush them to
670
+ * the backend, repeat — bounds memory and is quietly catastrophic on cost: with ids arriving in arbitrary
671
+ * order, every flush touches nearly every chunk again, so an 11M-id stream at a 1M-id buffer would issue
672
+ * **11x** the OCC round-trips of a single pass. In a library whose headline claim is honest cost, silently
673
+ * multiplying someone's write bill by the size of their input is not a bounded-memory strategy; it is a
674
+ * different bug.
675
+ *
676
+ * So the staging buffer is capped, but it folds into per-chunk **compressed bitmaps** rather than being
677
+ * written out. Peak memory is the roaring representation of the ids seen so far — which is what the segment
678
+ * itself costs, and the floor for any implementation that writes each chunk exactly once — while the backend
679
+ * still sees exactly one read-modify-write per distinct chunk regardless of stream length. This is strictly
680
+ * less memory than the previous sync-only version, which held every remainder as an uncompressed JS number.
681
+ *
682
+ * There is no way to do better without a sorted stream: a chunk can only be written once it is known that no
683
+ * further ids for it will arrive, and unsorted input never gives that guarantee. Callers streaming *very*
684
+ * large sets want `bulkLoadCrbmGeneration` instead — it writes one immutable Cold object rather than tens of
685
+ * thousands of warm rows. The crossover is documented in the guide.
686
+ */
527
687
  async mutateMany(seg, ids, apply) {
528
688
  const byChunk = /* @__PURE__ */ new Map();
529
- for (const id of ids) {
689
+ const staged = /* @__PURE__ */ new Map();
690
+ let pending = 0;
691
+ const tickId = yieldEvery(this.clock, YIELD_EVERY_IDS);
692
+ const compact = () => {
693
+ for (const [chunkKey, rems] of staged) {
694
+ if (rems.length === 0) continue;
695
+ const existing = byChunk.get(chunkKey);
696
+ if (existing === void 0) byChunk.set(chunkKey, this.codec.fromValues(rems));
697
+ else existing.addMany(rems);
698
+ rems.length = 0;
699
+ }
700
+ pending = 0;
701
+ };
702
+ const stage = (id) => {
530
703
  const { chunkKey, remainder } = splitId(id);
531
- const rems = byChunk.get(chunkKey) ?? [];
704
+ let rems = staged.get(chunkKey);
705
+ if (rems === void 0) {
706
+ rems = [];
707
+ staged.set(chunkKey, rems);
708
+ }
532
709
  rems.push(remainder);
533
- byChunk.set(chunkKey, rems);
710
+ pending += 1;
711
+ };
712
+ if (Symbol.asyncIterator in ids) {
713
+ for await (const id of ids) {
714
+ stage(id);
715
+ if (pending >= MUTATE_STAGE_IDS) compact();
716
+ const pause = tickId();
717
+ if (pause !== null) await pause;
718
+ }
719
+ } else {
720
+ for (const id of ids) {
721
+ stage(id);
722
+ if (pending >= MUTATE_STAGE_IDS) compact();
723
+ const pause = tickId();
724
+ if (pause !== null) await pause;
725
+ }
534
726
  }
727
+ compact();
535
728
  await mapWithConcurrency(
536
729
  [...byChunk],
537
730
  this.writeConcurrency,
538
- ([chunkKey, rems]) => this.readModifyWrite(seg, chunkKey, (d) => {
539
- for (const r of rems) apply(d, r);
731
+ ([chunkKey, bitmap]) => this.readModifyWrite(seg, chunkKey, (d) => {
732
+ apply(d, bitmap);
540
733
  })
541
734
  );
542
735
  }
@@ -590,12 +783,22 @@ var SegmentEngine = class {
590
783
  }
591
784
  return row;
592
785
  }
593
- async collectWarm(seg) {
786
+ async collectWarm(seg, op, rowCeiling) {
594
787
  const rows = /* @__PURE__ */ new Map();
595
788
  let bytes = 0;
596
789
  for await (const row of this.warm.listChunks(seg, this.readOpts)) {
597
790
  rows.set(row.chunkKey, row.bytes);
598
791
  bytes += row.bytes.length;
792
+ if (bytes > this.maxWarmScanBytes) {
793
+ throw new BudgetExceededError(
794
+ `${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\`.)`
795
+ );
796
+ }
797
+ if (rowCeiling !== null && rows.size > rowCeiling) {
798
+ throw new BudgetExceededError(
799
+ `${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\``
800
+ );
801
+ }
599
802
  }
600
803
  if (this.metricsOn) {
601
804
  this.metrics.onEvent({
@@ -1083,22 +1286,44 @@ function aadFor(ref, generation, scope) {
1083
1286
  }
1084
1287
 
1085
1288
  // src/core/crbm/crc32c.ts
1086
- var TABLE = (() => {
1289
+ var TABLES = (() => {
1087
1290
  const POLY = 2197175160;
1088
- const table = new Uint32Array(256);
1291
+ const t0 = new Uint32Array(256);
1089
1292
  for (let n = 0; n < 256; n++) {
1090
1293
  let c = n;
1091
- for (let k = 0; k < 8; k++) {
1092
- c = c & 1 ? POLY ^ c >>> 1 : c >>> 1;
1294
+ for (let k = 0; k < 8; k++) c = c & 1 ? POLY ^ c >>> 1 : c >>> 1;
1295
+ t0[n] = c >>> 0;
1296
+ }
1297
+ const tables = [t0];
1298
+ for (let k = 1; k < 8; k++) {
1299
+ const prev = tables[k - 1];
1300
+ const next = new Uint32Array(256);
1301
+ for (let n = 0; n < 256; n++) {
1302
+ const v = prev[n];
1303
+ next[n] = (v >>> 8 ^ t0[v & 255]) >>> 0;
1093
1304
  }
1094
- table[n] = c >>> 0;
1305
+ tables.push(next);
1095
1306
  }
1096
- return table;
1307
+ return tables;
1097
1308
  })();
1309
+ var T0 = TABLES[0];
1310
+ var T1 = TABLES[1];
1311
+ var T2 = TABLES[2];
1312
+ var T3 = TABLES[3];
1313
+ var T4 = TABLES[4];
1314
+ var T5 = TABLES[5];
1315
+ var T6 = TABLES[6];
1316
+ var T7 = TABLES[7];
1098
1317
  function crc32c(bytes, seed = 0) {
1099
1318
  let crc = (seed ^ 4294967295) >>> 0;
1100
- for (let i = 0; i < bytes.length; i++) {
1101
- crc = (TABLE[(crc ^ bytes[i]) & 255] ^ crc >>> 8) >>> 0;
1319
+ let i = 0;
1320
+ const n8 = bytes.length - bytes.length % 8;
1321
+ for (; i < n8; i += 8) {
1322
+ crc ^= bytes[i] | bytes[i + 1] << 8 | bytes[i + 2] << 16 | bytes[i + 3] << 24;
1323
+ 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;
1324
+ }
1325
+ for (; i < bytes.length; i++) {
1326
+ crc = (T0[(crc ^ bytes[i]) & 255] ^ crc >>> 8) >>> 0;
1102
1327
  }
1103
1328
  return (crc ^ 4294967295) >>> 0;
1104
1329
  }
@@ -1609,6 +1834,8 @@ function coldBlobReader(driver, key) {
1609
1834
  };
1610
1835
  }
1611
1836
  var DEFAULT_CURRENT_GEN_TTL_MS = 2e3;
1837
+ var BULK_FLUSH_IDS = 1 << 20;
1838
+ var YIELD_EVERY_IDS2 = 1 << 14;
1612
1839
  var DEFAULT_MAX_OPEN_SEGMENTS = 1024;
1613
1840
  var DEFAULT_MAX_OPEN_INDEX_BYTES = 64 * 1024 * 1024;
1614
1841
  var CrbmColdChunkSource = class {
@@ -1827,11 +2054,14 @@ var CrbmColdChunkSource = class {
1827
2054
  };
1828
2055
  function writeCrbmGeneration(driver, key, chunks, options = {}) {
1829
2056
  const sorted = [...chunks].sort((a, b) => a.chunkKey - b.chunkKey);
2057
+ const tick = yieldEvery(options.clock);
1830
2058
  return driver.putImmutable(key, async (sink) => {
1831
2059
  const writer = new CrbmWriter(sink, { generation: key.generation, crypto: options.crypto });
1832
2060
  for (const { chunkKey, bitmap } of sorted) {
1833
2061
  if (bitmap.isEmpty) continue;
1834
2062
  await writer.addChunk(chunkKey, bitmap.serialize(), bitmap.size);
2063
+ const pause = tick();
2064
+ if (pause !== null) await pause;
1835
2065
  }
1836
2066
  await writer.finish();
1837
2067
  });
@@ -1839,6 +2069,7 @@ function writeCrbmGeneration(driver, key, chunks, options = {}) {
1839
2069
  async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
1840
2070
  const chunkKeys = [];
1841
2071
  let cardinality = 0;
2072
+ const tick = yieldEvery(options.clock);
1842
2073
  const { size, sha256 } = await driver.putImmutable(key, async (sink) => {
1843
2074
  const writer = new CrbmWriter(sink, { generation: key.generation, crypto: options.crypto });
1844
2075
  for await (const { chunkKey, bitmap } of chunks) {
@@ -1846,6 +2077,8 @@ async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
1846
2077
  await writer.addChunk(chunkKey, bitmap.serialize(), bitmap.size);
1847
2078
  chunkKeys.push(chunkKey);
1848
2079
  cardinality += bitmap.size;
2080
+ const pause = tick();
2081
+ if (pause !== null) await pause;
1849
2082
  }
1850
2083
  await writer.finish();
1851
2084
  });
@@ -1886,20 +2119,53 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
1886
2119
  }
1887
2120
  const codec = requireCodec(options.codec, "bulkLoadCrbmGeneration");
1888
2121
  const byChunk = /* @__PURE__ */ new Map();
1889
- for await (const id of toAsyncIterable(ids)) {
2122
+ const pendingByChunk = /* @__PURE__ */ new Map();
2123
+ let pending = 0;
2124
+ const tickChunk = yieldEvery(options.clock);
2125
+ const tickId = yieldEvery(options.clock, YIELD_EVERY_IDS2);
2126
+ const flushPending = async () => {
2127
+ for (const [chunkKey, rems] of pendingByChunk) {
2128
+ if (rems.length === 0) continue;
2129
+ const existingBitmap = byChunk.get(chunkKey);
2130
+ if (existingBitmap === void 0) byChunk.set(chunkKey, codec.fromValues(rems));
2131
+ else existingBitmap.addMany(rems);
2132
+ rems.length = 0;
2133
+ const pause = tickChunk();
2134
+ if (pause !== null) await pause;
2135
+ }
2136
+ pending = 0;
2137
+ };
2138
+ const ingest = (id) => {
1890
2139
  const { chunkKey, remainder } = splitId(id);
1891
- let bitmap = byChunk.get(chunkKey);
1892
- if (bitmap === void 0) {
1893
- bitmap = codec.empty();
1894
- byChunk.set(chunkKey, bitmap);
2140
+ let bucket = pendingByChunk.get(chunkKey);
2141
+ if (bucket === void 0) {
2142
+ bucket = [];
2143
+ pendingByChunk.set(chunkKey, bucket);
2144
+ }
2145
+ bucket.push(remainder);
2146
+ return ++pending >= BULK_FLUSH_IDS;
2147
+ };
2148
+ if (Symbol.asyncIterator in ids) {
2149
+ for await (const id of ids) {
2150
+ if (ingest(id)) await flushPending();
2151
+ const pause = tickId();
2152
+ if (pause !== null) await pause;
2153
+ }
2154
+ } else {
2155
+ for (const id of ids) {
2156
+ if (ingest(id)) await flushPending();
2157
+ const pause = tickId();
2158
+ if (pause !== null) await pause;
1895
2159
  }
1896
- bitmap.add(remainder);
1897
2160
  }
2161
+ await flushPending();
1898
2162
  let cardinality = 0;
1899
2163
  const chunks = [];
1900
2164
  for (const [chunkKey, bitmap] of byChunk) {
1901
2165
  cardinality += bitmap.size;
1902
2166
  chunks.push({ chunkKey, bitmap });
2167
+ const pause = tickChunk();
2168
+ if (pause !== null) await pause;
1903
2169
  }
1904
2170
  if (options.keystore !== void 0 && options.registry === void 0) {
1905
2171
  throw new ValidationError("bulk-load encryption requires a registry to store the wrapped DEK");
@@ -1922,7 +2188,10 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
1922
2188
  crypto = { aead: minted.aead, aadFor: (scope) => aadFor(key, key.generation, scope) };
1923
2189
  }
1924
2190
  }
1925
- const { size, sha256 } = await writeCrbmGeneration(driver, key, chunks, { crypto });
2191
+ const { size, sha256 } = await writeCrbmGeneration(driver, key, chunks, {
2192
+ crypto,
2193
+ clock: options.clock
2194
+ });
1926
2195
  if (options.registry !== void 0) {
1927
2196
  const becameCurrent = await publishGeneration(options.registry, key, {
1928
2197
  wrappedDeks: newWrapped
@@ -1938,13 +2207,6 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
1938
2207
  }
1939
2208
  return { size, sha256, chunkCount: chunks.length, cardinality };
1940
2209
  }
1941
- async function* toAsyncIterable(src) {
1942
- if (Symbol.asyncIterator in src) {
1943
- yield* src;
1944
- } else {
1945
- yield* src;
1946
- }
1947
- }
1948
2210
 
1949
2211
  // src/core/compaction.ts
1950
2212
  var DEFAULT_MAX_BITMAP_BYTES2 = 1 << 20;
@@ -3472,6 +3734,7 @@ async function eraseWarm(warm, ref) {
3472
3734
  }
3473
3735
 
3474
3736
  // src/core/consistency.ts
3737
+ var DEFAULT_MAX_SCAN_SEGMENTS = 25e4;
3475
3738
  var DEFAULT_CHECK_CONCURRENCY = 8;
3476
3739
  async function listGenerations(cold, ref) {
3477
3740
  const present = /* @__PURE__ */ new Set();
@@ -3483,8 +3746,21 @@ async function runConsistencyCheck(deps, options = {}) {
3483
3746
  if (!Number.isInteger(concurrency) || concurrency < 1) {
3484
3747
  throw new ValidationError(`concurrency must be a positive integer; got ${concurrency}`);
3485
3748
  }
3749
+ const maxScanSegments = options.maxScanSegments ?? DEFAULT_MAX_SCAN_SEGMENTS;
3750
+ if (!Number.isFinite(maxScanSegments) || maxScanSegments < 1) {
3751
+ throw new ValidationError(
3752
+ `maxScanSegments must be a finite number >= 1; got ${String(options.maxScanSegments)}`
3753
+ );
3754
+ }
3486
3755
  const recs = [];
3487
- for await (const rec of deps.registry.list(options.namespace)) recs.push(rec);
3756
+ for await (const rec of deps.registry.list(options.namespace)) {
3757
+ recs.push(rec);
3758
+ if (recs.length > maxScanSegments) {
3759
+ throw new BudgetExceededError(
3760
+ `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.`
3761
+ );
3762
+ }
3763
+ }
3488
3764
  const results = await mapWithConcurrency(recs, concurrency, async (rec) => {
3489
3765
  if (rec.status === "destroyed") return { kind: "ok" };
3490
3766
  const ref = { segment: rec.segment, namespace: rec.namespace };
@@ -3676,6 +3952,6 @@ var RetryingRegistryDriver = class {
3676
3952
  }
3677
3953
  };
3678
3954
 
3679
- export { AWS_US_EAST_1_ONDEMAND, BoundedLru, BufferReader, BufferSink, CountingMetricsSink, CrbmColdChunkSource, CrbmReader, CrbmWriter, DEFAULT_BUDGET, 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, compactSegment, destroySegment, eraseNamespace, estimateCost, findCompactable, gcOrphanGenerations, groundedReport, isTransient, joinId, publishGeneration, resolveBudget, resolvePerOpBudget, runCompactionCycle, runConsistencyCheck, runExport, safeMetrics, segmentKey, splitId, validateCompactionOptions, withRetry, writeCrbmGeneration };
3955
+ 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 };
3680
3956
  //# sourceMappingURL=index.js.map
3681
3957
  //# sourceMappingURL=index.js.map