@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 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();
@@ -2203,6 +2218,10 @@ async function publishGeneration(registry, key, options = {}) {
2203
2218
  currentGen: key.generation,
2204
2219
  wrappedDeks: options.wrappedDeks
2205
2220
  });
2221
+ } else if (record.status === "destroyed") {
2222
+ throw new ValidationError(
2223
+ `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.`
2224
+ );
2206
2225
  } else if (record.currentGen > key.generation) {
2207
2226
  return false;
2208
2227
  } else if (record.currentGen === key.generation) {
@@ -2422,6 +2441,8 @@ async function compactSegmentInner(ref, deps, options) {
2422
2441
  let tally;
2423
2442
  try {
2424
2443
  tally = await writeCrbmGenerationStream(deps.cold, gen0, stream, {
2444
+ clock: deps.clock,
2445
+ // cooperative if the caller's clock can yield; a no-op if it only tells the time
2425
2446
  crypto: cryptoAt(aead, 0)
2426
2447
  });
2427
2448
  } catch (err) {
@@ -2549,7 +2570,8 @@ async function compactSegmentInner(ref, deps, options) {
2549
2570
  deps.cold,
2550
2571
  { namespace: ref.namespace, segment: ref.segment, generation: newGen },
2551
2572
  stream,
2552
- cryptoAt(aead, newGen)
2573
+ cryptoAt(aead, newGen),
2574
+ deps.clock
2553
2575
  );
2554
2576
  try {
2555
2577
  await deps.registry.compareAndSwap(ref, leaseToken, {
@@ -2646,8 +2668,8 @@ async function* mergeChunksStream(ref, g, pinned, deps, maxBytes, readCrypto) {
2646
2668
  if (!merged.isEmpty) yield { chunkKey, bitmap: merged };
2647
2669
  }
2648
2670
  }
2649
- async function stageGeneration(cold, key, chunks, crypto) {
2650
- const tally = await writeCrbmGenerationStream(cold, key, chunks, { crypto });
2671
+ async function stageGeneration(cold, key, chunks, crypto, clock) {
2672
+ const tally = await writeCrbmGenerationStream(cold, key, chunks, { crypto, clock });
2651
2673
  await verifyGeneration(cold, key, tally, crypto);
2652
2674
  }
2653
2675
  async function verifyGeneration(cold, key, expected, crypto) {
@@ -4028,12 +4050,13 @@ async function shredSegment(ref, deps, allowCleartext) {
4028
4050
  }
4029
4051
  async function eraseWarm(warm, ref) {
4030
4052
  let deleted = 0;
4053
+ let conflicts = 0;
4031
4054
  for (let pass = 0; pass < MAX_WARM_PASSES; pass++) {
4055
+ conflicts = 0;
4032
4056
  const rows = [];
4033
4057
  for await (const row of warm.listChunks(ref))
4034
4058
  rows.push({ chunkKey: row.chunkKey, token: row.token });
4035
4059
  if (rows.length === 0) break;
4036
- let conflicts = 0;
4037
4060
  for (const { chunkKey, token } of rows) {
4038
4061
  const chunkRef = { namespace: ref.namespace, segment: ref.segment, chunkKey };
4039
4062
  try {
@@ -4046,6 +4069,11 @@ async function eraseWarm(warm, ref) {
4046
4069
  }
4047
4070
  if (conflicts === 0) break;
4048
4071
  }
4072
+ if (conflicts > 0) {
4073
+ throw new WriteConflictError(
4074
+ `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.`
4075
+ );
4076
+ }
4049
4077
  return deleted;
4050
4078
  }
4051
4079
 
@@ -4145,17 +4173,47 @@ var RetryingWarmDriver = class {
4145
4173
  deleteConditional(ref, expected) {
4146
4174
  return withRetry(() => this.inner.deleteConditional(ref, expected), this.policy, this.deps);
4147
4175
  }
4176
+ /**
4177
+ * Stream the inner driver's rows, retrying only until the first one arrives.
4178
+ *
4179
+ * **This used to buffer the whole scan** — `for await (…) out.push(row)` inside `withRetry`, then
4180
+ * `yield* out` — which made the retry trivially safe and quietly defeated every bound above it. The engine's
4181
+ * `collectWarm` refuses *during* enumeration precisely so resident memory is `O(ceiling)` rather than
4182
+ * `O(segment)`; with the buffering wrapper in place (and it is wired by default) it saw its first row only
4183
+ * after the entire segment was already in memory. Measured on a 500-row segment under
4184
+ * `budget: { maxRequests: 3 }`: **500 rows materialised with the default wiring, 4 with `retry: false`**.
4185
+ * Both paths threw the same `BudgetExceededError`, which is why no test caught it — the error was never the
4186
+ * distinguishing observable, the row count was.
4187
+ *
4188
+ * **The trade this makes.** A mid-stream fault now propagates instead of being retried. It cannot be retried
4189
+ * honestly: rows have already been handed to the consumer, so restarting the iterator would re-yield them,
4190
+ * and the driver interface exposes no resumption token to continue from. Retry is kept where it pays —
4191
+ * establishing the scan, which is where transient connect/throttle failures actually land — and bounded
4192
+ * memory wins the conflict, because invariant 6 ("bounded memory & cost, always") is a hard invariant and
4193
+ * mid-scan retry is not. A caller who needs whole-scan retry can wrap the operation at their own level,
4194
+ * where re-reading from the start is safe.
4195
+ */
4148
4196
  async *listChunks(ref, opts) {
4149
- const rows = await withRetry(
4197
+ const started = await withRetry(
4150
4198
  async () => {
4151
- const out = [];
4152
- for await (const row of this.inner.listChunks(ref, opts)) out.push(row);
4153
- return out;
4199
+ const iterator = this.inner.listChunks(ref, opts)[Symbol.asyncIterator]();
4200
+ const first = await iterator.next();
4201
+ return { iterator, first };
4154
4202
  },
4155
4203
  this.policy,
4156
4204
  this.deps
4157
4205
  );
4158
- yield* rows;
4206
+ if (started.first.done === true) return;
4207
+ try {
4208
+ yield started.first.value;
4209
+ for (; ; ) {
4210
+ const next = await started.iterator.next();
4211
+ if (next.done === true) return;
4212
+ yield next.value;
4213
+ }
4214
+ } finally {
4215
+ await started.iterator.return?.();
4216
+ }
4159
4217
  }
4160
4218
  };
4161
4219
  var RetryingColdChunkSource = class {