@cloudbitmaps/core 0.4.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 +66 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +110 -20
- package/dist/index.d.ts +110 -20
- package/dist/index.js +66 -22
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -314,14 +314,22 @@ type MetricEvent = {
|
|
|
314
314
|
* Which chunk-aligned combine this was. **Optional for backward compatibility** — absent means
|
|
315
315
|
* `'intersect'`, which is all this event reported before `union`/`andNot` existed.
|
|
316
316
|
*
|
|
317
|
-
* `
|
|
318
|
-
* of every operand, so there is nothing
|
|
319
|
-
*
|
|
317
|
+
* For `'union'` over its *include* operands alone, `skippedChunks` is 0 by construction — union reads
|
|
318
|
+
* every chunk of every operand, so there is nothing to prune. It can still be non-zero when a union
|
|
319
|
+
* carries `exclude` operands, because a suppression list may hold keys no include does and those are
|
|
320
|
+
* legitimately never fetched. So: 0 is the expected shape for a plain union, not a guarantee.
|
|
320
321
|
*/
|
|
321
322
|
readonly op?: 'intersect' | 'union' | 'andNot';
|
|
322
323
|
/** Operand count, including any `exclude` (suppression) operands. */
|
|
323
324
|
readonly operands: number;
|
|
324
|
-
/**
|
|
325
|
+
/**
|
|
326
|
+
* Distinct chunk **keys** selected to fetch — for `'intersect'`, those present in every operand.
|
|
327
|
+
*
|
|
328
|
+
* **Keys, not requests.** The actual cold GETs are roughly `fetchedChunks × operands`, plus one per
|
|
329
|
+
* `exclude` that holds a given key, which is why this number is smaller than what the per-op budget
|
|
330
|
+
* charges for the same call. The two are different units by design; if you are reconciling a bill, the
|
|
331
|
+
* budget's accounting is the one that models requests.
|
|
332
|
+
*/
|
|
325
333
|
readonly fetchedChunks: number;
|
|
326
334
|
/**
|
|
327
335
|
* **Distinct** chunk-keys pruned — never fetched (the chunk-skipping saving). Counts distinct keys,
|
|
@@ -498,8 +506,8 @@ declare const DEFAULT_MAX_WARM_SCAN_BYTES: number;
|
|
|
498
506
|
* write here is a read-modify-write, so 4 in flight is up to 8 concurrent requests per `addMany` call — and a
|
|
499
507
|
* server handling many concurrent calls multiplies that again. Provisioned-capacity backends answer a burst
|
|
500
508
|
* with throttling, which is free only while retries can absorb it (4 attempts, exponential backoff, full
|
|
501
|
-
* jitter). Swept against a backend that throttles on concurrent requests in
|
|
502
|
-
* counting a lost id or a surfaced error as a failure:
|
|
509
|
+
* jitter). Swept against a **modelled** backend — an in-process fake that throttles on concurrent requests in
|
|
510
|
+
* flight, not a real DynamoDB — 64 chunks, 5 trials each, counting a lost id or a surfaced error as a failure:
|
|
503
511
|
*
|
|
504
512
|
* ```text
|
|
505
513
|
* backend capacity: 1 2 4 8
|
|
@@ -609,6 +617,17 @@ declare class SegmentEngine {
|
|
|
609
617
|
segmentSize(seg: SegmentRef): Promise<SegmentSize | null>;
|
|
610
618
|
iterate(seg: SegmentRef): AsyncGenerator<number>;
|
|
611
619
|
/**
|
|
620
|
+
* `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
|
|
621
|
+
*
|
|
622
|
+
* The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
|
|
623
|
+
* afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
|
|
624
|
+
* applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
|
|
625
|
+
* operand is read **only at the keys that survived the intersection** — the cost is bounded by the
|
|
626
|
+
* audience's surviving key count and **never scales with the size of the suppression list**. Subtracting a
|
|
627
|
+
* 61,000-chunk global opt-out list costs at most one read per surviving key, not 61,000.
|
|
628
|
+
*
|
|
629
|
+
* ---
|
|
630
|
+
*
|
|
612
631
|
* Chunk-skipping intersection. Computes
|
|
613
632
|
* `seg[0] ∩ seg[1] ∩ …` and streams the result ids ascending. It aligns each segment's *effective*
|
|
614
633
|
* chunk-key set (Warm ∪ Cold), keeps only keys present in **all** operands (a key missing from any
|
|
@@ -633,15 +652,6 @@ declare class SegmentEngine {
|
|
|
633
652
|
* re-resolves fresh (bypassing the TTL), which can hop generations even sub-TTL — still whole/immutable per
|
|
634
653
|
* read, never torn. Full intra-op snapshot isolation is deferred (gap #4).
|
|
635
654
|
*/
|
|
636
|
-
/**
|
|
637
|
-
* `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
|
|
638
|
-
*
|
|
639
|
-
* The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
|
|
640
|
-
* afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
|
|
641
|
-
* applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
|
|
642
|
-
* operand is read **only at the keys that survived the intersection**, so a 61,000-chunk global opt-out list
|
|
643
|
-
* intersected against a narrow audience costs a handful of reads, not 61,000.
|
|
644
|
-
*/
|
|
645
655
|
intersect(segs: readonly SegmentRef[], options?: {
|
|
646
656
|
concurrency?: number;
|
|
647
657
|
budget?: BudgetOption;
|
|
@@ -730,8 +740,10 @@ declare class SegmentEngine {
|
|
|
730
740
|
* So the staging buffer is capped, but it folds into per-chunk **compressed bitmaps** rather than being
|
|
731
741
|
* written out. Peak memory is the roaring representation of the ids seen so far — which is what the segment
|
|
732
742
|
* itself costs, and the floor for any implementation that writes each chunk exactly once — while the backend
|
|
733
|
-
* still sees exactly one read-modify-write per distinct chunk regardless of stream length.
|
|
734
|
-
* less
|
|
743
|
+
* still sees exactly one read-modify-write per distinct chunk regardless of stream length. Above the staging cap
|
|
744
|
+
* this holds less than the previous sync-only version, which kept every remainder as an uncompressed JS
|
|
745
|
+
* number; below it — the common `addMany` — nothing is compacted until the end and the two are comparable.
|
|
746
|
+
* The guarantee is that a stream of *any* length stays bounded, not that every call got smaller.
|
|
735
747
|
*
|
|
736
748
|
* There is no way to do better without a sorted stream: a chunk can only be written once it is known that no
|
|
737
749
|
* further ids for it will arrive, and unsorted input never gives that guarantee. Callers streaming *very*
|
|
@@ -1055,8 +1067,16 @@ interface CompactionDeps {
|
|
|
1055
1067
|
readonly cold: IColdDriver;
|
|
1056
1068
|
readonly warm: IWarmDriver;
|
|
1057
1069
|
readonly registry: IRegistryDriver;
|
|
1058
|
-
/**
|
|
1059
|
-
|
|
1070
|
+
/**
|
|
1071
|
+
* Injected time source for lease expiry (the determinism seam — `core/` never reads ambient time).
|
|
1072
|
+
*
|
|
1073
|
+
* `now` is required; `sleep`/`yieldNow` are **optional and used only for cooperative yielding** while writing
|
|
1074
|
+
* a generation. Supplying a full {@link Clock} (the facade does) makes compaction hand the event loop back
|
|
1075
|
+
* periodically instead of blocking it for the whole write; supplying a bare `{ now }` keeps the previous
|
|
1076
|
+
* uninterrupted behaviour. Optional rather than required because every existing caller passes `{ now }`, and
|
|
1077
|
+
* requiring more would be a breaking change — the reason the yield below shipped unreachable the first time.
|
|
1078
|
+
*/
|
|
1079
|
+
readonly clock: Pick<Clock, 'now'> & Partial<Pick<Clock, 'sleep' | 'yieldNow'>>;
|
|
1060
1080
|
readonly maxBitmapBytes?: number;
|
|
1061
1081
|
/**
|
|
1062
1082
|
* Bitmap codec — compaction only merges bitmaps through it. Optional in the type so this
|
|
@@ -1515,6 +1535,56 @@ declare class LocalFsRegistryDriver implements IRegistryDriver {
|
|
|
1515
1535
|
private withRowLock;
|
|
1516
1536
|
}
|
|
1517
1537
|
|
|
1538
|
+
/**
|
|
1539
|
+
* Cooperative yielding for the long CPU-bound loops.
|
|
1540
|
+
*
|
|
1541
|
+
* Everything this library does on the hot read path is small. Bulk-load is the exception: it is the one place
|
|
1542
|
+
* where a single call legitimately occupies the CPU for hundreds of milliseconds, and Node has exactly one thread
|
|
1543
|
+
* to occupy. Measured on a 1M-id load, 62,455 chunks: **442 ms wall, and 450 ms during which the event loop did
|
|
1544
|
+
* not turn at all**. Wire that to a request handler and every other in-flight request on the instance — health
|
|
1545
|
+
* checks included — waits out the whole load.
|
|
1546
|
+
*
|
|
1547
|
+
* The fix is not to make the work asynchronous. That was measured too, and it is a 7x *regression*: handing each
|
|
1548
|
+
* chunk's insert to the threadpool costs ~9 µs of dispatch against ~1.5 µs of actual work once ids are spread
|
|
1549
|
+
* across ~62,000 chunks (636 ms versus 92 ms). The work is genuinely CPU-bound and genuinely small per chunk;
|
|
1550
|
+
* the only thing wrong with it is that it never stops to let anyone else go. So the fix is to keep the work
|
|
1551
|
+
* synchronous and periodically hand the loop back.
|
|
1552
|
+
*
|
|
1553
|
+
* TWO TRAPS, both of which make a plausible implementation measure as no change at all:
|
|
1554
|
+
*
|
|
1555
|
+
* 1. **`await Promise.resolve()` — and equally `Clock.sleep(0)` — do nothing.** Both resolve on a microtask, and
|
|
1556
|
+
* the microtask queue drains to empty before the event loop advances a single phase. A loop awaiting them
|
|
1557
|
+
* yields to other *microtasks* and to nothing else; no pending I/O callback runs. Measured: 555 ms of
|
|
1558
|
+
* starvation against a 568 ms unyielded baseline **on the synthetic yield-primitive benchmark below** —
|
|
1559
|
+
* a different, smaller workload than the 442 ms end-to-end load above, which is why the two baselines
|
|
1560
|
+
* differ. The yield must be a **macrotask** — hence
|
|
1561
|
+
* `Clock.yieldNow`, backed by `setImmediate` in production wiring.
|
|
1562
|
+
* 2. **Yielding per unit of work costs more than the work.** The yield has to be periodic, and `everyN` has to be
|
|
1563
|
+
* large enough that its cost disappears into the batch it interrupts.
|
|
1564
|
+
*
|
|
1565
|
+
* At `everyN = 1024` over that same synthetic workload the trade is close to free: 569 ms wall against a 568 ms
|
|
1566
|
+
* baseline, with the worst event-loop gap down from 568 ms to 13.8 ms.
|
|
1567
|
+
*
|
|
1568
|
+
* **Three experiments, three sets of numbers — do not mix them.** (a) The end-to-end 1M-id bulk load:
|
|
1569
|
+
* 442 ms wall / 450 ms blocked before, 256 ms / 19 ms after. (b) This synthetic yield-primitive comparison:
|
|
1570
|
+
* 568 ms / 568 ms unyielded, 569 ms / 13.8 ms with `setImmediate`. (c) A per-chunk insert microbenchmark:
|
|
1571
|
+
* 92 ms sync versus 636 ms via the threadpool. Quoting a baseline from one against a result from another is
|
|
1572
|
+
* how this file previously published a 92 ms operation with 819 ms of starvation inside it.
|
|
1573
|
+
*/
|
|
1574
|
+
/**
|
|
1575
|
+
* The only part of a `Clock` that yielding actually needs.
|
|
1576
|
+
*
|
|
1577
|
+
* Deliberately looser than `Clock` so the compaction path can opt in: `CompactionDeps.clock` is declared as
|
|
1578
|
+
* `Pick<Clock, 'now'>` plus optional yield members, because its callers (including the `compact-segments` CLI)
|
|
1579
|
+
* have always supplied a bare `{ now }`. Requiring a full `Clock` there would have been a breaking change for
|
|
1580
|
+
* every one of them — which is exactly why the compaction yield shipped as dead code: the type could not carry
|
|
1581
|
+
* a clock capable of yielding.
|
|
1582
|
+
*/
|
|
1583
|
+
interface Yielder {
|
|
1584
|
+
yieldNow?(): Promise<void>;
|
|
1585
|
+
sleep?(ms: number): Promise<void>;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1518
1588
|
interface CrbmReaderOptions {
|
|
1519
1589
|
/** Speculative tail size in bytes (default 256 KB; clamped up to at least the footer size). */
|
|
1520
1590
|
readonly tailBytes?: number;
|
|
@@ -1704,7 +1774,7 @@ declare function writeCrbmGeneration(driver: IColdDriver, key: GenKey, chunks: I
|
|
|
1704
1774
|
bitmap: CodecBitmap;
|
|
1705
1775
|
}>, options?: {
|
|
1706
1776
|
crypto?: CrbmCrypto;
|
|
1707
|
-
clock?:
|
|
1777
|
+
clock?: Yielder;
|
|
1708
1778
|
}): Promise<{
|
|
1709
1779
|
size: number;
|
|
1710
1780
|
sha256: string;
|
|
@@ -2097,6 +2167,26 @@ declare class RetryingWarmDriver implements IWarmDriver {
|
|
|
2097
2167
|
token: Token;
|
|
2098
2168
|
}>;
|
|
2099
2169
|
deleteConditional(ref: ChunkRef, expected: Token): Promise<void>;
|
|
2170
|
+
/**
|
|
2171
|
+
* Stream the inner driver's rows, retrying only until the first one arrives.
|
|
2172
|
+
*
|
|
2173
|
+
* **This used to buffer the whole scan** — `for await (…) out.push(row)` inside `withRetry`, then
|
|
2174
|
+
* `yield* out` — which made the retry trivially safe and quietly defeated every bound above it. The engine's
|
|
2175
|
+
* `collectWarm` refuses *during* enumeration precisely so resident memory is `O(ceiling)` rather than
|
|
2176
|
+
* `O(segment)`; with the buffering wrapper in place (and it is wired by default) it saw its first row only
|
|
2177
|
+
* after the entire segment was already in memory. Measured on a 500-row segment under
|
|
2178
|
+
* `budget: { maxRequests: 3 }`: **500 rows materialised with the default wiring, 4 with `retry: false`**.
|
|
2179
|
+
* Both paths threw the same `BudgetExceededError`, which is why no test caught it — the error was never the
|
|
2180
|
+
* distinguishing observable, the row count was.
|
|
2181
|
+
*
|
|
2182
|
+
* **The trade this makes.** A mid-stream fault now propagates instead of being retried. It cannot be retried
|
|
2183
|
+
* honestly: rows have already been handed to the consumer, so restarting the iterator would re-yield them,
|
|
2184
|
+
* and the driver interface exposes no resumption token to continue from. Retry is kept where it pays —
|
|
2185
|
+
* establishing the scan, which is where transient connect/throttle failures actually land — and bounded
|
|
2186
|
+
* memory wins the conflict, because invariant 6 ("bounded memory & cost, always") is a hard invariant and
|
|
2187
|
+
* mid-scan retry is not. A caller who needs whole-scan retry can wrap the operation at their own level,
|
|
2188
|
+
* where re-reading from the start is safe.
|
|
2189
|
+
*/
|
|
2100
2190
|
listChunks(ref: SegmentRef, opts?: WarmReadOptions): AsyncIterable<{
|
|
2101
2191
|
chunkKey: number;
|
|
2102
2192
|
} & WarmRow>;
|
package/dist/index.d.ts
CHANGED
|
@@ -314,14 +314,22 @@ type MetricEvent = {
|
|
|
314
314
|
* Which chunk-aligned combine this was. **Optional for backward compatibility** — absent means
|
|
315
315
|
* `'intersect'`, which is all this event reported before `union`/`andNot` existed.
|
|
316
316
|
*
|
|
317
|
-
* `
|
|
318
|
-
* of every operand, so there is nothing
|
|
319
|
-
*
|
|
317
|
+
* For `'union'` over its *include* operands alone, `skippedChunks` is 0 by construction — union reads
|
|
318
|
+
* every chunk of every operand, so there is nothing to prune. It can still be non-zero when a union
|
|
319
|
+
* carries `exclude` operands, because a suppression list may hold keys no include does and those are
|
|
320
|
+
* legitimately never fetched. So: 0 is the expected shape for a plain union, not a guarantee.
|
|
320
321
|
*/
|
|
321
322
|
readonly op?: 'intersect' | 'union' | 'andNot';
|
|
322
323
|
/** Operand count, including any `exclude` (suppression) operands. */
|
|
323
324
|
readonly operands: number;
|
|
324
|
-
/**
|
|
325
|
+
/**
|
|
326
|
+
* Distinct chunk **keys** selected to fetch — for `'intersect'`, those present in every operand.
|
|
327
|
+
*
|
|
328
|
+
* **Keys, not requests.** The actual cold GETs are roughly `fetchedChunks × operands`, plus one per
|
|
329
|
+
* `exclude` that holds a given key, which is why this number is smaller than what the per-op budget
|
|
330
|
+
* charges for the same call. The two are different units by design; if you are reconciling a bill, the
|
|
331
|
+
* budget's accounting is the one that models requests.
|
|
332
|
+
*/
|
|
325
333
|
readonly fetchedChunks: number;
|
|
326
334
|
/**
|
|
327
335
|
* **Distinct** chunk-keys pruned — never fetched (the chunk-skipping saving). Counts distinct keys,
|
|
@@ -498,8 +506,8 @@ declare const DEFAULT_MAX_WARM_SCAN_BYTES: number;
|
|
|
498
506
|
* write here is a read-modify-write, so 4 in flight is up to 8 concurrent requests per `addMany` call — and a
|
|
499
507
|
* server handling many concurrent calls multiplies that again. Provisioned-capacity backends answer a burst
|
|
500
508
|
* with throttling, which is free only while retries can absorb it (4 attempts, exponential backoff, full
|
|
501
|
-
* jitter). Swept against a backend that throttles on concurrent requests in
|
|
502
|
-
* counting a lost id or a surfaced error as a failure:
|
|
509
|
+
* jitter). Swept against a **modelled** backend — an in-process fake that throttles on concurrent requests in
|
|
510
|
+
* flight, not a real DynamoDB — 64 chunks, 5 trials each, counting a lost id or a surfaced error as a failure:
|
|
503
511
|
*
|
|
504
512
|
* ```text
|
|
505
513
|
* backend capacity: 1 2 4 8
|
|
@@ -609,6 +617,17 @@ declare class SegmentEngine {
|
|
|
609
617
|
segmentSize(seg: SegmentRef): Promise<SegmentSize | null>;
|
|
610
618
|
iterate(seg: SegmentRef): AsyncGenerator<number>;
|
|
611
619
|
/**
|
|
620
|
+
* `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
|
|
621
|
+
*
|
|
622
|
+
* The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
|
|
623
|
+
* afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
|
|
624
|
+
* applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
|
|
625
|
+
* operand is read **only at the keys that survived the intersection** — the cost is bounded by the
|
|
626
|
+
* audience's surviving key count and **never scales with the size of the suppression list**. Subtracting a
|
|
627
|
+
* 61,000-chunk global opt-out list costs at most one read per surviving key, not 61,000.
|
|
628
|
+
*
|
|
629
|
+
* ---
|
|
630
|
+
*
|
|
612
631
|
* Chunk-skipping intersection. Computes
|
|
613
632
|
* `seg[0] ∩ seg[1] ∩ …` and streams the result ids ascending. It aligns each segment's *effective*
|
|
614
633
|
* chunk-key set (Warm ∪ Cold), keeps only keys present in **all** operands (a key missing from any
|
|
@@ -633,15 +652,6 @@ declare class SegmentEngine {
|
|
|
633
652
|
* re-resolves fresh (bypassing the TTL), which can hop generations even sub-TTL — still whole/immutable per
|
|
634
653
|
* read, never torn. Full intra-op snapshot isolation is deferred (gap #4).
|
|
635
654
|
*/
|
|
636
|
-
/**
|
|
637
|
-
* `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
|
|
638
|
-
*
|
|
639
|
-
* The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
|
|
640
|
-
* afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
|
|
641
|
-
* applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
|
|
642
|
-
* operand is read **only at the keys that survived the intersection**, so a 61,000-chunk global opt-out list
|
|
643
|
-
* intersected against a narrow audience costs a handful of reads, not 61,000.
|
|
644
|
-
*/
|
|
645
655
|
intersect(segs: readonly SegmentRef[], options?: {
|
|
646
656
|
concurrency?: number;
|
|
647
657
|
budget?: BudgetOption;
|
|
@@ -730,8 +740,10 @@ declare class SegmentEngine {
|
|
|
730
740
|
* So the staging buffer is capped, but it folds into per-chunk **compressed bitmaps** rather than being
|
|
731
741
|
* written out. Peak memory is the roaring representation of the ids seen so far — which is what the segment
|
|
732
742
|
* itself costs, and the floor for any implementation that writes each chunk exactly once — while the backend
|
|
733
|
-
* still sees exactly one read-modify-write per distinct chunk regardless of stream length.
|
|
734
|
-
* less
|
|
743
|
+
* still sees exactly one read-modify-write per distinct chunk regardless of stream length. Above the staging cap
|
|
744
|
+
* this holds less than the previous sync-only version, which kept every remainder as an uncompressed JS
|
|
745
|
+
* number; below it — the common `addMany` — nothing is compacted until the end and the two are comparable.
|
|
746
|
+
* The guarantee is that a stream of *any* length stays bounded, not that every call got smaller.
|
|
735
747
|
*
|
|
736
748
|
* There is no way to do better without a sorted stream: a chunk can only be written once it is known that no
|
|
737
749
|
* further ids for it will arrive, and unsorted input never gives that guarantee. Callers streaming *very*
|
|
@@ -1055,8 +1067,16 @@ interface CompactionDeps {
|
|
|
1055
1067
|
readonly cold: IColdDriver;
|
|
1056
1068
|
readonly warm: IWarmDriver;
|
|
1057
1069
|
readonly registry: IRegistryDriver;
|
|
1058
|
-
/**
|
|
1059
|
-
|
|
1070
|
+
/**
|
|
1071
|
+
* Injected time source for lease expiry (the determinism seam — `core/` never reads ambient time).
|
|
1072
|
+
*
|
|
1073
|
+
* `now` is required; `sleep`/`yieldNow` are **optional and used only for cooperative yielding** while writing
|
|
1074
|
+
* a generation. Supplying a full {@link Clock} (the facade does) makes compaction hand the event loop back
|
|
1075
|
+
* periodically instead of blocking it for the whole write; supplying a bare `{ now }` keeps the previous
|
|
1076
|
+
* uninterrupted behaviour. Optional rather than required because every existing caller passes `{ now }`, and
|
|
1077
|
+
* requiring more would be a breaking change — the reason the yield below shipped unreachable the first time.
|
|
1078
|
+
*/
|
|
1079
|
+
readonly clock: Pick<Clock, 'now'> & Partial<Pick<Clock, 'sleep' | 'yieldNow'>>;
|
|
1060
1080
|
readonly maxBitmapBytes?: number;
|
|
1061
1081
|
/**
|
|
1062
1082
|
* Bitmap codec — compaction only merges bitmaps through it. Optional in the type so this
|
|
@@ -1515,6 +1535,56 @@ declare class LocalFsRegistryDriver implements IRegistryDriver {
|
|
|
1515
1535
|
private withRowLock;
|
|
1516
1536
|
}
|
|
1517
1537
|
|
|
1538
|
+
/**
|
|
1539
|
+
* Cooperative yielding for the long CPU-bound loops.
|
|
1540
|
+
*
|
|
1541
|
+
* Everything this library does on the hot read path is small. Bulk-load is the exception: it is the one place
|
|
1542
|
+
* where a single call legitimately occupies the CPU for hundreds of milliseconds, and Node has exactly one thread
|
|
1543
|
+
* to occupy. Measured on a 1M-id load, 62,455 chunks: **442 ms wall, and 450 ms during which the event loop did
|
|
1544
|
+
* not turn at all**. Wire that to a request handler and every other in-flight request on the instance — health
|
|
1545
|
+
* checks included — waits out the whole load.
|
|
1546
|
+
*
|
|
1547
|
+
* The fix is not to make the work asynchronous. That was measured too, and it is a 7x *regression*: handing each
|
|
1548
|
+
* chunk's insert to the threadpool costs ~9 µs of dispatch against ~1.5 µs of actual work once ids are spread
|
|
1549
|
+
* across ~62,000 chunks (636 ms versus 92 ms). The work is genuinely CPU-bound and genuinely small per chunk;
|
|
1550
|
+
* the only thing wrong with it is that it never stops to let anyone else go. So the fix is to keep the work
|
|
1551
|
+
* synchronous and periodically hand the loop back.
|
|
1552
|
+
*
|
|
1553
|
+
* TWO TRAPS, both of which make a plausible implementation measure as no change at all:
|
|
1554
|
+
*
|
|
1555
|
+
* 1. **`await Promise.resolve()` — and equally `Clock.sleep(0)` — do nothing.** Both resolve on a microtask, and
|
|
1556
|
+
* the microtask queue drains to empty before the event loop advances a single phase. A loop awaiting them
|
|
1557
|
+
* yields to other *microtasks* and to nothing else; no pending I/O callback runs. Measured: 555 ms of
|
|
1558
|
+
* starvation against a 568 ms unyielded baseline **on the synthetic yield-primitive benchmark below** —
|
|
1559
|
+
* a different, smaller workload than the 442 ms end-to-end load above, which is why the two baselines
|
|
1560
|
+
* differ. The yield must be a **macrotask** — hence
|
|
1561
|
+
* `Clock.yieldNow`, backed by `setImmediate` in production wiring.
|
|
1562
|
+
* 2. **Yielding per unit of work costs more than the work.** The yield has to be periodic, and `everyN` has to be
|
|
1563
|
+
* large enough that its cost disappears into the batch it interrupts.
|
|
1564
|
+
*
|
|
1565
|
+
* At `everyN = 1024` over that same synthetic workload the trade is close to free: 569 ms wall against a 568 ms
|
|
1566
|
+
* baseline, with the worst event-loop gap down from 568 ms to 13.8 ms.
|
|
1567
|
+
*
|
|
1568
|
+
* **Three experiments, three sets of numbers — do not mix them.** (a) The end-to-end 1M-id bulk load:
|
|
1569
|
+
* 442 ms wall / 450 ms blocked before, 256 ms / 19 ms after. (b) This synthetic yield-primitive comparison:
|
|
1570
|
+
* 568 ms / 568 ms unyielded, 569 ms / 13.8 ms with `setImmediate`. (c) A per-chunk insert microbenchmark:
|
|
1571
|
+
* 92 ms sync versus 636 ms via the threadpool. Quoting a baseline from one against a result from another is
|
|
1572
|
+
* how this file previously published a 92 ms operation with 819 ms of starvation inside it.
|
|
1573
|
+
*/
|
|
1574
|
+
/**
|
|
1575
|
+
* The only part of a `Clock` that yielding actually needs.
|
|
1576
|
+
*
|
|
1577
|
+
* Deliberately looser than `Clock` so the compaction path can opt in: `CompactionDeps.clock` is declared as
|
|
1578
|
+
* `Pick<Clock, 'now'>` plus optional yield members, because its callers (including the `compact-segments` CLI)
|
|
1579
|
+
* have always supplied a bare `{ now }`. Requiring a full `Clock` there would have been a breaking change for
|
|
1580
|
+
* every one of them — which is exactly why the compaction yield shipped as dead code: the type could not carry
|
|
1581
|
+
* a clock capable of yielding.
|
|
1582
|
+
*/
|
|
1583
|
+
interface Yielder {
|
|
1584
|
+
yieldNow?(): Promise<void>;
|
|
1585
|
+
sleep?(ms: number): Promise<void>;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1518
1588
|
interface CrbmReaderOptions {
|
|
1519
1589
|
/** Speculative tail size in bytes (default 256 KB; clamped up to at least the footer size). */
|
|
1520
1590
|
readonly tailBytes?: number;
|
|
@@ -1704,7 +1774,7 @@ declare function writeCrbmGeneration(driver: IColdDriver, key: GenKey, chunks: I
|
|
|
1704
1774
|
bitmap: CodecBitmap;
|
|
1705
1775
|
}>, options?: {
|
|
1706
1776
|
crypto?: CrbmCrypto;
|
|
1707
|
-
clock?:
|
|
1777
|
+
clock?: Yielder;
|
|
1708
1778
|
}): Promise<{
|
|
1709
1779
|
size: number;
|
|
1710
1780
|
sha256: string;
|
|
@@ -2097,6 +2167,26 @@ declare class RetryingWarmDriver implements IWarmDriver {
|
|
|
2097
2167
|
token: Token;
|
|
2098
2168
|
}>;
|
|
2099
2169
|
deleteConditional(ref: ChunkRef, expected: Token): Promise<void>;
|
|
2170
|
+
/**
|
|
2171
|
+
* Stream the inner driver's rows, retrying only until the first one arrives.
|
|
2172
|
+
*
|
|
2173
|
+
* **This used to buffer the whole scan** — `for await (…) out.push(row)` inside `withRetry`, then
|
|
2174
|
+
* `yield* out` — which made the retry trivially safe and quietly defeated every bound above it. The engine's
|
|
2175
|
+
* `collectWarm` refuses *during* enumeration precisely so resident memory is `O(ceiling)` rather than
|
|
2176
|
+
* `O(segment)`; with the buffering wrapper in place (and it is wired by default) it saw its first row only
|
|
2177
|
+
* after the entire segment was already in memory. Measured on a 500-row segment under
|
|
2178
|
+
* `budget: { maxRequests: 3 }`: **500 rows materialised with the default wiring, 4 with `retry: false`**.
|
|
2179
|
+
* Both paths threw the same `BudgetExceededError`, which is why no test caught it — the error was never the
|
|
2180
|
+
* distinguishing observable, the row count was.
|
|
2181
|
+
*
|
|
2182
|
+
* **The trade this makes.** A mid-stream fault now propagates instead of being retried. It cannot be retried
|
|
2183
|
+
* honestly: rows have already been handed to the consumer, so restarting the iterator would re-yield them,
|
|
2184
|
+
* and the driver interface exposes no resumption token to continue from. Retry is kept where it pays —
|
|
2185
|
+
* establishing the scan, which is where transient connect/throttle failures actually land — and bounded
|
|
2186
|
+
* memory wins the conflict, because invariant 6 ("bounded memory & cost, always") is a hard invariant and
|
|
2187
|
+
* mid-scan retry is not. A caller who needs whole-scan retry can wrap the operation at their own level,
|
|
2188
|
+
* where re-reading from the start is safe.
|
|
2189
|
+
*/
|
|
2100
2190
|
listChunks(ref: SegmentRef, opts?: WarmReadOptions): AsyncIterable<{
|
|
2101
2191
|
chunkKey: number;
|
|
2102
2192
|
} & WarmRow>;
|
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();
|
|
@@ -2312,6 +2327,8 @@ async function compactSegmentInner(ref, deps, options) {
|
|
|
2312
2327
|
let tally;
|
|
2313
2328
|
try {
|
|
2314
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
|
|
2315
2332
|
crypto: cryptoAt(aead, 0)
|
|
2316
2333
|
});
|
|
2317
2334
|
} catch (err) {
|
|
@@ -2439,7 +2456,8 @@ async function compactSegmentInner(ref, deps, options) {
|
|
|
2439
2456
|
deps.cold,
|
|
2440
2457
|
{ namespace: ref.namespace, segment: ref.segment, generation: newGen },
|
|
2441
2458
|
stream,
|
|
2442
|
-
cryptoAt(aead, newGen)
|
|
2459
|
+
cryptoAt(aead, newGen),
|
|
2460
|
+
deps.clock
|
|
2443
2461
|
);
|
|
2444
2462
|
try {
|
|
2445
2463
|
await deps.registry.compareAndSwap(ref, leaseToken, {
|
|
@@ -2536,8 +2554,8 @@ async function* mergeChunksStream(ref, g, pinned, deps, maxBytes, readCrypto) {
|
|
|
2536
2554
|
if (!merged.isEmpty) yield { chunkKey, bitmap: merged };
|
|
2537
2555
|
}
|
|
2538
2556
|
}
|
|
2539
|
-
async function stageGeneration(cold, key, chunks, crypto) {
|
|
2540
|
-
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 });
|
|
2541
2559
|
await verifyGeneration(cold, key, tally, crypto);
|
|
2542
2560
|
}
|
|
2543
2561
|
async function verifyGeneration(cold, key, expected, crypto) {
|
|
@@ -3829,17 +3847,43 @@ var RetryingWarmDriver = class {
|
|
|
3829
3847
|
deleteConditional(ref, expected) {
|
|
3830
3848
|
return withRetry(() => this.inner.deleteConditional(ref, expected), this.policy, this.deps);
|
|
3831
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
|
+
*/
|
|
3832
3870
|
async *listChunks(ref, opts) {
|
|
3833
|
-
const
|
|
3871
|
+
const started = await withRetry(
|
|
3834
3872
|
async () => {
|
|
3835
|
-
const
|
|
3836
|
-
|
|
3837
|
-
return
|
|
3873
|
+
const iterator = this.inner.listChunks(ref, opts)[Symbol.asyncIterator]();
|
|
3874
|
+
const first = await iterator.next();
|
|
3875
|
+
return { iterator, first };
|
|
3838
3876
|
},
|
|
3839
3877
|
this.policy,
|
|
3840
3878
|
this.deps
|
|
3841
3879
|
);
|
|
3842
|
-
|
|
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
|
+
}
|
|
3843
3887
|
}
|
|
3844
3888
|
};
|
|
3845
3889
|
var RetryingColdChunkSource = class {
|