@cloudbitmaps/core 0.14.0 → 0.15.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.js CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  recordFromNew,
45
45
  sameIncarnation,
46
46
  serializeRegistryEnvelope,
47
+ validateChunkKeyOrder,
47
48
  validateChunkRef,
48
49
  validateNewRegistryRecord,
49
50
  validateRegistryPatch,
@@ -51,7 +52,7 @@ import {
51
52
  validateUserNamespace,
52
53
  validateUserRef,
53
54
  webCryptoEntropy
54
- } from "./chunk-5W3XS2IU.js";
55
+ } from "./chunk-B7IGS3W6.js";
55
56
 
56
57
  // src/core/bit-route.ts
57
58
  var U32_MAX = 4294967295;
@@ -102,24 +103,64 @@ async function collectWithinBudget(source, budget, op) {
102
103
  return out;
103
104
  }
104
105
 
106
+ // src/core/chunk-stream.ts
107
+ var ChunkStream = class {
108
+ iterator;
109
+ failure;
110
+ constructor(stream) {
111
+ this.iterator = stream[Symbol.asyncIterator]();
112
+ }
113
+ /** The chunk for `key`, which must be the next key the stream was opened over. */
114
+ take(key) {
115
+ if (this.failure !== void 0) return Promise.reject(this.failure.error);
116
+ return this.iterator.next().then(
117
+ (step) => {
118
+ if (step.done === true) {
119
+ throw this.failure?.error ?? new IntegrityError(`chunk stream ended before chunk ${key}`);
120
+ }
121
+ if (step.value.key !== key) {
122
+ throw new IntegrityError(
123
+ `chunk stream answered chunk ${step.value.key} where ${key} was due`
124
+ );
125
+ }
126
+ return step.value;
127
+ },
128
+ (error) => {
129
+ this.failure ??= { error };
130
+ throw error;
131
+ }
132
+ );
133
+ }
134
+ /** Stop the stream: no further range is started, and ranges already in flight finish and are dropped. */
135
+ close() {
136
+ this.iterator.return?.(void 0).catch(() => void 0);
137
+ }
138
+ };
139
+
105
140
  // src/core/chunk-window.ts
