@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.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 ??
|
|
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,
|
|
412
|
+
return this.mutateMany(seg, ids, applyAddAll);
|
|
376
413
|
}
|
|
377
414
|
removeMany(seg, ids) {
|
|
378
|
-
return this.mutateMany(seg, ids,
|
|
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
|
-
|
|
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(
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
488
|
-
for (const k of
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
559
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
3871
|
+
const started = await withRetry(
|
|
3639
3872
|
async () => {
|
|
3640
|
-
const
|
|
3641
|
-
|
|
3642
|
-
return
|
|
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
|
-
|
|
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
|