@cloudbitmaps/core 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +81 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +129 -27
- package/dist/index.d.ts +129 -27
- package/dist/index.js +81 -23
- package/dist/index.js.map +1 -1
- package/dist/mysql/index.cjs.map +1 -1
- package/dist/mysql/index.js.map +1 -1
- package/dist/postgres/index.cjs.map +1 -1
- package/dist/postgres/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -142,7 +142,9 @@ async function collectWithinBudget(source, budget, op) {
|
|
|
142
142
|
var YIELD_EVERY = 1024;
|
|
143
143
|
function yieldEvery(clock, everyN = YIELD_EVERY) {
|
|
144
144
|
if (clock === void 0) return () => null;
|
|
145
|
-
const
|
|
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;
|
|
146
148
|
let n = 0;
|
|
147
149
|
return () => {
|
|
148
150
|
if (++n < everyN) return null;
|
|
@@ -339,6 +341,17 @@ var InstantClock = class {
|
|
|
339
341
|
sleep() {
|
|
340
342
|
return Promise.resolve();
|
|
341
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
|
+
}
|
|
342
355
|
};
|
|
343
356
|
var ZeroRng = class {
|
|
344
357
|
next() {
|
|
@@ -456,6 +469,17 @@ var SegmentEngine = class {
|
|
|
456
469
|
}
|
|
457
470
|
}
|
|
458
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
|
+
*
|
|
459
483
|
* Chunk-skipping intersection. Computes
|
|
460
484
|
* `seg[0] ∩ seg[1] ∩ …` and streams the result ids ascending. It aligns each segment's *effective*
|
|
461
485
|
* chunk-key set (Warm ∪ Cold), keeps only keys present in **all** operands (a key missing from any
|
|
@@ -480,15 +504,6 @@ var SegmentEngine = class {
|
|
|
480
504
|
* re-resolves fresh (bypassing the TTL), which can hop generations even sub-TTL — still whole/immutable per
|
|
481
505
|
* read, never torn. Full intra-op snapshot isolation is deferred (gap #4).
|
|
482
506
|
*/
|
|
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
507
|
intersect(segs, options) {
|
|
493
508
|
return this.combine(segs, options?.exclude ?? [], "all", "intersect", options);
|
|
494
509
|
}
|
|
@@ -676,8 +691,10 @@ var SegmentEngine = class {
|
|
|
676
691
|
* So the staging buffer is capped, but it folds into per-chunk **compressed bitmaps** rather than being
|
|
677
692
|
* written out. Peak memory is the roaring representation of the ids seen so far — which is what the segment
|
|
678
693
|
* 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.
|
|
680
|
-
* less
|
|
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.
|
|
681
698
|
*
|
|
682
699
|
* There is no way to do better without a sorted stream: a chunk can only be written once it is known that no
|
|
683
700
|
* further ids for it will arrive, and unsorted input never gives that guarantee. Callers streaming *very*
|
|
@@ -720,8 +737,6 @@ var SegmentEngine = class {
|
|
|
720
737
|
for (const id of ids) {
|
|
721
738
|
stage(id);
|
|
722
739
|
if (pending >= MUTATE_STAGE_IDS) compact();
|
|
723
|
-
const pause = tickId();
|
|
724
|
-
if (pause !== null) await pause;
|
|
725
740
|
}
|
|
726
741
|
}
|
|
727
742
|
compact();
|
|
@@ -2093,6 +2108,10 @@ async function publishGeneration(registry, key, options = {}) {
|
|
|
2093
2108
|
currentGen: key.generation,
|
|
2094
2109
|
wrappedDeks: options.wrappedDeks
|
|
2095
2110
|
});
|
|
2111
|
+
} else if (record.status === "destroyed") {
|
|
2112
|
+
throw new ValidationError(
|
|
2113
|
+
`segment "${key.segment}" was destroyed (crypto-shredded) while generation ${key.generation} was being written \u2014 refusing to publish it; the written object is unreadable. Use a new segment.`
|
|
2114
|
+
);
|
|
2096
2115
|
} else if (record.currentGen > key.generation) {
|
|
2097
2116
|
return false;
|
|
2098
2117
|
} else if (record.currentGen === key.generation) {
|
|
@@ -2312,6 +2331,8 @@ async function compactSegmentInner(ref, deps, options) {
|
|
|
2312
2331
|
let tally;
|
|
2313
2332
|
try {
|
|
2314
2333
|
tally = await writeCrbmGenerationStream(deps.cold, gen0, stream, {
|
|
2334
|
+
clock: deps.clock,
|
|
2335
|
+
// cooperative if the caller's clock can yield; a no-op if it only tells the time
|
|
2315
2336
|
crypto: cryptoAt(aead, 0)
|
|
2316
2337
|
});
|
|
2317
2338
|
} catch (err) {
|
|
@@ -2439,7 +2460,8 @@ async function compactSegmentInner(ref, deps, options) {
|
|
|
2439
2460
|
deps.cold,
|
|
2440
2461
|
{ namespace: ref.namespace, segment: ref.segment, generation: newGen },
|
|
2441
2462
|
stream,
|
|
2442
|
-
cryptoAt(aead, newGen)
|
|
2463
|
+
cryptoAt(aead, newGen),
|
|
2464
|
+
deps.clock
|
|
2443
2465
|
);
|
|
2444
2466
|
try {
|
|
2445
2467
|
await deps.registry.compareAndSwap(ref, leaseToken, {
|
|
@@ -2536,8 +2558,8 @@ async function* mergeChunksStream(ref, g, pinned, deps, maxBytes, readCrypto) {
|
|
|
2536
2558
|
if (!merged.isEmpty) yield { chunkKey, bitmap: merged };
|
|
2537
2559
|
}
|
|
2538
2560
|
}
|
|
2539
|
-
async function stageGeneration(cold, key, chunks, crypto) {
|
|
2540
|
-
const tally = await writeCrbmGenerationStream(cold, key, chunks, { crypto });
|
|
2561
|
+
async function stageGeneration(cold, key, chunks, crypto, clock) {
|
|
2562
|
+
const tally = await writeCrbmGenerationStream(cold, key, chunks, { crypto, clock });
|
|
2541
2563
|
await verifyGeneration(cold, key, tally, crypto);
|
|
2542
2564
|
}
|
|
2543
2565
|
async function verifyGeneration(cold, key, expected, crypto) {
|
|
@@ -3712,12 +3734,13 @@ async function shredSegment(ref, deps, allowCleartext) {
|
|
|
3712
3734
|
}
|
|
3713
3735
|
async function eraseWarm(warm, ref) {
|
|
3714
3736
|
let deleted = 0;
|
|
3737
|
+
let conflicts = 0;
|
|
3715
3738
|
for (let pass = 0; pass < MAX_WARM_PASSES; pass++) {
|
|
3739
|
+
conflicts = 0;
|
|
3716
3740
|
const rows = [];
|
|
3717
3741
|
for await (const row of warm.listChunks(ref))
|
|
3718
3742
|
rows.push({ chunkKey: row.chunkKey, token: row.token });
|
|
3719
3743
|
if (rows.length === 0) break;
|
|
3720
|
-
let conflicts = 0;
|
|
3721
3744
|
for (const { chunkKey, token } of rows) {
|
|
3722
3745
|
const chunkRef = { namespace: ref.namespace, segment: ref.segment, chunkKey };
|
|
3723
3746
|
try {
|
|
@@ -3730,6 +3753,11 @@ async function eraseWarm(warm, ref) {
|
|
|
3730
3753
|
}
|
|
3731
3754
|
if (conflicts === 0) break;
|
|
3732
3755
|
}
|
|
3756
|
+
if (conflicts > 0) {
|
|
3757
|
+
throw new WriteConflictError(
|
|
3758
|
+
`destroySegment: ${conflicts} warm row(s) of "${ref.segment}" were rewritten during every one of ${MAX_WARM_PASSES} erase passes \u2014 the segment was NOT destroyed and its cleartext warm deltas are still readable. Stop writes to this segment and retry.`
|
|
3759
|
+
);
|
|
3760
|
+
}
|
|
3733
3761
|
return deleted;
|
|
3734
3762
|
}
|
|
3735
3763
|
|
|
@@ -3829,17 +3857,47 @@ var RetryingWarmDriver = class {
|
|
|
3829
3857
|
deleteConditional(ref, expected) {
|
|
3830
3858
|
return withRetry(() => this.inner.deleteConditional(ref, expected), this.policy, this.deps);
|
|
3831
3859
|
}
|
|
3860
|
+
/**
|
|
3861
|
+
* Stream the inner driver's rows, retrying only until the first one arrives.
|
|
3862
|
+
*
|
|
3863
|
+
* **This used to buffer the whole scan** — `for await (…) out.push(row)` inside `withRetry`, then
|
|
3864
|
+
* `yield* out` — which made the retry trivially safe and quietly defeated every bound above it. The engine's
|
|
3865
|
+
* `collectWarm` refuses *during* enumeration precisely so resident memory is `O(ceiling)` rather than
|
|
3866
|
+
* `O(segment)`; with the buffering wrapper in place (and it is wired by default) it saw its first row only
|
|
3867
|
+
* after the entire segment was already in memory. Measured on a 500-row segment under
|
|
3868
|
+
* `budget: { maxRequests: 3 }`: **500 rows materialised with the default wiring, 4 with `retry: false`**.
|
|
3869
|
+
* Both paths threw the same `BudgetExceededError`, which is why no test caught it — the error was never the
|
|
3870
|
+
* distinguishing observable, the row count was.
|
|
3871
|
+
*
|
|
3872
|
+
* **The trade this makes.** A mid-stream fault now propagates instead of being retried. It cannot be retried
|
|
3873
|
+
* honestly: rows have already been handed to the consumer, so restarting the iterator would re-yield them,
|
|
3874
|
+
* and the driver interface exposes no resumption token to continue from. Retry is kept where it pays —
|
|
3875
|
+
* establishing the scan, which is where transient connect/throttle failures actually land — and bounded
|
|
3876
|
+
* memory wins the conflict, because invariant 6 ("bounded memory & cost, always") is a hard invariant and
|
|
3877
|
+
* mid-scan retry is not. A caller who needs whole-scan retry can wrap the operation at their own level,
|
|
3878
|
+
* where re-reading from the start is safe.
|
|
3879
|
+
*/
|
|
3832
3880
|
async *listChunks(ref, opts) {
|
|
3833
|
-
const
|
|
3881
|
+
const started = await withRetry(
|
|
3834
3882
|
async () => {
|
|
3835
|
-
const
|
|
3836
|
-
|
|
3837
|
-
return
|
|
3883
|
+
const iterator = this.inner.listChunks(ref, opts)[Symbol.asyncIterator]();
|
|
3884
|
+
const first = await iterator.next();
|
|
3885
|
+
return { iterator, first };
|
|
3838
3886
|
},
|
|
3839
3887
|
this.policy,
|
|
3840
3888
|
this.deps
|
|
3841
3889
|
);
|
|
3842
|
-
|
|
3890
|
+
if (started.first.done === true) return;
|
|
3891
|
+
try {
|
|
3892
|
+
yield started.first.value;
|
|
3893
|
+
for (; ; ) {
|
|
3894
|
+
const next = await started.iterator.next();
|
|
3895
|
+
if (next.done === true) return;
|
|
3896
|
+
yield next.value;
|
|
3897
|
+
}
|
|
3898
|
+
} finally {
|
|
3899
|
+
await started.iterator.return?.();
|
|
3900
|
+
}
|
|
3843
3901
|
}
|
|
3844
3902
|
};
|
|
3845
3903
|
var RetryingColdChunkSource = class {
|