106
141
  var ChunkWindow = class {
107
- constructor(keys, fetch, max, ramp) {
142
+ constructor(keys, fetch, max, ramp, rampStart = 1) {
108
143
  this.keys = keys;
109
144
  this.fetch = fetch;
110
145
  this.max = max;
111
146
  this.ramp = ramp;
147
+ this.rampStart = rampStart;
112
148
  }
113
149
  keys;
114
150
  fetch;
115
151
  max;
116
152
  ramp;
153
+ rampStart;
117
154
  open = [];
118
155
  launched = 0;
119
156
  taken = 0;
157
+ /** Resolves once every read launched and not yet taken has settled; it never rejects, as the reads never do. */
158
+ async settle() {
159
+ await Promise.all(this.open);
160
+ }
120
161
  /** The next read's result in key order, or the error that read raised. */
121
162
  async take() {
122
- const width = this.ramp ? Math.min(this.max, 2 ** Math.min(this.taken, 30)) : this.max;
163
+ const width = this.ramp ? Math.min(this.max, Math.max(this.rampStart, 2 ** Math.min(this.taken, 30))) : this.max;
123
164
  while (this.launched < this.keys.length && this.launched - this.taken < width) {
124
165
  this.open.push(
125
166
  this.fetch(this.keys[this.launched++]).then(
@@ -320,6 +361,7 @@ var CountingMetricsSink = class _CountingMetricsSink {
320
361
  // src/core/engine.ts
321
362
  var DEFAULT_INTERSECT_CONCURRENCY = 32;
322
363
  var COMBINE_WINDOW_START = 8;
364
+ var COMBINE_RANGE_START = 4;
323
365
  var ZERO_CLOCK = { now: () => 0 };
324
366
  function windowOf(range) {
325
367
  const after = range?.after;
@@ -447,6 +489,11 @@ var SegmentEngine = class {
447
489
  * promise per distinct key in flight.
448
490
  */
449
491
  openReads = /* @__PURE__ */ new Map();
492
+ /**
493
+ * The chunk streams open right now. An {@link invalidate} of a segment marks its streams, which then cache no chunk
494
+ * they deliver, as a read whose entry an invalidation dropped does not (streams hold no `openReads` entry).
495
+ */
496
+ openStreams = /* @__PURE__ */ new Set();
450
497
  constructor(deps) {
451
498
  this.storage = deps.storage;
452
499
  this.cache = deps.cache;
@@ -527,9 +574,27 @@ var SegmentEngine = class {
527
574
  );
528
575
  }
529
576
  /**
530
- * Every id, ascending, reading ahead through a window of up to 32 chunk fetches that opens 1, 2, 4 … 32 wide, so a
531
- * read that stops early has fetched only a handful of chunks past the one it stopped in; with `range`, only the ids in `(after, through]`, fetching only the
532
- * chunks the range overlaps (see {@link IdRange}).
577
+ * The chunks of `seg` at `chunkKeys`, taken in ascending key order by a read that may stop at any point: one stream of
578
+ * coalesced ranges when the source has `getChunks`, opening 1, 2, 4 … ranges wide up to {@link DEFAULT_INTERSECT_CONCURRENCY},
579
+ * else the chunk-by-chunk window of that many reads. `close` stops what is left of it.
580
+ */
581
+ chunkSequence(seg, chunkKeys, gen) {
582
+ const streamed = this.openStreamed(seg, chunkKeys, gen, DEFAULT_INTERSECT_CONCURRENCY, 1);
583
+ if (streamed !== void 0) {
584
+ return {
585
+ take: (chunkKey) => this.streamedChunk(streamed, chunkKey),
586
+ close: () => this.closeStreamed(streamed)
587
+ };
588
+ }
589
+ const window = this.chunkWindow(seg, chunkKeys, gen, true);
590
+ return { take: () => window.take(), close: () => void 0 };
591
+ }
592
+ /**
593
+ * Every id, ascending, reading the chunks as one stream of coalesced ranges (chunks that sit near each other are one
594
+ * request) that opens 1, 2, 4 … 32 ranges wide, so a read that stops early has asked for only a few ranges past the
595
+ * one it stopped in (each at most 1 MiB); with `range`, only the ids in `(after, through]`, fetching only the
596
+ * chunks the range overlaps (see {@link IdRange}). A source that reads chunk by chunk is read through a window of up to
597
+ * 32 chunk fetches that opens 1, 2, 4 … 32 wide.
533
598
  *
534
599
  * Two generators, not one with a branch: a second `yield` site in the full read's generator grows its frame, and
535
600
  * every id of a read that asked for no range paid for it (measured at about 5% per id). The full read keeps the
@@ -542,11 +607,15 @@ var SegmentEngine = class {
542
607
  const chunkKeys = await this.chunkKeys(seg);
543
608
  checkBudget(this.budget, chunkKeys.length, "iterate");
544
609
  const gen = await this.cacheVersion(seg);
545
- const window = this.chunkWindow(seg, chunkKeys, gen, true);
546
- for (const chunkKey of chunkKeys) {
547
- const chunk = await window.take();
548
- if (chunk === null) continue;
549
- for (const remainder of chunk) yield joinId(chunkKey, remainder);
610
+ const chunks = this.chunkSequence(seg, chunkKeys, gen);
611
+ try {
612
+ for (const chunkKey of chunkKeys) {
613
+ const chunk = await chunks.take(chunkKey);
614
+ if (chunk === null) continue;
615
+ for (const remainder of chunk) yield joinId(chunkKey, remainder);
616
+ }
617
+ } finally {
618
+ chunks.close();
550
619
  }
551
620
  }
552
621
  async *iterateRange(seg, range) {
@@ -555,15 +624,19 @@ var SegmentEngine = class {
555
624
  const chunkKeys = keysWithin(await this.chunkKeys(seg), w);
556
625
  checkBudget(this.budget, chunkKeys.length, "iterate");
557
626
  const gen = await this.cacheVersion(seg);
558
- const window = this.chunkWindow(seg, chunkKeys, gen, true);
559
- for (const chunkKey of chunkKeys) {
560
- const chunk = await window.take();
561
- if (chunk === null) continue;
562
- if (isEdge(chunkKey, w)) {
563
- for (const id of edgeIds(chunk, chunkKey, w)) yield id;
564
- } else {
565
- for (const remainder of chunk) yield joinId(chunkKey, remainder);
627
+ const chunks = this.chunkSequence(seg, chunkKeys, gen);
628
+ try {
629
+ for (const chunkKey of chunkKeys) {
630
+ const chunk = await chunks.take(chunkKey);
631
+ if (chunk === null) continue;
632
+ if (isEdge(chunkKey, w)) {
633
+ for (const id of edgeIds(chunk, chunkKey, w)) yield id;
634
+ } else {
635
+ for (const remainder of chunk) yield joinId(chunkKey, remainder);
636
+ }
566
637
  }
638
+ } finally {
639
+ chunks.close();
567
640
  }
568
641
  }
569
642
  /**
@@ -578,12 +651,16 @@ var SegmentEngine = class {
578
651
  const chunkKeys = w === null ? await this.chunkKeys(seg) : keysWithin(await this.chunkKeys(seg), w);
579
652
  checkBudget(this.budget, chunkKeys.length, "iterate");
580
653
  const gen = await this.cacheVersion(seg);
581
- const window = this.chunkWindow(seg, chunkKeys, gen, true);
582
- for (const chunkKey of chunkKeys) {
583
- const chunk = await window.take();
584
- if (chunk === null) continue;
585
- const ids = chunkIds(chunk, chunkKey, w);
586
- if (ids.length > 0) yield ids;
654
+ const chunks = this.chunkSequence(seg, chunkKeys, gen);
655
+ try {
656
+ for (const chunkKey of chunkKeys) {
657
+ const chunk = await chunks.take(chunkKey);
658
+ if (chunk === null) continue;
659
+ const ids = chunkIds(chunk, chunkKey, w);
660
+ if (ids.length > 0) yield ids;
661
+ }
662
+ } finally {
663
+ chunks.close();
587
664
  }
588
665
  }
589
666
  /**
@@ -600,11 +677,11 @@ var SegmentEngine = class {
600
677
  *
601
678
  * Chunk-skipping intersection. Aligns each segment's chunk-key set (read from the `.crbm` index, no payload),
602
679
  * keeps only keys present in **all** operands (a key missing from any operand can't contribute → its Storage
603
- * chunks are never fetched — the core saving), then for each surviving key fetches the operands' chunks in
604
- * parallel and hands them to the codec for the AND, streaming results through a bounded in-flight window.
680
+ * chunks are never fetched — the core saving), then reads each operand's surviving chunks as one stream of coalesced
681
+ * ranges and hands them, key by key, to the codec for the AND, streaming results through a bounded window.
605
682
  *
606
- * **Memory:** the Storage payload footprint is bounded by the window (`concurrency × operands × chunk`), not by
607
- * segment size — that's the Lambda-friendly property.
683
+ * **Memory:** the Storage payload footprint is bounded by the window (`concurrency × operands × chunk`, a range being
684
+ * at most a chunk's cap of 1 MiB), not by segment size — that's the Lambda-friendly property.
608
685
  *
609
686
  * Generation-consistent within the call (normal case): each operand's current generation is resolved **once**
610
687
  * up front (before the fan-out) and threaded into every chunk read, so a concurrent load can't corrupt or tear
@@ -615,7 +692,9 @@ var SegmentEngine = class {
615
692
  * waiting for the TTL, so a shorter call can meet them too: **the reader cache evicting an operand mid-call**
616
693
  * (`maxOpenSegments`), whose re-read re-resolves fresh; a sweep deleting the generation it was reading, which
617
694
  * heals the read forward; and an invalidation, which this store's own `load`, `rollback` and `eraseSubject` make
618
- * and `invalidate()` makes on request. Still whole/immutable per read, never torn.
695
+ * and `invalidate()` makes on request. Still whole/immutable per read, never torn. An operand's stream resolves its
696
+ * segment again before it serves each chunk, so each of those moves a running read as it would a read of one chunk, and
697
+ * the ranges it had requested of the earlier generation are dropped, not served.
619
698
  */
620
699
  intersect(segs, options) {
621
700
  return this.combine(segs, options?.exclude ?? [], "all", "intersect", options);
@@ -717,38 +796,61 @@ var SegmentEngine = class {
717
796
  skippedChunks: distinctKeys.size - common.length
718
797
  });
719
798
  }
799
+ const streams = [];
800
+ const streamExcludes = mode === "any" || operands.length === 1;
801
+ const open4 = (o, wanted) => {
802
+ o.streamed = this.openStreamed(
803
+ o.seg,
804
+ common.filter(wanted),
805
+ o.gen,
806
+ limit,
807
+ COMBINE_RANGE_START
808
+ );
809
+ if (o.streamed?.stream) streams.push(o.streamed);
810
+ };
811
+ for (const o of operands) open4(o, mode === "all" ? () => true : (k) => o.keys.has(k));
812
+ if (streamExcludes) for (const e of excludes) open4(e, (k) => e.keys.has(k));
720
813
  return {
721
814
  w,
722
815
  window: new CombineWindow(
723
816
  common,
724
817
  limit,
725
818
  (key) => this.combineChunk(operands, excludes, mode, key)
726
- )
819
+ ),
820
+ streams
727
821
  };
728
822
  }
729
823
  async *combine(segs, excludeSegs, mode, op, options) {
730
824
  const plan = await this.combinePlan(segs, excludeSegs, mode, op, options);
731
825
  if (plan === null) return;
732
826
  const { w, window } = plan;
733
- for (let slot = await window.next(); slot !== void 0; slot = await window.next()) {
734
- if (slot.result && !slot.result.isEmpty) {
735
- if (w !== null && isEdge(slot.key, w)) {
736
- for (const id of edgeIds(slot.result, slot.key, w)) yield id;
737
- } else {
738
- for (const remainder of slot.result) yield joinId(slot.key, remainder);
827
+ try {
828
+ for (let slot = await window.next(); slot !== void 0; slot = await window.next()) {
829
+ if (slot.result && !slot.result.isEmpty) {
830
+ if (w !== null && isEdge(slot.key, w)) {
831
+ for (const id of edgeIds(slot.result, slot.key, w)) yield id;
832
+ } else {
833
+ for (const remainder of slot.result) yield joinId(slot.key, remainder);
834
+ }
739
835
  }
740
836
  }
837
+ } finally {
838
+ for (const stream of plan.streams) this.closeStreamed(stream);
741
839
  }
742
840
  }
743
841
  async *combineBatches(segs, excludeSegs, mode, op, options) {
744
842
  const plan = await this.combinePlan(segs, excludeSegs, mode, op, options);
745
843
  if (plan === null) return;
746
844
  const { w, window } = plan;
747
- for (let slot = await window.next(); slot !== void 0; slot = await window.next()) {
748
- if (slot.result) {
749
- const ids = chunkIds(slot.result, slot.key, w);
750
- if (ids.length > 0) yield ids;
845
+ try {
846
+ for (let slot = await window.next(); slot !== void 0; slot = await window.next()) {
847
+ if (slot.result) {
848
+ const ids = chunkIds(slot.result, slot.key, w);
849
+ if (ids.length > 0) yield ids;
850
+ }
751
851
  }
852
+ } finally {
853
+ for (const stream of plan.streams) this.closeStreamed(stream);
752
854
  }
753
855
  }
754
856
  /**
@@ -766,9 +868,7 @@ var SegmentEngine = class {
766
868
  const relevant = excludes.filter((e) => e.keys.has(chunkKey));
767
869
  const together = relevant.length > 0 && (mode === "any" || present.length === 1);
768
870
  const reads = together ? [...present, ...relevant] : present;
769
- const fetched = await Promise.all(
770
- reads.map((op) => this.storageChunk({ ...op.seg, chunkKey }, op.gen))
771
- );
871
+ const fetched = await Promise.all(reads.map((op) => this.operandChunk(op, chunkKey)));
772
872
  const chunks = together ? fetched.slice(0, present.length) : fetched;
773
873
  if (mode === "all" && chunks.some((c) => c === null)) return null;
774
874
  const first = chunks[0];
@@ -794,6 +894,92 @@ var SegmentEngine = class {
794
894
  }
795
895
  return acc;
796
896
  }
897
+ /**
898
+ * One operand's chunk at `key`: from its stream when it has one, else a read of that chunk on its own. Not `async`,
899
+ * so a stream's take is made before anything is awaited: concurrent takes of one stream must be made in key order.
900
+ */
901
+ operandChunk(op, chunkKey) {
902
+ return op.streamed === void 0 ? this.storageChunk({ ...op.seg, chunkKey }, op.gen) : this.streamedChunk(op.streamed, chunkKey);
903
+ }
904
+ /**
905
+ * Open `keys` of `seg` as a stream of coalesced ranges, or `undefined` when the read is per key: a source with no
906
+ * `getChunks`, a segment with no generation, or nothing to read. The chunks already decoded in the cache are taken out
907
+ * of the key list first and served from there, so a repeat read asks the source for nothing it holds. Nothing is
908
+ * requested until the first chunk is asked for.
909
+ */
910
+ openStreamed(seg, keys, gen, concurrency, rampStart) {
911
+ const getChunks = this.storage.getChunks;
912
+ if (getChunks === void 0 || gen === null || keys.length === 0) return void 0;
913
+ let wanted = keys;
914
+ let inStream;
915
+ if (this.cache) {
916
+ const misses = [];
917
+ for (const chunkKey of keys) {
918
+ const hit = this.cache.get(this.chunkCacheKey({ ...seg, chunkKey }, gen));
919
+ if (!hit) misses.push(chunkKey);
920
+ if (this.metricsOn) this.metrics.onEvent({ kind: "cache", hit: hit !== void 0 });
921
+ }
922
+ wanted = misses;
923
+ if (misses.length < keys.length) inStream = new Set(misses);
924
+ }
925
+ const onRequest = this.metricsOn ? (request) => {
926
+ this.metrics.onEvent({
927
+ kind: "storage.get",
928
+ namespace: seg.namespace,
929
+ segment: seg.segment,
930
+ bytes: request.bytes,
931
+ ms: request.ms
932
+ });
933
+ } : void 0;
934
+ const stream = wanted.length === 0 ? void 0 : new ChunkStream(
935
+ getChunks.call(this.storage, seg, wanted, {
936
+ concurrency,
937
+ ramp: rampStart,
938
+ ...onRequest === void 0 ? {} : { onRequest }
939
+ })
940
+ );
941
+ const streamed = { seg, gen, inStream, stream, invalidated: false };
942
+ if (stream) this.openStreams.add(streamed);
943
+ return streamed;
944
+ }
945
+ closeStreamed(streamed) {
946
+ streamed.stream?.close();
947
+ this.openStreams.delete(streamed);
948
+ }
949
+ /**
950
+ * The decoded chunk at `chunkKey` of a streamed read: the next chunk of its stream, or, for a chunk the cache held
951
+ * when the read opened, the cached instance while it is still there, and a read of that chunk alone once it is not.
952
+ */
953
+ streamedChunk(streamed, chunkKey) {
954
+ if (streamed.inStream !== void 0 && !streamed.inStream.has(chunkKey)) {
955
+ const ref = { ...streamed.seg, chunkKey };
956
+ const hit = this.cache?.get(this.chunkCacheKey(ref, streamed.gen));
957
+ return hit ? Promise.resolve(hit) : this.storageChunk(ref, streamed.gen);
958
+ }
959
+ return streamed.stream.take(chunkKey).then((read) => {
960
+ if (read.bytes === null) return null;
961
+ const bitmap = this.decodeChunk(read.bytes, chunkKey);
962
+ if (this.cache && !streamed.invalidated) {
963
+ const version = this.streamedVersion(streamed.gen, read.version);
964
+ if (version !== null) {
965
+ this.cache.set(this.chunkCacheKey({ ...streamed.seg, chunkKey }, version), bitmap);
966
+ }
967
+ }
968
+ return bitmap;
969
+ });
970
+ }
971
+ /**
972
+ * The version a streamed chunk is cached under, or `null` for one not to cache: the version its read reports when the
973
+ * source names versions as its lookups do (`currentVersion`), and otherwise the one the read planned under, since a
974
+ * lookup by generation number would never find a chunk cached under a stream's version.
975
+ */
976
+ streamedVersion(planned, read) {
977
+ return planned === void 0 || this.storage.currentVersion === void 0 ? planned : read;
978
+ }
979
+ /** The cache key of a chunk: by the version it was read under, or by segment and key alone for a source with none. */
980
+ chunkCacheKey(ref, version) {
981
+ return version === void 0 ? chunkRefKey(ref) : chunkGenKey(ref, version);
982
+ }
797
983
  /** The segment's chunk keys, ascending — a shape read off the index, no payload. Keys are untrusted (invariant 5). */
798
984
  async chunkKeys(seg) {
799
985
  const keys = [...await this.storage.listChunkKeys(seg)];
@@ -888,6 +1074,9 @@ var SegmentEngine = class {
888
1074
  */
889
1075
  invalidate(ref) {
890
1076
  const prefix = segmentPrefix(ref);
1077
+ for (const streamed of this.openStreams) {
1078
+ if (segmentPrefix(streamed.seg) === prefix) streamed.invalidated = true;
1079
+ }
891
1080
  this.cache?.deleteWhere((key) => key.startsWith(prefix));
892
1081
  for (const key of this.openReads.keys()) if (key.startsWith(prefix)) this.openReads.delete(key);
893
1082
  this.storage.invalidate?.(ref);
@@ -929,7 +1118,7 @@ var SegmentEngine = class {
929
1118
  */
930
1119
  async storageChunk(ref, gen) {
931
1120
  if (gen === null) return null;
932
- const cacheKey = gen === void 0 ? chunkRefKey(ref) : chunkGenKey(ref, gen);
1121
+ const cacheKey = this.chunkCacheKey(ref, gen);
933
1122
  if (this.cache) {
934
1123
  const cached = this.cache.get(cacheKey);
935
1124
  if (cached) {
@@ -941,31 +1130,71 @@ var SegmentEngine = class {
941
1130
  const open4 = this.openReads.get(cacheKey);
942
1131
  if (open4) return open4.read;
943
1132
  const token = {};
944
- const entry = { token, read: this.fetchChunk(ref, cacheKey, token) };
1133
+ const entry = { token, read: this.fetchChunk(ref, gen, cacheKey, token) };
945
1134
  this.openReads.set(cacheKey, entry);
946
- const settled = () => {
1135
+ const settled2 = () => {
947
1136
  if (this.openReads.get(cacheKey) === entry) this.openReads.delete(cacheKey);
948
1137
  };
949
- entry.read.then(settled, settled);
1138
+ entry.read.then(settled2, settled2);
950
1139
  return entry.read;
951
1140
  }
952
- /** One request for one chunk, decoded, range-checked and cached. */
953
- async fetchChunk(ref, cacheKey, token) {
954
- const startedAt = this.metricsOn ? this.clock.now() : 0;
955
- const bytes = await this.storage.getChunk(ref);
956
- if (this.metricsOn) {
957
- this.metrics.onEvent({
958
- kind: "storage.get",
959
- namespace: ref.namespace,
960
- segment: ref.segment,
961
- bytes: bytes ? bytes.length : 0,
962
- ms: Math.max(0, this.clock.now() - startedAt)
963
- });
1141
+ /**
1142
+ * A chunk's stored bytes decoded under the size cap (invariant 5) and range-checked: the one place a chunk becomes a
1143
+ * bitmap, whether its bytes came from a request of its own or from a stream of coalesced ranges.
1144
+ */
1145
+ decodeChunk(bytes, chunkKey) {
1146
+ const bitmap = this.codec.safeDeserialize(bytes, this.maxBitmapBytes);
1147
+ this.assertChunkPayloadInRange(bitmap, chunkKey);
1148
+ return bitmap;
1149
+ }
1150
+ /**
1151
+ * One chunk, decoded, range-checked and cached. A source with `getChunks` is read through it, as a one-key stream, so
1152
+ * a `storage.get` is reported only for a request that was sent (a chunk the source holds in memory sends none);
1153
+ * the chunk is cached under the version it came from, as a stream's chunk is. A source without it is read by
1154
+ * `getChunk`, one event a call.
1155
+ */
1156
+ async fetchChunk(ref, gen, cacheKey, token) {
1157
+ const getChunks = this.storage.getChunks;
1158
+ let bytes;
1159
+ let key = cacheKey;
1160
+ if (getChunks === void 0) {
1161
+ const startedAt = this.metricsOn ? this.clock.now() : 0;
1162
+ bytes = await this.storage.getChunk(ref);
1163
+ if (this.metricsOn) {
1164
+ this.metrics.onEvent({
1165
+ kind: "storage.get",
1166
+ namespace: ref.namespace,
1167
+ segment: ref.segment,
1168
+ bytes: bytes ? bytes.length : 0,
1169
+ ms: Math.max(0, this.clock.now() - startedAt)
1170
+ });
1171
+ }
1172
+ } else {
1173
+ const stream = new ChunkStream(
1174
+ getChunks.call(this.storage, ref, [ref.chunkKey], {
1175
+ ...this.metricsOn ? {
1176
+ onRequest: (request) => this.metrics.onEvent({
1177
+ kind: "storage.get",
1178
+ namespace: ref.namespace,
1179
+ segment: ref.segment,
1180
+ bytes: request.bytes,
1181
+ ms: request.ms
1182
+ })
1183
+ } : {}
1184
+ })
1185
+ );
1186
+ try {
1187
+ const read = await stream.take(ref.chunkKey);
1188
+ bytes = read.bytes;
1189
+ const version = this.streamedVersion(gen, read.version);
1190
+ key = version === null ? "" : this.chunkCacheKey(ref, version);
1191
+ } finally {
1192
+ stream.close();
1193
+ }
964
1194
  }
965
1195
  if (!bytes) return null;
966
- const bitmap = this.codec.safeDeserialize(bytes, this.maxBitmapBytes);
967
- this.assertChunkPayloadInRange(bitmap, ref.chunkKey);
968
- if (this.openReads.get(cacheKey)?.token === token) this.cache?.set(cacheKey, bitmap);
1196
+ const bitmap = this.decodeChunk(bytes, ref.chunkKey);
1197
+ if (key !== "" && this.openReads.get(cacheKey)?.token === token) this.cache?.set(key, bitmap);
969
1198
  return bitmap;
970
1199
  }
971
1200
  };
@@ -1546,7 +1775,7 @@ function buildReport(input) {
1546
1775
  "Request cost is from the supplied workload rates (live-metrics-derived request cost is a later phase).",
1547
1776
  loadsPerMonth > 0 ? `Loads modeled: ${loadsPerMonth}/mo, each ${requestsPerLoad} PUT-class request(s) for the object plus ${shown(STORE_LOAD_PUT_CLASS)} PUT-class and ${shown(loadGets)} GETs that store.load() adds (the pointer, a check that the next generation number is free, a check that the current generation is there, and a listing every ${LIST_COLLECTION_CADENCE}th load).` : "Loads are NOT modeled \u2014 set workload.loadsPerMonth (+ requestsPerLoad for multipart) to include them.",
1548
1777
  ...intersects > 0 ? [
1549
- `Intersections priced cold: ${intersectGets} GETs each, ${getsPerColdOperand} for each of ${operandsPerIntersect} operand(s) plus ${chunksPerIntersect} chunk read(s); cacheHitRate does not apply.`
1778
+ `Intersections priced cold: ${intersectGets} GETs each, ${getsPerColdOperand} for each of ${operandsPerIntersect} operand(s) plus ${chunksPerIntersect} chunk range request(s); cacheHitRate does not apply.`
1550
1779
  ] : [],
1551
1780
  hotSegments > 0 ? genTtlMs > 0 ? `Pointer refresh modeled: ${hotSegments} hot segment(s) in each of ${readerProcesses} reader process(es), each re-reading its pointer at most every ${genTtlMs} ms, and at most once a point read.` : "Pointer refresh: none \u2014 genTtlMs 0 turns the timed refresh off. A reader still re-reads a pointer on an eviction, a swept generation or an invalidation, which this does not price." : readsPerSec > 0 ? "The pointer refresh is NOT modeled \u2014 set workload.hotSegments to the segments each long-lived reader keeps reading, and workload.readerProcesses to how many readers there are." : null,
1552
1781
  hotSegments > DEFAULT_MAX_OPEN_SEGMENTS ? `More hot segments in a reader than a store keeps open by default (${DEFAULT_MAX_OPEN_SEGMENTS}): a read of a segment the reader evicted opens it again, a pointer and a tail read, which this does not price. Raise cache.readerMax, and cache.readerMaxBytes, to keep them open.` : null,
@@ -2522,6 +2751,40 @@ function crc32c(bytes, seed = 0) {
2522
2751
  return (crc ^ 4294967295) >>> 0;
2523
2752
  }
2524
2753
 
2754
+ // src/core/crbm/plan-reads.ts
2755
+ var MAX_COALESCE_GAP_BYTES = 256 * 1024;
2756
+ var MAX_COALESCED_READ_BYTES = DEFAULT_MAX_BITMAP_BYTES;
2757
+ var MAX_RANGES_IN_FLIGHT = 32;
2758
+ function planChunkReads(extents, region) {
2759
+ const reads = [];
2760
+ let chunks = [];
2761
+ let start = 0;
2762
+ let end = 0;
2763
+ const flush = () => {
2764
+ if (chunks.length > 0) reads.push({ offset: start, length: end - start, chunks });
2765
+ chunks = [];
2766
+ };
2767
+ let previousEnd = region.start;
2768
+ for (const extent of extents) {
2769
+ const extentEnd = extent.offset + extent.length;
2770
+ if (!(Number.isSafeInteger(extent.offset) && Number.isSafeInteger(extent.length) && extent.length >= 1 && extent.offset >= previousEnd && extentEnd <= region.end)) {
2771
+ throw new IntegrityError(`chunk ${extent.key} is not inside the chunk region, in order`);
2772
+ }
2773
+ previousEnd = extentEnd;
2774
+ if (chunks.length > 0 && extent.offset - end <= MAX_COALESCE_GAP_BYTES && extentEnd - start <= MAX_COALESCED_READ_BYTES) {
2775
+ chunks.push(extent);
2776
+ end = extentEnd;
2777
+ continue;
2778
+ }
2779
+ flush();
2780
+ chunks.push(extent);
2781
+ start = extent.offset;
2782
+ end = extentEnd;
2783
+ }
2784
+ flush();
2785
+ return reads;
2786
+ }
2787
+
2525
2788
  // src/core/crbm/varint.ts
2526
2789
  var U32_CEIL = 4294967296;
2527
2790
  function writeVarint(out, value) {
@@ -2602,8 +2865,14 @@ function readU64(view, offset, field) {
2602
2865
  }
2603
2866
  return Number(big);
2604
2867
  }
2868
+ var keepLimits = /* @__PURE__ */ new WeakMap();
2869
+ function openCrbmReaderKeeping(blob, options, keepChunkBytesUpTo) {
2870
+ const asked = { ...options };
2871
+ if (keepChunkBytesUpTo !== void 0) keepLimits.set(asked, keepChunkBytesUpTo);
2872
+ return CrbmReader.open(blob, asked);
2873
+ }
2605
2874
  var CrbmReader = class _CrbmReader {
2606
- constructor(blob, objectSize, generation, lineage, totalCardinality, index, servedFromTail, crypto, footerCrc, metadata, metadataWeight) {
2875
+ constructor(blob, objectSize, generation, lineage, totalCardinality, index, servedFromTail, crypto, footerCrc, metadata, metadataWeight, payloadEnd, kept) {
2607
2876
  this.blob = blob;
2608
2877
  this.objectSize = objectSize;
2609
2878
  this.generation = generation;
@@ -2615,6 +2884,8 @@ var CrbmReader = class _CrbmReader {
2615
2884
  this.footerCrc = footerCrc;
2616
2885
  this.metadata = metadata;
2617
2886
  this.metadataWeight = metadataWeight;
2887
+ this.payloadEnd = payloadEnd;
2888
+ this.kept = kept;
2618
2889
  }
2619
2890
  blob;
2620
2891
  objectSize;
@@ -2627,6 +2898,8 @@ var CrbmReader = class _CrbmReader {
2627
2898
  footerCrc;
2628
2899
  metadata;
2629
2900
  metadataWeight;
2901
+ payloadEnd;
2902
+ kept;
2630
2903
  /**
2631
2904
  * Which object this reader opened, as its size and its footer's CRC. The footer covers the index's CRC, so two
2632
2905
  * objects with the same fingerprint hold the same chunks, as far as a CRC can tell; a pin compares it to know that
@@ -2651,11 +2924,12 @@ var CrbmReader = class _CrbmReader {
2651
2924
  /**
2652
2925
  * What this reader holds, the weight the storage reader cache bounds on: its parsed index
2653
2926
  * ({@link retainedIndexBytes}), plus a weight for the metadata it decoded, from its canonical length and its number
2654
- * of keys. Equal to {@link retainedIndexBytes} for a generation with no metadata. A cache heuristic, not a contract:
2655
- * how the metadata is weighed may change in any release.
2927
+ * of keys, plus the chunk bytes kept from the open, when it kept any. Equal to {@link retainedIndexBytes} for a
2928
+ * generation with no metadata and nothing kept. A cache heuristic, not a contract: how the metadata is weighed may
2929
+ * change in any release.
2656
2930
  */
2657
2931
  get retainedBytes() {
2658
- return this.retainedIndexBytes + this.metadataWeight;
2932
+ return this.retainedIndexBytes + this.metadataWeight + (this.kept?.length ?? 0);
2659
2933
  }
2660
2934
  /** Per-chunk cardinality (`chunkKey → count`) from the parsed index — no payload reads. */
2661
2935
  cardinalities() {
@@ -2853,7 +3127,11 @@ var CrbmReader = class _CrbmReader {
2853
3127
  options.crypto,
2854
3128
  storedFooterCrc,
2855
3129
  metadata,
2856
- metadata === void 0 ? 0 : RETAINED_BYTES_PER_METADATA_BYTE * metadataLength + RETAINED_BYTES_PER_METADATA_KEY * Object.keys(metadata).length
3130
+ metadata === void 0 ? 0 : RETAINED_BYTES_PER_METADATA_BYTE * metadataLength + RETAINED_BYTES_PER_METADATA_KEY * Object.keys(metadata).length,
3131
+ payloadEnd,
3132
+ // Only a tail that began at the front of the object holds the chunks, and only a region the caller's limit
3133
+ // allows is kept: a copy of just that region, so the tail buffer is not held.
3134
+ tailCoversFrom === 0 && payloadEnd - PAYLOAD_START <= (keepLimits.get(options) ?? 0) ? tail.slice(PAYLOAD_START, payloadEnd) : void 0
2857
3135
  );
2858
3136
  }
2859
3137
  /** Chunk keys present in this generation, ascending. */
@@ -2895,7 +3173,30 @@ var CrbmReader = class _CrbmReader {
2895
3173
  if (offset < PAYLOAD_START || offset + length > this.objectSize - FOOTER_BYTES) {
2896
3174
  throw new IntegrityError(`chunk ${chunkKey} payload out of bounds`);
2897
3175
  }
2898
- const bytes = await this.blob.getRange(offset, length);
3176
+ const held = this.keptRange(offset, length);
3177
+ return this.openChunk(slot, chunkKey, held ?? await this.blob.getRange(offset, length));
3178
+ }
3179
+ /**
3180
+ * `[offset, offset + length)` of the object from the kept chunk region, or `undefined` when it is not wholly in it
3181
+ * (or nothing was kept). The bounds checks are defence on untrusted bytes: the index's validation at open keeps every
3182
+ * extent inside the region, so they do not fire for a reader that opened.
3183
+ *
3184
+ * The range is returned as a copy, so nothing a caller or an AEAD does with it changes the next read.
3185
+ */
3186
+ keptRange(offset, length) {
3187
+ const kept = this.kept;
3188
+ if (kept === void 0 || offset < PAYLOAD_START || offset + length > PAYLOAD_START + kept.length) {
3189
+ return void 0;
3190
+ }
3191
+ const from = offset - PAYLOAD_START;
3192
+ return kept.slice(from, from + length);
3193
+ }
3194
+ /**
3195
+ * Check one chunk's stored bytes against the index, and open them: the CRC32C first, then, on an encrypted object,
3196
+ * the AEAD with the chunk's own associated data. The one check every read of a chunk goes through, whether the
3197
+ * bytes came from a range of their own or from a slice of a larger one.
3198
+ */
3199
+ openChunk(slot, chunkKey, bytes) {
2899
3200
  if (crc32c(bytes) !== this.index.crcs[slot]) {
2900
3201
  throw new IntegrityError(`chunk ${chunkKey} payload CRC mismatch`);
2901
3202
  }
@@ -2912,6 +3213,154 @@ var CrbmReader = class _CrbmReader {
2912
3213
  this.crypto.aadFor(chunkKey)
2913
3214
  );
2914
3215
  }
3216
+ /**
3217
+ * The next needed chunk of the range in `cur`, checked and opened, as a stream hands it out; the range is let go of
3218
+ * when this is its last chunk.
3219
+ */
3220
+ nextChunk(cur, slot, key) {
3221
+ const range = cur.range;
3222
+ const chunk = range.read.chunks[cur.at++];
3223
+ if (cur.at === range.read.chunks.length) cur.range = void 0;
3224
+ const from = chunk.offset - range.read.offset;
3225
+ const bytes = this.openChunk(slot, chunk.key, range.bytes.subarray(from, from + chunk.length));
3226
+ return { key, bytes };
3227
+ }
3228
+ /**
3229
+ * Read several chunks as a stream, one storage request per merged range ({@link planChunkReads}) rather than one per
3230
+ * chunk. `chunkKeys` are in ascending order, a key may repeat, and anything else is a {@link ValidationError}. One
3231
+ * item comes out per position of `chunkKeys`, in order: the chunk's bytes, or `null` where this generation has none,
3232
+ * as {@link getChunk} answers for an absent key. A key given twice is read once and answered at both positions with
3233
+ * the same view.
3234
+ *
3235
+ * The whole key list is planned once, up front, against this one generation's index. The ranges then run through a
3236
+ * window of `options.concurrency` reads ({@link MAX_RANGES_IN_FLIGHT} by default) that counts a range until the
3237
+ * consumer has taken it, so a stream never holds more than that many ranges, in flight or landed, however many keys
3238
+ * it was given or how slowly it is read; with `options.ramp` the window opens 1, 2, 4 … wide. A range is at most 1 MiB
3239
+ * unless it is one chunk, which is at most the payload cap, so the bound is that many times the per-chunk cap
3240
+ * (1 MiB, and 28 bytes more for an encrypted chunk). Nothing is read until the first item is asked for, and a
3241
+ * consumer that stops launches no more: ranges already in flight finish and are dropped, and a failure of one of
3242
+ * those is never raised.
3243
+ *
3244
+ * Every chunk is held to the check a read of it alone gets: its slice of the range must match its index entry's
3245
+ * CRC32C, and on an encrypted object it opens under its own associated data, so a chunk moved to another place in
3246
+ * the object, or another key's bytes, is refused. The bytes in a gap between needed chunks are never parsed. A
3247
+ * range that comes back shorter than asked is an {@link IntegrityError}, and a chunk that fails its check ends the
3248
+ * stream with that error when its turn comes, as a read of it alone would fail.
3249
+ *
3250
+ * `options.readRange`, when given, runs each range read, so a caller can retry one request without repeating the
3251
+ * others; `options.now` times each request for `options.onRequest`, which hears of every range request that was sent, once, when
3252
+ * it settles: taken, abandoned after the consumer stopped, or failed (a reader has no clock of its own, so `ms` is 0
3253
+ * without one).
3254
+ * A plain chunk is a writable view into the range it was read in: copy a chunk to keep it.
3255
+ *
3256
+ * A stream that fails (a range that errors, a chunk that fails its check) raises at once and sends nothing further:
3257
+ * the ranges it still had in flight finish in the background, are not retried and never raise. Its `settled()`
3258
+ * resolves when those have finished, for a caller that is about to read again and does not want them beside its next
3259
+ * window (the source awaits it before a heal, and only then). It waits as long as the slowest of them takes to
3260
+ * answer, which only the store's own read timeout bounds, so a read that never answers delays a heal and never an
3261
+ * error.
3262
+ */
3263
+ readChunks(chunkKeys, options = {}) {
3264
+ const open4 = { window: void 0 };
3265
+ const stream = this.streamChunks(chunkKeys, options, open4);
3266
+ return Object.assign(stream, {
3267
+ settled: () => open4.window?.settle() ?? Promise.resolve()
3268
+ });
3269
+ }
3270
+ async *streamChunks(chunkKeys, options, open4) {
3271
+ const width = options.concurrency ?? MAX_RANGES_IN_FLIGHT;
3272
+ if (!Number.isInteger(width) || width < 1) {
3273
+ throw new ValidationError(
3274
+ `concurrency must be a positive integer; got ${options.concurrency}`
3275
+ );
3276
+ }
3277
+ validateChunkKeyOrder(chunkKeys);
3278
+ const ramp = options.ramp ?? false;
3279
+ if (typeof ramp === "number" && (!Number.isInteger(ramp) || ramp < 1)) {
3280
+ throw new ValidationError(`ramp must be a boolean or a positive integer; got ${ramp}`);
3281
+ }
3282
+ const readRange = options.readRange ?? ((read) => read());
3283
+ const now = options.now ?? (() => 0);
3284
+ const slots = chunkKeys.map((key) => this.slotOf(key));
3285
+ const needed = [...new Set(slots.filter((slot) => slot >= 0))];
3286
+ const extents = needed.map((slot) => ({
3287
+ key: this.index.keys[slot],
3288
+ offset: this.index.offsets[slot],
3289
+ length: this.index.lengths[slot]
3290
+ }));
3291
+ const reads = planChunkReads(extents, { start: PAYLOAD_START, end: this.payloadEnd });
3292
+ let closed = false;
3293
+ const report = (bytes, ms) => {
3294
+ try {
3295
+ options.onRequest?.({ bytes, ms });
3296
+ } catch {
3297
+ }
3298
+ };
3299
+ const window = new ChunkWindow(
3300
+ reads.map((_, i) => i),
3301
+ async (i) => {
3302
+ const read = reads[i];
3303
+ const held = this.keptRange(read.offset, read.length);
3304
+ if (held !== void 0) return { bytes: held };
3305
+ const startedAt = now();
3306
+ let sent = false;
3307
+ let moved = 0;
3308
+ try {
3309
+ const bytes = await readRange(() => {
3310
+ if (closed) throw new StreamClosed();
3311
+ sent = true;
3312
+ return this.blob.getRange(read.offset, read.length);
3313
+ });
3314
+ moved = bytes.length;
3315
+ if (bytes.length !== read.length) {
3316
+ throw new IntegrityError(
3317
+ `.crbm range [${read.offset}, +${read.length}) read short (${bytes.length} bytes)`
3318
+ );
3319
+ }
3320
+ return { bytes };
3321
+ } finally {
3322
+ if (sent) report(moved, Math.max(0, now() - startedAt));
3323
+ }
3324
+ },
3325
+ width,
3326
+ ramp !== false,
3327
+ typeof ramp === "number" ? ramp : 1
3328
+ );
3329
+ open4.window = window;
3330
+ let next = 0;
3331
+ const cur = { range: void 0, at: 0 };
3332
+ let last;
3333
+ try {
3334
+ for (const [i, key] of chunkKeys.entries()) {
3335
+ if (slots[i] < 0) {
3336
+ yield { key, bytes: null };
3337
+ continue;
3338
+ }
3339
+ if (last !== void 0 && last.key === key) {
3340
+ yield { key, bytes: last.bytes };
3341
+ if (chunkKeys[i + 1] !== key) last = void 0;
3342
+ continue;
3343
+ }
3344
+ if (cur.range === void 0) {
3345
+ cur.range = { read: reads[next++], ...await window.take() };
3346
+ cur.at = 0;
3347
+ }
3348
+ if (chunkKeys[i + 1] === key) {
3349
+ last = this.nextChunk(cur, slots[i], key);
3350
+ yield last;
3351
+ } else {
3352
+ yield this.nextChunk(cur, slots[i], key);
3353
+ }
3354
+ }
3355
+ } finally {
3356
+ closed = true;
3357
+ }
3358
+ }
3359
+ };
3360
+ var StreamClosed = class extends Error {
3361
+ constructor() {
3362
+ super("the chunk stream was closed");
3363
+ }
2915
3364
  };
2916
3365
  function within(bytes, start, from, to) {
2917
3366
  if (from < start || to > start + bytes.length) {
@@ -3041,6 +3490,33 @@ function parseIndex(indexBytes, payloadEnd, maxPayloadBytes, minPayloadBytes = 1
3041
3490
  };
3042
3491
  }
3043
3492
 
3493
+ // src/core/item-pull.ts
3494
+ var ItemPull = class {
3495
+ iterator;
3496
+ item;
3497
+ constructor(source) {
3498
+ this.iterator = source[Symbol.asyncIterator]();
3499
+ }
3500
+ /** Waits for the next item and keeps it inside; `false` once the source has ended. A failure of the source throws. */
3501
+ async advance() {
3502
+ const step = await this.iterator.next();
3503
+ if (step.done === true) return false;
3504
+ this.item = step.value;
3505
+ return true;
3506
+ }
3507
+ /** Hands over the item {@link advance} fetched, through `map`, and forgets it. */
3508
+ take(map) {
3509
+ const item = this.item;
3510
+ this.item = void 0;
3511
+ return map(item);
3512
+ }
3513
+ /** Ends the source early (a consumer that stopped), as leaving a `for await` does. */
3514
+ async close() {
3515
+ this.item = void 0;
3516
+ await this.iterator.return?.();
3517
+ }
3518
+ };
3519
+
3044
3520
  // src/core/crbm/writer.ts
3045
3521
  var CrbmWriter = class {
3046
3522
  constructor(sink, options) {
@@ -3575,6 +4051,10 @@ var CrbmStorageChunkSource = class {
3575
4051
  this.keystore = keystore;
3576
4052
  this.requireEncryption = requireEncryption ?? false;
3577
4053
  this.readerOptions = readerOptions;
4054
+ const refreshes = registry !== void 0 && clock !== void 0 && (currentGenTtlMs ?? DEFAULT_CURRENT_GEN_TTL_MS) > 0;
4055
+ this.keepChunkBytesUpTo = refreshes ? Math.floor(
4056
+ (maxOpenIndexBytes ?? DEFAULT_MAX_OPEN_INDEX_BYTES) / (maxOpenSegments ?? DEFAULT_MAX_OPEN_SEGMENTS)
4057
+ ) : 0;
3578
4058
  this.clock = clock;
3579
4059
  this.currentGenTtlMs = currentGenTtlMs ?? DEFAULT_CURRENT_GEN_TTL_MS;
3580
4060
  this.snapshots = new BoundedLru({
@@ -3638,6 +4118,8 @@ var CrbmStorageChunkSource = class {
3638
4118
  keystore;
3639
4119
  requireEncryption;
3640
4120
  readerOptions;
4121
+ /** The most chunk bytes a reader keeps from its open: the reader cache's byte bound over its count bound, per reader; 0 without a timed pointer refresh. */
4122
+ keepChunkBytesUpTo;
3641
4123
  clock;
3642
4124
  currentGenTtlMs;
3643
4125
  /**
@@ -4082,11 +4564,12 @@ var CrbmStorageChunkSource = class {
4082
4564
  segment: ref.segment,
4083
4565
  generation: target.generation
4084
4566
  };
4085
- return openChecked(this.driver, genKey, {
4086
- ...this.readerOptions,
4087
- crypto,
4088
- lineage: target.lineage
4089
- });
4567
+ return openChecked(
4568
+ this.driver,
4569
+ genKey,
4570
+ { ...this.readerOptions, crypto, lineage: target.lineage },
4571
+ this.keepChunkBytesUpTo
4572
+ );
4090
4573
  }
4091
4574
  /**
4092
4575
  * Open the generation a resolution found, and hold the row's summary against the object while it is open anyway:
@@ -4173,6 +4656,128 @@ var CrbmStorageChunkSource = class {
4173
4656
  validateChunkRef(ref);
4174
4657
  return this.withFreshSnapshot(ref, (reader) => reader.getChunk(ref.chunkKey), null);
4175
4658
  }
4659
+ /**
4660
+ * Several chunks as a stream, in as few storage requests as {@link CrbmReader.readChunks} can make them (see
4661
+ * {@link StorageChunkSource.getChunks}). The stream is served from one resolved generation, and every chunk says
4662
+ * which version it came from. A generation swept, or an object replaced under its number, while the stream runs
4663
+ * re-resolves the segment and carries on with the keys not yet yielded from whatever is current then, so each chunk
4664
+ * is whole and from one generation and the stream may describe two; a second failure with no chunk yielded in between
4665
+ * propagates. `options.retry` runs the resolution, each range request and the check that a failed read's object was
4666
+ * replaced, so a transient fault repeats that step alone. Nothing is resolved until the first chunk is asked for.
4667
+ */
4668
+ async *getChunks(ref, keys, options) {
4669
+ validateUserRef(ref);
4670
+ for (const chunkKey of keys) validateChunkRef({ ...ref, chunkKey });
4671
+ validateChunkKeyOrder(keys);
4672
+ const retry = options?.retry;
4673
+ let yielded = 0;
4674
+ let healed = false;
4675
+ for (; ; ) {
4676
+ let snap = this.liveSnapshot(ref);
4677
+ let pending = snap.reader;
4678
+ let chunks;
4679
+ try {
4680
+ let tried = false;
4681
+ const reader = await (retry === void 0 ? pending : retry(() => {
4682
+ if (tried) {
4683
+ snap = this.liveSnapshot(ref);
4684
+ pending = snap.reader;
4685
+ }
4686
+ tried = true;
4687
+ return pending;
4688
+ }));
4689
+ if (reader === null) {
4690
+ for (; yielded < keys.length; yielded++) {
4691
+ yield { key: keys[yielded], bytes: null, version: null };
4692
+ }
4693
+ return;
4694
+ }
4695
+ const version = versionOf(reader.generation, reader.lineage);
4696
+ chunks = reader.readChunks(keys.slice(yielded), {
4697
+ ...options?.concurrency === void 0 ? {} : { concurrency: options.concurrency },
4698
+ ...options?.ramp === void 0 ? {} : { ramp: options.ramp },
4699
+ ...retry === void 0 ? {} : { readRange: retry },
4700
+ ...options?.onRequest === void 0 ? {} : { onRequest: options.onRequest },
4701
+ now: () => this.now()
4702
+ });
4703
+ const pull = new ItemPull(chunks);
4704
+ let movedOn = false;
4705
+ try {
4706
+ while (await pull.advance()) {
4707
+ const live = this.liveSnapshot(ref);
4708
+ if (live !== snap) {
4709
+ const now = await live.reader;
4710
+ if (now === null || versionOf(now.generation, now.lineage) !== version) {
4711
+ movedOn = true;
4712
+ break;
4713
+ }
4714
+ snap = live;
4715
+ }
4716
+ yield pull.take((chunk) => {
4717
+ yielded += 1;
4718
+ healed = false;
4719
+ return { ...chunk, version };
4720
+ });
4721
+ }
4722
+ } finally {
4723
+ await pull.close();
4724
+ }
4725
+ if (!movedOn) return;
4726
+ await chunks.settled();
4727
+ healed = false;
4728
+ continue;
4729
+ } catch (err) {
4730
+ if (healed) throw err;
4731
+ if (!isNotFoundError(err) && !await this.replacedUnder(ref, pending, err, retry))
4732
+ throw err;
4733
+ await chunks?.settled();
4734
+ this.dropStale(segmentKey(ref), snap);
4735
+ healed = true;
4736
+ }
4737
+ }
4738
+ }
4739
+ /**
4740
+ * {@link getChunks} of a specific generation, the pinned shape read. It yields the chunks without a version: the
4741
+ * caller that pinned the generation knows it. It never moves to another generation; `held` as for
4742
+ * {@link getChunkAt}.
4743
+ */
4744
+ async *getChunksAt(ref, generation, keys, held, options) {
4745
+ validateUserRef(ref);
4746
+ for (const chunkKey of keys) validateChunkRef({ ...ref, chunkKey });
4747
+ validateChunkKeyOrder(keys);
4748
+ const retry = options?.retry;
4749
+ const open4 = () => this.readerAt(ref, generation, held?.version, held?.fingerprint);
4750
+ const reader = await (retry === void 0 ? open4() : retry(open4));
4751
+ if (reader === null) {
4752
+ for (const key of keys) yield { key, bytes: null };
4753
+ return;
4754
+ }
4755
+ const fingerprint = held?.fingerprint;
4756
+ if (held !== void 0 && fingerprint !== void 0 && this.replacedPins.get(this.heldKey(ref, held.version, fingerprint)) !== void 0)
4757
+ throw notThePinned(ref, generation);
4758
+ try {
4759
+ const pull = new ItemPull(
4760
+ reader.readChunks(keys, {
4761
+ ...options?.concurrency === void 0 ? {} : { concurrency: options.concurrency },
4762
+ ...options?.ramp === void 0 ? {} : { ramp: options.ramp },
4763
+ ...retry === void 0 ? {} : { readRange: retry },
4764
+ ...options?.onRequest === void 0 ? {} : { onRequest: options.onRequest },
4765
+ now: () => this.now()
4766
+ })
4767
+ );
4768
+ try {
4769
+ while (await pull.advance()) yield pull.take((chunk) => chunk);
4770
+ } finally {
4771
+ await pull.close();
4772
+ }
4773
+ } catch (err) {
4774
+ if (held === void 0 || fingerprint === void 0) throw err;
4775
+ if (!(isIntegrityError(err) || isValidationError(err))) throw err;
4776
+ const check = () => this.throwIfReplaced(ref, generation, held.version, fingerprint);
4777
+ await (retry === void 0 ? check() : retry(check));
4778
+ throw err;
4779
+ }
4780
+ }
4176
4781
  async listChunkKeys(ref) {
4177
4782
  validateUserRef(ref);
4178
4783
  return this.withFreshSnapshot(ref, (reader) => reader.chunkKeys(), []);
@@ -4259,17 +4864,26 @@ var CrbmStorageChunkSource = class {
4259
4864
  * tight loop on a segment whose pointer names an object that is permanently absent — and an N-way
4260
4865
  * `intersect` would do it N times. Gated in `tests/core/storage-source-heal-open.test.ts` by counting calls.
4261
4866
  */
4262
- async withFreshSnapshot(ref, read, ifGone) {
4867
+ async withFreshSnapshot(ref, read, ifGone, retry) {
4263
4868
  for (let attempt = 0; attempt < 2; attempt++) {
4264
- const snap = this.liveSnapshot(ref);
4265
- const pending = snap.reader;
4869
+ let snap = this.liveSnapshot(ref);
4870
+ let pending = snap.reader;
4266
4871
  try {
4267
- const reader = await pending;
4872
+ let tried = false;
4873
+ const reader = await (retry === void 0 ? pending : retry(() => {
4874
+ if (tried) {
4875
+ snap = this.liveSnapshot(ref);
4876
+ pending = snap.reader;
4877
+ }
4878
+ tried = true;
4879
+ return pending;
4880
+ }));
4268
4881
  if (reader === null) return ifGone;
4269
4882
  return await read(reader);
4270
4883
  } catch (err) {
4271
4884
  if (attempt === 1) throw err;
4272
- if (!isNotFoundError(err) && !await this.replacedUnder(ref, pending, err)) throw err;
4885
+ if (!isNotFoundError(err) && !await this.replacedUnder(ref, pending, err, retry))
4886
+ throw err;
4273
4887
  this.dropStale(segmentKey(ref), snap);
4274
4888
  }
4275
4889
  }
@@ -4285,7 +4899,7 @@ var CrbmStorageChunkSource = class {
4285
4899
  * the error is real, and it stands. Only these two errors pay for the footer read, never a read that succeeded. A
4286
4900
  * transient fault in the check is rethrown; a footer that cannot be read says nothing, and the error stands.
4287
4901
  */
4288
- async replacedUnder(ref, pending, err) {
4902
+ async replacedUnder(ref, pending, err, retry) {
4289
4903
  if (!(isIntegrityError(err) || isValidationError(err))) return false;
4290
4904
  let reader;
4291
4905
  try {
@@ -4300,7 +4914,8 @@ var CrbmStorageChunkSource = class {
4300
4914
  generation: reader.generation
4301
4915
  };
4302
4916
  try {
4303
- return !await CrbmReader.sameObject(storageBlobReader(this.driver, at), reader.fingerprint);
4917
+ const check = () => CrbmReader.sameObject(storageBlobReader(this.driver, at), reader.fingerprint);
4918
+ return !await (retry === void 0 ? check() : retry(check));
4304
4919
  } catch (checkErr) {
4305
4920
  if (isNotFoundError(checkErr)) return true;
4306
4921
  if (isTransientError(checkErr)) throw checkErr;
@@ -4580,7 +5195,7 @@ async function provesOwnObject(storage, key, fingerprint) {
4580
5195
  throw err;
4581
5196
  }
4582
5197
  }
4583
- async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
5198
+ async function bulkLoadAhead(driver, ref, ahead, ids, options = {}) {
4584
5199
  if (options.keystore === void 0 && options.requireEncryption === true) {
4585
5200
  throw new ValidationError("requireEncryption: a load needs a keystore to write encrypted");
4586
5201
  }
@@ -4600,27 +5215,30 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
4600
5215
  throw new ValidationError("an encrypted load requires a registry to store the wrapped DEK");
4601
5216
  }
4602
5217
  const passed = options.row;
4603
- const existing = passed !== void 0 && passed !== null && (passed.wrappedDeks === void 0 || passed.wrappedDeks.length === 0) ? passed : options.registry !== void 0 ? await options.registry.get(key) : null;
5218
+ const existing = passed !== void 0 && passed !== null && (passed.wrappedDeks === void 0 || passed.wrappedDeks.length === 0) ? passed : options.registry !== void 0 ? await options.registry.get(ref) : null;
4604
5219
  if (existing?.status === "destroyed") {
4605
5220
  throw new ValidationError(
4606
- `segment "${key.segment}" is destroyed (crypto-shredded) \u2014 refusing to write; use a new segment`
5221
+ `segment "${ref.segment}" is destroyed (crypto-shredded) \u2014 refusing to write; use a new segment`
4607
5222
  );
4608
5223
  }
4609
5224
  if (existing?.wrappedDeks !== void 0 && existing.wrappedDeks.length > 0 && options.keystore === void 0) {
4610
5225
  throw new KeyUnavailableError(
4611
- `segment "${key.segment}" is encrypted but this load has no keystore \u2014 refusing to write a cleartext generation onto an encrypted segment. Pass the keystore holding its DEK.`
5226
+ `segment "${ref.segment}" is encrypted but this load has no keystore \u2014 refusing to write a cleartext generation onto an encrypted segment. Pass the keystore holding its DEK.`
4612
5227
  );
4613
5228
  }
5229
+ const generation = await ahead.generation;
5230
+ const key = { namespace: ref.namespace, segment: ref.segment, generation };
4614
5231
  let crypto;
4615
5232
  let newWrapped;
4616
5233
  if (options.keystore !== void 0) {
4617
5234
  if (existing?.wrappedDeks !== void 0 && existing.wrappedDeks.length > 0) {
4618
- const aead = await options.keystore.openDek(existing.wrappedDeks);
5235
+ const early = ahead.unwrapped;
5236
+ const aead = early !== void 0 && sameKeys(early.wrapped, existing.wrappedDeks) ? await early.aead : await options.keystore.openDek(existing.wrappedDeks);
4619
5237
  crypto = { aead, aadFor: (scope) => aadFor(key, key.generation, scope) };
4620
5238
  } else if (existing !== null && existing.currentGen !== null) {
4621
5239
  if (options.requireEncryption === true) {
4622
5240
  throw new ValidationError(
4623
- `requireEncryption: segment "${key.segment}" already has generation ${existing.currentGen} in cleartext, so this load cannot be encrypted \u2014 encryption is chosen when a segment is first loaded. Load into a new segment with the keystore wired, then drop this one.`
5241
+ `requireEncryption: segment "${ref.segment}" already has generation ${existing.currentGen} in cleartext, so this load cannot be encrypted \u2014 encryption is chosen when a segment is first loaded. Load into a new segment with the keystore wired, then drop this one.`
4624
5242
  );
4625
5243
  }
4626
5244
  } else {
@@ -4727,8 +5345,12 @@ async function bucketIds(ids, codec, clock) {
4727
5345
  function openGenerationReader(storage, key, crypto, options = {}) {
4728
5346
  return openChecked(storage, key, { ...options, crypto });
4729
5347
  }
4730
- async function openChecked(storage, key, options) {
4731
- const reader = await CrbmReader.open(storageBlobReader(storage, key), options);
5348
+ async function openChecked(storage, key, options, keepChunkBytesUpTo) {
5349
+ const reader = await openCrbmReaderKeeping(
5350
+ storageBlobReader(storage, key),
5351
+ options,
5352
+ keepChunkBytesUpTo
5353
+ );
4732
5354
  if (reader.generation !== key.generation) {
4733
5355
  throw new IntegrityError(
4734
5356
  `segment "${key.segment}" generation ${key.generation}: its footer says generation ${reader.generation}`
@@ -4789,6 +5411,30 @@ var PinnedStorageChunkSource = class {
4789
5411
  if (pin === void 0) return this.inner.getChunk(ref);
4790
5412
  return pin.generation === null ? Promise.resolve(null) : this.inner.getChunkAt(ref, pin.generation, heldBy(pin));
4791
5413
  }
5414
+ /**
5415
+ * A pinned segment's chunks come from the pinned generation alone, under the version a pinned read keys by
5416
+ * ({@link currentVersion}); another segment's go to the live source, which answers with its own.
5417
+ */
5418
+ getChunks(ref, keys, options) {
5419
+ const pin = this.pinFor(ref);
5420
+ if (pin === void 0) return this.inner.getChunks(ref, keys, options);
5421
+ return this.pinnedChunks(ref, pin, keys, options);
5422
+ }
5423
+ async *pinnedChunks(ref, pin, keys, options) {
5424
+ if (pin.generation === null) {
5425
+ for (const key of keys) yield { key, bytes: null, version: null };
5426
+ return;
5427
+ }
5428
+ const version = await this.currentVersion(ref);
5429
+ const pull = new ItemPull(
5430
+ this.inner.getChunksAt(ref, pin.generation, keys, heldBy(pin), options)
5431
+ );
5432
+ try {
5433
+ while (await pull.advance()) yield pull.take((chunk) => ({ ...chunk, version }));
5434
+ } finally {
5435
+ await pull.close();
5436
+ }
5437
+ }
4792
5438
  listChunkKeys(ref) {
4793
5439
  const pin = this.pinFor(ref);
4794
5440
  if (pin === void 0) return this.inner.listChunkKeys(ref);
@@ -4891,7 +5537,11 @@ async function withRetry(op, policy, deps) {
4891
5537
  }
4892
5538
 
4893
5539
  // src/core/load.ts
4894
- async function currentCardinality(ref, deps, record) {
5540
+ function settled(promise) {
5541
+ promise.catch(() => void 0);
5542
+ return promise;
5543
+ }
5544
+ async function currentCardinality(ref, deps, record, unwrapped) {
4895
5545
  if (record === null || record.currentGen === null || record.status === "destroyed") {
4896
5546
  return { cardinality: null, fromSummary: false };
4897
5547
  }
@@ -4905,7 +5555,7 @@ async function currentCardinality(ref, deps, record) {
4905
5555
  `segment "${ref.segment}" is encrypted but load was given no keystore`
4906
5556
  );
4907
5557
  }
4908
- aead = await deps.keystore.openDek(wrapped);
5558
+ aead = await (unwrapped ?? deps.keystore.openDek(wrapped));
4909
5559
  const opened = aead;
4910
5560
  crypto = { aead: opened, aadFor: (scope) => aadFor(ref, generation, scope) };
4911
5561
  }
@@ -4956,35 +5606,44 @@ async function loadSegment(ref, input, deps, options = {}) {
4956
5606
  const row = await deps.registry.get(ref);
4957
5607
  const fromToken = row?.token;
4958
5608
  const fromGeneration = row?.currentGen ?? void 0;
5609
+ const wrappedKeys = row?.wrappedDeks;
5610
+ const keystore = deps.keystore;
5611
+ const unwrapped = row !== null && row.status !== "destroyed" && wrappedKeys !== void 0 && wrappedKeys.length > 0 && keystore !== void 0 ? { wrapped: wrappedKeys, aead: settled((async () => keystore.openDek(wrappedKeys))()) } : void 0;
4959
5612
  const needsBefore = guard?.minRetained !== void 0 || options.allowEmpty !== true;
4960
- const current = needsBefore ? await currentCardinality(ref, deps, row) : { cardinality: null, fromSummary: false };
5613
+ const current = needsBefore ? await currentCardinality(ref, deps, row, unwrapped?.aead) : { cardinality: null, fromSummary: false };
4961
5614
  const before = current.cardinality;
4962
- const { generation, checked } = await nextLoadGeneration(ref, deps, row);
4963
- const key = { namespace: ref.namespace, segment: ref.segment, generation };
5615
+ const numbering = settled(nextLoadGeneration(ref, deps, row));
4964
5616
  let written;
4965
5617
  try {
4966
- written = await bulkLoadCrbmGeneration(deps.storage, key, ids, {
4967
- registry: deps.registry,
4968
- publish: false,
4969
- keystore: deps.keystore,
4970
- requireEncryption: deps.requireEncryption,
4971
- codec: deps.codec,
4972
- clock: deps.clock,
4973
- metadata,
4974
- row
4975
- });
5618
+ written = await bulkLoadAhead(
5619
+ deps.storage,
5620
+ ref,
5621
+ { generation: settled(numbering.then((n) => n.generation)), unwrapped },
5622
+ ids,
5623
+ {
5624
+ registry: deps.registry,
5625
+ publish: false,
5626
+ keystore: deps.keystore,
5627
+ requireEncryption: deps.requireEncryption,
5628
+ codec: deps.codec,
5629
+ clock: deps.clock,
5630
+ metadata,
5631
+ row
5632
+ }
5633
+ );
4976
5634
  } catch (err) {
4977
5635
  if (!isWriteConflictError(err)) throw err;
5636
+ const { generation: generation2 } = await numbering;
4978
5637
  audit.onEvent({
4979
5638
  kind: "segment.load-refused",
4980
5639
  segment: ref.segment,
4981
5640
  namespace: ref.namespace,
4982
- generation,
5641
+ generation: generation2,
4983
5642
  reason: "superseded",
4984
5643
  cardinality: 0
4985
5644
  });
4986
5645
  return {
4987
- generation,
5646
+ generation: generation2,
4988
5647
  published: false,
4989
5648
  reason: "superseded",
4990
5649
  size: 0,
@@ -4995,6 +5654,8 @@ async function loadSegment(ref, input, deps, options = {}) {
4995
5654
  collected: []
4996
5655
  };
4997
5656
  }
5657
+ const { generation, checked } = await numbering;
5658
+ const key = { namespace: ref.namespace, segment: ref.segment, generation };
4998
5659
  const reclaim = async () => {
4999
5660
  const now = await deps.registry.get(ref);
5000
5661
  if (now !== null && (now.token === fromToken || now.status === "destroyed")) {
@@ -5235,6 +5896,7 @@ function describingSummary(record) {
5235
5896
  }
5236
5897
 
5237
5898
  // src/core/erase-id.ts
5899
+ var HOLDS_CONCURRENCY = 4;
5238
5900
  async function eraseIdFromSegment(ref, id, deps, options = {}) {
5239
5901
  validateUserRef(ref);
5240
5902
  const { chunkKey, remainder } = splitId(id);
@@ -5296,13 +5958,37 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
5296
5958
  throw err;
5297
5959
  }
5298
5960
  };
5961
+ const holdsEach = async (generations, stopAtHolder) => {
5962
+ let firstHolder = Infinity;
5963
+ const settled2 = await mapWithConcurrency(
5964
+ generations,
5965
+ HOLDS_CONCURRENCY,
5966
+ async (generation2, index) => {
5967
+ if (stopAtHolder && index > firstHolder) {
5968
+ return { generation: generation2, held: null, fault: void 0 };
5969
+ }
5970
+ try {
5971
+ const held = await holds(generation2);
5972
+ if (held === true && index < firstHolder) firstHolder = index;
5973
+ return { generation: generation2, held, fault: void 0 };
5974
+ } catch (fault) {
5975
+ return { generation: generation2, held: null, fault: { error: fault } };
5976
+ }
5977
+ }
5978
+ );
5979
+ const outcomes = [];
5980
+ for (const { generation: generation2, held, fault } of settled2) {
5981
+ if (fault !== void 0) throw fault.error;
5982
+ outcomes.push({ generation: generation2, held });
5983
+ if (stopAtHolder && held === true) break;
5984
+ }
5985
+ return outcomes;
5986
+ };
5299
5987
  const holderLeft = async (clean) => {
5300
5988
  const present = /* @__PURE__ */ new Set();
5301
5989
  for await (const key2 of deps.storage.list(ref)) present.add(key2.generation);
5302
- for (const generation2 of [...present].sort((a, b) => b - a)) {
5303
- if (!clean.has(generation2) && await holds(generation2) === true) return generation2;
5304
- }
5305
- return void 0;
5990
+ const toRead = [...present].sort((a, b) => b - a).filter((generation2) => !clean.has(generation2));
5991
+ return (await holdsEach(toRead, true)).find((outcome) => outcome.held === true)?.generation;
5306
5992
  };
5307
5993
  const cannotRemove = (generation2) => new WriteConflictError(
5308
5994
  `erasure of segment ${ref.segment} could not remove generation ${generation2}, which still holds the id and is still in the bucket; re-run`
@@ -5323,20 +6009,21 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
5323
6009
  const newestFirst = [...others].sort((a, b) => b - a);
5324
6010
  const clean = /* @__PURE__ */ new Set([from]);
5325
6011
  const holdersAbove = [];
5326
- for (const generation2 of newestFirst.filter((g) => g > from)) {
5327
- const held = await holds(generation2);
6012
+ for (const { generation: generation2, held } of await holdsEach(
6013
+ newestFirst.filter((g) => g > from),
6014
+ false
6015
+ )) {
5328
6016
  if (held === true) holdersAbove.push(generation2);
5329
6017
  else if (held === false) clean.add(generation2);
5330
6018
  }
5331
6019
  let holderBelow;
5332
6020
  if (holdersAbove.length === 0) {
5333
- for (const generation2 of newestFirst.filter((g) => g < from)) {
5334
- const held = await holds(generation2);
5335
- if (held === true) {
5336
- holderBelow = generation2;
5337
- break;
5338
- }
5339
- if (held === false) clean.add(generation2);
6021
+ for (const { generation: generation2, held } of await holdsEach(
6022
+ newestFirst.filter((g) => g < from),
6023
+ true
6024
+ )) {
6025
+ if (held === true) holderBelow = generation2;
6026
+ else if (held === false) clean.add(generation2);
5340
6027
  }
5341
6028
  }
5342
6029
  const newest = holdersAbove[0] ?? holderBelow;
@@ -5451,25 +6138,31 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
5451
6138
  if (left !== void 0) throw cannotRemove(left);
5452
6139
  return { ...base, erased: true, fromGeneration: from, generation, collected };
5453
6140
  }
5454
- var REWRITE_READ_AHEAD = 32;
6141
+ var REWRITE_RANGES_AHEAD = 4;
5455
6142
  async function* rewrite(reader, chunkKey, replacement, codec, maxBytes, read) {
5456
6143
  const keys = [...reader.chunkKeys()].sort((a, b) => a - b);
5457
- const window = new ChunkWindow(
6144
+ const stream = reader.readChunks(
5458
6145
  keys.filter((k) => k !== chunkKey),
5459
- (k) => read(() => reader.getChunk(k)),
5460
- REWRITE_READ_AHEAD,
5461
- false
6146
+ { concurrency: REWRITE_RANGES_AHEAD, readRange: read }
5462
6147
  );
5463
- for (const k of keys) {
5464
- if (k === chunkKey) {
5465
- yield { chunkKey: k, bitmap: replacement };
5466
- continue;
6148
+ try {
6149
+ for (const k of keys) {
6150
+ if (k === chunkKey) {
6151
+ yield { chunkKey: k, bitmap: replacement };
6152
+ continue;
6153
+ }
6154
+ const item = await stream.next();
6155
+ if (item.done === true || item.value.key !== k) {
6156
+ throw new IntegrityError(`the chunk stream ended before chunk ${k}`);
6157
+ }
6158
+ const bytes = item.value.bytes;
6159
+ if (bytes === null) continue;
6160
+ const bitmap = codec.safeDeserialize(bytes, maxBytes);
6161
+ assertRemaindersInRange(bitmap, k);
6162
+ yield { chunkKey: k, bitmap };
5467
6163
  }
5468
- const bytes = await window.take();
5469
- if (bytes === null) continue;
5470
- const bitmap = codec.safeDeserialize(bytes, maxBytes);
5471
- assertRemaindersInRange(bitmap, k);
5472
- yield { chunkKey: k, bitmap };
6164
+ } finally {
6165
+ await stream.return(void 0);
5473
6166
  }
5474
6167
  }
5475
6168
  function assertRemaindersInRange(bitmap, chunkKey) {
@@ -5624,9 +6317,8 @@ async function eraseNamespace(namespace, deps, options) {
5624
6317
  segment: rec.segment
5625
6318
  }));
5626
6319
  const audit = safeAudit(options.audit ?? NOOP_AUDIT);
5627
- const destroyed = [];
5628
6320
  let segmentsShredded = 0;
5629
- for (const ref of refs) {
6321
+ const destroyed = await mapWithConcurrency(refs, ERASE_CONCURRENCY, async (ref) => {
5630
6322
  let result;
5631
6323
  try {
5632
6324
  result = await shredSegment(ref, deps, options.allowCleartext ?? false);
@@ -5639,12 +6331,12 @@ async function eraseNamespace(namespace, deps, options) {
5639
6331
  reason: isWriteConflictError(err) ? "contended" : `failed: ${err instanceof Error ? err.message : String(err)}`
5640
6332
  };
5641
6333
  }
5642
- destroyed.push(result);
5643
6334
  if (result.cryptoShredded) {
5644
6335
  segmentsShredded += 1;
5645
6336
  audit.onEvent({ kind: "segment.erase", namespace: ref.namespace, segment: ref.segment });
5646
6337
  }
5647
- }
6338
+ return result;
6339
+ });
5648
6340
  audit.onEvent({ kind: "namespace.erase", namespace, segmentsShredded });
5649
6341
  return { destroyed };
5650
6342
  }
@@ -6383,6 +7075,7 @@ var RetryingStorageChunkSource = class {
6383
7075
  deps;
6384
7076
  /** Present only when the inner source supports it — so capability detection stays honest. */
6385
7077
  sizeOf;
7078
+ getChunks;
6386
7079
  cardinalities;
6387
7080
  summary;
6388
7081
  currentGeneration;
@@ -6405,6 +7098,16 @@ var RetryingStorageChunkSource = class {
6405
7098
  if (innerSizeOf) {
6406
7099
  this.sizeOf = (ref) => withRetry(() => innerSizeOf.call(inner, ref), this.policy, this.deps);
6407
7100
  }
7101
+ const innerGetChunks = inner.getChunks;
7102
+ if (innerGetChunks) {
7103
+ this.getChunks = (ref, keys, options) => {
7104
+ const outer = options?.retry;
7105
+ return innerGetChunks.call(inner, ref, keys, {
7106
+ ...options,
7107
+ retry: (request) => withRetry(outer === void 0 ? request : () => outer(request), this.policy, this.deps)
7108
+ });
7109
+ };
7110
+ }
6408
7111
  const innerCardinalities = inner.cardinalities;
6409
7112
  if (innerCardinalities) {
6410
7113
  this.cardinalities = (ref) => withRetry(() => innerCardinalities.call(inner, ref), this.policy, this.deps);