@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 +296 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +260 -11
- package/dist/index.d.ts +260 -11
- package/dist/index.js +296 -57
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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 ??
|
|
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,
|
|
499
|
+
return this.mutateMany(seg, ids, applyAddAll);
|
|
463
500
|
}
|
|
464
501
|
removeMany(seg, ids) {
|
|
465
|
-
return this.mutateMany(seg, ids,
|
|
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
|
-
|
|
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(
|
|
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
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
|
575
|
-
for (const k of
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
646
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
4187
|
+
const started = await withRetry(
|
|
3955
4188
|
async () => {
|
|
3956
|
-
const
|
|
3957
|
-
|
|
3958
|
-
return
|
|
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
|
-
|
|
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;
|