@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 CHANGED
@@ -226,7 +226,9 @@ async function mapWithConcurrency(items, limit, fn) {
226
226
  var YIELD_EVERY = 1024;
227
227
  function yieldEvery(clock, everyN = YIELD_EVERY) {
228
228
  if (clock === void 0) return () => null;
229
- const pause = clock.yieldNow !== void 0 ? () => clock.yieldNow() : () => clock.sleep(1);
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;
230
232
  let n = 0;
231
233
  return () => {
232
234
  if (++n < everyN) return null;
@@ -426,6 +428,17 @@ var InstantClock = class {
426
428
  sleep() {
427
429
  return Promise.resolve();
428
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
+ }
429
442
  };
430
443
  var ZeroRng = class {
431
444
  next() {
@@ -543,6 +556,17 @@ var SegmentEngine = class {
543
556
  }
544
557
  }
545
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
+ *
546
570
  * Chunk-skipping intersection. Computes
547
571
  * `seg[0] ∩ seg[1] ∩ …` and streams the result ids ascending. It aligns each segment's *effective*
548
572
  * chunk-key set (Warm ∪ Cold), keeps only keys present in **all** operands (a key missing from any
@@ -567,15 +591,6 @@ var SegmentEngine = class {
567
591
  * re-resolves fresh (bypassing the TTL), which can hop generations even sub-TTL — still whole/immutable per
568
592
  * read, never torn. Full intra-op snapshot isolation is deferred (gap #4).
569
593
  */
570
- /**
571
- * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
572
- *
573
- * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
574
- * afterwards (intersect into a temp segment, then remove) writes an intermediate segment that nobody wants;
575
- * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
576
- * operand is read **only at the keys that survived the intersection**, so a 61,000-chunk global opt-out list
577
- * intersected against a narrow audience costs a handful of reads, not 61,000.
578
- */
579
594
  intersect(segs, options) {
580
595
  return this.combine(segs, options?.exclude ?? [], "all", "intersect", options);
581
596
  }
@@ -763,8 +778,10 @@ var SegmentEngine = class {
763
778
  * So the staging buffer is capped, but it folds into per-chunk **compressed bitmaps** rather than being
764
779
  * written out. Peak memory is the roaring representation of the ids seen so far — which is what the segment
765
780
  * itself costs, and the floor for any implementation that writes each chunk exactly once — while the backend
766
- * still sees exactly one read-modify-write per distinct chunk regardless of stream length. This is strictly
767
- * less memory than the previous sync-only version, which held every remainder as an uncompressed JS number.
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.
768
785
  *
769
786
  * There is no way to do better without a sorted stream: a chunk can only be written once it is known that no
770
787
  * further ids for it will arrive, and unsorted input never gives that guarantee. Callers streaming *very*
@@ -807,8 +824,6 @@ var SegmentEngine = class {
807
824
  for (const id of ids) {
808
825
  stage(id);
809
826
  if (pending >= MUTATE_STAGE_IDS) compact();
810
- const pause = tickId();
811
- if (pause !== null) await pause;
812
827
  }
813
828
  }
814
829
  compact();
@@ -2422,6 +2437,8 @@ async function compactSegmentInner(ref, deps, options) {
2422
2437
  let tally;
2423
2438
  try {
2424
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
2425
2442
  crypto: cryptoAt(aead, 0)
2426
2443
  });
2427
2444
  } catch (err) {
@@ -2549,7 +2566,8 @@ async function compactSegmentInner(ref, deps, options) {
2549
2566
  deps.cold,
2550
2567
  { namespace: ref.namespace, segment: ref.segment, generation: newGen },
2551
2568
  stream,
2552
- cryptoAt(aead, newGen)
2569
+ cryptoAt(aead, newGen),
2570
+ deps.clock
2553
2571
  );
2554
2572
  try {
2555
2573
  await deps.registry.compareAndSwap(ref, leaseToken, {
@@ -2646,8 +2664,8 @@ async function* mergeChunksStream(ref, g, pinned, deps, maxBytes, readCrypto) {
2646
2664
  if (!merged.isEmpty) yield { chunkKey, bitmap: merged };
2647
2665
  }
2648
2666
  }
2649
- async function stageGeneration(cold, key, chunks, crypto) {
2650
- 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 });
2651
2669
  await verifyGeneration(cold, key, tally, crypto);
2652
2670
  }
2653
2671
  async function verifyGeneration(cold, key, expected, crypto) {
@@ -4145,17 +4163,43 @@ var RetryingWarmDriver = class {
4145
4163
  deleteConditional(ref, expected) {
4146
4164
  return withRetry(() => this.inner.deleteConditional(ref, expected), this.policy, this.deps);
4147
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
+ */
4148
4186
  async *listChunks(ref, opts) {
4149
- const rows = await withRetry(
4187
+ const started = await withRetry(
4150
4188
  async () => {
4151
- const out = [];
4152
- for await (const row of this.inner.listChunks(ref, opts)) out.push(row);
4153
- return out;
4189
+ const iterator = this.inner.listChunks(ref, opts)[Symbol.asyncIterator]();
4190
+ const first = await iterator.next();
4191
+ return { iterator, first };
4154
4192
  },
4155
4193
  this.policy,
4156
4194
  this.deps
4157
4195
  );
4158
- yield* rows;
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
+ }
4159
4203
  }
4160
4204
  };
4161
4205
  var RetryingColdChunkSource = class {