@cloudbitmaps/core 0.11.2 → 0.13.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/{chunk-5OMC5U7U.js → chunk-5W3XS2IU.js} +292 -22
- package/dist/chunk-5W3XS2IU.js.map +7 -0
- package/dist/core/audit.d.ts +6 -0
- package/dist/core/chunk-window.d.ts +24 -0
- package/dist/core/codec.d.ts +33 -1
- package/dist/core/consistency.d.ts +21 -2
- package/dist/core/cost.d.ts +91 -10
- package/dist/core/crbm/format.d.ts +42 -3
- package/dist/core/crbm/reader.d.ts +45 -3
- package/dist/core/crbm/writer.d.ts +32 -3
- package/dist/core/crbm-storage-source.d.ts +204 -23
- package/dist/core/crypto.d.ts +11 -7
- package/dist/core/determinism.d.ts +7 -0
- package/dist/core/engine.d.ts +41 -8
- package/dist/core/erase-id.d.ts +23 -3
- package/dist/core/erasure.d.ts +8 -4
- package/dist/core/generation-gc.d.ts +102 -4
- package/dist/core/load-input.d.ts +55 -0
- package/dist/core/load.d.ts +65 -10
- package/dist/core/metadata.d.ts +42 -0
- package/dist/core/metrics.d.ts +7 -1
- package/dist/core/pinned-storage-source.d.ts +2 -1
- package/dist/core/ports.d.ts +119 -13
- package/dist/core/registry-scan.d.ts +6 -0
- package/dist/core/retention-sweep.d.ts +47 -2
- package/dist/core/retry.d.ts +14 -0
- package/dist/core/rollback.d.ts +34 -4
- package/dist/core/summary.d.ts +61 -0
- package/dist/core/token.d.ts +36 -0
- package/dist/core/validate.d.ts +2 -0
- package/dist/driver-kit.js +39 -20
- package/dist/driver-kit.js.map +2 -2
- package/dist/drivers/_shared/entropy.d.ts +5 -0
- package/dist/drivers/_shared/object-registry.d.ts +36 -6
- package/dist/drivers/_shared/registry.d.ts +47 -19
- package/dist/drivers/localfs/registry.d.ts +10 -0
- package/dist/drivers/memory.d.ts +9 -3
- package/dist/drivers/retry/retrying-chunk-source.d.ts +2 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1878 -501
- package/dist/index.js.map +4 -4
- package/dist/testing/fuzz-core.d.ts +7 -6
- package/package.json +1 -1
- package/dist/chunk-5OMC5U7U.js.map +0 -7
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DEFAULT_NAMESPACE,
|
|
6
6
|
IntegrityError,
|
|
7
7
|
KeyUnavailableError,
|
|
8
|
+
MAX_METADATA_BYTES,
|
|
8
9
|
MAX_NAME_LENGTH,
|
|
9
10
|
NotFoundError,
|
|
10
11
|
RESERVED_NAMESPACE_PREFIX,
|
|
@@ -13,11 +14,18 @@ import {
|
|
|
13
14
|
ValidationError,
|
|
14
15
|
WriteConflictError,
|
|
15
16
|
applyRegistryPatch,
|
|
17
|
+
assertRegistryCanWrite,
|
|
16
18
|
brandAsBackend,
|
|
19
|
+
canonicalMetadataJson,
|
|
20
|
+
copiedMetadata,
|
|
17
21
|
decodeNameFromPath,
|
|
22
|
+
drawIncarnation,
|
|
23
|
+
drawWrite,
|
|
18
24
|
encodeNameForKey,
|
|
19
25
|
encodeNameForPath,
|
|
20
26
|
encodedNameLength,
|
|
27
|
+
entropyIsAvailable,
|
|
28
|
+
incarnationOf,
|
|
21
29
|
isCloudRoaringError,
|
|
22
30
|
isIntegrityError,
|
|
23
31
|
isNotFoundError,
|
|
@@ -27,18 +35,23 @@ import {
|
|
|
27
35
|
isValidationError,
|
|
28
36
|
isWriteConflictError,
|
|
29
37
|
mapWithConcurrency,
|
|
38
|
+
metadataBytes,
|
|
39
|
+
metadataFromBytes,
|
|
30
40
|
namespacePathPart,
|
|
41
|
+
newIncarnationToken,
|
|
42
|
+
nextRegistryToken,
|
|
31
43
|
parseRegistryEnvelope,
|
|
32
44
|
recordFromNew,
|
|
33
|
-
|
|
45
|
+
sameIncarnation,
|
|
34
46
|
serializeRegistryEnvelope,
|
|
35
47
|
validateChunkRef,
|
|
36
48
|
validateNewRegistryRecord,
|
|
37
49
|
validateRegistryPatch,
|
|
38
50
|
validateSegmentRef,
|
|
39
51
|
validateUserNamespace,
|
|
40
|
-
validateUserRef
|
|
41
|
-
|
|
52
|
+
validateUserRef,
|
|
53
|
+
webCryptoEntropy
|
|
54
|
+
} from "./chunk-5W3XS2IU.js";
|
|
42
55
|
|
|
43
56
|
// src/core/bit-route.ts
|
|
44
57
|
var U32_MAX = 4294967295;
|
|
@@ -89,6 +102,110 @@ async function collectWithinBudget(source, budget, op) {
|
|
|
89
102
|
return out;
|
|
90
103
|
}
|
|
91
104
|
|
|
105
|
+
// src/core/chunk-window.ts
|
|
106
|
+
var ChunkWindow = class {
|
|
107
|
+
constructor(keys, fetch, max, ramp) {
|
|
108
|
+
this.keys = keys;
|
|
109
|
+
this.fetch = fetch;
|
|
110
|
+
this.max = max;
|
|
111
|
+
this.ramp = ramp;
|
|
112
|
+
}
|
|
113
|
+
keys;
|
|
114
|
+
fetch;
|
|
115
|
+
max;
|
|
116
|
+
ramp;
|
|
117
|
+
open = [];
|
|
118
|
+
launched = 0;
|
|
119
|
+
taken = 0;
|
|
120
|
+
/** The next read's result in key order, or the error that read raised. */
|
|
121
|
+
async take() {
|
|
122
|
+
const width = this.ramp ? Math.min(this.max, 2 ** Math.min(this.taken, 30)) : this.max;
|
|
123
|
+
while (this.launched < this.keys.length && this.launched - this.taken < width) {
|
|
124
|
+
this.open.push(
|
|
125
|
+
this.fetch(this.keys[this.launched++]).then(
|
|
126
|
+
(value) => ({ value }),
|
|
127
|
+
(cause) => ({ value: void 0, error: { cause } })
|
|
128
|
+
)
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const slot = await this.open.shift();
|
|
132
|
+
this.taken += 1;
|
|
133
|
+
if (slot.error) throw slot.error.cause;
|
|
134
|
+
return slot.value;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// src/core/crbm/format.ts
|
|
139
|
+
var MAGIC = Uint8Array.of(67, 82, 66, 77);
|
|
140
|
+
var VERSION_MAJOR = 1;
|
|
141
|
+
var VERSION_MINOR = 0;
|
|
142
|
+
var PREAMBLE_BYTES = 8;
|
|
143
|
+
var PAYLOAD_START = PREAMBLE_BYTES;
|
|
144
|
+
var FOOTER_BYTES = 104;
|
|
145
|
+
var FOOTER = {
|
|
146
|
+
indexOffset: 0,
|
|
147
|
+
// u64
|
|
148
|
+
indexLength: 8,
|
|
149
|
+
// u64
|
|
150
|
+
indexCrc32c: 16,
|
|
151
|
+
// u32
|
|
152
|
+
flags: 20,
|
|
153
|
+
// u32
|
|
154
|
+
payloadCodecId: 24,
|
|
155
|
+
// u16 — which codec wrote the chunk payloads
|
|
156
|
+
elementWidth: 26,
|
|
157
|
+
// u8
|
|
158
|
+
containerCodec: 27,
|
|
159
|
+
// u8
|
|
160
|
+
versionMajor: 28,
|
|
161
|
+
// u8
|
|
162
|
+
versionMinor: 29,
|
|
163
|
+
// u8
|
|
164
|
+
reserved2: 30,
|
|
165
|
+
// u16
|
|
166
|
+
generation: 32,
|
|
167
|
+
// u64
|
|
168
|
+
indexNonce: 40,
|
|
169
|
+
// 12 B
|
|
170
|
+
indexTag: 52,
|
|
171
|
+
// 16 B
|
|
172
|
+
keyId: 68,
|
|
173
|
+
// 16 B
|
|
174
|
+
chunkCount: 84,
|
|
175
|
+
// u32
|
|
176
|
+
totalCardinality: 88,
|
|
177
|
+
// u64
|
|
178
|
+
footerCrc32c: 96,
|
|
179
|
+
// u32 — covers footer bytes [0, 96)
|
|
180
|
+
endMagic: 100
|
|
181
|
+
// char[4]
|
|
182
|
+
};
|
|
183
|
+
var FOOTER_CRC_COVERAGE = FOOTER.footerCrc32c;
|
|
184
|
+
var FLAG_ENCRYPTED = 1 << 0;
|
|
185
|
+
var FLAG_INDEX_COMPRESSED = 1 << 1;
|
|
186
|
+
var FLAG_LITTLE_ENDIAN = 1 << 2;
|
|
187
|
+
var FLAG_EXTENSION = 1 << 3;
|
|
188
|
+
var KNOWN_FLAGS = FLAG_ENCRYPTED | FLAG_LITTLE_ENDIAN | FLAG_EXTENSION;
|
|
189
|
+
var ELEMENT_WIDTH_32 = 32;
|
|
190
|
+
var PAYLOAD_CODEC_ROARING_PORTABLE = 1;
|
|
191
|
+
var KNOWN_PAYLOAD_CODEC_IDS = /* @__PURE__ */ new Set([
|
|
192
|
+
PAYLOAD_CODEC_ROARING_PORTABLE
|
|
193
|
+
]);
|
|
194
|
+
var CONTAINER_CODEC_NONE = 0;
|
|
195
|
+
var DEFAULT_TAIL_BYTES = 256 * 1024;
|
|
196
|
+
var DEFAULT_MAX_INDEX_BYTES = 8 * 1024 * 1024;
|
|
197
|
+
var DEFAULT_MAX_BITMAP_BYTES = 1 << 20;
|
|
198
|
+
var MAX_CHUNK_CARDINALITY = 65536;
|
|
199
|
+
var EXT_MAGIC = Uint8Array.of(67, 82, 66, 88);
|
|
200
|
+
var EXT_TRAILER_BYTES = 12;
|
|
201
|
+
var EXT_SECTION_HEADER_BYTES = 5;
|
|
202
|
+
var MAX_EXT_BYTES = 4 * 1024;
|
|
203
|
+
var EXT_SECTION_METADATA = 1;
|
|
204
|
+
var CRC32C_BYTES = 4;
|
|
205
|
+
var AEAD_NONCE_BYTES = 12;
|
|
206
|
+
var AEAD_TAG_BYTES = 16;
|
|
207
|
+
var DEFAULT_MAX_PAYLOAD_BYTES = DEFAULT_MAX_BITMAP_BYTES + AEAD_NONCE_BYTES + AEAD_TAG_BYTES;
|
|
208
|
+
|
|
92
209
|
// src/core/keys.ts
|
|
93
210
|
var FIELD = " ";
|
|
94
211
|
var NO_NAMESPACE = "/";
|
|
@@ -201,8 +318,8 @@ var CountingMetricsSink = class _CountingMetricsSink {
|
|
|
201
318
|
};
|
|
202
319
|
|
|
203
320
|
// src/core/engine.ts
|
|
204
|
-
var
|
|
205
|
-
var
|
|
321
|
+
var DEFAULT_INTERSECT_CONCURRENCY = 32;
|
|
322
|
+
var COMBINE_WINDOW_START = 8;
|
|
206
323
|
var ZERO_CLOCK = { now: () => 0 };
|
|
207
324
|
function windowOf(range) {
|
|
208
325
|
const after = range?.after;
|
|
@@ -255,37 +372,6 @@ function keysWithin(keys, w) {
|
|
|
255
372
|
};
|
|
256
373
|
return keys.slice(firstAtLeast(w.loKey), firstAtLeast(w.hiKey + 1));
|
|
257
374
|
}
|
|
258
|
-
var ChunkWindow = class {
|
|
259
|
-
constructor(keys, fetch, max, ramp) {
|
|
260
|
-
this.keys = keys;
|
|
261
|
-
this.fetch = fetch;
|
|
262
|
-
this.max = max;
|
|
263
|
-
this.ramp = ramp;
|
|
264
|
-
}
|
|
265
|
-
keys;
|
|
266
|
-
fetch;
|
|
267
|
-
max;
|
|
268
|
-
ramp;
|
|
269
|
-
open = [];
|
|
270
|
-
launched = 0;
|
|
271
|
-
taken = 0;
|
|
272
|
-
/** The next chunk in key order (null if the source holds none), or the error its read raised. */
|
|
273
|
-
async take() {
|
|
274
|
-
const width = this.ramp ? Math.min(this.max, 2 ** Math.min(this.taken, 30)) : this.max;
|
|
275
|
-
while (this.launched < this.keys.length && this.launched - this.taken < width) {
|
|
276
|
-
this.open.push(
|
|
277
|
-
this.fetch(this.keys[this.launched++]).then(
|
|
278
|
-
(chunk) => ({ chunk }),
|
|
279
|
-
(cause) => ({ chunk: null, error: { cause } })
|
|
280
|
-
)
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
const slot = await this.open.shift();
|
|
284
|
-
this.taken += 1;
|
|
285
|
-
if (slot.error) throw slot.error.cause;
|
|
286
|
-
return slot.chunk;
|
|
287
|
-
}
|
|
288
|
-
};
|
|
289
375
|
var SegmentEngine = class {
|
|
290
376
|
storage;
|
|
291
377
|
cache;
|
|
@@ -296,6 +382,12 @@ var SegmentEngine = class {
|
|
|
296
382
|
metricsOn;
|
|
297
383
|
/** Resolved per-op budget (null = disabled); undefined deps ⇒ the generous default. */
|
|
298
384
|
budget;
|
|
385
|
+
/**
|
|
386
|
+
* The chunk reads open right now, by cache key: a caller that misses the cache and finds its key here awaits that
|
|
387
|
+
* read instead of making its own. An entry lives from the request until it settles, so the map holds at most one
|
|
388
|
+
* promise per distinct key in flight.
|
|
389
|
+
*/
|
|
390
|
+
openReads = /* @__PURE__ */ new Map();
|
|
299
391
|
constructor(deps) {
|
|
300
392
|
this.storage = deps.storage;
|
|
301
393
|
this.cache = deps.cache;
|
|
@@ -313,15 +405,19 @@ var SegmentEngine = class {
|
|
|
313
405
|
return chunk ? chunk.has(remainder) : false;
|
|
314
406
|
}
|
|
315
407
|
/**
|
|
316
|
-
* Cardinality. When the Storage source can
|
|
317
|
-
*
|
|
318
|
-
*
|
|
408
|
+
* Cardinality. When the Storage source can describe the current generation (the `.crbm` source can), the count is
|
|
409
|
+
* that description's: the registry row's summary when the row has one it can use, which is one request when cold
|
|
410
|
+
* and no read of the object, and otherwise the sum of the object's index. Either way, **zero payload reads**. A
|
|
411
|
+
* source that can only serve per-chunk cardinality sums those, and one with neither (the in-memory source) falls back
|
|
412
|
+
* to fetching every chunk.
|
|
319
413
|
*
|
|
320
|
-
* The index sum
|
|
414
|
+
* The row's summary and the index sum are trusted, not confirmed: the source holds the summary against the object
|
|
415
|
+
* whenever it opens it, and the reader checked the index for internal consistency when it opened
|
|
321
416
|
* the object, and a corrupt index that is still internally consistent yields a wrong count. `iterate()` and the
|
|
322
417
|
* combines decode the payloads.
|
|
323
418
|
*/
|
|
324
419
|
async count(seg) {
|
|
420
|
+
if (this.storage.summary) return (await this.storage.summary(seg))?.cardinality ?? 0;
|
|
325
421
|
const cardinalities = this.storage.cardinalities ? await this.storage.cardinalities(seg) : null;
|
|
326
422
|
if (cardinalities) {
|
|
327
423
|
let total2 = 0;
|
|
@@ -339,6 +435,17 @@ var SegmentEngine = class {
|
|
|
339
435
|
for (let i = 0; i < chunkKeys.length; i++) total += (await window.take())?.size ?? 0;
|
|
340
436
|
return total;
|
|
341
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* What the segment's current generation is: its number, its id count and its metadata, from one resolution, so the
|
|
440
|
+
* three cannot straddle a publish. A source with no summary answers from `count()` and the generation it resolves.
|
|
441
|
+
*/
|
|
442
|
+
async stat(seg) {
|
|
443
|
+
if (this.storage.summary) {
|
|
444
|
+
return await this.storage.summary(seg) ?? { generation: null, cardinality: 0 };
|
|
445
|
+
}
|
|
446
|
+
const generation = this.storage.currentGeneration ? await this.storage.currentGeneration(seg) : null;
|
|
447
|
+
return { generation, cardinality: await this.count(seg) };
|
|
448
|
+
}
|
|
342
449
|
/** Whether the Storage source can measure segment size (for grounded cost); false ⇒ storage isn't grounded. */
|
|
343
450
|
get supportsStorageSize() {
|
|
344
451
|
return typeof this.storage.sizeOf === "function";
|
|
@@ -361,7 +468,7 @@ var SegmentEngine = class {
|
|
|
361
468
|
);
|
|
362
469
|
}
|
|
363
470
|
/**
|
|
364
|
-
* Every id, ascending, reading ahead through a window of up to
|
|
471
|
+
* Every id, ascending, reading ahead through a window of up to 32 chunk fetches that opens 1, 2, 4 … 32 wide, so a
|
|
365
472
|
* 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
|
|
366
473
|
* chunks the range overlaps (see {@link IdRange}).
|
|
367
474
|
*
|
|
@@ -520,10 +627,17 @@ var SegmentEngine = class {
|
|
|
520
627
|
);
|
|
521
628
|
const inFlight = [];
|
|
522
629
|
let next = 0;
|
|
523
|
-
|
|
630
|
+
let taken = 0;
|
|
631
|
+
const fill = () => {
|
|
632
|
+
const width = Math.min(limit, Math.max(COMBINE_WINDOW_START, 2 ** Math.min(taken, 30)));
|
|
633
|
+
while (next < common.length && inFlight.length < width)
|
|
634
|
+
inFlight.push(startAt(common[next++]));
|
|
635
|
+
};
|
|
636
|
+
fill();
|
|
524
637
|
while (inFlight.length > 0) {
|
|
525
638
|
const slot = await inFlight.shift();
|
|
526
|
-
|
|
639
|
+
taken += 1;
|
|
640
|
+
fill();
|
|
527
641
|
if (slot.error !== void 0) throw slot.error;
|
|
528
642
|
if (slot.result && !slot.result.isEmpty) {
|
|
529
643
|
if (w !== null && isEdge(slot.key, w)) {
|
|
@@ -546,9 +660,13 @@ var SegmentEngine = class {
|
|
|
546
660
|
async combineChunk(operands, excludes, mode, chunkKey) {
|
|
547
661
|
const present = mode === "all" ? operands : operands.filter((o) => o.keys.has(chunkKey));
|
|
548
662
|
if (present.length === 0) return null;
|
|
549
|
-
const
|
|
550
|
-
|
|
663
|
+
const relevant = excludes.filter((e) => e.keys.has(chunkKey));
|
|
664
|
+
const together = relevant.length > 0 && (mode === "any" || present.length === 1);
|
|
665
|
+
const reads = together ? [...present, ...relevant] : present;
|
|
666
|
+
const fetched = await Promise.all(
|
|
667
|
+
reads.map((op) => this.storageChunk({ ...op.seg, chunkKey }, op.gen))
|
|
551
668
|
);
|
|
669
|
+
const chunks = together ? fetched.slice(0, present.length) : fetched;
|
|
552
670
|
if (mode === "all" && chunks.some((c) => c === null)) return null;
|
|
553
671
|
const first = chunks[0];
|
|
554
672
|
const acc = first ? first.clone() : this.codec.empty();
|
|
@@ -563,11 +681,8 @@ var SegmentEngine = class {
|
|
|
563
681
|
}
|
|
564
682
|
}
|
|
565
683
|
if (acc.isEmpty) return null;
|
|
566
|
-
const relevant = excludes.filter((e) => e.keys.has(chunkKey));
|
|
567
684
|
if (relevant.length > 0) {
|
|
568
|
-
const cuts = await Promise.all(
|
|
569
|
-
relevant.map((e) => this.storageChunk({ ...e.seg, chunkKey }, e.gen))
|
|
570
|
-
);
|
|
685
|
+
const cuts = together ? fetched.slice(present.length) : await Promise.all(relevant.map((e) => this.storageChunk({ ...e.seg, chunkKey }, e.gen)));
|
|
571
686
|
for (const cut of cuts) {
|
|
572
687
|
if (cut === null) continue;
|
|
573
688
|
acc.andNotInPlace(cut);
|
|
@@ -671,6 +786,7 @@ var SegmentEngine = class {
|
|
|
671
786
|
invalidate(ref) {
|
|
672
787
|
const prefix = segmentPrefix(ref);
|
|
673
788
|
this.cache?.deleteWhere((key) => key.startsWith(prefix));
|
|
789
|
+
for (const key of this.openReads.keys()) if (key.startsWith(prefix)) this.openReads.delete(key);
|
|
674
790
|
this.storage.invalidate?.(ref);
|
|
675
791
|
}
|
|
676
792
|
/** The segment's current generation, resolved once per op (`undefined` ⇒ source can't report it). */
|
|
@@ -700,7 +816,13 @@ var SegmentEngine = class {
|
|
|
700
816
|
* `gen === undefined` ⇒ the source can't report a generation (it only ever serves one) ⇒ the key stays
|
|
701
817
|
* generation-free. Superseded-generation entries age out under the LRU ceiling — no active purge.
|
|
702
818
|
*
|
|
703
|
-
*
|
|
819
|
+
* Concurrent misses of one key share **one** request, with or without a cache: the first caller starts the read,
|
|
820
|
+
* later ones await it, and the entry goes when the read settles, so a failure is every waiting caller's and the
|
|
821
|
+
* next call reads again. The key carries the version exactly as the cache's does, so two versions of one chunk
|
|
822
|
+
* are two reads, never one.
|
|
823
|
+
*
|
|
824
|
+
* The returned instance is **shared** (it may be the cached one, and every caller that joined a read gets the same
|
|
825
|
+
* one): callers read it or clone it, never mutate it.
|
|
704
826
|
*/
|
|
705
827
|
async storageChunk(ref, gen) {
|
|
706
828
|
if (gen === null) return null;
|
|
@@ -713,6 +835,19 @@ var SegmentEngine = class {
|
|
|
713
835
|
}
|
|
714
836
|
if (this.metricsOn) this.metrics.onEvent({ kind: "cache", hit: false });
|
|
715
837
|
}
|
|
838
|
+
const open4 = this.openReads.get(cacheKey);
|
|
839
|
+
if (open4) return open4.read;
|
|
840
|
+
const token = {};
|
|
841
|
+
const entry = { token, read: this.fetchChunk(ref, cacheKey, token) };
|
|
842
|
+
this.openReads.set(cacheKey, entry);
|
|
843
|
+
const settled = () => {
|
|
844
|
+
if (this.openReads.get(cacheKey) === entry) this.openReads.delete(cacheKey);
|
|
845
|
+
};
|
|
846
|
+
entry.read.then(settled, settled);
|
|
847
|
+
return entry.read;
|
|
848
|
+
}
|
|
849
|
+
/** One request for one chunk, decoded, range-checked and cached. */
|
|
850
|
+
async fetchChunk(ref, cacheKey, token) {
|
|
716
851
|
const startedAt = this.metricsOn ? this.clock.now() : 0;
|
|
717
852
|
const bytes = await this.storage.getChunk(ref);
|
|
718
853
|
if (this.metricsOn) {
|
|
@@ -727,7 +862,7 @@ var SegmentEngine = class {
|
|
|
727
862
|
if (!bytes) return null;
|
|
728
863
|
const bitmap = this.codec.safeDeserialize(bytes, this.maxBitmapBytes);
|
|
729
864
|
this.assertChunkPayloadInRange(bitmap, ref.chunkKey);
|
|
730
|
-
this.cache?.set(cacheKey, bitmap);
|
|
865
|
+
if (this.openReads.get(cacheKey)?.token === token) this.cache?.set(cacheKey, bitmap);
|
|
731
866
|
return bitmap;
|
|
732
867
|
}
|
|
733
868
|
};
|
|
@@ -871,6 +1006,111 @@ var BoundedLru = class {
|
|
|
871
1006
|
}
|
|
872
1007
|
};
|
|
873
1008
|
|
|
1009
|
+
// src/core/generation-gc.ts
|
|
1010
|
+
async function nextGeneration(ref, deps) {
|
|
1011
|
+
return aboveEverything(ref, deps, await deps.registry.get(ref));
|
|
1012
|
+
}
|
|
1013
|
+
async function aboveEverything(ref, deps, record) {
|
|
1014
|
+
let highest = record?.currentGen ?? -1;
|
|
1015
|
+
for await (const key of deps.storage.list(ref)) {
|
|
1016
|
+
if (key.generation > highest) highest = key.generation;
|
|
1017
|
+
}
|
|
1018
|
+
return highest + 1;
|
|
1019
|
+
}
|
|
1020
|
+
async function nextLoadGeneration(ref, deps, row) {
|
|
1021
|
+
const generation = (row?.currentGen ?? -1) + 1;
|
|
1022
|
+
try {
|
|
1023
|
+
await deps.storage.getTail({ namespace: ref.namespace, segment: ref.segment, generation }, 0);
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
if (isNotFoundError(err)) return { generation, checked: true };
|
|
1026
|
+
}
|
|
1027
|
+
return { generation: await aboveEverything(ref, deps, row), checked: false };
|
|
1028
|
+
}
|
|
1029
|
+
var LIST_COLLECTION_CADENCE = 16;
|
|
1030
|
+
async function gcOrphanGenerations(ref, deps, options = {}) {
|
|
1031
|
+
const keep = options.keep ?? 1;
|
|
1032
|
+
if (!Number.isInteger(keep) || keep < 0) {
|
|
1033
|
+
throw new ValidationError(`keep must be a non-negative integer; got ${String(keep)}`);
|
|
1034
|
+
}
|
|
1035
|
+
const record = await deps.registry.get(ref);
|
|
1036
|
+
if (record === null) return [];
|
|
1037
|
+
const current = record.currentGen;
|
|
1038
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1039
|
+
for await (const key of deps.storage.list(ref)) seen.add(key.generation);
|
|
1040
|
+
const gens = [...seen];
|
|
1041
|
+
const after = await deps.registry.get(ref);
|
|
1042
|
+
if (after === null)
|
|
1043
|
+
throw new WriteConflictError(`registry row for segment ${ref.segment} was purged mid-pass`);
|
|
1044
|
+
if (record.status === "destroyed" && after.token !== record.token) {
|
|
1045
|
+
throw new WriteConflictError(
|
|
1046
|
+
`segment ${ref.segment} changed incarnation while its generations were being listed`
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
const cutoff = current === null || after.currentGen === null ? null : Math.min(current, after.currentGen);
|
|
1050
|
+
const stillCollectable = async () => {
|
|
1051
|
+
const still = await deps.registry.get(ref);
|
|
1052
|
+
if (still === null) {
|
|
1053
|
+
throw new WriteConflictError(`registry row for segment ${ref.segment} was purged mid-pass`);
|
|
1054
|
+
}
|
|
1055
|
+
const ok = record.status === "destroyed" ? still.token === after.token : cutoff === null || still.currentGen !== null && still.currentGen >= cutoff;
|
|
1056
|
+
if (!ok) {
|
|
1057
|
+
throw new WriteConflictError(
|
|
1058
|
+
`segment ${ref.segment} changed incarnation while its generations were being collected`
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
const toDelete = record.status === "destroyed" ? gens.sort((a, b) => a - b) : cutoff === null ? (
|
|
1063
|
+
// No Storage pointer yet, so "below current" selects nothing and there is nothing safe to infer: an object
|
|
1064
|
+
// here is either a load about to publish or an orphan we cannot tell apart from it. Deleting would race
|
|
1065
|
+
// that publish into a dangling pointer. It is collected once a pointer exists.
|
|
1066
|
+
[]
|
|
1067
|
+
) : (
|
|
1068
|
+
// Delete generations below the cutoff, except the newest `keep` of them (the grace window).
|
|
1069
|
+
gens.filter((g) => g < cutoff).sort((a, b) => b - a).slice(keep)
|
|
1070
|
+
);
|
|
1071
|
+
for (const generation of toDelete) {
|
|
1072
|
+
await stillCollectable();
|
|
1073
|
+
await deps.storage.delete({ namespace: ref.namespace, segment: ref.segment, generation });
|
|
1074
|
+
}
|
|
1075
|
+
return toDelete;
|
|
1076
|
+
}
|
|
1077
|
+
async function collectAfterLoad(ref, deps, options) {
|
|
1078
|
+
const { generation, keep, byName, proveCurrent } = options;
|
|
1079
|
+
if (keep >= generation) return [];
|
|
1080
|
+
const periodic = generation % LIST_COLLECTION_CADENCE === 0;
|
|
1081
|
+
if (byName && keep <= 1 && !periodic) {
|
|
1082
|
+
if (keep === 1 && proveCurrent !== void 0 && !await objectIsThere(deps.storage, proveCurrent)) {
|
|
1083
|
+
return gcOrphanGenerations(ref, deps, { keep });
|
|
1084
|
+
}
|
|
1085
|
+
return collectByName(ref, deps, { generation, keep });
|
|
1086
|
+
}
|
|
1087
|
+
return gcOrphanGenerations(ref, deps, { keep });
|
|
1088
|
+
}
|
|
1089
|
+
async function objectIsThere(storage, key) {
|
|
1090
|
+
try {
|
|
1091
|
+
await storage.getTail(key, 0);
|
|
1092
|
+
return true;
|
|
1093
|
+
} catch {
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
async function collectByName(ref, deps, options) {
|
|
1098
|
+
const { generation, keep } = options;
|
|
1099
|
+
if (!Number.isInteger(keep) || keep < 0 || keep > 1) {
|
|
1100
|
+
throw new ValidationError(`collecting by name needs a keep of 0 or 1; got ${String(keep)}`);
|
|
1101
|
+
}
|
|
1102
|
+
const evicted = generation - keep - 1;
|
|
1103
|
+
if (evicted < 0) return [];
|
|
1104
|
+
const still = await deps.registry.get(ref);
|
|
1105
|
+
if (still === null || still.currentGen === null || still.currentGen < generation) return [];
|
|
1106
|
+
await deps.storage.delete({
|
|
1107
|
+
namespace: ref.namespace,
|
|
1108
|
+
segment: ref.segment,
|
|
1109
|
+
generation: evicted
|
|
1110
|
+
});
|
|
1111
|
+
return [evicted];
|
|
1112
|
+
}
|
|
1113
|
+
|
|
874
1114
|
// src/core/reader-defaults.ts
|
|
875
1115
|
var DEFAULT_CURRENT_GEN_TTL_MS = 2e3;
|
|
876
1116
|
var REFRESH_RETRY_MS = 500;
|
|
@@ -918,9 +1158,20 @@ var AWS_US_EAST_1_ONDEMAND = deepFreeze({
|
|
|
918
1158
|
var HOURS_PER_MONTH = 730;
|
|
919
1159
|
var SECONDS_PER_MONTH = HOURS_PER_MONTH * 3600;
|
|
920
1160
|
var GIB = 1024 ** 3;
|
|
921
|
-
var
|
|
922
|
-
var
|
|
923
|
-
var
|
|
1161
|
+
var STORE_LOAD_PUT_CLASS = 1 + 1 / LIST_COLLECTION_CADENCE;
|
|
1162
|
+
var STORE_LOAD_POINTER_READS = 3 + 2 / LIST_COLLECTION_CADENCE;
|
|
1163
|
+
var STORE_LOAD_EXISTENCE_CHECKS = 2 - 1 / LIST_COLLECTION_CADENCE;
|
|
1164
|
+
var RETENTION_SWEEP_REQUESTS = deepFreeze({
|
|
1165
|
+
conditionalDelete: {
|
|
1166
|
+
retirement: { reads: 9, writes: 3, deletes: 1 },
|
|
1167
|
+
purge: { reads: 4, writes: 0, deletes: 2 }
|
|
1168
|
+
},
|
|
1169
|
+
tombstoning: {
|
|
1170
|
+
retirement: { reads: 8, writes: 3, deletes: 0 },
|
|
1171
|
+
purge: { reads: 3, writes: 1, deletes: 0 }
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
var shown = (n) => String(Number(n.toFixed(2)));
|
|
924
1175
|
function requireFiniteNonNeg(n, field) {
|
|
925
1176
|
if (n === void 0 || !Number.isFinite(n) || n < 0) {
|
|
926
1177
|
throw new ValidationError(`${field} must be a finite number >= 0; got ${n}`);
|
|
@@ -1057,8 +1308,8 @@ function bytesWords(bytes) {
|
|
|
1057
1308
|
const units = ["bytes", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
|
1058
1309
|
let value = bytes;
|
|
1059
1310
|
let unit = 0;
|
|
1060
|
-
const
|
|
1061
|
-
while (unit < units.length - 1 &&
|
|
1311
|
+
const shown2 = (v, u) => u === 0 ? Math.round(v) : Number(v.toFixed(2));
|
|
1312
|
+
while (unit < units.length - 1 && shown2(value, unit) >= 1024) {
|
|
1062
1313
|
value /= 1024;
|
|
1063
1314
|
unit += 1;
|
|
1064
1315
|
}
|
|
@@ -1085,6 +1336,10 @@ function buildReport(input) {
|
|
|
1085
1336
|
requireFiniteNonNeg(storage.getPerMillion, "pricing.storage.getPerMillion");
|
|
1086
1337
|
requireFiniteNonNeg(storage.putPerMillion, "pricing.storage.putPerMillion");
|
|
1087
1338
|
requireFiniteNonNeg(storage.storagePerGiBMonth, "pricing.storage.storagePerGiBMonth");
|
|
1339
|
+
const pointerRead = requireFiniteNonNeg(
|
|
1340
|
+
storage.requestsPerPointerRead ?? 1,
|
|
1341
|
+
"pricing.storage.requestsPerPointerRead"
|
|
1342
|
+
);
|
|
1088
1343
|
const sizedRead = requireFiniteNonNeg(
|
|
1089
1344
|
storage.requestsPerSizedRead ?? 1,
|
|
1090
1345
|
"pricing.storage.requestsPerSizedRead"
|
|
@@ -1131,6 +1386,15 @@ function buildReport(input) {
|
|
|
1131
1386
|
input.workload.genTtlMs ?? DEFAULT_CURRENT_GEN_TTL_MS,
|
|
1132
1387
|
"genTtlMs"
|
|
1133
1388
|
);
|
|
1389
|
+
const retirementsPerMonth = requireFiniteNonNeg(
|
|
1390
|
+
input.workload.retirementsPerMonth ?? 0,
|
|
1391
|
+
"retirementsPerMonth"
|
|
1392
|
+
);
|
|
1393
|
+
const purgesPerMonth = requireFiniteNonNeg(input.workload.purgesPerMonth ?? 0, "purgesPerMonth");
|
|
1394
|
+
const conditionalDelete = input.workload.conditionalDelete ?? true;
|
|
1395
|
+
if (typeof conditionalDelete !== "boolean") {
|
|
1396
|
+
throw new ValidationError(`conditionalDelete must be a boolean; got ${conditionalDelete}`);
|
|
1397
|
+
}
|
|
1134
1398
|
const storageGetUSD = storage.getPerMillion / 1e6;
|
|
1135
1399
|
const putUSD = storage.putPerMillion / 1e6;
|
|
1136
1400
|
const missFraction = 1 - cacheHitRate;
|
|
@@ -1139,16 +1403,26 @@ function buildReport(input) {
|
|
|
1139
1403
|
const refreshes = genTtlMs > 0 ? Math.min(readerProcesses * hotSegments * S * 1e3 / genTtlMs, readsPerSec * S) : 0;
|
|
1140
1404
|
const storageUSD = storedGiB * storage.storagePerGiBMonth;
|
|
1141
1405
|
const readsUSD = readMisses * storageGetUSD;
|
|
1142
|
-
const
|
|
1406
|
+
const getsPerColdOperand = pointerRead + sizedRead;
|
|
1407
|
+
const intersectGets = chunksPerIntersect + getsPerColdOperand * operandsPerIntersect;
|
|
1143
1408
|
const intersectsUSD = intersects * intersectGets * storageGetUSD;
|
|
1144
|
-
const loadGets =
|
|
1409
|
+
const loadGets = STORE_LOAD_POINTER_READS * pointerRead + STORE_LOAD_EXISTENCE_CHECKS;
|
|
1145
1410
|
const loadsUSD = loadsPerMonth * ((requestsPerLoad + STORE_LOAD_PUT_CLASS) * putUSD + loadGets * storageGetUSD);
|
|
1146
|
-
const refreshUSD = refreshes *
|
|
1147
|
-
const
|
|
1411
|
+
const refreshUSD = refreshes * pointerRead * storageGetUSD;
|
|
1412
|
+
const sweep = RETENTION_SWEEP_REQUESTS[conditionalDelete ? "conditionalDelete" : "tombstoning"];
|
|
1413
|
+
const retentionUSD = retirementsPerMonth * (sweep.retirement.reads * storageGetUSD + sweep.retirement.writes * putUSD) + purgesPerMonth * (sweep.purge.reads * storageGetUSD + sweep.purge.writes * putUSD);
|
|
1414
|
+
const total = readsUSD + intersectsUSD + storageUSD + loadsUSD + refreshUSD + retentionUSD;
|
|
1148
1415
|
const headroom = Math.max(0, baselineUSD - storageUSD - refreshUSD);
|
|
1149
1416
|
const readsCross = storageGetUSD > 0 && missFraction > 0 ? headroom / (S * storageGetUSD * missFraction) : Infinity;
|
|
1150
1417
|
const verdict = total <= baselineUSD * 0.1 ? "win-big" : total < baselineUSD ? "win" : "lose-zone";
|
|
1151
|
-
const dominant = Math.max(
|
|
1418
|
+
const dominant = Math.max(
|
|
1419
|
+
readsUSD,
|
|
1420
|
+
intersectsUSD,
|
|
1421
|
+
storageUSD,
|
|
1422
|
+
loadsUSD,
|
|
1423
|
+
refreshUSD,
|
|
1424
|
+
retentionUSD
|
|
1425
|
+
);
|
|
1152
1426
|
let driver = "storage";
|
|
1153
1427
|
if (dominant === readsUSD && readsUSD > 0) driver = "point reads (object GETs)";
|
|
1154
1428
|
else if (dominant === intersectsUSD && intersectsUSD > 0) {
|
|
@@ -1156,6 +1430,8 @@ function buildReport(input) {
|
|
|
1156
1430
|
} else if (dominant === loadsUSD && loadsUSD > 0)
|
|
1157
1431
|
driver = "loads (object, listing and pointer requests)";
|
|
1158
1432
|
else if (dominant === refreshUSD && refreshUSD > 0) driver = "the pointer refresh (object GETs)";
|
|
1433
|
+
else if (dominant === retentionUSD && retentionUSD > 0)
|
|
1434
|
+
driver = "the retention sweep (registry reads and writes)";
|
|
1159
1435
|
const sized = baseline.sized;
|
|
1160
1436
|
const noBytes = Math.round(storageBytes) === 0;
|
|
1161
1437
|
const redisWords = sized === void 0 ? void 0 : noBytes ? `$${baselineUSD.toFixed(2)}/mo cheapest Redis cluster in the catalogue, as no bytes are counted (${clusterWords(sized.cluster)})` : `$${baselineUSD.toFixed(2)}/mo Redis that would hold ${bytesWords(storageBytes)} (${clusterWords(sized.cluster)})`;
|
|
@@ -1165,12 +1441,15 @@ function buildReport(input) {
|
|
|
1165
1441
|
const notes = [
|
|
1166
1442
|
"Same-region egress treated as free; internet egress is not modeled.",
|
|
1167
1443
|
"Request cost is from the supplied workload rates (live-metrics-derived request cost is a later phase).",
|
|
1168
|
-
loadsPerMonth > 0 ? `Loads modeled: ${loadsPerMonth}/mo, each ${requestsPerLoad} PUT-class request(s) for the object plus ${STORE_LOAD_PUT_CLASS} PUT-class and ${loadGets} GETs that store.load() adds (
|
|
1444
|
+
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.",
|
|
1169
1445
|
...intersects > 0 ? [
|
|
1170
|
-
`Intersections priced cold: ${intersectGets} GETs each, ${
|
|
1446
|
+
`Intersections priced cold: ${intersectGets} GETs each, ${getsPerColdOperand} for each of ${operandsPerIntersect} operand(s) plus ${chunksPerIntersect} chunk read(s); cacheHitRate does not apply.`
|
|
1171
1447
|
] : [],
|
|
1172
1448
|
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,
|
|
1173
1449
|
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,
|
|
1450
|
+
...retirementsPerMonth > 0 || purgesPerMonth > 0 ? [
|
|
1451
|
+
`Retention sweep modeled: ${retirementsPerMonth}/mo retirements at ${sweep.retirement.reads} reads, ${sweep.retirement.writes} writes and ${sweep.retirement.deletes} delete(s) each, ${purgesPerMonth}/mo purges at ${sweep.purge.reads} reads, ${sweep.purge.writes} writes and ${sweep.purge.deletes} delete(s) each; reads are priced at the GET rate and writes at the PUT rate, deletes at nothing, as S3 bills none` + (conditionalDelete ? "." : "; a registry that only tombstones is read again, two reads per purged segment, by every later full sweep, which is not priced.")
|
|
1452
|
+
] : [],
|
|
1174
1453
|
...input.extraNotes ?? [],
|
|
1175
1454
|
// Last, so the notes a report already carried keep their places.
|
|
1176
1455
|
redisNote
|
|
@@ -1182,7 +1461,8 @@ function buildReport(input) {
|
|
|
1182
1461
|
intersects: intersectsUSD,
|
|
1183
1462
|
storage: storageUSD,
|
|
1184
1463
|
loads: loadsUSD,
|
|
1185
|
-
pointerRefresh: refreshUSD
|
|
1464
|
+
pointerRefresh: refreshUSD,
|
|
1465
|
+
retention: retentionUSD
|
|
1186
1466
|
},
|
|
1187
1467
|
total
|
|
1188
1468
|
},
|
|
@@ -1291,7 +1571,10 @@ async function drainRegistry(registry, options) {
|
|
|
1291
1571
|
const advice = options.narrowable === false ? `${raise[0].toUpperCase()}${raise.slice(1)} if the namespace really is that large and the memory is ` : `Narrow it with \`namespace\`, or ${raise} if the fleet really is that large and the memory is `;
|
|
1292
1572
|
const rows = [];
|
|
1293
1573
|
for await (const rec of registry.list(options.namespace)) {
|
|
1294
|
-
if (options.namespace === void 0 && isReservedRow(rec))
|
|
1574
|
+
if (options.namespace === void 0 && isReservedRow(rec)) {
|
|
1575
|
+
options.onReserved?.(rec);
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1295
1578
|
if (rows.length >= maxScanSegments) {
|
|
1296
1579
|
throw new BudgetExceededError(
|
|
1297
1580
|
`${op} would enumerate more than ${maxScanSegments} segments \u2014 the scan was abandoned there rather than completed. ${advice}available (a record is a few hundred bytes resident).`
|
|
@@ -1463,16 +1746,19 @@ var BufferReader = class {
|
|
|
1463
1746
|
var MemoryRegistryDriver = class {
|
|
1464
1747
|
rows = /* @__PURE__ */ new Map();
|
|
1465
1748
|
now;
|
|
1749
|
+
entropy;
|
|
1466
1750
|
seq = 0;
|
|
1467
1751
|
constructor(options = {}) {
|
|
1468
1752
|
this.now = options.now ?? (() => Date.now());
|
|
1753
|
+
this.entropy = options.entropy ?? webCryptoEntropy;
|
|
1469
1754
|
}
|
|
1470
|
-
nextToken() {
|
|
1755
|
+
nextToken(incarnation) {
|
|
1471
1756
|
this.seq += 1;
|
|
1472
|
-
return
|
|
1757
|
+
return `${incarnation}.${this.seq}.${drawWrite(this.entropy)}`;
|
|
1473
1758
|
}
|
|
1759
|
+
/** `conditionalDelete`: a delete checks the token and removes the row in one step, with no await between. */
|
|
1474
1760
|
capabilities() {
|
|
1475
|
-
return { strongRead: true };
|
|
1761
|
+
return entropyIsAvailable(this.entropy) ? { strongRead: true, conditionalDelete: true } : { strongRead: true, canWrite: false, conditionalDelete: true };
|
|
1476
1762
|
}
|
|
1477
1763
|
async get(ref) {
|
|
1478
1764
|
validateSegmentRef(ref);
|
|
@@ -1481,25 +1767,25 @@ var MemoryRegistryDriver = class {
|
|
|
1481
1767
|
}
|
|
1482
1768
|
async create(ref, record) {
|
|
1483
1769
|
validateSegmentRef(ref);
|
|
1484
|
-
validateNewRegistryRecord(record);
|
|
1770
|
+
const checked = validateNewRegistryRecord(record);
|
|
1485
1771
|
const key = segmentKey(ref);
|
|
1486
1772
|
if (this.rows.has(key)) {
|
|
1487
1773
|
throw new WriteConflictError(`registry row already exists for segment ${ref.segment}`);
|
|
1488
1774
|
}
|
|
1489
|
-
const token = this.nextToken();
|
|
1490
|
-
this.rows.set(key, structuredClone(recordFromNew(ref,
|
|
1775
|
+
const token = this.nextToken(drawIncarnation(this.entropy));
|
|
1776
|
+
this.rows.set(key, structuredClone(recordFromNew(ref, checked, this.now(), token)));
|
|
1491
1777
|
return { token };
|
|
1492
1778
|
}
|
|
1493
1779
|
async compareAndSwap(ref, expected, patch) {
|
|
1494
1780
|
validateSegmentRef(ref);
|
|
1495
|
-
validateRegistryPatch(patch);
|
|
1781
|
+
const checked = validateRegistryPatch(patch);
|
|
1496
1782
|
const key = segmentKey(ref);
|
|
1497
1783
|
const existing = this.rows.get(key);
|
|
1498
1784
|
if (!existing || existing.token !== expected) {
|
|
1499
1785
|
throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);
|
|
1500
1786
|
}
|
|
1501
|
-
const token = this.nextToken();
|
|
1502
|
-
this.rows.set(key, structuredClone(applyRegistryPatch(existing,
|
|
1787
|
+
const token = this.nextToken(incarnationOf(existing.token) ?? drawIncarnation(this.entropy));
|
|
1788
|
+
this.rows.set(key, structuredClone(applyRegistryPatch(existing, checked, this.now(), token)));
|
|
1503
1789
|
return { token };
|
|
1504
1790
|
}
|
|
1505
1791
|
async *list(namespace) {
|
|
@@ -1816,43 +2102,48 @@ var LocalFsRegistryDriver = class {
|
|
|
1816
2102
|
constructor(root, options = {}) {
|
|
1817
2103
|
this.root = root;
|
|
1818
2104
|
this.now = options.now ?? (() => Date.now());
|
|
2105
|
+
this.entropy = options.entropy ?? webCryptoEntropy;
|
|
1819
2106
|
}
|
|
1820
2107
|
root;
|
|
1821
2108
|
now;
|
|
2109
|
+
entropy;
|
|
2110
|
+
/**
|
|
2111
|
+
* `conditionalDelete`: a delete reads the row, checks it, and unlinks it under the row's lock, which every writer on
|
|
2112
|
+
* the root in this process takes, so no write can land between the check and the unlink.
|
|
2113
|
+
*/
|
|
1822
2114
|
capabilities() {
|
|
1823
|
-
return { strongRead: true };
|
|
2115
|
+
return entropyIsAvailable(this.entropy) ? { strongRead: true, conditionalDelete: true } : { strongRead: true, canWrite: false, conditionalDelete: true };
|
|
1824
2116
|
}
|
|
1825
2117
|
async get(ref) {
|
|
1826
2118
|
const env = await this.readRow(registryRowPath(this.root, ref));
|
|
1827
2119
|
return env && !env.deleted ? env.record : null;
|
|
1828
2120
|
}
|
|
1829
2121
|
async create(ref, record) {
|
|
1830
|
-
validateNewRegistryRecord(record);
|
|
2122
|
+
const checked = validateNewRegistryRecord(record);
|
|
1831
2123
|
const path = registryRowPath(this.root, ref);
|
|
1832
2124
|
return this.withRowLock(path, async () => {
|
|
1833
2125
|
const current = await this.readRow(path);
|
|
1834
2126
|
if (current !== null && !current.deleted) {
|
|
1835
2127
|
throw new WriteConflictError(`registry row already exists for segment ${ref.segment}`);
|
|
1836
2128
|
}
|
|
1837
|
-
const
|
|
1838
|
-
|
|
1839
|
-
await this.writeRow(path, false, recordFromNew(ref, record, this.now(), token));
|
|
2129
|
+
const token = newIncarnationToken(this.entropy, current?.record);
|
|
2130
|
+
await this.writeRow(path, false, recordFromNew(ref, checked, this.now(), token));
|
|
1840
2131
|
return { token };
|
|
1841
2132
|
});
|
|
1842
2133
|
}
|
|
1843
2134
|
async compareAndSwap(ref, expected, patch) {
|
|
1844
|
-
validateRegistryPatch(patch);
|
|
2135
|
+
const checked = validateRegistryPatch(patch);
|
|
1845
2136
|
const path = registryRowPath(this.root, ref);
|
|
1846
2137
|
return this.withRowLock(path, async () => {
|
|
1847
2138
|
const current = await this.readRow(path);
|
|
1848
2139
|
if (current === null || current.deleted || current.record.token !== expected) {
|
|
1849
2140
|
throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);
|
|
1850
2141
|
}
|
|
1851
|
-
const token =
|
|
2142
|
+
const token = nextRegistryToken(current.record, this.entropy);
|
|
1852
2143
|
await this.writeRow(
|
|
1853
2144
|
path,
|
|
1854
2145
|
false,
|
|
1855
|
-
applyRegistryPatch(current.record,
|
|
2146
|
+
applyRegistryPatch(current.record, checked, this.now(), token)
|
|
1856
2147
|
);
|
|
1857
2148
|
return { token };
|
|
1858
2149
|
});
|
|
@@ -1886,10 +2177,23 @@ var LocalFsRegistryDriver = class {
|
|
|
1886
2177
|
} else if (current === null || current.deleted) {
|
|
1887
2178
|
return;
|
|
1888
2179
|
}
|
|
1889
|
-
|
|
2180
|
+
if (incarnationOf(current.record.token) !== void 0) {
|
|
2181
|
+
await this.unlinkRow(path);
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
const token = nextRegistryToken(current.record, this.entropy);
|
|
1890
2185
|
await this.writeRow(path, true, { ...current.record, token, updatedAt: this.now() });
|
|
1891
2186
|
});
|
|
1892
2187
|
}
|
|
2188
|
+
/** Remove a row's file and make the removal durable (fsync the directory). */
|
|
2189
|
+
async unlinkRow(path) {
|
|
2190
|
+
try {
|
|
2191
|
+
await unlink2(path);
|
|
2192
|
+
} catch (err) {
|
|
2193
|
+
if (!isCode(err, "ENOENT")) throw mapFsError(err);
|
|
2194
|
+
}
|
|
2195
|
+
await fsyncDir(dirname2(path));
|
|
2196
|
+
}
|
|
1893
2197
|
/** Namespaces to scan: just the one requested, or every namespace dir under the root. */
|
|
1894
2198
|
async namespaceDirs(namespace) {
|
|
1895
2199
|
if (namespace !== void 0) return [namespace];
|
|
@@ -2045,6 +2349,8 @@ function yieldEvery(clock, everyN = YIELD_EVERY) {
|
|
|
2045
2349
|
var AAD_VERSION = 1;
|
|
2046
2350
|
var SCOPE_CHUNK = 0;
|
|
2047
2351
|
var SCOPE_INDEX = 1;
|
|
2352
|
+
var SCOPE_METADATA = 2;
|
|
2353
|
+
var SCOPE_SUMMARY = 3;
|
|
2048
2354
|
function aadFor(ref, generation, scope) {
|
|
2049
2355
|
const enc = new TextEncoder();
|
|
2050
2356
|
const ns = enc.encode(ref.namespace ?? "");
|
|
@@ -2064,9 +2370,9 @@ function aadFor(ref, generation, scope) {
|
|
|
2064
2370
|
o += seg.length;
|
|
2065
2371
|
view.setBigUint64(o, BigInt(generation), true);
|
|
2066
2372
|
o += 8;
|
|
2067
|
-
out[o] = scope === "index" ? SCOPE_INDEX : SCOPE_CHUNK;
|
|
2373
|
+
out[o] = scope === "index" ? SCOPE_INDEX : scope === "metadata" ? SCOPE_METADATA : scope === "summary" ? SCOPE_SUMMARY : SCOPE_CHUNK;
|
|
2068
2374
|
o += 1;
|
|
2069
|
-
view.setUint32(o, scope === "
|
|
2375
|
+
view.setUint32(o, typeof scope === "number" ? scope : 0, true);
|
|
2070
2376
|
return out;
|
|
2071
2377
|
}
|
|
2072
2378
|
|
|
@@ -2148,71 +2454,9 @@ function readVarint(bytes, offset) {
|
|
|
2148
2454
|
throw new IntegrityError("varint exceeds u32 range");
|
|
2149
2455
|
}
|
|
2150
2456
|
|
|
2151
|
-
// src/core/crbm/format.ts
|
|
2152
|
-
var MAGIC = Uint8Array.of(67, 82, 66, 77);
|
|
2153
|
-
var VERSION_MAJOR = 1;
|
|
2154
|
-
var VERSION_MINOR = 0;
|
|
2155
|
-
var PREAMBLE_BYTES = 8;
|
|
2156
|
-
var PAYLOAD_START = PREAMBLE_BYTES;
|
|
2157
|
-
var FOOTER_BYTES = 104;
|
|
2158
|
-
var FOOTER = {
|
|
2159
|
-
indexOffset: 0,
|
|
2160
|
-
// u64
|
|
2161
|
-
indexLength: 8,
|
|
2162
|
-
// u64
|
|
2163
|
-
indexCrc32c: 16,
|
|
2164
|
-
// u32
|
|
2165
|
-
flags: 20,
|
|
2166
|
-
// u32
|
|
2167
|
-
payloadCodecId: 24,
|
|
2168
|
-
// u16 — which codec wrote the chunk payloads
|
|
2169
|
-
elementWidth: 26,
|
|
2170
|
-
// u8
|
|
2171
|
-
containerCodec: 27,
|
|
2172
|
-
// u8
|
|
2173
|
-
versionMajor: 28,
|
|
2174
|
-
// u8
|
|
2175
|
-
versionMinor: 29,
|
|
2176
|
-
// u8
|
|
2177
|
-
reserved2: 30,
|
|
2178
|
-
// u16
|
|
2179
|
-
generation: 32,
|
|
2180
|
-
// u64
|
|
2181
|
-
indexNonce: 40,
|
|
2182
|
-
// 12 B
|
|
2183
|
-
indexTag: 52,
|
|
2184
|
-
// 16 B
|
|
2185
|
-
keyId: 68,
|
|
2186
|
-
// 16 B
|
|
2187
|
-
chunkCount: 84,
|
|
2188
|
-
// u32
|
|
2189
|
-
totalCardinality: 88,
|
|
2190
|
-
// u64
|
|
2191
|
-
footerCrc32c: 96,
|
|
2192
|
-
// u32 — covers footer bytes [0, 96)
|
|
2193
|
-
endMagic: 100
|
|
2194
|
-
// char[4]
|
|
2195
|
-
};
|
|
2196
|
-
var FOOTER_CRC_COVERAGE = FOOTER.footerCrc32c;
|
|
2197
|
-
var FLAG_ENCRYPTED = 1 << 0;
|
|
2198
|
-
var FLAG_INDEX_COMPRESSED = 1 << 1;
|
|
2199
|
-
var FLAG_LITTLE_ENDIAN = 1 << 2;
|
|
2200
|
-
var KNOWN_FLAGS = FLAG_ENCRYPTED | FLAG_LITTLE_ENDIAN;
|
|
2201
|
-
var ELEMENT_WIDTH_32 = 32;
|
|
2202
|
-
var PAYLOAD_CODEC_ROARING_PORTABLE = 1;
|
|
2203
|
-
var KNOWN_PAYLOAD_CODEC_IDS = /* @__PURE__ */ new Set([
|
|
2204
|
-
PAYLOAD_CODEC_ROARING_PORTABLE
|
|
2205
|
-
]);
|
|
2206
|
-
var CONTAINER_CODEC_NONE = 0;
|
|
2207
|
-
var DEFAULT_TAIL_BYTES = 256 * 1024;
|
|
2208
|
-
var DEFAULT_MAX_INDEX_BYTES = 8 * 1024 * 1024;
|
|
2209
|
-
var DEFAULT_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
|
|
2210
|
-
var MAX_CHUNK_CARDINALITY = 65536;
|
|
2211
|
-
var CRC32C_BYTES = 4;
|
|
2212
|
-
var AEAD_NONCE_BYTES = 12;
|
|
2213
|
-
var AEAD_TAG_BYTES = 16;
|
|
2214
|
-
|
|
2215
2457
|
// src/core/crbm/reader.ts
|
|
2458
|
+
var RETAINED_BYTES_PER_METADATA_BYTE = 2;
|
|
2459
|
+
var RETAINED_BYTES_PER_METADATA_KEY = 160;
|
|
2216
2460
|
var MIN_INDEX_RECORD_BYTES = 4 + CRC32C_BYTES;
|
|
2217
2461
|
function indexCapacity(indexLength) {
|
|
2218
2462
|
return Math.min(65536, Math.floor(indexLength / MIN_INDEX_RECORD_BYTES));
|
|
@@ -2243,6 +2487,11 @@ function checkedFooter(tail, size) {
|
|
|
2243
2487
|
}
|
|
2244
2488
|
return { footer, fview, storedFooterCrc };
|
|
2245
2489
|
}
|
|
2490
|
+
async function footerSaysEncrypted(blob) {
|
|
2491
|
+
const { bytes: tail, size } = await blob.getTail(FOOTER_BYTES);
|
|
2492
|
+
const { fview } = checkedFooter(tail, size);
|
|
2493
|
+
return (fview.getUint32(FOOTER.flags, true) & FLAG_ENCRYPTED) !== 0;
|
|
2494
|
+
}
|
|
2246
2495
|
function readU64(view, offset, field) {
|
|
2247
2496
|
const big = view.getBigUint64(offset, true);
|
|
2248
2497
|
if (big > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
@@ -2251,7 +2500,7 @@ function readU64(view, offset, field) {
|
|
|
2251
2500
|
return Number(big);
|
|
2252
2501
|
}
|
|
2253
2502
|
var CrbmReader = class _CrbmReader {
|
|
2254
|
-
constructor(blob, objectSize, generation, lineage, totalCardinality, index, servedFromTail, crypto, footerCrc) {
|
|
2503
|
+
constructor(blob, objectSize, generation, lineage, totalCardinality, index, servedFromTail, crypto, footerCrc, metadata, metadataWeight) {
|
|
2255
2504
|
this.blob = blob;
|
|
2256
2505
|
this.objectSize = objectSize;
|
|
2257
2506
|
this.generation = generation;
|
|
@@ -2261,6 +2510,8 @@ var CrbmReader = class _CrbmReader {
|
|
|
2261
2510
|
this.servedFromTail = servedFromTail;
|
|
2262
2511
|
this.crypto = crypto;
|
|
2263
2512
|
this.footerCrc = footerCrc;
|
|
2513
|
+
this.metadata = metadata;
|
|
2514
|
+
this.metadataWeight = metadataWeight;
|
|
2264
2515
|
}
|
|
2265
2516
|
blob;
|
|
2266
2517
|
objectSize;
|
|
@@ -2271,6 +2522,8 @@ var CrbmReader = class _CrbmReader {
|
|
|
2271
2522
|
servedFromTail;
|
|
2272
2523
|
crypto;
|
|
2273
2524
|
footerCrc;
|
|
2525
|
+
metadata;
|
|
2526
|
+
metadataWeight;
|
|
2274
2527
|
/**
|
|
2275
2528
|
* Which object this reader opened, as its size and its footer's CRC. The footer covers the index's CRC, so two
|
|
2276
2529
|
* objects with the same fingerprint hold the same chunks, as far as a CRC can tell; a pin compares it to know that
|
|
@@ -2292,6 +2545,15 @@ var CrbmReader = class _CrbmReader {
|
|
|
2292
2545
|
const { keys, cardinalityMinusOne, lengths, crcs, offsets } = this.index;
|
|
2293
2546
|
return keys.byteLength + cardinalityMinusOne.byteLength + lengths.byteLength + crcs.byteLength + offsets.byteLength;
|
|
2294
2547
|
}
|
|
2548
|
+
/**
|
|
2549
|
+
* What this reader holds, the weight the storage reader cache bounds on: its parsed index
|
|
2550
|
+
* ({@link retainedIndexBytes}), plus a weight for the metadata it decoded, from its canonical length and its number
|
|
2551
|
+
* of keys. Equal to {@link retainedIndexBytes} for a generation with no metadata. A cache heuristic, not a contract:
|
|
2552
|
+
* how the metadata is weighed may change in any release.
|
|
2553
|
+
*/
|
|
2554
|
+
get retainedBytes() {
|
|
2555
|
+
return this.retainedIndexBytes + this.metadataWeight;
|
|
2556
|
+
}
|
|
2295
2557
|
/** Per-chunk cardinality (`chunkKey → count`) from the parsed index — no payload reads. */
|
|
2296
2558
|
cardinalities() {
|
|
2297
2559
|
const { keys, cardinalityMinusOne } = this.index;
|
|
@@ -2315,7 +2577,6 @@ var CrbmReader = class _CrbmReader {
|
|
|
2315
2577
|
}
|
|
2316
2578
|
static async open(blob, options = {}) {
|
|
2317
2579
|
const tailBytes = Math.max(options.tailBytes ?? DEFAULT_TAIL_BYTES, FOOTER_BYTES);
|
|
2318
|
-
const maxPayloadBytes = options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
|
|
2319
2580
|
const maxIndexBytes = options.maxIndexBytes ?? DEFAULT_MAX_INDEX_BYTES;
|
|
2320
2581
|
const { bytes: tail, size } = await blob.getTail(tailBytes);
|
|
2321
2582
|
const { footer, fview, storedFooterCrc } = checkedFooter(tail, size);
|
|
@@ -2358,6 +2619,13 @@ var CrbmReader = class _CrbmReader {
|
|
|
2358
2619
|
const chunkCount = fview.getUint32(FOOTER.chunkCount, true);
|
|
2359
2620
|
const totalCardinality = readU64(fview, FOOTER.totalCardinality, "total_cardinality");
|
|
2360
2621
|
const generation = readU64(fview, FOOTER.generation, "generation");
|
|
2622
|
+
if (!encrypted && options.crypto !== void 0) {
|
|
2623
|
+
throw new IntegrityError(
|
|
2624
|
+
`.crbm generation ${generation} is not encrypted, but it was opened with a key: a cleartext object where an encrypted one belongs is forged, corrupt, or a write that never published (a crash, before the segment's key was made); a load's collection, dropSegment or deleting the object removes it`
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
const versionMinor = footer[FOOTER.versionMinor];
|
|
2628
|
+
const hasExtension = (flags & FLAG_EXTENSION) !== 0;
|
|
2361
2629
|
if (indexOffset < PAYLOAD_START || indexOffset + indexLength > size - FOOTER_BYTES) {
|
|
2362
2630
|
throw new IntegrityError(
|
|
2363
2631
|
`.crbm index region [${indexOffset}, +${indexLength}) out of bounds`
|
|
@@ -2368,20 +2636,27 @@ var CrbmReader = class _CrbmReader {
|
|
|
2368
2636
|
}
|
|
2369
2637
|
const tailCoversFrom = size - tail.length;
|
|
2370
2638
|
if (tailCoversFrom === 0) {
|
|
2371
|
-
if (!magicMatches(tail, 0) || tail[4] !== versionMajor) {
|
|
2639
|
+
if (!magicMatches(tail, 0) || tail[4] !== versionMajor || tail[5] !== versionMinor) {
|
|
2372
2640
|
throw new IntegrityError(".crbm preamble magic/version mismatch with footer");
|
|
2373
2641
|
}
|
|
2374
2642
|
}
|
|
2375
|
-
|
|
2643
|
+
const lowest = hasExtension ? Math.max(PAYLOAD_START, indexOffset - EXT_TRAILER_BYTES - MAX_EXT_BYTES) : indexOffset;
|
|
2644
|
+
let region;
|
|
2645
|
+
let regionStart;
|
|
2376
2646
|
let servedFromTail;
|
|
2377
2647
|
if (indexOffset >= tailCoversFrom) {
|
|
2378
|
-
|
|
2379
|
-
|
|
2648
|
+
region = tail;
|
|
2649
|
+
regionStart = tailCoversFrom;
|
|
2380
2650
|
servedFromTail = true;
|
|
2381
2651
|
} else {
|
|
2382
|
-
|
|
2652
|
+
region = await blob.getRange(lowest, indexOffset + indexLength - lowest);
|
|
2653
|
+
regionStart = lowest;
|
|
2383
2654
|
servedFromTail = false;
|
|
2384
2655
|
}
|
|
2656
|
+
const indexBytes = region.subarray(
|
|
2657
|
+
indexOffset - regionStart,
|
|
2658
|
+
indexOffset - regionStart + indexLength
|
|
2659
|
+
);
|
|
2385
2660
|
if (crc32c(indexBytes) !== indexCrc) {
|
|
2386
2661
|
throw new IntegrityError(".crbm index CRC mismatch");
|
|
2387
2662
|
}
|
|
@@ -2393,10 +2668,57 @@ var CrbmReader = class _CrbmReader {
|
|
|
2393
2668
|
},
|
|
2394
2669
|
options.crypto.aadFor("index")
|
|
2395
2670
|
) : indexBytes;
|
|
2671
|
+
let payloadEnd = indexOffset;
|
|
2672
|
+
let metadata;
|
|
2673
|
+
let metadataLength = 0;
|
|
2674
|
+
if (hasExtension) {
|
|
2675
|
+
const trailerStart = indexOffset - EXT_TRAILER_BYTES;
|
|
2676
|
+
if (trailerStart < PAYLOAD_START) {
|
|
2677
|
+
throw new IntegrityError(".crbm flags an extension block, but has no room for one");
|
|
2678
|
+
}
|
|
2679
|
+
let window = { bytes: region, start: regionStart };
|
|
2680
|
+
if (trailerStart < regionStart) {
|
|
2681
|
+
window = { bytes: await blob.getRange(lowest, indexOffset - lowest), start: lowest };
|
|
2682
|
+
servedFromTail = false;
|
|
2683
|
+
}
|
|
2684
|
+
const trailer = within(window.bytes, window.start, trailerStart, indexOffset);
|
|
2685
|
+
const tview = new DataView(trailer.buffer, trailer.byteOffset, trailer.byteLength);
|
|
2686
|
+
if (!EXT_MAGIC.every((b, i) => trailer[8 + i] === b)) {
|
|
2687
|
+
throw new IntegrityError(".crbm extension block trailer magic mismatch");
|
|
2688
|
+
}
|
|
2689
|
+
const sectionsLength = tview.getUint32(0, true);
|
|
2690
|
+
if (sectionsLength > MAX_EXT_BYTES) {
|
|
2691
|
+
throw new IntegrityError(
|
|
2692
|
+
`.crbm extension block ${sectionsLength}B exceeds cap ${MAX_EXT_BYTES}B`
|
|
2693
|
+
);
|
|
2694
|
+
}
|
|
2695
|
+
const extStart = trailerStart - sectionsLength;
|
|
2696
|
+
if (extStart < PAYLOAD_START) {
|
|
2697
|
+
throw new IntegrityError(`.crbm extension block of ${sectionsLength}B out of bounds`);
|
|
2698
|
+
}
|
|
2699
|
+
let covered;
|
|
2700
|
+
if (extStart >= window.start) {
|
|
2701
|
+
covered = within(window.bytes, window.start, extStart, trailerStart + 4);
|
|
2702
|
+
} else {
|
|
2703
|
+
covered = await blob.getRange(extStart, sectionsLength + 4);
|
|
2704
|
+
servedFromTail = false;
|
|
2705
|
+
if (covered.length !== sectionsLength + 4) {
|
|
2706
|
+
throw new IntegrityError(".crbm extension block read short");
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
if (crc32c(covered) !== tview.getUint32(4, true)) {
|
|
2710
|
+
throw new IntegrityError(".crbm extension block CRC mismatch");
|
|
2711
|
+
}
|
|
2712
|
+
({ metadata, metadataLength } = extensionContents(
|
|
2713
|
+
covered.subarray(0, sectionsLength),
|
|
2714
|
+
options.crypto
|
|
2715
|
+
));
|
|
2716
|
+
payloadEnd = extStart;
|
|
2717
|
+
}
|
|
2396
2718
|
const index = parseIndex(
|
|
2397
2719
|
indexForParse,
|
|
2398
|
-
|
|
2399
|
-
maxPayloadBytes,
|
|
2720
|
+
payloadEnd,
|
|
2721
|
+
options.maxPayloadBytes ?? (encrypted ? DEFAULT_MAX_PAYLOAD_BYTES : DEFAULT_MAX_BITMAP_BYTES),
|
|
2400
2722
|
encrypted ? AEAD_NONCE_BYTES + AEAD_TAG_BYTES : 1
|
|
2401
2723
|
);
|
|
2402
2724
|
if (encrypted) {
|
|
@@ -2426,7 +2748,9 @@ var CrbmReader = class _CrbmReader {
|
|
|
2426
2748
|
index,
|
|
2427
2749
|
servedFromTail,
|
|
2428
2750
|
options.crypto,
|
|
2429
|
-
storedFooterCrc
|
|
2751
|
+
storedFooterCrc,
|
|
2752
|
+
metadata,
|
|
2753
|
+
metadata === void 0 ? 0 : RETAINED_BYTES_PER_METADATA_BYTE * metadataLength + RETAINED_BYTES_PER_METADATA_KEY * Object.keys(metadata).length
|
|
2430
2754
|
);
|
|
2431
2755
|
}
|
|
2432
2756
|
/** Chunk keys present in this generation, ascending. */
|
|
@@ -2486,6 +2810,67 @@ var CrbmReader = class _CrbmReader {
|
|
|
2486
2810
|
);
|
|
2487
2811
|
}
|
|
2488
2812
|
};
|
|
2813
|
+
function within(bytes, start, from, to) {
|
|
2814
|
+
if (from < start || to > start + bytes.length) {
|
|
2815
|
+
throw new IntegrityError(".crbm extension block read short");
|
|
2816
|
+
}
|
|
2817
|
+
return bytes.subarray(from - start, to - start);
|
|
2818
|
+
}
|
|
2819
|
+
function extensionCorrupt(message) {
|
|
2820
|
+
throw new IntegrityError(`.crbm extension block: ${message}`);
|
|
2821
|
+
}
|
|
2822
|
+
function extensionContents(sections, crypto) {
|
|
2823
|
+
const view = new DataView(sections.buffer, sections.byteOffset, sections.byteLength);
|
|
2824
|
+
let metadata;
|
|
2825
|
+
let metadataLength = 0;
|
|
2826
|
+
let lastType = 0;
|
|
2827
|
+
let pos = 0;
|
|
2828
|
+
while (pos < sections.length) {
|
|
2829
|
+
if (sections.length - pos < EXT_SECTION_HEADER_BYTES)
|
|
2830
|
+
extensionCorrupt("a section header is cut off");
|
|
2831
|
+
const type = sections[pos];
|
|
2832
|
+
const length = view.getUint32(pos + 1, true);
|
|
2833
|
+
pos += EXT_SECTION_HEADER_BYTES;
|
|
2834
|
+
if (type <= lastType) {
|
|
2835
|
+
extensionCorrupt(
|
|
2836
|
+
type === 0 ? "section type 0 is not a section type" : `section type ${type} after ${lastType}`
|
|
2837
|
+
);
|
|
2838
|
+
}
|
|
2839
|
+
if (length > sections.length - pos) extensionCorrupt(`section ${type} runs past the block`);
|
|
2840
|
+
const body = sections.subarray(pos, pos + length);
|
|
2841
|
+
pos += length;
|
|
2842
|
+
lastType = type;
|
|
2843
|
+
if (type === EXT_SECTION_METADATA)
|
|
2844
|
+
({ metadata, metadataLength } = metadataSection(body, crypto));
|
|
2845
|
+
}
|
|
2846
|
+
return { metadata, metadataLength };
|
|
2847
|
+
}
|
|
2848
|
+
function metadataSection(body, crypto) {
|
|
2849
|
+
if (crypto === void 0) {
|
|
2850
|
+
return { metadata: metadataFromBytes(body, extensionCorrupt), metadataLength: body.length };
|
|
2851
|
+
}
|
|
2852
|
+
const framing = AEAD_NONCE_BYTES + AEAD_TAG_BYTES;
|
|
2853
|
+
if (body.length <= framing || body.length > framing + MAX_METADATA_BYTES) {
|
|
2854
|
+
extensionCorrupt(`sealed metadata of ${body.length}B is not a nonce, up to 1 KiB and a tag`);
|
|
2855
|
+
}
|
|
2856
|
+
let plain;
|
|
2857
|
+
try {
|
|
2858
|
+
plain = crypto.aead.open(
|
|
2859
|
+
{
|
|
2860
|
+
nonce: body.subarray(0, AEAD_NONCE_BYTES),
|
|
2861
|
+
ciphertext: body.subarray(AEAD_NONCE_BYTES, body.length - AEAD_TAG_BYTES),
|
|
2862
|
+
tag: body.subarray(body.length - AEAD_TAG_BYTES)
|
|
2863
|
+
},
|
|
2864
|
+
crypto.aadFor("metadata")
|
|
2865
|
+
);
|
|
2866
|
+
} catch (err) {
|
|
2867
|
+
if (!isIntegrityError(err)) throw err;
|
|
2868
|
+
extensionCorrupt(
|
|
2869
|
+
"the sealed metadata does not open: tampered bytes, a block moved from another object, or a CrbmCrypto whose aadFor does not map the 'metadata' scope"
|
|
2870
|
+
);
|
|
2871
|
+
}
|
|
2872
|
+
return { metadata: metadataFromBytes(plain, extensionCorrupt), metadataLength: plain.length };
|
|
2873
|
+
}
|
|
2489
2874
|
function parseIndex(indexBytes, payloadEnd, maxPayloadBytes, minPayloadBytes = 1) {
|
|
2490
2875
|
const capacity = indexCapacity(indexBytes.length);
|
|
2491
2876
|
const keys = new Uint16Array(capacity);
|
|
@@ -2520,7 +2905,9 @@ function parseIndex(indexBytes, payloadEnd, maxPayloadBytes, minPayloadBytes = 1
|
|
|
2520
2905
|
}
|
|
2521
2906
|
if (chunkKey > 65535) throw new IntegrityError(`.crbm chunkKey ${chunkKey} out of range`);
|
|
2522
2907
|
if (len < minPayloadBytes || len > maxPayloadBytes) {
|
|
2523
|
-
throw new IntegrityError(
|
|
2908
|
+
throw new IntegrityError(
|
|
2909
|
+
`.crbm chunk ${chunkKey} length ${len} invalid (most is ${maxPayloadBytes})`
|
|
2910
|
+
);
|
|
2524
2911
|
}
|
|
2525
2912
|
if (card < 1 || card > MAX_CHUNK_CARDINALITY) {
|
|
2526
2913
|
throw new IntegrityError(`.crbm chunk ${chunkKey} cardinality ${card} invalid`);
|
|
@@ -2561,6 +2948,9 @@ var CrbmWriter = class {
|
|
|
2561
2948
|
`generation must be a non-negative integer; got ${options.generation}`
|
|
2562
2949
|
);
|
|
2563
2950
|
}
|
|
2951
|
+
this.metadata = metadataBytes(options.metadata, (message) => {
|
|
2952
|
+
throw new ValidationError(message);
|
|
2953
|
+
});
|
|
2564
2954
|
}
|
|
2565
2955
|
sink;
|
|
2566
2956
|
options;
|
|
@@ -2570,6 +2960,11 @@ var CrbmWriter = class {
|
|
|
2570
2960
|
totalCardinality = 0;
|
|
2571
2961
|
preambleWritten = false;
|
|
2572
2962
|
finished = false;
|
|
2963
|
+
/** The metadata's canonical JSON, copied at construction; `undefined` leaves the footer's extension flag clear. */
|
|
2964
|
+
metadata;
|
|
2965
|
+
/** Bytes written so far, the footer included once {@link finish} has run. */
|
|
2966
|
+
written = 0;
|
|
2967
|
+
footerCrc32c;
|
|
2573
2968
|
/**
|
|
2574
2969
|
* Append one chunk's payload (the roaring-serialized bytes) with its cardinality. `chunkKey` must be
|
|
2575
2970
|
* strictly greater than the previous one (ascending, no duplicates); `cardinality` is `[1, 65536]`.
|
|
@@ -2591,7 +2986,8 @@ var CrbmWriter = class {
|
|
|
2591
2986
|
}
|
|
2592
2987
|
if (payload.length === 0) throw new ValidationError("chunk payload must be non-empty");
|
|
2593
2988
|
await this.ensurePreamble();
|
|
2594
|
-
const
|
|
2989
|
+
const crypto = this.options.crypto;
|
|
2990
|
+
const stored = crypto ? framed(crypto.aead.seal(payload, crypto.aadFor(chunkKey))) : payload;
|
|
2595
2991
|
await this.sink.write(stored);
|
|
2596
2992
|
this.entries.push({
|
|
2597
2993
|
chunkKey,
|
|
@@ -2604,20 +3000,18 @@ var CrbmWriter = class {
|
|
|
2604
3000
|
this.totalCardinality += cardinality;
|
|
2605
3001
|
this.lastKey = chunkKey;
|
|
2606
3002
|
}
|
|
2607
|
-
/**
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
const out = new Uint8Array(sealed.nonce.length + sealed.ciphertext.length + sealed.tag.length);
|
|
2612
|
-
out.set(sealed.nonce, 0);
|
|
2613
|
-
out.set(sealed.ciphertext, sealed.nonce.length);
|
|
2614
|
-
out.set(sealed.tag, sealed.nonce.length + sealed.ciphertext.length);
|
|
2615
|
-
return out;
|
|
2616
|
-
}
|
|
2617
|
-
/** Write the index region + the fixed footer. After this the object is complete and immutable. */
|
|
3003
|
+
/**
|
|
3004
|
+
* Write the extension block (when there is metadata), the index region and the fixed footer. After this the object
|
|
3005
|
+
* is complete and immutable.
|
|
3006
|
+
*/
|
|
2618
3007
|
async finish() {
|
|
2619
3008
|
if (this.finished) throw new ValidationError("CrbmWriter already finished");
|
|
2620
3009
|
await this.ensurePreamble();
|
|
3010
|
+
if (this.metadata !== void 0) {
|
|
3011
|
+
const block = this.extensionBlock(this.metadata);
|
|
3012
|
+
await this.sink.write(block);
|
|
3013
|
+
this.offset += block.length;
|
|
3014
|
+
}
|
|
2621
3015
|
const indexPlain = this.encodeIndex();
|
|
2622
3016
|
let indexRegion = indexPlain;
|
|
2623
3017
|
let indexNonce;
|
|
@@ -2633,8 +3027,22 @@ var CrbmWriter = class {
|
|
|
2633
3027
|
this.offset += indexRegion.length;
|
|
2634
3028
|
const footer = this.buildFooter(indexOffset, indexRegion, indexNonce, indexTag);
|
|
2635
3029
|
await this.sink.write(footer);
|
|
3030
|
+
this.written = this.offset + footer.length;
|
|
3031
|
+
this.footerCrc32c = new DataView(footer.buffer, footer.byteOffset).getUint32(
|
|
3032
|
+
FOOTER.footerCrc32c,
|
|
3033
|
+
true
|
|
3034
|
+
);
|
|
2636
3035
|
this.finished = true;
|
|
2637
3036
|
}
|
|
3037
|
+
/**
|
|
3038
|
+
* The finished object's size and its footer's CRC: what names it, as a reader's fingerprint does. A writer that
|
|
3039
|
+
* has to tell its own object from another stored later under the same key compares these with the object's
|
|
3040
|
+
* footer. Throws before {@link finish}.
|
|
3041
|
+
*/
|
|
3042
|
+
get identity() {
|
|
3043
|
+
if (this.footerCrc32c === void 0) throw new ValidationError("CrbmWriter not finished");
|
|
3044
|
+
return { size: this.written, footerCrc: this.footerCrc32c };
|
|
3045
|
+
}
|
|
2638
3046
|
async ensurePreamble() {
|
|
2639
3047
|
if (this.preambleWritten) return;
|
|
2640
3048
|
const preamble = new Uint8Array(PREAMBLE_BYTES);
|
|
@@ -2644,6 +3052,25 @@ var CrbmWriter = class {
|
|
|
2644
3052
|
await this.sink.write(preamble);
|
|
2645
3053
|
this.preambleWritten = true;
|
|
2646
3054
|
}
|
|
3055
|
+
/**
|
|
3056
|
+
* The extension block: one metadata section (`u8 type ‖ u32 length ‖ bytes`), then the trailer
|
|
3057
|
+
* (`u32 sectionsLength ‖ u32 crc32c(sections ‖ sectionsLength) ‖ "CRBX"`). Encrypted, the section holds
|
|
3058
|
+
* `nonce ‖ ciphertext ‖ tag`, sealed with the metadata scope of the associated data.
|
|
3059
|
+
*/
|
|
3060
|
+
extensionBlock(metadata) {
|
|
3061
|
+
const crypto = this.options.crypto;
|
|
3062
|
+
const body = crypto ? framed(crypto.aead.seal(metadata, crypto.aadFor("metadata"))) : metadata;
|
|
3063
|
+
const sectionsLength = EXT_SECTION_HEADER_BYTES + body.length;
|
|
3064
|
+
const block = new Uint8Array(sectionsLength + EXT_TRAILER_BYTES);
|
|
3065
|
+
const view = new DataView(block.buffer);
|
|
3066
|
+
block[0] = EXT_SECTION_METADATA;
|
|
3067
|
+
view.setUint32(1, body.length, true);
|
|
3068
|
+
block.set(body, EXT_SECTION_HEADER_BYTES);
|
|
3069
|
+
view.setUint32(sectionsLength, sectionsLength, true);
|
|
3070
|
+
view.setUint32(sectionsLength + 4, crc32c(block.subarray(0, sectionsLength + 4)), true);
|
|
3071
|
+
block.set(EXT_MAGIC, sectionsLength + 8);
|
|
3072
|
+
return block;
|
|
3073
|
+
}
|
|
2647
3074
|
encodeIndex() {
|
|
2648
3075
|
const out = [];
|
|
2649
3076
|
let prevKey = 0;
|
|
@@ -2671,7 +3098,11 @@ var CrbmWriter = class {
|
|
|
2671
3098
|
view.setBigUint64(FOOTER.indexOffset, BigInt(indexOffset), true);
|
|
2672
3099
|
view.setBigUint64(FOOTER.indexLength, BigInt(indexRegion.length), true);
|
|
2673
3100
|
view.setUint32(FOOTER.indexCrc32c, crc32c(indexRegion), true);
|
|
2674
|
-
view.setUint32(
|
|
3101
|
+
view.setUint32(
|
|
3102
|
+
FOOTER.flags,
|
|
3103
|
+
FLAG_LITTLE_ENDIAN | (encrypted ? FLAG_ENCRYPTED : 0) | (this.metadata === void 0 ? 0 : FLAG_EXTENSION),
|
|
3104
|
+
true
|
|
3105
|
+
);
|
|
2675
3106
|
view.setUint16(
|
|
2676
3107
|
FOOTER.payloadCodecId,
|
|
2677
3108
|
this.options.payloadCodecId ?? PAYLOAD_CODEC_ROARING_PORTABLE,
|
|
@@ -2691,6 +3122,226 @@ var CrbmWriter = class {
|
|
|
2691
3122
|
return footer;
|
|
2692
3123
|
}
|
|
2693
3124
|
};
|
|
3125
|
+
function framed(sealed) {
|
|
3126
|
+
const out = new Uint8Array(sealed.nonce.length + sealed.ciphertext.length + sealed.tag.length);
|
|
3127
|
+
out.set(sealed.nonce, 0);
|
|
3128
|
+
out.set(sealed.ciphertext, sealed.nonce.length);
|
|
3129
|
+
out.set(sealed.tag, sealed.nonce.length + sealed.ciphertext.length);
|
|
3130
|
+
return out;
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
// src/core/load-input.ts
|
|
3134
|
+
var MAX_SERIALIZED_LOAD_BYTES = 4 + 8192 + 8 * 65536 + 65536 * 8192;
|
|
3135
|
+
var DecodedLoadInput = class {
|
|
3136
|
+
constructor(bitmap) {
|
|
3137
|
+
this.bitmap = bitmap;
|
|
3138
|
+
}
|
|
3139
|
+
bitmap;
|
|
3140
|
+
};
|
|
3141
|
+
function tagOf(value) {
|
|
3142
|
+
return Object.prototype.toString.call(value).slice(8, -1);
|
|
3143
|
+
}
|
|
3144
|
+
var VIEW = Object.getPrototypeOf(Uint8Array.prototype);
|
|
3145
|
+
var viewGetter = (name) => Object.getOwnPropertyDescriptor(VIEW, name)?.get;
|
|
3146
|
+
var bufferOf = viewGetter("buffer");
|
|
3147
|
+
var byteOffsetOf = viewGetter("byteOffset");
|
|
3148
|
+
var byteLengthOf = viewGetter("byteLength");
|
|
3149
|
+
function plainView(bytes, what) {
|
|
3150
|
+
let buffer;
|
|
3151
|
+
let offset;
|
|
3152
|
+
let length;
|
|
3153
|
+
try {
|
|
3154
|
+
buffer = bufferOf.call(bytes);
|
|
3155
|
+
offset = byteOffsetOf.call(bytes);
|
|
3156
|
+
length = byteLengthOf.call(bytes);
|
|
3157
|
+
} catch {
|
|
3158
|
+
throw new ValidationError(`${what} must be a Uint8Array of portable Roaring bytes`);
|
|
3159
|
+
}
|
|
3160
|
+
return length === 0 ? new Uint8Array(0) : new Uint8Array(buffer, offset, length);
|
|
3161
|
+
}
|
|
3162
|
+
var sharedByteLength = typeof SharedArrayBuffer === "function" ? Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, "byteLength")?.get : void 0;
|
|
3163
|
+
function isShared(buffer) {
|
|
3164
|
+
if (sharedByteLength === void 0) return false;
|
|
3165
|
+
try {
|
|
3166
|
+
sharedByteLength.call(buffer);
|
|
3167
|
+
return true;
|
|
3168
|
+
} catch {
|
|
3169
|
+
return false;
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
function overCap(what, length) {
|
|
3173
|
+
return new ValidationError(
|
|
3174
|
+
`${what} is ${length} bytes, more than any canonical 32-bit bitmap serializes to (${MAX_SERIALIZED_LOAD_BYTES}). Call runOptimize() on the bitmap before serializing it.`
|
|
3175
|
+
);
|
|
3176
|
+
}
|
|
3177
|
+
function prepareLoadInput(input, codec) {
|
|
3178
|
+
if (typeof input !== "object" || input === null) {
|
|
3179
|
+
throw new ValidationError(
|
|
3180
|
+
`a load takes ids, { serialized } or { bitmap }; got ${String(input)}`
|
|
3181
|
+
);
|
|
3182
|
+
}
|
|
3183
|
+
const tag = tagOf(input);
|
|
3184
|
+
if (tag === "Uint8Array" || tag === "Uint8ClampedArray") {
|
|
3185
|
+
throw new ValidationError(
|
|
3186
|
+
`a load's ids cannot be a ${tag} or Buffer: each byte would be loaded as an id. Pass portable Roaring bytes as { serialized }, and ids as a Uint32Array or an array of numbers.`
|
|
3187
|
+
);
|
|
3188
|
+
}
|
|
3189
|
+
if (Symbol.iterator in input || Symbol.asyncIterator in input) return input;
|
|
3190
|
+
const keys = Object.keys(input);
|
|
3191
|
+
if (keys.length === 1 && keys[0] === "serialized") {
|
|
3192
|
+
return decode(input.serialized, "{ serialized }", codec);
|
|
3193
|
+
}
|
|
3194
|
+
if (keys.length === 1 && keys[0] === "bitmap") {
|
|
3195
|
+
const bitmap = input.bitmap;
|
|
3196
|
+
if (typeof bitmap !== "object" || bitmap === null || typeof bitmap.serialize !== "function") {
|
|
3197
|
+
throw new ValidationError(`{ bitmap } needs an object with serialize('portable')`);
|
|
3198
|
+
}
|
|
3199
|
+
const what = "{ bitmap }'s serialize('portable')";
|
|
3200
|
+
if (typeof bitmap.getSerializationSizeInBytes === "function") {
|
|
3201
|
+
const size = bitmap.getSerializationSizeInBytes("portable");
|
|
3202
|
+
if (typeof size === "number" && size > MAX_SERIALIZED_LOAD_BYTES) throw overCap(what, size);
|
|
3203
|
+
}
|
|
3204
|
+
return decode(bitmap.serialize("portable"), what, codec);
|
|
3205
|
+
}
|
|
3206
|
+
throw new ValidationError(
|
|
3207
|
+
`a load takes ids (an iterable of integers), { serialized } or { bitmap }, as the only key; got an object with keys [${keys.join(", ")}]. Load options go in the next argument.`
|
|
3208
|
+
);
|
|
3209
|
+
}
|
|
3210
|
+
function decode(bytes, what, codec) {
|
|
3211
|
+
if (typeof bytes !== "object" || bytes === null || tagOf(bytes) !== "Uint8Array") {
|
|
3212
|
+
throw new ValidationError(`${what} must be a Uint8Array of portable Roaring bytes`);
|
|
3213
|
+
}
|
|
3214
|
+
const view = plainView(bytes, what);
|
|
3215
|
+
if (view.byteLength > MAX_SERIALIZED_LOAD_BYTES) throw overCap(what, view.byteLength);
|
|
3216
|
+
const own = isShared(view.buffer) ? new Uint8Array(view) : view;
|
|
3217
|
+
try {
|
|
3218
|
+
return new DecodedLoadInput(
|
|
3219
|
+
codec.safeDeserialize(own, MAX_SERIALIZED_LOAD_BYTES, { whole: true })
|
|
3220
|
+
);
|
|
3221
|
+
} catch (err) {
|
|
3222
|
+
if (!isIntegrityError(err)) throw err;
|
|
3223
|
+
throw new ValidationError(`${what} is not a valid bitmap: ${err.message}`);
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
// src/core/summary.ts
|
|
3228
|
+
var NONCE_BYTES = 12;
|
|
3229
|
+
var TAG_BYTES = 16;
|
|
3230
|
+
var COUNT_BYTES = 8;
|
|
3231
|
+
var MAX_CARDINALITY = 2 ** 32;
|
|
3232
|
+
var BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
3233
|
+
function toBase64(bytes) {
|
|
3234
|
+
let out = "";
|
|
3235
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
3236
|
+
const a = bytes[i];
|
|
3237
|
+
const b = bytes[i + 1];
|
|
3238
|
+
const c = bytes[i + 2];
|
|
3239
|
+
out += BASE64[a >> 2] + BASE64[(a & 3) << 4 | (b ?? 0) >> 4];
|
|
3240
|
+
out += b === void 0 ? "=" : BASE64[(b & 15) << 2 | (c ?? 0) >> 6];
|
|
3241
|
+
out += c === void 0 ? "=" : BASE64[c & 63];
|
|
3242
|
+
}
|
|
3243
|
+
return out;
|
|
3244
|
+
}
|
|
3245
|
+
function fromBase64(text) {
|
|
3246
|
+
if (text.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text)) return void 0;
|
|
3247
|
+
const pad = text.endsWith("==") ? 2 : text.endsWith("=") ? 1 : 0;
|
|
3248
|
+
const out = new Uint8Array(text.length / 4 * 3 - pad);
|
|
3249
|
+
let o = 0;
|
|
3250
|
+
for (let i = 0; i < text.length; i += 4) {
|
|
3251
|
+
const n = BASE64.indexOf(text[i]) << 18 | BASE64.indexOf(text[i + 1]) << 12 | Math.max(BASE64.indexOf(text[i + 2]), 0) << 6 | Math.max(BASE64.indexOf(text[i + 3]), 0);
|
|
3252
|
+
if (o < out.length) out[o++] = n >> 16 & 255;
|
|
3253
|
+
if (o < out.length) out[o++] = n >> 8 & 255;
|
|
3254
|
+
if (o < out.length) out[o++] = n & 255;
|
|
3255
|
+
}
|
|
3256
|
+
return out;
|
|
3257
|
+
}
|
|
3258
|
+
function clearSummary(generation, cardinality, metadata) {
|
|
3259
|
+
return metadata === void 0 || Object.keys(metadata).length === 0 ? { generation, cardinality } : { generation, cardinality, metadata };
|
|
3260
|
+
}
|
|
3261
|
+
function sealSummary(aead, ref, generation, cardinality, metadata) {
|
|
3262
|
+
const meta = metadataBytes(metadata, (message) => {
|
|
3263
|
+
throw new IntegrityError(`summary metadata: ${message}`);
|
|
3264
|
+
});
|
|
3265
|
+
const plain = new Uint8Array(COUNT_BYTES + (meta?.length ?? 0));
|
|
3266
|
+
new DataView(plain.buffer).setBigUint64(0, BigInt(cardinality), true);
|
|
3267
|
+
if (meta !== void 0) plain.set(meta, COUNT_BYTES);
|
|
3268
|
+
const { nonce, ciphertext, tag } = aead.seal(plain, aadFor(ref, generation, "summary"));
|
|
3269
|
+
const framed2 = new Uint8Array(nonce.length + ciphertext.length + tag.length);
|
|
3270
|
+
framed2.set(nonce, 0);
|
|
3271
|
+
framed2.set(ciphertext, nonce.length);
|
|
3272
|
+
framed2.set(tag, nonce.length + ciphertext.length);
|
|
3273
|
+
return { generation, sealed: toBase64(framed2) };
|
|
3274
|
+
}
|
|
3275
|
+
function openSummary(aead, ref, summary) {
|
|
3276
|
+
const fail = (message) => {
|
|
3277
|
+
throw new IntegrityError(`registry record summary: ${message}`);
|
|
3278
|
+
};
|
|
3279
|
+
const framed2 = fromBase64(summary.sealed);
|
|
3280
|
+
const framing = NONCE_BYTES + TAG_BYTES;
|
|
3281
|
+
if (framed2 === void 0 || framed2.length < framing + COUNT_BYTES || framed2.length > framing + COUNT_BYTES + MAX_METADATA_BYTES) {
|
|
3282
|
+
fail("the sealed summary is not a nonce, a count, up to 1 KiB of metadata and a tag");
|
|
3283
|
+
}
|
|
3284
|
+
const bytes = framed2;
|
|
3285
|
+
let plain;
|
|
3286
|
+
try {
|
|
3287
|
+
plain = aead.open(
|
|
3288
|
+
{
|
|
3289
|
+
nonce: bytes.subarray(0, NONCE_BYTES),
|
|
3290
|
+
ciphertext: bytes.subarray(NONCE_BYTES, bytes.length - TAG_BYTES),
|
|
3291
|
+
tag: bytes.subarray(bytes.length - TAG_BYTES)
|
|
3292
|
+
},
|
|
3293
|
+
aadFor(ref, summary.generation, "summary")
|
|
3294
|
+
);
|
|
3295
|
+
} catch (err) {
|
|
3296
|
+
if (!isIntegrityError(err)) throw err;
|
|
3297
|
+
return fail(
|
|
3298
|
+
"the sealed summary does not open: tampered bytes, or a summary moved from another generation"
|
|
3299
|
+
);
|
|
3300
|
+
}
|
|
3301
|
+
if (plain.length < COUNT_BYTES) fail("the sealed summary is shorter than its count");
|
|
3302
|
+
const count = new DataView(plain.buffer, plain.byteOffset, plain.byteLength).getBigUint64(
|
|
3303
|
+
0,
|
|
3304
|
+
true
|
|
3305
|
+
);
|
|
3306
|
+
if (count > BigInt(MAX_CARDINALITY)) fail(`cardinality ${count} is outside 0 to 2^32`);
|
|
3307
|
+
const metadata = plain.length === COUNT_BYTES ? void 0 : metadataFromBytes(plain.subarray(COUNT_BYTES), fail);
|
|
3308
|
+
return { cardinality: Number(count), metadata };
|
|
3309
|
+
}
|
|
3310
|
+
function usableSummary(ref, row, aead) {
|
|
3311
|
+
const summary = row.summary;
|
|
3312
|
+
if (summary === void 0 || row.status !== "active" || summary.generation !== row.currentGen) {
|
|
3313
|
+
return void 0;
|
|
3314
|
+
}
|
|
3315
|
+
const keyed = row.wrappedDeks !== void 0 && row.wrappedDeks.length > 0;
|
|
3316
|
+
if ("sealed" in summary) {
|
|
3317
|
+
if (!keyed || aead === void 0) return void 0;
|
|
3318
|
+
try {
|
|
3319
|
+
return openSummary(aead, ref, summary);
|
|
3320
|
+
} catch (err) {
|
|
3321
|
+
if (isIntegrityError(err)) return void 0;
|
|
3322
|
+
throw err;
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
if (keyed) return void 0;
|
|
3326
|
+
return { cardinality: summary.cardinality, metadata: summary.metadata };
|
|
3327
|
+
}
|
|
3328
|
+
function canonicalOrNone(metadata) {
|
|
3329
|
+
if (metadata === void 0) return void 0;
|
|
3330
|
+
const json = canonicalMetadataJson(metadata, (message) => {
|
|
3331
|
+
throw new IntegrityError(`metadata: ${message}`);
|
|
3332
|
+
});
|
|
3333
|
+
return json === "{}" ? void 0 : json;
|
|
3334
|
+
}
|
|
3335
|
+
function summaryAgrees(summary, object) {
|
|
3336
|
+
return summary.cardinality === object.cardinality && canonicalOrNone(summary.metadata) === canonicalOrNone(object.metadata);
|
|
3337
|
+
}
|
|
3338
|
+
function metadataToCarry(object, authenticated) {
|
|
3339
|
+
const empty = (metadata) => metadata === void 0 || Object.keys(metadata).length === 0;
|
|
3340
|
+
return empty(object) && authenticated !== void 0 && !empty(authenticated.metadata) ? authenticated.metadata : object;
|
|
3341
|
+
}
|
|
3342
|
+
function summaryOf(ref, generation, description, aead) {
|
|
3343
|
+
return aead === void 0 ? clearSummary(generation, description.cardinality, description.metadata) : sealSummary(aead, ref, generation, description.cardinality, description.metadata);
|
|
3344
|
+
}
|
|
2694
3345
|
|
|
2695
3346
|
// src/core/crbm-storage-source.ts
|
|
2696
3347
|
function storageBlobReader(driver, key) {
|
|
@@ -2709,6 +3360,88 @@ var notThePinned = (ref, generation) => new NotFoundError(
|
|
|
2709
3360
|
function versionOf(generation, lineage) {
|
|
2710
3361
|
return lineage === void 0 ? String(generation) : `${generation}:${String(lineage)}`;
|
|
2711
3362
|
}
|
|
3363
|
+
function summaryOfGeneration(generation, description) {
|
|
3364
|
+
const { cardinality, metadata } = description;
|
|
3365
|
+
return metadata === void 0 || Object.keys(metadata).length === 0 ? { generation, cardinality } : { generation, cardinality, metadata: Object.freeze({ ...metadata }) };
|
|
3366
|
+
}
|
|
3367
|
+
var sameKeys = (a, b) => a !== void 0 && JSON.stringify(a) === JSON.stringify(b);
|
|
3368
|
+
function once(fn) {
|
|
3369
|
+
let pending;
|
|
3370
|
+
return () => {
|
|
3371
|
+
if (pending === void 0) {
|
|
3372
|
+
const mine = fn();
|
|
3373
|
+
pending = mine;
|
|
3374
|
+
mine.catch(() => {
|
|
3375
|
+
if (pending === mine) pending = void 0;
|
|
3376
|
+
});
|
|
3377
|
+
}
|
|
3378
|
+
return pending;
|
|
3379
|
+
};
|
|
3380
|
+
}
|
|
3381
|
+
var readerIsOf = (reader, live) => reader.generation === live.target.generation && reader.lineage === live.target.lineage;
|
|
3382
|
+
function describe(reader) {
|
|
3383
|
+
let cardinality = 0;
|
|
3384
|
+
for (const n of reader.cardinalities().values()) cardinality += n;
|
|
3385
|
+
return { cardinality, metadata: reader.metadata };
|
|
3386
|
+
}
|
|
3387
|
+
var Snapshot = class _Snapshot {
|
|
3388
|
+
constructor(target, open4, prior) {
|
|
3389
|
+
this.target = target;
|
|
3390
|
+
this.open = open4;
|
|
3391
|
+
this.prior = prior;
|
|
3392
|
+
}
|
|
3393
|
+
target;
|
|
3394
|
+
open;
|
|
3395
|
+
prior;
|
|
3396
|
+
/** When the snapshot's refresh clock started. A refresh that failed transiently moves it back, so it lapses sooner. */
|
|
3397
|
+
installedAtMs = 0;
|
|
3398
|
+
/** Set by the cache, to hear of a reader as it is opened. */
|
|
3399
|
+
onReader;
|
|
3400
|
+
opened;
|
|
3401
|
+
static live(target, open4, prior) {
|
|
3402
|
+
return new _Snapshot(target, open4, prior);
|
|
3403
|
+
}
|
|
3404
|
+
static eager(reader) {
|
|
3405
|
+
const snap = new _Snapshot(void 0, void 0, void 0);
|
|
3406
|
+
snap.opened = reader;
|
|
3407
|
+
return snap;
|
|
3408
|
+
}
|
|
3409
|
+
/** The reader, opened by the first call. */
|
|
3410
|
+
get reader() {
|
|
3411
|
+
if (this.opened === void 0) {
|
|
3412
|
+
const target = this.target;
|
|
3413
|
+
this.opened = target.then(async (live) => {
|
|
3414
|
+
if (live === null) return null;
|
|
3415
|
+
const kept = await this.reuse(live);
|
|
3416
|
+
this.prior = void 0;
|
|
3417
|
+
return kept ?? this.open(live);
|
|
3418
|
+
});
|
|
3419
|
+
this.onReader?.(this.opened);
|
|
3420
|
+
}
|
|
3421
|
+
return this.opened;
|
|
3422
|
+
}
|
|
3423
|
+
/** The reader if one has been asked for, without asking for one. */
|
|
3424
|
+
get openedReader() {
|
|
3425
|
+
return this.opened;
|
|
3426
|
+
}
|
|
3427
|
+
/** The prior snapshot's reader, if it is of what this snapshot resolved. */
|
|
3428
|
+
async reuse(live) {
|
|
3429
|
+
const prior = this.prior;
|
|
3430
|
+
const reader = prior === void 0 ? null : await prior.catch(() => null);
|
|
3431
|
+
return reader !== null && readerIsOf(reader, live) ? reader : null;
|
|
3432
|
+
}
|
|
3433
|
+
/** Keep the prior snapshot's reader when the refresh found its generation, so a count does not drop it. */
|
|
3434
|
+
async adopt(live) {
|
|
3435
|
+
if (this.opened !== void 0 || this.prior === void 0) return;
|
|
3436
|
+
const reader = await this.reuse(live);
|
|
3437
|
+
this.prior = void 0;
|
|
3438
|
+
if (reader !== null && this.opened === void 0) {
|
|
3439
|
+
this.opened = Promise.resolve(reader);
|
|
3440
|
+
this.onReader?.(this.opened);
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
var SNAPSHOT_BASE_BYTES = 256;
|
|
2712
3445
|
var BULK_FLUSH_IDS = 1 << 20;
|
|
2713
3446
|
var YIELD_EVERY_IDS = 1 << 14;
|
|
2714
3447
|
var CrbmStorageChunkSource = class {
|
|
@@ -2746,6 +3479,10 @@ var CrbmStorageChunkSource = class {
|
|
|
2746
3479
|
maxBytes: maxOpenIndexBytes ?? DEFAULT_MAX_OPEN_INDEX_BYTES,
|
|
2747
3480
|
clock: clock ?? { now: () => 0 }
|
|
2748
3481
|
});
|
|
3482
|
+
this.distrusted = new BoundedLru({
|
|
3483
|
+
maxEntries: maxOpenSegments ?? DEFAULT_MAX_OPEN_SEGMENTS,
|
|
3484
|
+
clock: { now: () => 0 }
|
|
3485
|
+
});
|
|
2749
3486
|
this.replacedPins = new BoundedLru({
|
|
2750
3487
|
maxEntries: maxOpenSegments ?? DEFAULT_MAX_OPEN_SEGMENTS,
|
|
2751
3488
|
clock: { now: () => 0 }
|
|
@@ -2787,23 +3524,46 @@ var CrbmStorageChunkSource = class {
|
|
|
2787
3524
|
* check again.
|
|
2788
3525
|
*/
|
|
2789
3526
|
invalidations = 0;
|
|
3527
|
+
/**
|
|
3528
|
+
* Generations whose row summary this process found to disagree with their object, by segment and generation. A
|
|
3529
|
+
* summary here is not used again: a count of that generation reads the object. Bounded as the readers are, so a
|
|
3530
|
+
* long-lived process cannot grow it without limit; one that falls out is trusted again until its next open finds
|
|
3531
|
+
* the disagreement again.
|
|
3532
|
+
*/
|
|
3533
|
+
distrusted;
|
|
2790
3534
|
registry;
|
|
2791
3535
|
keystore;
|
|
2792
3536
|
requireEncryption;
|
|
2793
3537
|
readerOptions;
|
|
2794
3538
|
clock;
|
|
2795
3539
|
currentGenTtlMs;
|
|
2796
|
-
/**
|
|
2797
|
-
|
|
3540
|
+
/**
|
|
3541
|
+
* The segment's live snapshot, refreshing on the TTL. Cheap within the TTL window. The snapshot holds the resolved
|
|
3542
|
+
* target, and its reader once a read has asked for one.
|
|
3543
|
+
*/
|
|
3544
|
+
liveSnapshot(ref) {
|
|
2798
3545
|
const key = segmentKey(ref);
|
|
2799
3546
|
const existing = this.snapshots.get(key);
|
|
2800
|
-
if (existing === void 0)
|
|
2801
|
-
|
|
3547
|
+
if (existing === void 0) {
|
|
3548
|
+
return this.install(
|
|
3549
|
+
key,
|
|
3550
|
+
Snapshot.live(this.resolveLive(ref), (live) => this.openLive(ref, live))
|
|
3551
|
+
);
|
|
3552
|
+
}
|
|
3553
|
+
if (!this.expired(existing.installedAtMs)) return existing;
|
|
2802
3554
|
const snap = this.install(
|
|
2803
3555
|
key,
|
|
2804
|
-
|
|
3556
|
+
Snapshot.live(
|
|
3557
|
+
this.refreshedTarget(ref, existing, () => this.retrySoon(snap)),
|
|
3558
|
+
(live) => this.openLive(ref, live),
|
|
3559
|
+
existing.openedReader
|
|
3560
|
+
)
|
|
2805
3561
|
);
|
|
2806
|
-
return snap
|
|
3562
|
+
return snap;
|
|
3563
|
+
}
|
|
3564
|
+
/** The current resolved reader for a segment, refreshing on the TTL. */
|
|
3565
|
+
resolvedReader(ref) {
|
|
3566
|
+
return this.liveSnapshot(ref).reader;
|
|
2807
3567
|
}
|
|
2808
3568
|
/**
|
|
2809
3569
|
* A refresh failed transiently and the prior reader keeps serving: make this snapshot lapse after
|
|
@@ -2813,45 +3573,56 @@ var CrbmStorageChunkSource = class {
|
|
|
2813
3573
|
retrySoon(snap) {
|
|
2814
3574
|
snap.installedAtMs = this.now() - (this.currentGenTtlMs - Math.min(this.currentGenTtlMs, REFRESH_RETRY_MS));
|
|
2815
3575
|
}
|
|
2816
|
-
/**
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
3576
|
+
/**
|
|
3577
|
+
* TTL elapsed: cheaply re-resolve the pointer. The row read is always fresh, so what the new snapshot says of the
|
|
3578
|
+
* generation (its summary included) is what the row says now, never the prior snapshot's. The reader is reopened
|
|
3579
|
+
* only if the generation or the row changed, and only when a read asks for it.
|
|
3580
|
+
*/
|
|
3581
|
+
async refreshedTarget(ref, existing, onTransientFault) {
|
|
3582
|
+
const prior = await existing.target.catch(() => null);
|
|
2820
3583
|
try {
|
|
2821
|
-
|
|
3584
|
+
return await this.resolveLive(ref, prior);
|
|
2822
3585
|
} catch (err) {
|
|
2823
3586
|
if (!isTransientError(err)) throw err;
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
if (target === void 0) {
|
|
2827
|
-
if (current === null) return this.resolveLatest(ref);
|
|
3587
|
+
const alive = prior !== null;
|
|
3588
|
+
if (!alive) return this.resolveLive(ref);
|
|
2828
3589
|
onTransientFault();
|
|
2829
|
-
return
|
|
2830
|
-
}
|
|
2831
|
-
if (target === null) return null;
|
|
2832
|
-
if (current !== null && target.generation === current.generation && target.lineage === current.lineage) {
|
|
2833
|
-
return current;
|
|
3590
|
+
return prior;
|
|
2834
3591
|
}
|
|
2835
|
-
return this.openForTarget(ref, target);
|
|
2836
3592
|
}
|
|
2837
3593
|
/**
|
|
2838
|
-
* Memoize a
|
|
2839
|
-
*
|
|
2840
|
-
*
|
|
3594
|
+
* Memoize a snapshot as the segment's at `now()`, forgetting it later if it resolves to null / throws
|
|
3595
|
+
* (identity-guarded, so a stale cleanup never clobbers a fresher snapshot). Installing the (pending) snapshot
|
|
3596
|
+
* **synchronously** is what lets concurrent callers coalesce onto one in-flight resolve.
|
|
2841
3597
|
*/
|
|
2842
|
-
install(key,
|
|
2843
|
-
|
|
3598
|
+
install(key, snap) {
|
|
3599
|
+
snap.installedAtMs = this.now();
|
|
2844
3600
|
this.snapshots.set(key, snap);
|
|
2845
3601
|
const forgetIfStale = () => {
|
|
2846
3602
|
if (this.snapshots.get(key) === snap) this.snapshots.delete(key);
|
|
2847
3603
|
};
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
3604
|
+
snap.onReader = (reader) => {
|
|
3605
|
+
reader.then((r) => {
|
|
3606
|
+
if (r === null) {
|
|
3607
|
+
forgetIfStale();
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
if (this.snapshots.peek(key) === snap) this.snapshots.setWeight(key, r.retainedBytes);
|
|
3611
|
+
}, forgetIfStale);
|
|
3612
|
+
};
|
|
3613
|
+
if (snap.target !== void 0) {
|
|
3614
|
+
snap.target.then(async (live) => {
|
|
3615
|
+
if (live === null) {
|
|
3616
|
+
forgetIfStale();
|
|
3617
|
+
return;
|
|
3618
|
+
}
|
|
3619
|
+
if (snap.openedReader === void 0 && this.snapshots.peek(key) === snap) {
|
|
3620
|
+
this.snapshots.setWeight(key, live.bytes);
|
|
3621
|
+
}
|
|
3622
|
+
await snap.adopt(live);
|
|
3623
|
+
}, forgetIfStale);
|
|
3624
|
+
}
|
|
3625
|
+
if (snap.openedReader !== void 0) snap.onReader(snap.openedReader);
|
|
2855
3626
|
return snap;
|
|
2856
3627
|
}
|
|
2857
3628
|
now() {
|
|
@@ -2883,12 +3654,12 @@ var CrbmStorageChunkSource = class {
|
|
|
2883
3654
|
* concurrent sweep. One retry, then propagate — same contract, same eviction rule.
|
|
2884
3655
|
*/
|
|
2885
3656
|
async currentGeneration(ref) {
|
|
2886
|
-
const
|
|
3657
|
+
const snap = this.liveSnapshot(ref);
|
|
2887
3658
|
try {
|
|
2888
|
-
return (await
|
|
3659
|
+
return (await snap.reader)?.generation ?? null;
|
|
2889
3660
|
} catch (err) {
|
|
2890
3661
|
if (!isNotFoundError(err)) throw err;
|
|
2891
|
-
this.dropStale(segmentKey(ref),
|
|
3662
|
+
this.dropStale(segmentKey(ref), snap);
|
|
2892
3663
|
return (await this.resolvedReader(ref))?.generation ?? null;
|
|
2893
3664
|
}
|
|
2894
3665
|
}
|
|
@@ -2908,6 +3679,7 @@ var CrbmStorageChunkSource = class {
|
|
|
2908
3679
|
const key = segmentKey(ref);
|
|
2909
3680
|
this.snapshots.delete(key);
|
|
2910
3681
|
const pinnedPrefix = `${key}@`;
|
|
3682
|
+
this.distrusted.deleteWhere((k) => k.startsWith(pinnedPrefix));
|
|
2911
3683
|
this.snapshots.deleteWhere((k) => k.startsWith(pinnedPrefix));
|
|
2912
3684
|
this.replacedPins.deleteWhere((k) => k.startsWith(pinnedPrefix));
|
|
2913
3685
|
for (const k of this.checking.keys()) if (k.startsWith(pinnedPrefix)) this.checking.delete(k);
|
|
@@ -2928,13 +3700,13 @@ var CrbmStorageChunkSource = class {
|
|
|
2928
3700
|
* almost always served from the cached snapshot with no backend call at all. One retry, then propagate.
|
|
2929
3701
|
*/
|
|
2930
3702
|
async currentVersion(ref) {
|
|
2931
|
-
const
|
|
3703
|
+
const snap = this.liveSnapshot(ref);
|
|
2932
3704
|
let reader;
|
|
2933
3705
|
try {
|
|
2934
|
-
reader = await
|
|
3706
|
+
reader = await snap.reader;
|
|
2935
3707
|
} catch (err) {
|
|
2936
3708
|
if (!isNotFoundError(err)) throw err;
|
|
2937
|
-
this.dropStale(segmentKey(ref),
|
|
3709
|
+
this.dropStale(segmentKey(ref), snap);
|
|
2938
3710
|
reader = await this.resolvedReader(ref);
|
|
2939
3711
|
}
|
|
2940
3712
|
return reader === null ? null : versionOf(reader.generation, reader.lineage);
|
|
@@ -2957,34 +3729,38 @@ var CrbmStorageChunkSource = class {
|
|
|
2957
3729
|
}
|
|
2958
3730
|
}
|
|
2959
3731
|
async pinOnce(ref) {
|
|
2960
|
-
const
|
|
2961
|
-
if (
|
|
3732
|
+
const live = await this.resolveLive(ref);
|
|
3733
|
+
if (live === null) return null;
|
|
3734
|
+
const target = live.target;
|
|
2962
3735
|
const version = versionOf(target.generation, target.lineage);
|
|
2963
3736
|
let reader;
|
|
2964
3737
|
let opened = true;
|
|
2965
3738
|
if (target.lineage === void 0) {
|
|
2966
|
-
reader = await this.
|
|
2967
|
-
this.install(
|
|
3739
|
+
reader = await this.openLive(ref, live);
|
|
3740
|
+
this.install(
|
|
3741
|
+
this.pinnedKey(ref, version, reader.fingerprint),
|
|
3742
|
+
Snapshot.eager(Promise.resolve(reader))
|
|
3743
|
+
);
|
|
2968
3744
|
} else {
|
|
2969
3745
|
const epoch = this.invalidations;
|
|
2970
3746
|
const key = this.pinnedKey(ref, version);
|
|
2971
3747
|
let entry = this.snapshots.get(key);
|
|
2972
3748
|
opened = entry === void 0;
|
|
2973
|
-
entry ??= this.install(key, this.
|
|
3749
|
+
entry ??= this.install(key, Snapshot.eager(this.openLive(ref, live)));
|
|
2974
3750
|
reader = await entry.reader;
|
|
2975
3751
|
if (reader === null) return null;
|
|
2976
3752
|
const replaced = (r) => this.replacedPins.get(this.heldKey(ref, version, r.fingerprint)) !== void 0;
|
|
2977
3753
|
const known = !opened && replaced(reader);
|
|
2978
3754
|
if (!known && this.invalidations === epoch && this.snapshots.peek(key) === void 0) {
|
|
2979
|
-
this.install(key, Promise.resolve(reader));
|
|
3755
|
+
this.install(key, Snapshot.eager(Promise.resolve(reader)));
|
|
2980
3756
|
}
|
|
2981
3757
|
if (known) {
|
|
2982
3758
|
const current = this.snapshots.peek(key);
|
|
2983
|
-
let fresh = current !== void 0 && current !== entry ? current : this.install(key, this.
|
|
3759
|
+
let fresh = current !== void 0 && current !== entry ? current : this.install(key, Snapshot.eager(this.openLive(ref, live)));
|
|
2984
3760
|
reader = await fresh.reader;
|
|
2985
3761
|
if (reader === null) return null;
|
|
2986
3762
|
if (fresh === current && replaced(reader)) {
|
|
2987
|
-
fresh = this.install(key, this.
|
|
3763
|
+
fresh = this.install(key, Snapshot.eager(this.openLive(ref, live)));
|
|
2988
3764
|
reader = await fresh.reader;
|
|
2989
3765
|
if (reader === null) return null;
|
|
2990
3766
|
}
|
|
@@ -3050,7 +3826,7 @@ var CrbmStorageChunkSource = class {
|
|
|
3050
3826
|
if (entry === void 0) {
|
|
3051
3827
|
if (fingerprint !== void 0 && this.replacedPins.get(this.heldKey(ref, at, fingerprint)) !== void 0)
|
|
3052
3828
|
throw notThePinned(ref, generation);
|
|
3053
|
-
entry = this.install(key, this.openAt(ref, generation));
|
|
3829
|
+
entry = this.install(key, Snapshot.eager(this.openAt(ref, generation)));
|
|
3054
3830
|
}
|
|
3055
3831
|
let reader;
|
|
3056
3832
|
try {
|
|
@@ -3185,58 +3961,94 @@ var CrbmStorageChunkSource = class {
|
|
|
3185
3961
|
return false;
|
|
3186
3962
|
}
|
|
3187
3963
|
/**
|
|
3188
|
-
* Evict a snapshot we just failed to read from — matched by
|
|
3964
|
+
* Evict a snapshot we just failed to read from — matched by the snapshot itself, so a concurrent call's
|
|
3189
3965
|
* fresher snapshot is never clobbered. The identity guard is the whole point: `install` replaces the entry
|
|
3190
3966
|
* wholesale, so comparing anything else would drop a good snapshot on the floor.
|
|
3191
3967
|
*/
|
|
3192
|
-
dropStale(key,
|
|
3193
|
-
|
|
3194
|
-
if (cur !== void 0 && cur.reader === pending) this.snapshots.delete(key);
|
|
3195
|
-
}
|
|
3196
|
-
async resolveLatest(ref) {
|
|
3197
|
-
const target = await this.resolveTarget(ref);
|
|
3198
|
-
if (target === null) return null;
|
|
3199
|
-
return this.openForTarget(ref, target);
|
|
3968
|
+
dropStale(key, snap) {
|
|
3969
|
+
if (this.snapshots.get(key) === snap) this.snapshots.delete(key);
|
|
3200
3970
|
}
|
|
3201
3971
|
/** Open a {@link CrbmReader} for an already-resolved generation target (decrypting if the segment is encrypted). */
|
|
3202
3972
|
async openForTarget(ref, target) {
|
|
3973
|
+
const crypto = await this.cryptoForRead(ref, target.generation, target.wrappedDeks);
|
|
3974
|
+
return this.openGeneration(ref, target, crypto);
|
|
3975
|
+
}
|
|
3976
|
+
openGeneration(ref, target, crypto) {
|
|
3203
3977
|
const genKey = {
|
|
3204
3978
|
namespace: ref.namespace,
|
|
3205
3979
|
segment: ref.segment,
|
|
3206
3980
|
generation: target.generation
|
|
3207
3981
|
};
|
|
3208
|
-
const crypto = await this.cryptoForRead(ref, target.generation, target.wrappedDeks);
|
|
3209
3982
|
return openChecked(this.driver, genKey, {
|
|
3210
3983
|
...this.readerOptions,
|
|
3211
3984
|
crypto,
|
|
3212
3985
|
lineage: target.lineage
|
|
3213
3986
|
});
|
|
3214
3987
|
}
|
|
3988
|
+
/**
|
|
3989
|
+
* Open the generation a resolution found, and hold the row's summary against the object while it is open anyway:
|
|
3990
|
+
* the same count and metadata, or the summary is not believed again in this process ({@link distrusted}). This
|
|
3991
|
+
* costs no request; a mismatch is not an error, since the object is the truth and this read has it.
|
|
3992
|
+
*/
|
|
3993
|
+
async openLive(ref, live) {
|
|
3994
|
+
const reader = await this.openGeneration(ref, live.target, await live.crypto());
|
|
3995
|
+
const summary = await live.summary();
|
|
3996
|
+
if (summary !== void 0 && !summaryAgrees(summary, describe(reader))) {
|
|
3997
|
+
this.distrusted.set(this.distrustKey(ref, live.target.generation, live.target.lineage), true);
|
|
3998
|
+
}
|
|
3999
|
+
return reader;
|
|
4000
|
+
}
|
|
4001
|
+
distrustKey(ref, generation, lineage) {
|
|
4002
|
+
return `${segmentKey(ref)}@${generation}:${String(lineage)}`;
|
|
4003
|
+
}
|
|
4004
|
+
/** What the row says of the generation, unless it is a summary this process has found to disagree with its object. */
|
|
4005
|
+
async trustedSummary(ref, live) {
|
|
4006
|
+
if (this.distrusted.peek(this.distrustKey(ref, live.target.generation, live.target.lineage)) !== void 0) {
|
|
4007
|
+
return void 0;
|
|
4008
|
+
}
|
|
4009
|
+
return live.summary();
|
|
4010
|
+
}
|
|
3215
4011
|
/**
|
|
3216
4012
|
* The current generation + its DEK wrappings: the registry's authoritative record (one strong read), or a
|
|
3217
4013
|
* `list` scan for the max generation when there's no registry (a registry-less source can only read cleartext
|
|
3218
|
-
* — there's nowhere a wrapped DEK could live).
|
|
4014
|
+
* — there's nowhere a wrapped DEK could live). The row's summary rides along, to be used or not by whoever
|
|
4015
|
+
* asks.
|
|
3219
4016
|
*/
|
|
3220
|
-
async
|
|
4017
|
+
async resolveLive(ref, prior) {
|
|
3221
4018
|
if (this.registry !== void 0) {
|
|
3222
4019
|
const record = await this.registry.get(ref);
|
|
3223
4020
|
if (record === null) return null;
|
|
3224
4021
|
if (record.status === "destroyed") return null;
|
|
3225
4022
|
if (record.currentGen === null) return null;
|
|
4023
|
+
const generation = record.currentGen;
|
|
4024
|
+
const keys = record.wrappedDeks;
|
|
4025
|
+
const unwrap = keys === void 0 || keys.length === 0 || this.keystore === void 0 ? void 0 : prior?.unwrap !== void 0 && sameKeys(prior.target.wrappedDeks, keys) ? prior.unwrap : once(() => this.keystore.openDek(keys));
|
|
4026
|
+
const crypto = once(() => this.cryptoForRead(ref, generation, keys, unwrap));
|
|
3226
4027
|
return {
|
|
3227
|
-
generation: record.
|
|
3228
|
-
|
|
3229
|
-
|
|
4028
|
+
target: { generation, lineage: record.token, wrappedDeks: record.wrappedDeks },
|
|
4029
|
+
crypto,
|
|
4030
|
+
unwrap,
|
|
4031
|
+
// The same checks as a read of the object makes (`requireEncryption`, a keystore for an encrypted row)
|
|
4032
|
+
// come first, then the row's summary, used only if it names this generation in the shape the keys call for.
|
|
4033
|
+
summary: once(async () => usableSummary(ref, record, (await crypto())?.aead)),
|
|
4034
|
+
bytes: SNAPSHOT_BASE_BYTES + (record.summary === void 0 ? 0 : JSON.stringify(record.summary).length)
|
|
3230
4035
|
};
|
|
3231
4036
|
}
|
|
3232
4037
|
let maxGen = -1;
|
|
3233
4038
|
for await (const key of this.driver.list(ref)) {
|
|
3234
4039
|
if (key.generation > maxGen) maxGen = key.generation;
|
|
3235
4040
|
}
|
|
3236
|
-
|
|
4041
|
+
if (maxGen < 0) return null;
|
|
4042
|
+
return {
|
|
4043
|
+
target: { generation: maxGen },
|
|
4044
|
+
crypto: () => Promise.resolve(void 0),
|
|
4045
|
+
unwrap: void 0,
|
|
4046
|
+
summary: () => Promise.resolve(void 0),
|
|
4047
|
+
bytes: SNAPSHOT_BASE_BYTES
|
|
4048
|
+
};
|
|
3237
4049
|
}
|
|
3238
4050
|
/** Build the per-generation decryption context for an encrypted segment (undefined for cleartext). */
|
|
3239
|
-
async cryptoForRead(ref, generation, wrappedDeks) {
|
|
4051
|
+
async cryptoForRead(ref, generation, wrappedDeks, unwrap) {
|
|
3240
4052
|
if (wrappedDeks === void 0 || wrappedDeks.length === 0) {
|
|
3241
4053
|
if (this.requireEncryption) {
|
|
3242
4054
|
throw new KeyUnavailableError(
|
|
@@ -3245,12 +4057,13 @@ var CrbmStorageChunkSource = class {
|
|
|
3245
4057
|
}
|
|
3246
4058
|
return void 0;
|
|
3247
4059
|
}
|
|
3248
|
-
|
|
4060
|
+
const keystore = this.keystore;
|
|
4061
|
+
if (keystore === void 0) {
|
|
3249
4062
|
throw new KeyUnavailableError(
|
|
3250
4063
|
`segment "${ref.segment}" is encrypted but this CrbmStorageChunkSource has no keystore`
|
|
3251
4064
|
);
|
|
3252
4065
|
}
|
|
3253
|
-
const aead = await
|
|
4066
|
+
const aead = await (unwrap ?? (() => keystore.openDek(wrappedDeks)))();
|
|
3254
4067
|
return { aead, aadFor: (scope) => aadFor(ref, generation, scope) };
|
|
3255
4068
|
}
|
|
3256
4069
|
async getChunk(ref) {
|
|
@@ -3277,6 +4090,46 @@ var CrbmStorageChunkSource = class {
|
|
|
3277
4090
|
null
|
|
3278
4091
|
);
|
|
3279
4092
|
}
|
|
4093
|
+
/**
|
|
4094
|
+
* The current generation's number, id count and metadata. The answer comes from the row the snapshot resolved when
|
|
4095
|
+
* it can: the row's summary of the generation it names, on an active row, in the shape the row's keys call for
|
|
4096
|
+
* (a sealed one is opened with the segment's key), and one this process has not found to disagree with its
|
|
4097
|
+
* object. That is one registry read when the snapshot is cold, none when it is warm, and no read of the object.
|
|
4098
|
+
* With no summary it can use (an older row, one for another generation, one that does not open), it opens the
|
|
4099
|
+
* generation and answers from the object. A row that names an object that is gone still answers, while a read
|
|
4100
|
+
* of the object throws: the number is true of the generation the row names.
|
|
4101
|
+
*
|
|
4102
|
+
* What the row says is not confirmed here: a summary edited to agree with itself by whoever can write the row is
|
|
4103
|
+
* believed until the next time the object is opened, which holds it against the summary.
|
|
4104
|
+
*/
|
|
4105
|
+
async summary(ref) {
|
|
4106
|
+
validateUserRef(ref);
|
|
4107
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
4108
|
+
const snap = this.liveSnapshot(ref);
|
|
4109
|
+
try {
|
|
4110
|
+
const live = await snap.target;
|
|
4111
|
+
if (live === null) return null;
|
|
4112
|
+
const row = await this.trustedSummary(ref, live);
|
|
4113
|
+
if (row !== void 0) return summaryOfGeneration(live.target.generation, row);
|
|
4114
|
+
const reader = await snap.reader;
|
|
4115
|
+
return reader === null ? null : summaryOfGeneration(reader.generation, describe(reader));
|
|
4116
|
+
} catch (err) {
|
|
4117
|
+
if (attempt === 1) throw err;
|
|
4118
|
+
const checks = isNotFoundError(err) || isIntegrityError(err) || isValidationError(err);
|
|
4119
|
+
if (!checks || !isNotFoundError(err) && !await this.replacedUnder(ref, snap.reader, err)) {
|
|
4120
|
+
throw err;
|
|
4121
|
+
}
|
|
4122
|
+
this.dropStale(segmentKey(ref), snap);
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
return null;
|
|
4126
|
+
}
|
|
4127
|
+
/** {@link summary} of a specific generation, from the object a pin holds. `held` as for {@link getChunkAt}. */
|
|
4128
|
+
async summaryAt(ref, generation, held) {
|
|
4129
|
+
validateUserRef(ref);
|
|
4130
|
+
const reader = await this.readerAt(ref, generation, held?.version, held?.fingerprint);
|
|
4131
|
+
return reader === null ? null : summaryOfGeneration(reader.generation, describe(reader));
|
|
4132
|
+
}
|
|
3280
4133
|
/**
|
|
3281
4134
|
* Run `read` against the pinned snapshot, healing the torn-read window generation GC can open: if the
|
|
3282
4135
|
* generation we resolved was superseded *and* swept (the grace window elapsed), the Storage driver throws
|
|
@@ -3305,40 +4158,143 @@ var CrbmStorageChunkSource = class {
|
|
|
3305
4158
|
*/
|
|
3306
4159
|
async withFreshSnapshot(ref, read, ifGone) {
|
|
3307
4160
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3308
|
-
const
|
|
4161
|
+
const snap = this.liveSnapshot(ref);
|
|
4162
|
+
const pending = snap.reader;
|
|
3309
4163
|
try {
|
|
3310
4164
|
const reader = await pending;
|
|
3311
4165
|
if (reader === null) return ifGone;
|
|
3312
4166
|
return await read(reader);
|
|
3313
4167
|
} catch (err) {
|
|
3314
|
-
if (
|
|
3315
|
-
this.
|
|
4168
|
+
if (attempt === 1) throw err;
|
|
4169
|
+
if (!isNotFoundError(err) && !await this.replacedUnder(ref, pending, err)) throw err;
|
|
4170
|
+
this.dropStale(segmentKey(ref), snap);
|
|
3316
4171
|
}
|
|
3317
4172
|
}
|
|
3318
4173
|
return ifGone;
|
|
3319
4174
|
}
|
|
4175
|
+
/**
|
|
4176
|
+
* Whether `err`, raised by a read through the snapshot's reader, came from an object that is no longer the one the
|
|
4177
|
+
* reader opened. A generation number can be taken again once its object is deleted (an erasure removes the
|
|
4178
|
+
* generations above a rolled-back pointer that held the id, a collection pass can stop part-way, and a load then
|
|
4179
|
+
* numbers `currentGen + 1` again), so a reader that still holds the old object's index reads the new object's
|
|
4180
|
+
* bytes at the old offsets: a chunk checksum fails, or a range runs past the end. The object's footer says which it
|
|
4181
|
+
* was, as it does for a pin ({@link CrbmReader.sameObject}): replaced, or gone, re-resolves; the same object means
|
|
4182
|
+
* the error is real, and it stands. Only these two errors pay for the footer read, never a read that succeeded. A
|
|
4183
|
+
* transient fault in the check is rethrown; a footer that cannot be read says nothing, and the error stands.
|
|
4184
|
+
*/
|
|
4185
|
+
async replacedUnder(ref, pending, err) {
|
|
4186
|
+
if (!(isIntegrityError(err) || isValidationError(err))) return false;
|
|
4187
|
+
let reader;
|
|
4188
|
+
try {
|
|
4189
|
+
reader = await pending;
|
|
4190
|
+
} catch {
|
|
4191
|
+
return false;
|
|
4192
|
+
}
|
|
4193
|
+
if (reader === null) return false;
|
|
4194
|
+
const at = {
|
|
4195
|
+
namespace: ref.namespace,
|
|
4196
|
+
segment: ref.segment,
|
|
4197
|
+
generation: reader.generation
|
|
4198
|
+
};
|
|
4199
|
+
try {
|
|
4200
|
+
return !await CrbmReader.sameObject(storageBlobReader(this.driver, at), reader.fingerprint);
|
|
4201
|
+
} catch (checkErr) {
|
|
4202
|
+
if (isNotFoundError(checkErr)) return true;
|
|
4203
|
+
if (isTransientError(checkErr)) throw checkErr;
|
|
4204
|
+
return false;
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
3320
4207
|
};
|
|
3321
|
-
function
|
|
3322
|
-
const
|
|
4208
|
+
function* encodeEach(chunks) {
|
|
4209
|
+
for (const { chunkKey, bitmap } of chunks) {
|
|
4210
|
+
if (bitmap.isEmpty) continue;
|
|
4211
|
+
bitmap.optimize?.();
|
|
4212
|
+
yield { chunkKey, payload: bitmap.serialize(), cardinality: bitmap.size };
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
async function writeEncodedChunks(driver, key, chunks, options) {
|
|
3323
4216
|
const tick = yieldEvery(options.clock);
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
4217
|
+
let chunkCount = 0;
|
|
4218
|
+
let cardinality = 0;
|
|
4219
|
+
let identity;
|
|
4220
|
+
const { size, sha256 } = await driver.putImmutable(key, async (sink) => {
|
|
4221
|
+
const writer = new CrbmWriter(sink, {
|
|
4222
|
+
generation: key.generation,
|
|
4223
|
+
crypto: options.crypto,
|
|
4224
|
+
metadata: options.metadata
|
|
4225
|
+
});
|
|
4226
|
+
for (const chunk of chunks) {
|
|
4227
|
+
await writer.addChunk(chunk.chunkKey, chunk.payload, chunk.cardinality);
|
|
4228
|
+
chunkCount++;
|
|
4229
|
+
cardinality += chunk.cardinality;
|
|
3330
4230
|
const pause = tick();
|
|
3331
4231
|
if (pause !== null) await pause;
|
|
3332
4232
|
}
|
|
3333
4233
|
await writer.finish();
|
|
4234
|
+
identity = writer.identity;
|
|
3334
4235
|
});
|
|
4236
|
+
if (identity === void 0) {
|
|
4237
|
+
throw new IntegrityError(
|
|
4238
|
+
`the driver committed ${key.segment}.${key.generation} without writing it`
|
|
4239
|
+
);
|
|
4240
|
+
}
|
|
4241
|
+
return {
|
|
4242
|
+
size,
|
|
4243
|
+
sha256,
|
|
4244
|
+
chunkCount,
|
|
4245
|
+
cardinality,
|
|
4246
|
+
fingerprint: fingerprintFor(identity.size, identity.footerCrc)
|
|
4247
|
+
};
|
|
4248
|
+
}
|
|
4249
|
+
async function holdsObject(storage, key, fingerprint) {
|
|
4250
|
+
try {
|
|
4251
|
+
return await CrbmReader.sameObject(storageBlobReader(storage, key), fingerprint);
|
|
4252
|
+
} catch {
|
|
4253
|
+
return false;
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
function objectIsEncrypted(storage, key) {
|
|
4257
|
+
return footerSaysEncrypted(storageBlobReader(storage, key));
|
|
4258
|
+
}
|
|
4259
|
+
function rememberingTail(blob) {
|
|
4260
|
+
let first;
|
|
4261
|
+
return {
|
|
4262
|
+
getRange: (offset, length) => blob.getRange(offset, length),
|
|
4263
|
+
async getTail(maxBytes) {
|
|
4264
|
+
if (first !== void 0 && (maxBytes <= first.bytes.length || first.bytes.length === first.size)) {
|
|
4265
|
+
const take = Math.min(maxBytes, first.bytes.length);
|
|
4266
|
+
return { bytes: first.bytes.subarray(first.bytes.length - take), size: first.size };
|
|
4267
|
+
}
|
|
4268
|
+
const read = await blob.getTail(maxBytes);
|
|
4269
|
+
first ??= read;
|
|
4270
|
+
return read;
|
|
4271
|
+
}
|
|
4272
|
+
};
|
|
4273
|
+
}
|
|
4274
|
+
async function openRollbackTarget(storage, key, crypto) {
|
|
4275
|
+
const blob = rememberingTail(storageBlobReader(storage, key));
|
|
4276
|
+
await blob.getTail(DEFAULT_TAIL_BYTES);
|
|
4277
|
+
const encrypted = await footerSaysEncrypted(blob);
|
|
4278
|
+
if (encrypted !== (crypto !== void 0)) return { encrypted, reader: void 0 };
|
|
4279
|
+
const reader = await CrbmReader.open(blob, { crypto });
|
|
4280
|
+
if (reader.generation !== key.generation) {
|
|
4281
|
+
throw new IntegrityError(
|
|
4282
|
+
`segment "${key.segment}" generation ${key.generation}: its footer says generation ${reader.generation}`
|
|
4283
|
+
);
|
|
4284
|
+
}
|
|
4285
|
+
return { encrypted, reader };
|
|
3335
4286
|
}
|
|
3336
4287
|
async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
|
|
3337
4288
|
const chunkKeys = [];
|
|
3338
4289
|
let cardinality = 0;
|
|
3339
4290
|
const tick = yieldEvery(options.clock);
|
|
4291
|
+
let identity;
|
|
3340
4292
|
const { size, sha256 } = await driver.putImmutable(key, async (sink) => {
|
|
3341
|
-
const writer = new CrbmWriter(sink, {
|
|
4293
|
+
const writer = new CrbmWriter(sink, {
|
|
4294
|
+
generation: key.generation,
|
|
4295
|
+
crypto: options.crypto,
|
|
4296
|
+
metadata: options.metadata
|
|
4297
|
+
});
|
|
3342
4298
|
for await (const { chunkKey, bitmap } of chunks) {
|
|
3343
4299
|
if (bitmap.isEmpty) continue;
|
|
3344
4300
|
bitmap.optimize?.();
|
|
@@ -3349,12 +4305,57 @@ async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
|
|
|
3349
4305
|
if (pause !== null) await pause;
|
|
3350
4306
|
}
|
|
3351
4307
|
await writer.finish();
|
|
4308
|
+
identity = writer.identity;
|
|
3352
4309
|
});
|
|
3353
|
-
|
|
4310
|
+
if (identity === void 0) {
|
|
4311
|
+
throw new IntegrityError(
|
|
4312
|
+
`the driver committed ${key.segment}.${key.generation} without writing it`
|
|
4313
|
+
);
|
|
4314
|
+
}
|
|
4315
|
+
return {
|
|
4316
|
+
size,
|
|
4317
|
+
sha256,
|
|
4318
|
+
chunkKeys,
|
|
4319
|
+
cardinality,
|
|
4320
|
+
fingerprint: fingerprintFor(identity.size, identity.footerCrc)
|
|
4321
|
+
};
|
|
4322
|
+
}
|
|
4323
|
+
function refuseCleartextOntoKey(key, record, cleartext) {
|
|
4324
|
+
if (cleartext !== true || record.wrappedDeks === void 0 || record.wrappedDeks.length === 0) {
|
|
4325
|
+
return;
|
|
4326
|
+
}
|
|
4327
|
+
throw new KeyUnavailableError(
|
|
4328
|
+
`segment "${key.segment}" is encrypted, and generation ${key.generation} was written in cleartext: another writer created the segment with a key while this one was writing. Nothing was published. Re-run the write with the keystore that holds the segment's key.`
|
|
4329
|
+
);
|
|
3354
4330
|
}
|
|
4331
|
+
var UNANSWERED_RESENDS = 3;
|
|
4332
|
+
var UNANSWERED_RESEND_BASE_MS = 500;
|
|
3355
4333
|
async function publishGeneration(registry, key, options = {}) {
|
|
4334
|
+
let fresh;
|
|
4335
|
+
let failedOn;
|
|
4336
|
+
let unanswered;
|
|
4337
|
+
let resends = 0;
|
|
4338
|
+
let sawUnanswered = false;
|
|
4339
|
+
let lastRecord;
|
|
3356
4340
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
3357
|
-
const
|
|
4341
|
+
const reused = attempt === 0 && options.row !== void 0;
|
|
4342
|
+
const record = fresh !== void 0 ? fresh : reused ? options.row ?? null : await registry.get(key);
|
|
4343
|
+
fresh = void 0;
|
|
4344
|
+
unanswered = void 0;
|
|
4345
|
+
lastRecord = record;
|
|
4346
|
+
if (failedOn !== void 0) {
|
|
4347
|
+
let landed;
|
|
4348
|
+
try {
|
|
4349
|
+
landed = await landedHere(record, failedOn, key, options.holdsOwnObject);
|
|
4350
|
+
} catch (proofErr) {
|
|
4351
|
+
throw outcomeUnknown(key, proofErr);
|
|
4352
|
+
}
|
|
4353
|
+
if (landed) return true;
|
|
4354
|
+
if (record !== null && record.status === "active" && record.currentGen === key.generation) {
|
|
4355
|
+
return false;
|
|
4356
|
+
}
|
|
4357
|
+
failedOn = void 0;
|
|
4358
|
+
}
|
|
3358
4359
|
try {
|
|
3359
4360
|
if (options.expectFrom !== void 0 && record?.currentGen !== options.expectFrom) {
|
|
3360
4361
|
return false;
|
|
@@ -3365,103 +4366,138 @@ async function publishGeneration(registry, key, options = {}) {
|
|
|
3365
4366
|
if (options.expectToken !== void 0 && record?.token !== options.expectToken) {
|
|
3366
4367
|
return false;
|
|
3367
4368
|
}
|
|
4369
|
+
if (record === null && options.summary !== void 0 && "sealed" in options.summary && options.wrappedDeks === void 0) {
|
|
4370
|
+
if (reused) continue;
|
|
4371
|
+
return false;
|
|
4372
|
+
}
|
|
3368
4373
|
if (record === null) {
|
|
3369
4374
|
await registry.create(key, {
|
|
3370
4375
|
currentGen: key.generation,
|
|
3371
|
-
wrappedDeks: options.wrappedDeks
|
|
4376
|
+
wrappedDeks: options.wrappedDeks,
|
|
4377
|
+
...options.summary === void 0 ? {} : { summary: options.summary }
|
|
3372
4378
|
});
|
|
3373
4379
|
} else if (record.status === "destroyed") {
|
|
3374
4380
|
throw new ValidationError(
|
|
3375
4381
|
`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.`
|
|
3376
4382
|
);
|
|
3377
4383
|
} else if (record.currentGen === null) {
|
|
4384
|
+
refuseCleartextOntoKey(key, record, options.cleartext);
|
|
3378
4385
|
await registry.compareAndSwap(key, record.token, {
|
|
3379
4386
|
currentGen: key.generation,
|
|
3380
|
-
...options.wrappedDeks === void 0 ? {} : { wrappedDeks: options.wrappedDeks }
|
|
4387
|
+
...options.wrappedDeks === void 0 ? {} : { wrappedDeks: options.wrappedDeks },
|
|
4388
|
+
...options.summary === void 0 ? {} : { summary: options.summary }
|
|
3381
4389
|
});
|
|
3382
4390
|
} else if (record.currentGen > key.generation) {
|
|
3383
4391
|
return false;
|
|
3384
4392
|
} else if (record.currentGen === key.generation) {
|
|
4393
|
+
if (reused) continue;
|
|
3385
4394
|
return true;
|
|
3386
4395
|
} else {
|
|
3387
4396
|
if (options.wrappedDeks !== void 0 && options.wrappedDeks.length > 0) {
|
|
3388
4397
|
throw new ValidationError(
|
|
3389
|
-
`publishGeneration: refusing to publish generation ${key.generation} of "${key.segment}" with new key material onto a segment that already has generation ${record.currentGen}
|
|
4398
|
+
`publishGeneration: refusing to publish generation ${key.generation} of "${key.segment}" with new key material onto a segment that already has generation ${record.currentGen}: its generations are not encrypted under this key, and nothing was published. If another writer published to the segment while this one was writing, re-run the write, which then uses the segment's own key; if the segment's generations are cleartext, encrypt a new segment and load into that instead.`
|
|
3390
4399
|
);
|
|
3391
4400
|
}
|
|
3392
|
-
|
|
4401
|
+
refuseCleartextOntoKey(key, record, options.cleartext);
|
|
4402
|
+
await registry.compareAndSwap(key, record.token, {
|
|
4403
|
+
currentGen: key.generation,
|
|
4404
|
+
...options.summary === void 0 ? {} : { summary: options.summary }
|
|
4405
|
+
});
|
|
3393
4406
|
}
|
|
3394
4407
|
return true;
|
|
3395
4408
|
} catch (err) {
|
|
3396
|
-
if (isWriteConflictError(err))
|
|
3397
|
-
|
|
4409
|
+
if (isWriteConflictError(err)) {
|
|
4410
|
+
failedOn = record;
|
|
4411
|
+
continue;
|
|
4412
|
+
}
|
|
4413
|
+
if (!isTransientError(err)) throw err;
|
|
4414
|
+
options.onUnanswered?.();
|
|
4415
|
+
sawUnanswered = true;
|
|
4416
|
+
let now;
|
|
4417
|
+
try {
|
|
4418
|
+
now = await registry.get(key);
|
|
4419
|
+
} catch (readErr) {
|
|
4420
|
+
if (isIntegrityError(readErr)) throw readErr;
|
|
4421
|
+
throw outcomeUnknown(key, err);
|
|
4422
|
+
}
|
|
4423
|
+
if (sameRow(now, record)) {
|
|
4424
|
+
const sleep = options.clock?.sleep;
|
|
4425
|
+
if (sleep === void 0 || resends >= UNANSWERED_RESENDS) throw outcomeUnknown(key, err);
|
|
4426
|
+
const bound = UNANSWERED_RESEND_BASE_MS * 2 ** resends;
|
|
4427
|
+
resends += 1;
|
|
4428
|
+
await sleep.call(options.clock, Math.floor((options.rng?.next() ?? 1) * bound));
|
|
4429
|
+
unanswered = outcomeUnknown(key, err);
|
|
4430
|
+
fresh = now;
|
|
4431
|
+
continue;
|
|
4432
|
+
}
|
|
4433
|
+
failedOn = record;
|
|
4434
|
+
fresh = now;
|
|
3398
4435
|
}
|
|
3399
4436
|
}
|
|
3400
4437
|
const final = await registry.get(key);
|
|
3401
|
-
if (final !== null && final.currentGen !== null && final.currentGen >= key.generation)
|
|
3402
|
-
|
|
4438
|
+
if (final !== null && final.currentGen !== null && final.currentGen >= key.generation) {
|
|
4439
|
+
if (final.currentGen !== key.generation) return false;
|
|
4440
|
+
if (!sawUnanswered) return true;
|
|
4441
|
+
try {
|
|
4442
|
+
return await landedHere(final, lastRecord ?? null, key, options.holdsOwnObject);
|
|
4443
|
+
} catch (proofErr) {
|
|
4444
|
+
throw outcomeUnknown(key, proofErr);
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
if (unanswered !== void 0) throw unanswered;
|
|
3403
4448
|
throw new WriteConflictError(
|
|
3404
4449
|
`publishGeneration: contention setting currentGen for ${key.segment}`
|
|
3405
4450
|
);
|
|
3406
4451
|
}
|
|
4452
|
+
async function landedHere(now, before, key, holdsOwnObject) {
|
|
4453
|
+
if (now === null || now.status === "destroyed" || now.currentGen !== key.generation) return false;
|
|
4454
|
+
if (before !== null && !sameIncarnation(before, now)) return false;
|
|
4455
|
+
if (holdsOwnObject === void 0) {
|
|
4456
|
+
throw new TransientError(
|
|
4457
|
+
`publish of "${key.segment}" generation ${key.generation}: whether its registry write landed could not be settled, since the caller gave no way to tell its own object from another's. Its object is kept; re-run the write, which numbers past it.`
|
|
4458
|
+
);
|
|
4459
|
+
}
|
|
4460
|
+
return holdsOwnObject();
|
|
4461
|
+
}
|
|
4462
|
+
function sameRow(a, b) {
|
|
4463
|
+
return a === null ? b === null : b !== null && a.token === b.token;
|
|
4464
|
+
}
|
|
4465
|
+
function outcomeUnknown(key, cause) {
|
|
4466
|
+
if (isTransientError(cause)) return cause;
|
|
4467
|
+
return new TransientError(
|
|
4468
|
+
`publish of "${key.segment}" generation ${key.generation}: whether its registry write landed could not be settled (${cause?.name ?? "error"} while checking its object). Its object is kept, since the write may still land; re-run the write, which numbers past it.`,
|
|
4469
|
+
{ cause }
|
|
4470
|
+
);
|
|
4471
|
+
}
|
|
4472
|
+
async function provesOwnObject(storage, key, fingerprint) {
|
|
4473
|
+
try {
|
|
4474
|
+
return await CrbmReader.sameObject(storageBlobReader(storage, key), fingerprint);
|
|
4475
|
+
} catch (err) {
|
|
4476
|
+
if (isNotFoundError(err)) return false;
|
|
4477
|
+
throw err;
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
3407
4480
|
async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
|
|
3408
4481
|
if (options.keystore === void 0 && options.requireEncryption === true) {
|
|
3409
4482
|
throw new ValidationError("requireEncryption: a load needs a keystore to write encrypted");
|
|
3410
4483
|
}
|
|
3411
4484
|
const codec = requireCodec(options.codec, "bulkLoadCrbmGeneration");
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
for (const [chunkKey, rems] of pendingByChunk) {
|
|
3419
|
-
if (rems.length === 0) continue;
|
|
3420
|
-
const existingBitmap = byChunk.get(chunkKey);
|
|
3421
|
-
if (existingBitmap === void 0) byChunk.set(chunkKey, codec.fromValues(rems));
|
|
3422
|
-
else existingBitmap.addMany(rems);
|
|
3423
|
-
rems.length = 0;
|
|
3424
|
-
const pause = tickChunk();
|
|
3425
|
-
if (pause !== null) await pause;
|
|
3426
|
-
}
|
|
3427
|
-
pending = 0;
|
|
3428
|
-
};
|
|
3429
|
-
const ingest = (id) => {
|
|
3430
|
-
const { chunkKey, remainder } = splitId(id);
|
|
3431
|
-
let bucket = pendingByChunk.get(chunkKey);
|
|
3432
|
-
if (bucket === void 0) {
|
|
3433
|
-
bucket = [];
|
|
3434
|
-
pendingByChunk.set(chunkKey, bucket);
|
|
3435
|
-
}
|
|
3436
|
-
bucket.push(remainder);
|
|
3437
|
-
return ++pending >= BULK_FLUSH_IDS;
|
|
3438
|
-
};
|
|
3439
|
-
if (Symbol.asyncIterator in ids) {
|
|
3440
|
-
for await (const id of ids) {
|
|
3441
|
-
if (ingest(id)) await flushPending();
|
|
3442
|
-
const pause = tickId();
|
|
3443
|
-
if (pause !== null) await pause;
|
|
3444
|
-
}
|
|
4485
|
+
let chunks;
|
|
4486
|
+
if (ids instanceof DecodedLoadInput && ids.bitmap.encodeChunks !== void 0) {
|
|
4487
|
+
const pause = yieldEvery(options.clock, 1);
|
|
4488
|
+
await pause();
|
|
4489
|
+
chunks = ids.bitmap.encodeChunks();
|
|
4490
|
+
await pause();
|
|
3445
4491
|
} else {
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
if (pause !== null) await pause;
|
|
3450
|
-
}
|
|
3451
|
-
}
|
|
3452
|
-
await flushPending();
|
|
3453
|
-
let cardinality = 0;
|
|
3454
|
-
const chunks = [];
|
|
3455
|
-
for (const [chunkKey, bitmap] of byChunk) {
|
|
3456
|
-
cardinality += bitmap.size;
|
|
3457
|
-
chunks.push({ chunkKey, bitmap });
|
|
3458
|
-
const pause = tickChunk();
|
|
3459
|
-
if (pause !== null) await pause;
|
|
4492
|
+
chunks = encodeEach(
|
|
4493
|
+
await bucketIds(ids instanceof DecodedLoadInput ? ids.bitmap : ids, codec, options.clock)
|
|
4494
|
+
);
|
|
3460
4495
|
}
|
|
3461
4496
|
if (options.keystore !== void 0 && options.registry === void 0) {
|
|
3462
4497
|
throw new ValidationError("an encrypted load requires a registry to store the wrapped DEK");
|
|
3463
4498
|
}
|
|
3464
|
-
const
|
|
4499
|
+
const passed = options.row;
|
|
4500
|
+
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;
|
|
3465
4501
|
if (existing?.status === "destroyed") {
|
|
3466
4502
|
throw new ValidationError(
|
|
3467
4503
|
`segment "${key.segment}" is destroyed (crypto-shredded) \u2014 refusing to write; use a new segment`
|
|
@@ -3490,13 +4526,25 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
|
|
|
3490
4526
|
crypto = { aead: minted.aead, aadFor: (scope) => aadFor(key, key.generation, scope) };
|
|
3491
4527
|
}
|
|
3492
4528
|
}
|
|
3493
|
-
const { size, sha256 } = await
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
4529
|
+
const { size, sha256, chunkCount, cardinality, fingerprint } = await writeEncodedChunks(
|
|
4530
|
+
driver,
|
|
4531
|
+
key,
|
|
4532
|
+
chunks,
|
|
4533
|
+
{ crypto, clock: options.clock, metadata: options.metadata }
|
|
4534
|
+
);
|
|
4535
|
+
const summary = summaryOf(
|
|
4536
|
+
key,
|
|
4537
|
+
key.generation,
|
|
4538
|
+
{ cardinality, metadata: options.metadata },
|
|
4539
|
+
crypto?.aead
|
|
4540
|
+
);
|
|
3497
4541
|
if (options.registry !== void 0 && options.publish !== false) {
|
|
3498
4542
|
const becameCurrent = await publishGeneration(options.registry, key, {
|
|
3499
|
-
wrappedDeks: newWrapped
|
|
4543
|
+
wrappedDeks: newWrapped,
|
|
4544
|
+
summary,
|
|
4545
|
+
cleartext: crypto === void 0,
|
|
4546
|
+
holdsOwnObject: () => provesOwnObject(driver, key, fingerprint),
|
|
4547
|
+
clock: options.clock
|
|
3500
4548
|
});
|
|
3501
4549
|
if (becameCurrent) {
|
|
3502
4550
|
safeAudit(options.audit ?? NOOP_AUDIT).onEvent({
|
|
@@ -3506,16 +4554,72 @@ async function bulkLoadCrbmGeneration(driver, key, ids, options = {}) {
|
|
|
3506
4554
|
generation: key.generation
|
|
3507
4555
|
});
|
|
3508
4556
|
}
|
|
3509
|
-
return {
|
|
3510
|
-
size,
|
|
3511
|
-
sha256,
|
|
3512
|
-
chunkCount
|
|
3513
|
-
cardinality,
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
4557
|
+
return {
|
|
4558
|
+
size,
|
|
4559
|
+
sha256,
|
|
4560
|
+
chunkCount,
|
|
4561
|
+
cardinality,
|
|
4562
|
+
summary,
|
|
4563
|
+
becameCurrent,
|
|
4564
|
+
wrappedDeks: newWrapped,
|
|
4565
|
+
encrypted: crypto !== void 0,
|
|
4566
|
+
fingerprint
|
|
4567
|
+
};
|
|
4568
|
+
}
|
|
4569
|
+
return {
|
|
4570
|
+
size,
|
|
4571
|
+
sha256,
|
|
4572
|
+
chunkCount,
|
|
4573
|
+
cardinality,
|
|
4574
|
+
summary,
|
|
4575
|
+
wrappedDeks: newWrapped,
|
|
4576
|
+
encrypted: crypto !== void 0,
|
|
4577
|
+
fingerprint
|
|
4578
|
+
};
|
|
4579
|
+
}
|
|
4580
|
+
async function bucketIds(ids, codec, clock) {
|
|
4581
|
+
const byChunk = /* @__PURE__ */ new Map();
|
|
4582
|
+
const pendingByChunk = /* @__PURE__ */ new Map();
|
|
4583
|
+
let pending = 0;
|
|
4584
|
+
const tickChunk = yieldEvery(clock);
|
|
4585
|
+
const tickId = yieldEvery(clock, YIELD_EVERY_IDS);
|
|
4586
|
+
const flushPending = async () => {
|
|
4587
|
+
for (const [chunkKey, rems] of pendingByChunk) {
|
|
4588
|
+
if (rems.length === 0) continue;
|
|
4589
|
+
const existingBitmap = byChunk.get(chunkKey);
|
|
4590
|
+
if (existingBitmap === void 0) byChunk.set(chunkKey, codec.fromValues(rems));
|
|
4591
|
+
else existingBitmap.addMany(rems);
|
|
4592
|
+
rems.length = 0;
|
|
4593
|
+
const pause = tickChunk();
|
|
4594
|
+
if (pause !== null) await pause;
|
|
4595
|
+
}
|
|
4596
|
+
pending = 0;
|
|
4597
|
+
};
|
|
4598
|
+
const ingest = (id) => {
|
|
4599
|
+
const { chunkKey, remainder } = splitId(id);
|
|
4600
|
+
let bucket = pendingByChunk.get(chunkKey);
|
|
4601
|
+
if (bucket === void 0) {
|
|
4602
|
+
bucket = [];
|
|
4603
|
+
pendingByChunk.set(chunkKey, bucket);
|
|
4604
|
+
}
|
|
4605
|
+
bucket.push(remainder);
|
|
4606
|
+
return ++pending >= BULK_FLUSH_IDS;
|
|
4607
|
+
};
|
|
4608
|
+
if (Symbol.asyncIterator in ids) {
|
|
4609
|
+
for await (const id of ids) {
|
|
4610
|
+
if (ingest(id)) await flushPending();
|
|
4611
|
+
const pause = tickId();
|
|
4612
|
+
if (pause !== null) await pause;
|
|
4613
|
+
}
|
|
4614
|
+
} else {
|
|
4615
|
+
for (const id of ids) {
|
|
4616
|
+
if (ingest(id)) await flushPending();
|
|
4617
|
+
const pause = tickId();
|
|
4618
|
+
if (pause !== null) await pause;
|
|
4619
|
+
}
|
|
3517
4620
|
}
|
|
3518
|
-
|
|
4621
|
+
await flushPending();
|
|
4622
|
+
return [...byChunk].map(([chunkKey, bitmap]) => ({ chunkKey, bitmap })).sort((a, b) => a.chunkKey - b.chunkKey);
|
|
3519
4623
|
}
|
|
3520
4624
|
function openGenerationReader(storage, key, crypto, options = {}) {
|
|
3521
4625
|
return openChecked(storage, key, { ...options, crypto });
|
|
@@ -3544,6 +4648,11 @@ async function verifyGeneration(storage, key, expected, crypto) {
|
|
|
3544
4648
|
`verify failed for ${key.segment}.${key.generation}: cardinality ${reader.count()} != ${expected.cardinality}`
|
|
3545
4649
|
);
|
|
3546
4650
|
}
|
|
4651
|
+
if (!summaryAgrees(expected, { cardinality: reader.count(), metadata: reader.metadata })) {
|
|
4652
|
+
throw new IntegrityError(
|
|
4653
|
+
`verify failed for ${key.segment}.${key.generation}: the object's metadata is not the metadata it was written with`
|
|
4654
|
+
);
|
|
4655
|
+
}
|
|
3547
4656
|
}
|
|
3548
4657
|
|
|
3549
4658
|
// src/core/pinned-storage-source.ts
|
|
@@ -3592,6 +4701,11 @@ var PinnedStorageChunkSource = class {
|
|
|
3592
4701
|
if (pin === void 0) return this.inner.cardinalities(ref);
|
|
3593
4702
|
return pin.generation === null ? Promise.resolve(null) : this.inner.cardinalitiesAt(ref, pin.generation, heldBy(pin));
|
|
3594
4703
|
}
|
|
4704
|
+
summary(ref) {
|
|
4705
|
+
const pin = this.pinFor(ref);
|
|
4706
|
+
if (pin === void 0) return this.inner.summary(ref);
|
|
4707
|
+
return pin.generation === null ? Promise.resolve(null) : this.inner.summaryAt(ref, pin.generation, heldBy(pin));
|
|
4708
|
+
}
|
|
3595
4709
|
currentGeneration(ref) {
|
|
3596
4710
|
const pin = this.pinFor(ref);
|
|
3597
4711
|
return pin === void 0 ? this.inner.currentGeneration(ref) : Promise.resolve(pin.generation);
|
|
@@ -3628,67 +4742,58 @@ var PinnedStorageChunkSource = class {
|
|
|
3628
4742
|
}
|
|
3629
4743
|
};
|
|
3630
4744
|
|
|
3631
|
-
// src/core/
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
4745
|
+
// src/core/retry.ts
|
|
4746
|
+
var DEFAULT_RETRY_POLICY = {
|
|
4747
|
+
maxAttempts: 4,
|
|
4748
|
+
baseDelayMs: 50,
|
|
4749
|
+
maxDelayMs: 2e3,
|
|
4750
|
+
backoffFactor: 2,
|
|
4751
|
+
jitter: "full"
|
|
4752
|
+
};
|
|
4753
|
+
function backoffDelayMs(policy, attempt) {
|
|
4754
|
+
const raw = policy.baseDelayMs * policy.backoffFactor ** (attempt - 1);
|
|
4755
|
+
return Math.min(policy.maxDelayMs, raw);
|
|
3639
4756
|
}
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
const
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
if (after === null)
|
|
3653
|
-
throw new WriteConflictError(`registry row for segment ${ref.segment} was purged mid-pass`);
|
|
3654
|
-
if (record.status === "destroyed" && after.token !== record.token) {
|
|
3655
|
-
throw new WriteConflictError(
|
|
3656
|
-
`segment ${ref.segment} changed incarnation while its generations were being listed`
|
|
4757
|
+
function applyJitter(policy, delayMs, rng) {
|
|
4758
|
+
if (policy.jitter === "none") return delayMs;
|
|
4759
|
+
return rng.next() * delayMs;
|
|
4760
|
+
}
|
|
4761
|
+
function retryRead(op, retry) {
|
|
4762
|
+
return retry === void 0 ? op() : withRetry(op, retry.policy ?? DEFAULT_RETRY_POLICY, retry);
|
|
4763
|
+
}
|
|
4764
|
+
async function withRetry(op, policy, deps) {
|
|
4765
|
+
const retryable = deps.isRetryable ?? isTransientError;
|
|
4766
|
+
if (!Number.isFinite(policy.maxAttempts) || policy.maxAttempts < 1) {
|
|
4767
|
+
throw new ValidationError(
|
|
4768
|
+
`retry.maxAttempts must be a finite number >= 1 (got ${String(policy.maxAttempts)})`
|
|
3657
4769
|
);
|
|
3658
4770
|
}
|
|
3659
|
-
const
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
}
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
4771
|
+
const attempts = Math.floor(policy.maxAttempts);
|
|
4772
|
+
let lastErr;
|
|
4773
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
4774
|
+
try {
|
|
4775
|
+
return await op();
|
|
4776
|
+
} catch (err) {
|
|
4777
|
+
lastErr = err;
|
|
4778
|
+
if (attempt >= attempts || !retryable(err)) throw err;
|
|
4779
|
+
const delayMs = applyJitter(policy, backoffDelayMs(policy, attempt), deps.rng);
|
|
4780
|
+
try {
|
|
4781
|
+
deps.onRetry?.({ attempt, delayMs, err });
|
|
4782
|
+
} catch {
|
|
4783
|
+
}
|
|
4784
|
+
await deps.clock.sleep(delayMs);
|
|
3670
4785
|
}
|
|
3671
|
-
};
|
|
3672
|
-
const toDelete = record.status === "destroyed" ? gens.sort((a, b) => a - b) : cutoff === null ? (
|
|
3673
|
-
// No Storage pointer yet, so "below current" selects nothing and there is nothing safe to infer: an object
|
|
3674
|
-
// here is either a load about to publish or an orphan we cannot tell apart from it. Deleting would race
|
|
3675
|
-
// that publish into a dangling pointer. It is collected once a pointer exists.
|
|
3676
|
-
[]
|
|
3677
|
-
) : (
|
|
3678
|
-
// Delete generations below the cutoff, except the newest `keep` of them (the grace window).
|
|
3679
|
-
gens.filter((g) => g < cutoff).sort((a, b) => b - a).slice(keep)
|
|
3680
|
-
);
|
|
3681
|
-
for (const generation of toDelete) {
|
|
3682
|
-
await stillCollectable();
|
|
3683
|
-
await deps.storage.delete({ namespace: ref.namespace, segment: ref.segment, generation });
|
|
3684
4786
|
}
|
|
3685
|
-
|
|
4787
|
+
throw lastErr;
|
|
3686
4788
|
}
|
|
3687
4789
|
|
|
3688
4790
|
// src/core/load.ts
|
|
3689
4791
|
async function currentCardinality(ref, deps, record) {
|
|
3690
|
-
if (record === null || record.currentGen === null || record.status === "destroyed")
|
|
4792
|
+
if (record === null || record.currentGen === null || record.status === "destroyed") {
|
|
4793
|
+
return { cardinality: null, fromSummary: false };
|
|
4794
|
+
}
|
|
3691
4795
|
const generation = record.currentGen;
|
|
4796
|
+
let aead;
|
|
3692
4797
|
let crypto;
|
|
3693
4798
|
const wrapped = record.wrappedDeks;
|
|
3694
4799
|
if (wrapped !== void 0 && wrapped.length > 0) {
|
|
@@ -3697,23 +4802,29 @@ async function currentCardinality(ref, deps, record) {
|
|
|
3697
4802
|
`segment "${ref.segment}" is encrypted but load was given no keystore`
|
|
3698
4803
|
);
|
|
3699
4804
|
}
|
|
3700
|
-
|
|
3701
|
-
|
|
4805
|
+
aead = await deps.keystore.openDek(wrapped);
|
|
4806
|
+
const opened = aead;
|
|
4807
|
+
crypto = { aead: opened, aadFor: (scope) => aadFor(ref, generation, scope) };
|
|
3702
4808
|
}
|
|
4809
|
+
const described = usableSummary(ref, record, aead);
|
|
4810
|
+
if (described !== void 0) return { cardinality: described.cardinality, fromSummary: true };
|
|
3703
4811
|
let reader;
|
|
3704
4812
|
try {
|
|
3705
|
-
reader = await
|
|
4813
|
+
reader = await retryRead(
|
|
4814
|
+
() => openGenerationReader(deps.storage, { ...ref, generation }, crypto),
|
|
4815
|
+
deps.readRetry
|
|
4816
|
+
);
|
|
3706
4817
|
} catch (err) {
|
|
3707
4818
|
if (!isNotFoundError(err)) throw err;
|
|
3708
|
-
return null;
|
|
4819
|
+
return { cardinality: null, fromSummary: false };
|
|
3709
4820
|
}
|
|
3710
4821
|
let total = 0;
|
|
3711
4822
|
for (const n of reader.cardinalities().values()) total += n;
|
|
3712
|
-
return total;
|
|
4823
|
+
return { cardinality: total, fromSummary: false };
|
|
3713
4824
|
}
|
|
3714
|
-
async function loadSegment(ref,
|
|
4825
|
+
async function loadSegment(ref, input, deps, options = {}) {
|
|
3715
4826
|
validateUserRef(ref);
|
|
3716
|
-
requireCodec(deps.codec, "loadSegment");
|
|
4827
|
+
const codec = requireCodec(deps.codec, "loadSegment");
|
|
3717
4828
|
const keep = options.keep ?? 1;
|
|
3718
4829
|
if (!Number.isInteger(keep) || keep < 0) {
|
|
3719
4830
|
throw new ValidationError(`keep must be a non-negative integer; got ${String(keep)}`);
|
|
@@ -3733,13 +4844,19 @@ async function loadSegment(ref, ids, deps, options = {}) {
|
|
|
3733
4844
|
throw new ValidationError(`guard.minRetained must be a fraction in 0..1; got ${String(s)}`);
|
|
3734
4845
|
}
|
|
3735
4846
|
}
|
|
4847
|
+
const metadata = copiedMetadata(options.metadata, (message) => {
|
|
4848
|
+
throw new ValidationError(message);
|
|
4849
|
+
});
|
|
3736
4850
|
const audit = safeAudit(options.audit ?? NOOP_AUDIT);
|
|
4851
|
+
const ids = prepareLoadInput(input, codec);
|
|
4852
|
+
assertRegistryCanWrite(deps.registry, "load");
|
|
3737
4853
|
const row = await deps.registry.get(ref);
|
|
3738
4854
|
const fromToken = row?.token;
|
|
3739
4855
|
const fromGeneration = row?.currentGen ?? void 0;
|
|
3740
4856
|
const needsBefore = guard?.minRetained !== void 0 || options.allowEmpty !== true;
|
|
3741
|
-
const
|
|
3742
|
-
const
|
|
4857
|
+
const current = needsBefore ? await currentCardinality(ref, deps, row) : { cardinality: null, fromSummary: false };
|
|
4858
|
+
const before = current.cardinality;
|
|
4859
|
+
const { generation, checked } = await nextLoadGeneration(ref, deps, row);
|
|
3743
4860
|
const key = { namespace: ref.namespace, segment: ref.segment, generation };
|
|
3744
4861
|
let written;
|
|
3745
4862
|
try {
|
|
@@ -3749,7 +4866,9 @@ async function loadSegment(ref, ids, deps, options = {}) {
|
|
|
3749
4866
|
keystore: deps.keystore,
|
|
3750
4867
|
requireEncryption: deps.requireEncryption,
|
|
3751
4868
|
codec: deps.codec,
|
|
3752
|
-
clock: deps.clock
|
|
4869
|
+
clock: deps.clock,
|
|
4870
|
+
metadata,
|
|
4871
|
+
row
|
|
3753
4872
|
});
|
|
3754
4873
|
} catch (err) {
|
|
3755
4874
|
if (!isWriteConflictError(err)) throw err;
|
|
@@ -3773,16 +4892,28 @@ async function loadSegment(ref, ids, deps, options = {}) {
|
|
|
3773
4892
|
collected: []
|
|
3774
4893
|
};
|
|
3775
4894
|
}
|
|
3776
|
-
const
|
|
4895
|
+
const reclaim = async () => {
|
|
3777
4896
|
const now = await deps.registry.get(ref);
|
|
3778
|
-
if (now
|
|
4897
|
+
if (now !== null && (now.token === fromToken || now.status === "destroyed")) {
|
|
4898
|
+
await deps.storage.delete(key);
|
|
4899
|
+
return;
|
|
4900
|
+
}
|
|
4901
|
+
const keyed = now !== null && now.wrappedDeks !== void 0 && now.wrappedDeks.length > 0;
|
|
4902
|
+
if ((now === null || keyed && !written.encrypted) && await holdsObject(deps.storage, key, written.fingerprint)) {
|
|
4903
|
+
await deps.storage.delete(key);
|
|
4904
|
+
}
|
|
4905
|
+
};
|
|
4906
|
+
let unanswered = false;
|
|
4907
|
+
const refuse = async (reason) => {
|
|
4908
|
+
await reclaim();
|
|
3779
4909
|
audit.onEvent({
|
|
3780
4910
|
kind: "segment.load-refused",
|
|
3781
4911
|
segment: ref.segment,
|
|
3782
4912
|
namespace: ref.namespace,
|
|
3783
4913
|
generation,
|
|
3784
4914
|
reason,
|
|
3785
|
-
cardinality: written.cardinality
|
|
4915
|
+
cardinality: written.cardinality,
|
|
4916
|
+
...unanswered ? { unanswered: true } : {}
|
|
3786
4917
|
});
|
|
3787
4918
|
return {
|
|
3788
4919
|
generation,
|
|
@@ -3805,19 +4936,37 @@ async function loadSegment(ref, ids, deps, options = {}) {
|
|
|
3805
4936
|
if (guard?.minRetained !== void 0 && before !== null) {
|
|
3806
4937
|
if (written.cardinality < before * guard.minRetained) return refuse("min-retained");
|
|
3807
4938
|
}
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
4939
|
+
let published;
|
|
4940
|
+
try {
|
|
4941
|
+
published = await publishGeneration(deps.registry, key, {
|
|
4942
|
+
row,
|
|
4943
|
+
wrappedDeks: written.wrappedDeks,
|
|
4944
|
+
summary: written.summary,
|
|
4945
|
+
cleartext: !written.encrypted,
|
|
4946
|
+
...fromToken === void 0 ? {} : { expectToken: fromToken },
|
|
4947
|
+
...needsBefore && fromGeneration !== void 0 ? { expectFrom: fromGeneration } : {},
|
|
4948
|
+
// The third case, and the one the two fences above structurally cannot cover: the guard judged a segment
|
|
4949
|
+
// that had NO ROW. Both `expectFrom` and `expectToken` compare against a value read from a row, so with no
|
|
4950
|
+
// row both are omitted and the publish becomes a bare forward-only advance — which lands over anything
|
|
4951
|
+
// that appeared in between. `before` was `null`, so the empty and `minRetained` bounds had nothing to
|
|
4952
|
+
// judge and passed vacuously. Verified: an empty generation published over a thousand ids that a
|
|
4953
|
+
// concurrent writer had created meanwhile, reporting success. A guarded write therefore has to fence on
|
|
4954
|
+
// the ABSENCE it relied on, exactly as it fences on the pointer it relied on.
|
|
4955
|
+
...needsBefore && row === null ? { expectAbsent: true } : {},
|
|
4956
|
+
// A registry write that fails without an answer is reconciled by reading the row: the pointer at this number
|
|
4957
|
+
// is this load's publish only over the object this load wrote, which one footer read proves.
|
|
4958
|
+
holdsOwnObject: () => provesOwnObject(deps.storage, key, written.fingerprint),
|
|
4959
|
+
// And the wait before a fresh write, when the first left the row as it was.
|
|
4960
|
+
clock: deps.clock,
|
|
4961
|
+
rng: deps.rng ?? deps.readRetry?.rng,
|
|
4962
|
+
onUnanswered: () => {
|
|
4963
|
+
unanswered = true;
|
|
4964
|
+
}
|
|
4965
|
+
});
|
|
4966
|
+
} catch (err) {
|
|
4967
|
+
if (isValidationError(err) || err instanceof KeyUnavailableError) await reclaim();
|
|
4968
|
+
throw err;
|
|
4969
|
+
}
|
|
3821
4970
|
if (!published) return refuse("superseded");
|
|
3822
4971
|
audit.onEvent({
|
|
3823
4972
|
kind: "segment.publish",
|
|
@@ -3825,7 +4974,13 @@ async function loadSegment(ref, ids, deps, options = {}) {
|
|
|
3825
4974
|
namespace: ref.namespace,
|
|
3826
4975
|
generation
|
|
3827
4976
|
});
|
|
3828
|
-
const
|
|
4977
|
+
const currentObjectGone = needsBefore && before === null;
|
|
4978
|
+
const collected = await collectAfterLoad(ref, deps, {
|
|
4979
|
+
generation,
|
|
4980
|
+
keep,
|
|
4981
|
+
byName: checked && !currentObjectGone && deps.collectByListing !== true,
|
|
4982
|
+
...current.fromSummary && fromGeneration !== void 0 ? { proveCurrent: { ...ref, generation: fromGeneration } } : {}
|
|
4983
|
+
});
|
|
3829
4984
|
return {
|
|
3830
4985
|
generation,
|
|
3831
4986
|
published: true,
|
|
@@ -3845,7 +5000,25 @@ async function listGenerations(ref, deps) {
|
|
|
3845
5000
|
const current = record?.currentGen ?? null;
|
|
3846
5001
|
const seen = /* @__PURE__ */ new Set();
|
|
3847
5002
|
for await (const key of deps.storage.list(ref)) seen.add(key.generation);
|
|
3848
|
-
|
|
5003
|
+
const described = record === null ? void 0 : await currentDescription(ref, record, deps);
|
|
5004
|
+
return [...seen].sort((a, b) => a - b).map(
|
|
5005
|
+
(generation) => generation === current ? { generation, current: true, ...described } : { generation, current: false }
|
|
5006
|
+
);
|
|
5007
|
+
}
|
|
5008
|
+
async function currentDescription(ref, record, deps) {
|
|
5009
|
+
if (record.summary === void 0) return void 0;
|
|
5010
|
+
let aead;
|
|
5011
|
+
if ("sealed" in record.summary && record.wrappedDeks !== void 0 && deps.keystore !== void 0) {
|
|
5012
|
+
try {
|
|
5013
|
+
aead = await deps.keystore.openDek(record.wrappedDeks);
|
|
5014
|
+
} catch {
|
|
5015
|
+
return void 0;
|
|
5016
|
+
}
|
|
5017
|
+
}
|
|
5018
|
+
const usable = usableSummary(ref, record, aead);
|
|
5019
|
+
if (usable === void 0) return void 0;
|
|
5020
|
+
const metadata = usable.metadata;
|
|
5021
|
+
return metadata === void 0 || Object.keys(metadata).length === 0 ? { cardinality: usable.cardinality } : { cardinality: usable.cardinality, metadata: Object.freeze({ ...metadata }) };
|
|
3849
5022
|
}
|
|
3850
5023
|
async function rollbackSegment(ref, toGeneration, deps, options = {}) {
|
|
3851
5024
|
validateUserRef(ref);
|
|
@@ -3879,8 +5052,34 @@ async function rollbackSegment(ref, toGeneration, deps, options = {}) {
|
|
|
3879
5052
|
`rollback: generation ${toGeneration} of "${ref.segment}" is above the current pointer (${record.currentGen}) \u2014 it may never have been published. Pass { allowForward: true } if you are undoing an earlier rollback.`
|
|
3880
5053
|
);
|
|
3881
5054
|
}
|
|
5055
|
+
const keyed = record.wrappedDeks !== void 0 && record.wrappedDeks.length > 0;
|
|
5056
|
+
let aead;
|
|
5057
|
+
if (keyed && deps.keystore !== void 0) {
|
|
5058
|
+
try {
|
|
5059
|
+
aead = await deps.keystore.openDek(record.wrappedDeks);
|
|
5060
|
+
} catch {
|
|
5061
|
+
}
|
|
5062
|
+
}
|
|
5063
|
+
const crypto = aead === void 0 ? void 0 : { aead, aadFor: (scope) => aadFor(ref, toGeneration, scope) };
|
|
5064
|
+
const { encrypted, reader } = await openRollbackTarget(
|
|
5065
|
+
deps.storage,
|
|
5066
|
+
{ ...ref, generation: toGeneration },
|
|
5067
|
+
crypto
|
|
5068
|
+
);
|
|
5069
|
+
if (encrypted !== keyed) {
|
|
5070
|
+
throw new IntegrityError(
|
|
5071
|
+
`rollback: generation ${toGeneration} of "${ref.segment}" is ${encrypted ? "encrypted" : "cleartext"}, but the segment is ${keyed ? "encrypted" : "cleartext"}, so no read of the segment could use it; it was never one of its generations. Roll back to another generation.`
|
|
5072
|
+
);
|
|
5073
|
+
}
|
|
5074
|
+
const summary = reader === void 0 ? void 0 : summaryOf(
|
|
5075
|
+
ref,
|
|
5076
|
+
toGeneration,
|
|
5077
|
+
{ cardinality: reader.count(), metadata: reader.metadata },
|
|
5078
|
+
aead
|
|
5079
|
+
);
|
|
3882
5080
|
const { token } = await deps.registry.compareAndSwap(ref, record.token, {
|
|
3883
|
-
currentGen: toGeneration
|
|
5081
|
+
currentGen: toGeneration,
|
|
5082
|
+
summary
|
|
3884
5083
|
});
|
|
3885
5084
|
let stillThere = false;
|
|
3886
5085
|
for await (const key of deps.storage.list(ref)) {
|
|
@@ -3889,15 +5088,31 @@ async function rollbackSegment(ref, toGeneration, deps, options = {}) {
|
|
|
3889
5088
|
break;
|
|
3890
5089
|
}
|
|
3891
5090
|
}
|
|
3892
|
-
|
|
5091
|
+
let replaced = false;
|
|
5092
|
+
if (stillThere && reader !== void 0 && toGeneration > (record.currentGen ?? -1)) {
|
|
5093
|
+
try {
|
|
5094
|
+
replaced = !await provesOwnObject(
|
|
5095
|
+
deps.storage,
|
|
5096
|
+
{ ...ref, generation: toGeneration },
|
|
5097
|
+
reader.fingerprint
|
|
5098
|
+
);
|
|
5099
|
+
} catch (err) {
|
|
5100
|
+
if (!isIntegrityError(err)) throw err;
|
|
5101
|
+
replaced = true;
|
|
5102
|
+
}
|
|
5103
|
+
}
|
|
5104
|
+
if (!stillThere || replaced) {
|
|
3893
5105
|
let undone = false;
|
|
3894
5106
|
try {
|
|
3895
|
-
await deps.registry.compareAndSwap(ref, token, {
|
|
5107
|
+
await deps.registry.compareAndSwap(ref, token, {
|
|
5108
|
+
currentGen: record.currentGen,
|
|
5109
|
+
summary: describingSummary(record)
|
|
5110
|
+
});
|
|
3896
5111
|
undone = true;
|
|
3897
5112
|
} catch {
|
|
3898
5113
|
}
|
|
3899
5114
|
throw new NotFoundError(
|
|
3900
|
-
`rollback: generation ${toGeneration} of "${ref.segment}" was collected while the pointer was moving` + (undone ? " \u2014 the pointer was put back" : `, and the move back failed, so the pointer may still name ${toGeneration} (a write that landed and lost its response reads as a failure here). Check which generation is current, then re-run a rollback to a generation that exists, or restore the object.`)
|
|
5115
|
+
`rollback: generation ${toGeneration} of "${ref.segment}" was ${replaced ? "replaced" : "collected"} while the pointer was moving` + (undone ? " \u2014 the pointer was put back" : `, and the move back failed, so the pointer may still name ${toGeneration} (a write that landed and lost its response reads as a failure here). Check which generation is current, then re-run a rollback to a generation that exists, or restore the object.`)
|
|
3901
5116
|
);
|
|
3902
5117
|
}
|
|
3903
5118
|
safeAudit(options.audit ?? NOOP_AUDIT).onEvent({
|
|
@@ -3909,15 +5124,21 @@ async function rollbackSegment(ref, toGeneration, deps, options = {}) {
|
|
|
3909
5124
|
});
|
|
3910
5125
|
return { fromGeneration: record.currentGen, generation: toGeneration };
|
|
3911
5126
|
}
|
|
5127
|
+
function describingSummary(record) {
|
|
5128
|
+
const { summary } = record;
|
|
5129
|
+
if (summary === void 0 || summary.generation !== record.currentGen) return void 0;
|
|
5130
|
+
const keyed = record.wrappedDeks !== void 0 && record.wrappedDeks.length > 0;
|
|
5131
|
+
return "sealed" in summary === keyed ? summary : void 0;
|
|
5132
|
+
}
|
|
3912
5133
|
|
|
3913
5134
|
// src/core/erase-id.ts
|
|
3914
|
-
var DEFAULT_MAX_BITMAP_BYTES2 = 1 << 20;
|
|
3915
5135
|
async function eraseIdFromSegment(ref, id, deps, options = {}) {
|
|
3916
5136
|
validateUserRef(ref);
|
|
3917
5137
|
const { chunkKey, remainder } = splitId(id);
|
|
3918
5138
|
const codec = requireCodec(deps.codec, "eraseIdFromSegment");
|
|
3919
|
-
const maxBytes = deps.maxBitmapBytes ??
|
|
5139
|
+
const maxBytes = deps.maxBitmapBytes ?? DEFAULT_MAX_BITMAP_BYTES;
|
|
3920
5140
|
const base = { segment: ref.segment, namespace: ref.namespace };
|
|
5141
|
+
const read = (op) => retryRead(op, deps.readRetry);
|
|
3921
5142
|
const record = await deps.registry.get(ref);
|
|
3922
5143
|
if (record === null) return { ...base, erased: false, reason: "absent", collected: [] };
|
|
3923
5144
|
if (record.status === "destroyed")
|
|
@@ -3954,13 +5175,18 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
|
|
|
3954
5175
|
return row.currentGen === from && row.token === fromToken ? null : "superseded";
|
|
3955
5176
|
};
|
|
3956
5177
|
const holds = async (generation2) => {
|
|
5178
|
+
const key2 = { ...base, generation: generation2 };
|
|
5179
|
+
const chunkIn = (crypto) => read(async () => (await openGenerationReader(deps.storage, key2, crypto)).getChunk(chunkKey));
|
|
3957
5180
|
try {
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
5181
|
+
let bytes;
|
|
5182
|
+
try {
|
|
5183
|
+
bytes = await chunkIn(cryptoAt(generation2));
|
|
5184
|
+
} catch (err) {
|
|
5185
|
+
if (!isIntegrityError(err) || cryptoAt(generation2) === void 0 || await read(() => objectIsEncrypted(deps.storage, key2))) {
|
|
5186
|
+
throw err;
|
|
5187
|
+
}
|
|
5188
|
+
bytes = await chunkIn(void 0);
|
|
5189
|
+
}
|
|
3964
5190
|
return bytes !== null && codec.safeDeserialize(bytes, maxBytes).has(remainder);
|
|
3965
5191
|
} catch (err) {
|
|
3966
5192
|
if (isNotFoundError(err)) return null;
|
|
@@ -4030,27 +5256,40 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
|
|
|
4030
5256
|
const stage = async () => {
|
|
4031
5257
|
let written;
|
|
4032
5258
|
try {
|
|
4033
|
-
const reader = await openGenerationReader(deps.storage, fromKey, cryptoAt(from));
|
|
4034
|
-
const bytes = await reader.getChunk(chunkKey);
|
|
5259
|
+
const reader = await read(() => openGenerationReader(deps.storage, fromKey, cryptoAt(from)));
|
|
5260
|
+
const bytes = await read(() => reader.getChunk(chunkKey));
|
|
4035
5261
|
if (bytes === null) return await notInCurrent();
|
|
4036
5262
|
const target = codec.safeDeserialize(bytes, maxBytes);
|
|
4037
5263
|
assertRemaindersInRange(target, chunkKey);
|
|
4038
5264
|
if (!target.has(remainder)) return await notInCurrent();
|
|
4039
5265
|
target.remove(remainder);
|
|
5266
|
+
assertRegistryCanWrite(deps.registry, "eraseIdFromSegment");
|
|
4040
5267
|
const generation2 = await nextGeneration(ref, deps);
|
|
4041
5268
|
const key2 = { ...base, generation: generation2 };
|
|
5269
|
+
const metadata = metadataToCarry(
|
|
5270
|
+
reader.metadata,
|
|
5271
|
+
aead === void 0 ? void 0 : usableSummary(ref, record, aead)
|
|
5272
|
+
);
|
|
4042
5273
|
const tally = await writeCrbmGenerationStream(
|
|
4043
5274
|
deps.storage,
|
|
4044
5275
|
key2,
|
|
4045
|
-
rewrite(reader, chunkKey, target, codec, maxBytes),
|
|
4046
|
-
{ crypto: cryptoAt(generation2), clock: deps.clock }
|
|
5276
|
+
rewrite(reader, chunkKey, target, codec, maxBytes, read),
|
|
5277
|
+
{ crypto: cryptoAt(generation2), clock: deps.clock, metadata }
|
|
4047
5278
|
);
|
|
4048
5279
|
written = generation2;
|
|
4049
5280
|
const beforeVerify = await deps.registry.get(ref);
|
|
4050
5281
|
const early = rowVerdict(beforeVerify);
|
|
4051
5282
|
if (early !== null) return refused(early, written);
|
|
4052
|
-
await
|
|
4053
|
-
|
|
5283
|
+
await read(
|
|
5284
|
+
() => verifyGeneration(deps.storage, key2, { ...tally, metadata }, cryptoAt(generation2))
|
|
5285
|
+
);
|
|
5286
|
+
const summary2 = summaryOf(
|
|
5287
|
+
ref,
|
|
5288
|
+
generation2,
|
|
5289
|
+
{ cardinality: tally.cardinality, metadata },
|
|
5290
|
+
aead
|
|
5291
|
+
);
|
|
5292
|
+
return { generation: generation2, key: key2, fingerprint: tally.fingerprint, summary: summary2 };
|
|
4054
5293
|
} catch (err) {
|
|
4055
5294
|
if (!isNotFoundError(err)) throw err;
|
|
4056
5295
|
let now;
|
|
@@ -4075,10 +5314,16 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
|
|
|
4075
5314
|
if (!staged.erased && staged.generation !== void 0) await discardRefused(staged.generation);
|
|
4076
5315
|
return staged;
|
|
4077
5316
|
}
|
|
4078
|
-
const { generation, key } = staged;
|
|
5317
|
+
const { generation, key, fingerprint, summary } = staged;
|
|
4079
5318
|
const published = await publishGeneration(deps.registry, key, {
|
|
4080
5319
|
expectFrom: from,
|
|
4081
|
-
expectToken: fromToken
|
|
5320
|
+
expectToken: fromToken,
|
|
5321
|
+
summary,
|
|
5322
|
+
// A write that ends without an answer is settled by reading the row, and a pointer at this number is this
|
|
5323
|
+
// rewrite's only over the object it wrote: the footer proves it, so another incarnation's cannot pass for it.
|
|
5324
|
+
holdsOwnObject: () => provesOwnObject(deps.storage, key, fingerprint),
|
|
5325
|
+
clock: deps.clock,
|
|
5326
|
+
rng: deps.rng ?? deps.readRetry?.rng
|
|
4082
5327
|
});
|
|
4083
5328
|
if (!published) {
|
|
4084
5329
|
await discardRefused(generation);
|
|
@@ -4103,14 +5348,21 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
|
|
|
4103
5348
|
if (left !== void 0) throw cannotRemove(left);
|
|
4104
5349
|
return { ...base, erased: true, fromGeneration: from, generation, collected };
|
|
4105
5350
|
}
|
|
4106
|
-
|
|
5351
|
+
var REWRITE_READ_AHEAD = 32;
|
|
5352
|
+
async function* rewrite(reader, chunkKey, replacement, codec, maxBytes, read) {
|
|
4107
5353
|
const keys = [...reader.chunkKeys()].sort((a, b) => a - b);
|
|
5354
|
+
const window = new ChunkWindow(
|
|
5355
|
+
keys.filter((k) => k !== chunkKey),
|
|
5356
|
+
(k) => read(() => reader.getChunk(k)),
|
|
5357
|
+
REWRITE_READ_AHEAD,
|
|
5358
|
+
false
|
|
5359
|
+
);
|
|
4108
5360
|
for (const k of keys) {
|
|
4109
5361
|
if (k === chunkKey) {
|
|
4110
5362
|
yield { chunkKey: k, bitmap: replacement };
|
|
4111
5363
|
continue;
|
|
4112
5364
|
}
|
|
4113
|
-
const bytes = await
|
|
5365
|
+
const bytes = await window.take();
|
|
4114
5366
|
if (bytes === null) continue;
|
|
4115
5367
|
const bitmap = codec.safeDeserialize(bytes, maxBytes);
|
|
4116
5368
|
assertRemaindersInRange(bitmap, k);
|
|
@@ -4129,8 +5381,8 @@ function assertRemaindersInRange(bitmap, chunkKey) {
|
|
|
4129
5381
|
// src/drivers/crypto.ts
|
|
4130
5382
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
4131
5383
|
var KEY_BYTES = 32;
|
|
4132
|
-
var
|
|
4133
|
-
var
|
|
5384
|
+
var NONCE_BYTES2 = 12;
|
|
5385
|
+
var TAG_BYTES2 = 16;
|
|
4134
5386
|
var NodeAead = class {
|
|
4135
5387
|
key;
|
|
4136
5388
|
constructor(key) {
|
|
@@ -4139,14 +5391,14 @@ var NodeAead = class {
|
|
|
4139
5391
|
this.key = Buffer.from(key);
|
|
4140
5392
|
}
|
|
4141
5393
|
seal(plaintext, aad) {
|
|
4142
|
-
const nonce = randomBytes(
|
|
5394
|
+
const nonce = randomBytes(NONCE_BYTES2);
|
|
4143
5395
|
const cipher = createCipheriv("aes-256-gcm", this.key, nonce);
|
|
4144
5396
|
cipher.setAAD(aad);
|
|
4145
5397
|
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
4146
5398
|
return { nonce, ciphertext, tag: cipher.getAuthTag() };
|
|
4147
5399
|
}
|
|
4148
5400
|
open(sealed, aad) {
|
|
4149
|
-
if (sealed.nonce.length !==
|
|
5401
|
+
if (sealed.nonce.length !== NONCE_BYTES2 || sealed.tag.length !== TAG_BYTES2)
|
|
4150
5402
|
throw new IntegrityError("AEAD nonce/tag have the wrong length");
|
|
4151
5403
|
const decipher = createDecipheriv("aes-256-gcm", this.key, sealed.nonce);
|
|
4152
5404
|
decipher.setAAD(aad);
|
|
@@ -4166,11 +5418,11 @@ function wrapKey(dek, kek, keyId) {
|
|
|
4166
5418
|
}
|
|
4167
5419
|
function unwrapKey(wrapped, kek, keyId) {
|
|
4168
5420
|
const blob = Buffer.from(wrapped, "base64");
|
|
4169
|
-
if (blob.length <
|
|
5421
|
+
if (blob.length < NONCE_BYTES2 + TAG_BYTES2)
|
|
4170
5422
|
throw new IntegrityError("wrapped DEK is too short to be valid");
|
|
4171
|
-
const nonce = blob.subarray(0,
|
|
4172
|
-
const tag = blob.subarray(blob.length -
|
|
4173
|
-
const ciphertext = blob.subarray(
|
|
5423
|
+
const nonce = blob.subarray(0, NONCE_BYTES2);
|
|
5424
|
+
const tag = blob.subarray(blob.length - TAG_BYTES2);
|
|
5425
|
+
const ciphertext = blob.subarray(NONCE_BYTES2, blob.length - TAG_BYTES2);
|
|
4174
5426
|
return new NodeAead(kek).open({ nonce, ciphertext, tag }, new TextEncoder().encode(keyId));
|
|
4175
5427
|
}
|
|
4176
5428
|
var InProcessKeystore = class {
|
|
@@ -4411,7 +5663,10 @@ async function shredSegment(ref, deps, allowCleartext, op = "destroySegment") {
|
|
|
4411
5663
|
status: "destroyed",
|
|
4412
5664
|
wrappedDeks: void 0,
|
|
4413
5665
|
// ← the crypto-shred: the only copy of the DEK wrappings is gone
|
|
4414
|
-
keyId: void 0
|
|
5666
|
+
keyId: void 0,
|
|
5667
|
+
// The current generation's cached count and metadata go with it: sealed, they cannot be opened without
|
|
5668
|
+
// the wrappings; clear, they would outlive the segment on its tombstone.
|
|
5669
|
+
summary: void 0
|
|
4415
5670
|
});
|
|
4416
5671
|
return { ...base, destroyed: true, cryptoShredded: encrypted };
|
|
4417
5672
|
} catch (err) {
|
|
@@ -4550,39 +5805,108 @@ function assertNotDestroyed(record, ref) {
|
|
|
4550
5805
|
|
|
4551
5806
|
// src/core/retention-sweep.ts
|
|
4552
5807
|
var DEFAULT_RETIRE_LIMIT = 100;
|
|
5808
|
+
var MAX_CONSECUTIVE_PURGE_FAULTS = 3;
|
|
4553
5809
|
var DEFAULT_TOMBSTONE_GRACE_MS = 864e5;
|
|
4554
5810
|
var DEFAULT_LOOKBACK_BUCKETS = 7;
|
|
4555
5811
|
async function rowsFromDueIndex(registry, options) {
|
|
4556
5812
|
const rows = [];
|
|
4557
|
-
const
|
|
5813
|
+
const pointers = /* @__PURE__ */ new Map();
|
|
5814
|
+
const litter = [];
|
|
5815
|
+
const gone = /* @__PURE__ */ new Set();
|
|
4558
5816
|
for (const bucket of dueBucketsAt(options.now, options.lookbackBuckets)) {
|
|
4559
5817
|
for await (const pointer of registry.list(dueNamespace(bucket))) {
|
|
4560
5818
|
const ref = decodeDueName(pointer.segment);
|
|
4561
5819
|
if (ref === null) continue;
|
|
4562
5820
|
if (isReservedNamespace(ref.namespace)) continue;
|
|
4563
5821
|
if (options.namespace !== void 0 && ref.namespace !== options.namespace) continue;
|
|
4564
|
-
const
|
|
4565
|
-
|
|
4566
|
-
|
|
5822
|
+
const found = {
|
|
5823
|
+
ref: { namespace: pointer.namespace, segment: pointer.segment },
|
|
5824
|
+
target: ref,
|
|
5825
|
+
token: pointer.token
|
|
5826
|
+
};
|
|
5827
|
+
const key = segmentKey(ref);
|
|
5828
|
+
const known = pointers.get(key);
|
|
5829
|
+
if (known !== void 0) {
|
|
5830
|
+
known.push(found);
|
|
5831
|
+
continue;
|
|
5832
|
+
}
|
|
5833
|
+
if (gone.has(key)) {
|
|
5834
|
+
litter.push(found);
|
|
5835
|
+
continue;
|
|
5836
|
+
}
|
|
4567
5837
|
if (rows.length >= options.maxScanSegments) {
|
|
4568
5838
|
throw new BudgetExceededError(
|
|
4569
5839
|
`retireExpired: the due index yielded more than ${options.maxScanSegments} segments \u2014 the scan was abandoned there rather than completed. Raise \`maxScanSegments\`, narrow with \`namespace\`, or reduce \`lookbackBuckets\`.`
|
|
4570
5840
|
);
|
|
4571
5841
|
}
|
|
4572
5842
|
const live = await registry.get(ref);
|
|
4573
|
-
if (live === null)
|
|
5843
|
+
if (live === null) {
|
|
5844
|
+
gone.add(key);
|
|
5845
|
+
litter.push(found);
|
|
5846
|
+
continue;
|
|
5847
|
+
}
|
|
4574
5848
|
rows.push(live);
|
|
5849
|
+
pointers.set(key, [found]);
|
|
4575
5850
|
}
|
|
4576
5851
|
}
|
|
4577
|
-
return rows;
|
|
5852
|
+
return { rows, pointers, litter };
|
|
5853
|
+
}
|
|
5854
|
+
async function rowsFromFleet(registry, options) {
|
|
5855
|
+
const held = [];
|
|
5856
|
+
const rows = await drainRegistry(registry, {
|
|
5857
|
+
namespace: options.namespace,
|
|
5858
|
+
maxScanSegments: options.maxScanSegments,
|
|
5859
|
+
op: "retireExpired",
|
|
5860
|
+
onReserved: (pointer) => {
|
|
5861
|
+
if (held.length >= options.maxScanSegments) return;
|
|
5862
|
+
const target = decodeDueName(pointer.segment);
|
|
5863
|
+
if (target === null || isReservedNamespace(target.namespace)) return;
|
|
5864
|
+
held.push({
|
|
5865
|
+
ref: { namespace: pointer.namespace, segment: pointer.segment },
|
|
5866
|
+
target,
|
|
5867
|
+
token: pointer.token
|
|
5868
|
+
});
|
|
5869
|
+
}
|
|
5870
|
+
});
|
|
5871
|
+
const pointers = /* @__PURE__ */ new Map();
|
|
5872
|
+
const litter = [];
|
|
5873
|
+
if (held.length === 0) return { rows, pointers, litter };
|
|
5874
|
+
const present = new Set(rows.map((rec) => segmentKey(rec)));
|
|
5875
|
+
for (const found of held) {
|
|
5876
|
+
const key = segmentKey(found.target);
|
|
5877
|
+
if (!present.has(key)) litter.push(found);
|
|
5878
|
+
else if (pointers.has(key)) pointers.get(key).push(found);
|
|
5879
|
+
else pointers.set(key, [found]);
|
|
5880
|
+
}
|
|
5881
|
+
return { rows, pointers, litter };
|
|
4578
5882
|
}
|
|
4579
|
-
async function forgetDuePointer(registry, ref, expiresAt) {
|
|
5883
|
+
async function forgetDuePointer(registry, ref, expiresAt, onFault) {
|
|
4580
5884
|
if (!canIndex(ref)) return;
|
|
4581
5885
|
try {
|
|
4582
5886
|
await registry.delete(dueIndexRef(dueBucket(expiresAt), ref));
|
|
5887
|
+
} catch (err) {
|
|
5888
|
+
onFault(err);
|
|
5889
|
+
}
|
|
5890
|
+
}
|
|
5891
|
+
async function filePurgePointer(registry, ref, purgeAt) {
|
|
5892
|
+
if (!canIndex(ref)) return;
|
|
5893
|
+
try {
|
|
5894
|
+
await registry.create(dueIndexRef(dueBucket(purgeAt), ref), { currentGen: null });
|
|
4583
5895
|
} catch {
|
|
4584
5896
|
}
|
|
4585
5897
|
}
|
|
5898
|
+
async function forgetPurgePointers(registry, ref, purgeAt, found, onFault) {
|
|
5899
|
+
if (!canIndex(ref)) return;
|
|
5900
|
+
const filed = dueIndexRef(dueBucket(purgeAt), ref);
|
|
5901
|
+
const all = found.some((p) => p.ref.namespace === filed.namespace) ? found : [...found, { ref: filed, target: ref }];
|
|
5902
|
+
for (const pointer of all) {
|
|
5903
|
+
try {
|
|
5904
|
+
await registry.delete(pointer.ref, pointer.token);
|
|
5905
|
+
} catch (err) {
|
|
5906
|
+
onFault(err);
|
|
5907
|
+
}
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
4586
5910
|
async function retireExpired(deps, options) {
|
|
4587
5911
|
if (options.namespace !== void 0) validateUserNamespace(options.namespace);
|
|
4588
5912
|
const now = options.now;
|
|
@@ -4615,32 +5939,40 @@ async function retireExpired(deps, options) {
|
|
|
4615
5939
|
}
|
|
4616
5940
|
const dryRun = options.dryRun === true;
|
|
4617
5941
|
const scan = options.scan ?? "fleet";
|
|
4618
|
-
const
|
|
5942
|
+
const scanned = scan === "index" ? await rowsFromDueIndex(deps.registry, {
|
|
4619
5943
|
now,
|
|
4620
5944
|
lookbackBuckets: options.lookbackBuckets ?? DEFAULT_LOOKBACK_BUCKETS,
|
|
4621
5945
|
namespace: options.namespace,
|
|
4622
5946
|
maxScanSegments
|
|
4623
|
-
}) : await
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
op: "retireExpired"
|
|
4627
|
-
});
|
|
5947
|
+
}) : await rowsFromFleet(deps.registry, { namespace: options.namespace, maxScanSegments });
|
|
5948
|
+
const rows = scanned.rows;
|
|
5949
|
+
const removesRows = deps.registry.capabilities().conditionalDelete === true;
|
|
4628
5950
|
const shards = options.shards;
|
|
4629
5951
|
const totalShards = options.totalShards ?? 0;
|
|
4630
|
-
const
|
|
5952
|
+
const owned = (ref) => shards === void 0 || totalShards <= 1 || shards.includes(shardOf(segmentKey(ref), totalShards));
|
|
5953
|
+
const mine = rows.filter(owned);
|
|
4631
5954
|
const entries = [];
|
|
4632
5955
|
let eligible = 0;
|
|
4633
5956
|
let retired = 0;
|
|
4634
5957
|
let wouldRetire = 0;
|
|
4635
5958
|
let tombstonesPurged = 0;
|
|
4636
5959
|
let limited = false;
|
|
5960
|
+
let purgeFaults = 0;
|
|
5961
|
+
let firstPurgeFault;
|
|
5962
|
+
const noteFault = (err) => {
|
|
5963
|
+
if (isWriteConflictError(err)) return;
|
|
5964
|
+
purgeFaults += 1;
|
|
5965
|
+
firstPurgeFault ??= failureReason(err);
|
|
5966
|
+
};
|
|
5967
|
+
let purgeRun = 0;
|
|
5968
|
+
let purging = true;
|
|
4637
5969
|
let attempted = 0;
|
|
4638
5970
|
for (const rec of mine) {
|
|
4639
5971
|
const ref = { namespace: rec.namespace, segment: rec.segment };
|
|
4640
5972
|
const base = { segment: rec.segment, namespace: rec.namespace };
|
|
4641
5973
|
const policy = readRetentionPolicy(rec.retention);
|
|
4642
5974
|
if (rec.status === "destroyed") {
|
|
4643
|
-
if (!purgeTombstones) continue;
|
|
5975
|
+
if (!purgeTombstones || !purging) continue;
|
|
4644
5976
|
const retiredAt = retirementStamp(rec.retention);
|
|
4645
5977
|
if (retiredAt === null) continue;
|
|
4646
5978
|
if (now - retiredAt < grace) continue;
|
|
@@ -4657,13 +5989,31 @@ async function retireExpired(deps, options) {
|
|
|
4657
5989
|
continue;
|
|
4658
5990
|
}
|
|
4659
5991
|
}
|
|
4660
|
-
if (!dryRun)
|
|
4661
|
-
|
|
5992
|
+
if (!dryRun) {
|
|
5993
|
+
await deps.registry.delete(ref, rec.token);
|
|
5994
|
+
tombstonesPurged += 1;
|
|
5995
|
+
purgeRun = 0;
|
|
5996
|
+
if (removesRows) {
|
|
5997
|
+
await forgetPurgePointers(
|
|
5998
|
+
deps.registry,
|
|
5999
|
+
ref,
|
|
6000
|
+
retiredAt + grace,
|
|
6001
|
+
scanned.pointers.get(segmentKey(ref)) ?? [],
|
|
6002
|
+
noteFault
|
|
6003
|
+
);
|
|
6004
|
+
}
|
|
6005
|
+
}
|
|
4662
6006
|
entries.push({
|
|
4663
6007
|
...base,
|
|
4664
6008
|
action: dryRun ? "would-purge-tombstone" : "purged-tombstone"
|
|
4665
6009
|
});
|
|
4666
6010
|
} catch (err) {
|
|
6011
|
+
if (!isWriteConflictError(err)) {
|
|
6012
|
+
attempted -= 1;
|
|
6013
|
+
purgeRun += 1;
|
|
6014
|
+
if (purgeRun >= MAX_CONSECUTIVE_PURGE_FAULTS) purging = false;
|
|
6015
|
+
noteFault(err);
|
|
6016
|
+
}
|
|
4667
6017
|
entries.push({ ...base, action: "skipped", reason: failureReason(err) });
|
|
4668
6018
|
}
|
|
4669
6019
|
continue;
|
|
@@ -4707,22 +6057,27 @@ async function retireExpired(deps, options) {
|
|
|
4707
6057
|
}
|
|
4708
6058
|
retired += 1;
|
|
4709
6059
|
entries.push({ ...base, action: "retired", expiresAt: livePolicy.expiresAt, result });
|
|
4710
|
-
await forgetDuePointer(deps.registry, ref, livePolicy.expiresAt);
|
|
6060
|
+
await forgetDuePointer(deps.registry, ref, livePolicy.expiresAt, noteFault);
|
|
4711
6061
|
if (purgeTombstones && result.generationsDeleted.length === 0 && result.generationsRemaining.length === 0) {
|
|
4712
6062
|
const tombstone = await deps.registry.get(ref).catch(() => null);
|
|
4713
6063
|
if (tombstone?.status === "destroyed") {
|
|
4714
6064
|
const deleted = await deps.registry.delete(ref, tombstone.token).then(
|
|
4715
6065
|
() => true,
|
|
4716
|
-
() =>
|
|
6066
|
+
(err) => {
|
|
6067
|
+
noteFault(err);
|
|
6068
|
+
return false;
|
|
6069
|
+
}
|
|
4717
6070
|
);
|
|
4718
6071
|
if (deleted) continue;
|
|
4719
6072
|
}
|
|
4720
6073
|
}
|
|
4721
|
-
await stampRetirement(deps.registry, ref, now).catch(() =>
|
|
6074
|
+
const stamped = await stampRetirement(deps.registry, ref, now).catch(() => false);
|
|
6075
|
+
if (stamped && removesRows) await filePurgePointer(deps.registry, ref, now + grace);
|
|
4722
6076
|
} catch (err) {
|
|
4723
6077
|
const after = await deps.registry.get(ref).catch(() => null);
|
|
4724
6078
|
if (after?.status === "destroyed") {
|
|
4725
|
-
await stampRetirement(deps.registry, ref, now).catch(() =>
|
|
6079
|
+
const stamped = await stampRetirement(deps.registry, ref, now).catch(() => false);
|
|
6080
|
+
if (stamped && removesRows) await filePurgePointer(deps.registry, ref, now + grace);
|
|
4726
6081
|
retired += 1;
|
|
4727
6082
|
entries.push({
|
|
4728
6083
|
...base,
|
|
@@ -4743,6 +6098,20 @@ async function retireExpired(deps, options) {
|
|
|
4743
6098
|
entries.push({ ...base, action: "skipped", reason: failureReason(err) });
|
|
4744
6099
|
}
|
|
4745
6100
|
}
|
|
6101
|
+
if (!dryRun && removesRows) {
|
|
6102
|
+
let attemptedLitter = 0;
|
|
6103
|
+
for (const pointer of scanned.litter) {
|
|
6104
|
+
if (!owned(pointer.target)) continue;
|
|
6105
|
+
if (attemptedLitter >= limit) break;
|
|
6106
|
+
attemptedLitter += 1;
|
|
6107
|
+
try {
|
|
6108
|
+
if (await deps.registry.get(pointer.target) !== null) continue;
|
|
6109
|
+
await deps.registry.delete(pointer.ref, pointer.token);
|
|
6110
|
+
} catch (err) {
|
|
6111
|
+
noteFault(err);
|
|
6112
|
+
}
|
|
6113
|
+
}
|
|
6114
|
+
}
|
|
4746
6115
|
return {
|
|
4747
6116
|
scanned: mine.length,
|
|
4748
6117
|
eligible,
|
|
@@ -4750,6 +6119,8 @@ async function retireExpired(deps, options) {
|
|
|
4750
6119
|
wouldRetire,
|
|
4751
6120
|
tombstonesPurged,
|
|
4752
6121
|
limited,
|
|
6122
|
+
purgeFaults,
|
|
6123
|
+
...firstPurgeFault === void 0 ? {} : { firstPurgeFault },
|
|
4753
6124
|
dryRun,
|
|
4754
6125
|
entries
|
|
4755
6126
|
};
|
|
@@ -4765,16 +6136,17 @@ function retirementStamp(meta) {
|
|
|
4765
6136
|
async function stampRetirement(registry, ref, now) {
|
|
4766
6137
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
4767
6138
|
const rec = await registry.get(ref);
|
|
4768
|
-
if (rec === null || rec.status !== "destroyed") return;
|
|
6139
|
+
if (rec === null || rec.status !== "destroyed") return false;
|
|
4769
6140
|
try {
|
|
4770
6141
|
await registry.compareAndSwap(ref, rec.token, {
|
|
4771
6142
|
retention: { ...rec.retention, [RETIRED_AT]: now }
|
|
4772
6143
|
});
|
|
4773
|
-
return;
|
|
6144
|
+
return true;
|
|
4774
6145
|
} catch (err) {
|
|
4775
6146
|
if (!isWriteConflictError(err)) throw err;
|
|
4776
6147
|
}
|
|
4777
6148
|
}
|
|
6149
|
+
return false;
|
|
4778
6150
|
}
|
|
4779
6151
|
async function isFullyReclaimed(deps, ref) {
|
|
4780
6152
|
for await (const key of deps.storage.list(ref)) {
|
|
@@ -4795,8 +6167,45 @@ async function generationsPresent(storage, ref) {
|
|
|
4795
6167
|
for await (const key of storage.list(ref)) present.add(key.generation);
|
|
4796
6168
|
return present;
|
|
4797
6169
|
}
|
|
6170
|
+
async function checkSummary(ref, live, deps) {
|
|
6171
|
+
const generation = live.currentGen;
|
|
6172
|
+
const keyed = live.wrappedDeks !== void 0 && live.wrappedDeks.length > 0;
|
|
6173
|
+
let aead;
|
|
6174
|
+
if (keyed) {
|
|
6175
|
+
if (deps.keystore === void 0) return { kind: "ok", unchecked: true };
|
|
6176
|
+
try {
|
|
6177
|
+
aead = await deps.keystore.openDek(live.wrappedDeks);
|
|
6178
|
+
} catch (err) {
|
|
6179
|
+
if (isIntegrityError(err) || isCloudRoaringError(err) && err.name === "KeyUnavailableError")
|
|
6180
|
+
return { kind: "ok", unchecked: true };
|
|
6181
|
+
throw err;
|
|
6182
|
+
}
|
|
6183
|
+
}
|
|
6184
|
+
const described = usableSummary(ref, live, aead);
|
|
6185
|
+
if (described === void 0) return { kind: "ok", unchecked: keyed };
|
|
6186
|
+
const reader = await openGenerationReader(
|
|
6187
|
+
deps.storage,
|
|
6188
|
+
{ namespace: ref.namespace, segment: ref.segment, generation },
|
|
6189
|
+
aead === void 0 ? void 0 : { aead, aadFor: (scope) => aadFor(ref, generation, scope) }
|
|
6190
|
+
);
|
|
6191
|
+
let cardinality = 0;
|
|
6192
|
+
for (const n of reader.cardinalities().values()) cardinality += n;
|
|
6193
|
+
if (summaryAgrees(described, { cardinality, metadata: reader.metadata })) return { kind: "ok" };
|
|
6194
|
+
return {
|
|
6195
|
+
kind: "issue",
|
|
6196
|
+
issue: {
|
|
6197
|
+
segment: ref.segment,
|
|
6198
|
+
namespace: ref.namespace,
|
|
6199
|
+
currentGen: generation,
|
|
6200
|
+
issue: "summary-mismatch"
|
|
6201
|
+
}
|
|
6202
|
+
};
|
|
6203
|
+
}
|
|
4798
6204
|
async function runConsistencyCheck(deps, options = {}) {
|
|
4799
6205
|
if (options.namespace !== void 0) validateUserNamespace(options.namespace);
|
|
6206
|
+
if (options.summaries !== void 0 && typeof options.summaries !== "boolean") {
|
|
6207
|
+
throw new ValidationError(`summaries must be a boolean; got ${String(options.summaries)}`);
|
|
6208
|
+
}
|
|
4800
6209
|
const concurrency = options.concurrency ?? DEFAULT_CHECK_CONCURRENCY;
|
|
4801
6210
|
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
4802
6211
|
throw new ValidationError(`concurrency must be a positive integer; got ${concurrency}`);
|
|
@@ -4818,7 +6227,10 @@ async function runConsistencyCheck(deps, options = {}) {
|
|
|
4818
6227
|
if (!live || live.status === "destroyed") return { kind: "ok" };
|
|
4819
6228
|
if (live.currentGen === null) return { kind: "ok" };
|
|
4820
6229
|
const present = await generationsPresent(deps.storage, ref);
|
|
4821
|
-
if (present.has(live.currentGen))
|
|
6230
|
+
if (present.has(live.currentGen)) {
|
|
6231
|
+
if (options.summaries !== true || live.summary === void 0) return { kind: "ok" };
|
|
6232
|
+
return await checkSummary(ref, live, deps);
|
|
6233
|
+
}
|
|
4822
6234
|
return {
|
|
4823
6235
|
kind: "issue",
|
|
4824
6236
|
issue: {
|
|
@@ -4841,53 +6253,13 @@ async function runConsistencyCheck(deps, options = {}) {
|
|
|
4841
6253
|
});
|
|
4842
6254
|
const inconsistent = [];
|
|
4843
6255
|
const errored = [];
|
|
6256
|
+
let summariesUnchecked = 0;
|
|
4844
6257
|
for (const r of results) {
|
|
4845
6258
|
if (r.kind === "issue") inconsistent.push(r.issue);
|
|
4846
6259
|
else if (r.kind === "error") errored.push(r.error);
|
|
6260
|
+
else if (r.unchecked === true) summariesUnchecked += 1;
|
|
4847
6261
|
}
|
|
4848
|
-
return { checked: recs.length, inconsistent, errored };
|
|
4849
|
-
}
|
|
4850
|
-
|
|
4851
|
-
// src/core/retry.ts
|
|
4852
|
-
var DEFAULT_RETRY_POLICY = {
|
|
4853
|
-
maxAttempts: 4,
|
|
4854
|
-
baseDelayMs: 50,
|
|
4855
|
-
maxDelayMs: 2e3,
|
|
4856
|
-
backoffFactor: 2,
|
|
4857
|
-
jitter: "full"
|
|
4858
|
-
};
|
|
4859
|
-
function backoffDelayMs(policy, attempt) {
|
|
4860
|
-
const raw = policy.baseDelayMs * policy.backoffFactor ** (attempt - 1);
|
|
4861
|
-
return Math.min(policy.maxDelayMs, raw);
|
|
4862
|
-
}
|
|
4863
|
-
function applyJitter(policy, delayMs, rng) {
|
|
4864
|
-
if (policy.jitter === "none") return delayMs;
|
|
4865
|
-
return rng.next() * delayMs;
|
|
4866
|
-
}
|
|
4867
|
-
async function withRetry(op, policy, deps) {
|
|
4868
|
-
const retryable = deps.isRetryable ?? isTransientError;
|
|
4869
|
-
if (!Number.isFinite(policy.maxAttempts) || policy.maxAttempts < 1) {
|
|
4870
|
-
throw new ValidationError(
|
|
4871
|
-
`retry.maxAttempts must be a finite number >= 1 (got ${String(policy.maxAttempts)})`
|
|
4872
|
-
);
|
|
4873
|
-
}
|
|
4874
|
-
const attempts = Math.floor(policy.maxAttempts);
|
|
4875
|
-
let lastErr;
|
|
4876
|
-
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
4877
|
-
try {
|
|
4878
|
-
return await op();
|
|
4879
|
-
} catch (err) {
|
|
4880
|
-
lastErr = err;
|
|
4881
|
-
if (attempt >= attempts || !retryable(err)) throw err;
|
|
4882
|
-
const delayMs = applyJitter(policy, backoffDelayMs(policy, attempt), deps.rng);
|
|
4883
|
-
try {
|
|
4884
|
-
deps.onRetry?.({ attempt, delayMs, err });
|
|
4885
|
-
} catch {
|
|
4886
|
-
}
|
|
4887
|
-
await deps.clock.sleep(delayMs);
|
|
4888
|
-
}
|
|
4889
|
-
}
|
|
4890
|
-
throw lastErr;
|
|
6262
|
+
return options.summaries === true ? { checked: recs.length, inconsistent, errored, summariesUnchecked } : { checked: recs.length, inconsistent, errored };
|
|
4891
6263
|
}
|
|
4892
6264
|
|
|
4893
6265
|
// src/drivers/retry/retrying-chunk-source.ts
|
|
@@ -4909,6 +6281,7 @@ var RetryingStorageChunkSource = class {
|
|
|
4909
6281
|
/** Present only when the inner source supports it — so capability detection stays honest. */
|
|
4910
6282
|
sizeOf;
|
|
4911
6283
|
cardinalities;
|
|
6284
|
+
summary;
|
|
4912
6285
|
currentGeneration;
|
|
4913
6286
|
invalidate;
|
|
4914
6287
|
exists;
|
|
@@ -4933,6 +6306,10 @@ var RetryingStorageChunkSource = class {
|
|
|
4933
6306
|
if (innerCardinalities) {
|
|
4934
6307
|
this.cardinalities = (ref) => withRetry(() => innerCardinalities.call(inner, ref), this.policy, this.deps);
|
|
4935
6308
|
}
|
|
6309
|
+
const innerSummary = inner.summary;
|
|
6310
|
+
if (innerSummary) {
|
|
6311
|
+
this.summary = (ref) => withRetry(() => innerSummary.call(inner, ref), this.policy, this.deps);
|
|
6312
|
+
}
|
|
4936
6313
|
const innerCurrentGeneration = inner.currentGeneration;
|
|
4937
6314
|
if (innerCurrentGeneration) {
|
|
4938
6315
|
this.currentGeneration = (ref) => withRetry(() => innerCurrentGeneration.call(inner, ref), this.policy, this.deps);
|