@cloudbitmaps/core 0.15.0 → 0.16.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-B7IGS3W6.js → chunk-LFUNPMXZ.js} +1 -1
- package/dist/{chunk-B7IGS3W6.js.map → chunk-LFUNPMXZ.js.map} +2 -2
- package/dist/core/codec.d.ts +10 -0
- package/dist/core/cost.d.ts +1 -1
- package/dist/core/crbm-storage-source.d.ts +3 -3
- package/dist/core/engine.d.ts +36 -6
- package/dist/core/load-input.d.ts +9 -0
- package/dist/core/load.d.ts +20 -1
- package/dist/core/ports.d.ts +29 -4
- package/dist/driver-kit.js +43 -10
- package/dist/driver-kit.js.map +2 -2
- package/dist/drivers/_shared/object-registry.d.ts +16 -3
- package/dist/drivers/localfs/registry.d.ts +4 -0
- package/dist/drivers/memory.d.ts +4 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +228 -47
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
package/dist/core/codec.d.ts
CHANGED
|
@@ -151,6 +151,16 @@ export interface CodecInterface {
|
|
|
151
151
|
safeDeserialize(bytes: Uint8Array, maxBytes: number, options?: {
|
|
152
152
|
readonly whole?: boolean;
|
|
153
153
|
}): CodecBitmap;
|
|
154
|
+
/**
|
|
155
|
+
* Whether `bitmap` is one this codec made: its own type, with the invariants its own operations keep (a `size` that
|
|
156
|
+
* is the true cardinality, a `serialize()` that decodes back to the same set). A load of a combine's chunks
|
|
157
|
+
* ({@link loadSegmentChunks}) writes bitmaps it is given without decoding them again, so it asks this of each and
|
|
158
|
+
* refuses any that answers `false`.
|
|
159
|
+
*
|
|
160
|
+
* **Optional.** A codec without it cannot vouch for a bitmap, so a load of chunks refuses every chunk it is given.
|
|
161
|
+
* The answer is the whole check: an `owns` that answers `true` for anything lets any object through as a bitmap.
|
|
162
|
+
*/
|
|
163
|
+
owns?(bitmap: unknown): boolean;
|
|
154
164
|
}
|
|
155
165
|
/**
|
|
156
166
|
* Resolve a codec that a **public core entry point** was given, failing fast when it is missing.
|
package/dist/core/cost.d.ts
CHANGED
|
@@ -140,7 +140,7 @@ export interface Workload {
|
|
|
140
140
|
* PUT-class requests one load's object write issues, each priced at the PUT rate. Default **1** (a single-object
|
|
141
141
|
* PUT). A multipart write of `P` parts bills `P + 2` (initiate, the parts, complete) — set it when you know your
|
|
142
142
|
* object sizes. The model adds what `store.load()` does around the write, at the default `keep` of 1: the pointer's
|
|
143
|
-
* write, PUT-class on S3, and
|
|
143
|
+
* write, PUT-class on S3, and four GETs: the pointer read twice, one check that the next generation number is
|
|
144
144
|
* free, and one that the current generation's object is there, which tells the collection that deletes by name it
|
|
145
145
|
* may. The current generation's size comes from the row's summary of it, so the load reads no index. Collection
|
|
146
146
|
* deletes the generation the window pushed out by name, so it lists only on every 16th generation, which adds a
|
|
@@ -6,7 +6,7 @@ import type { ChunkRef, ChunkRead, ReadChunksOptions, StorageChunkSource, Genera
|
|
|
6
6
|
import { CrbmReader } from './crbm/reader.js';
|
|
7
7
|
import type { CrbmReaderOptions } from './crbm/reader.js';
|
|
8
8
|
import type { CodecBitmap, CodecInterface } from './codec.js';
|
|
9
|
-
import { DecodedLoadInput } from './load-input.js';
|
|
9
|
+
import { ChunkLoadInput, DecodedLoadInput } from './load-input.js';
|
|
10
10
|
export interface CrbmStorageChunkSourceOptions extends CrbmReaderOptions {
|
|
11
11
|
/**
|
|
12
12
|
* Optional {@link IRegistryDriver}. When provided, the current generation is resolved via the registry's
|
|
@@ -655,7 +655,7 @@ export interface BulkLoadResult {
|
|
|
655
655
|
* {@link WriteConflictError} (write-once). Without a registry a `StorageChunkSource` serves the **highest**
|
|
656
656
|
* generation present, so a too-high number silently shadows real data; with one, `publishGeneration` decides.
|
|
657
657
|
*/
|
|
658
|
-
export declare function bulkLoadCrbmGeneration(driver: IStorageDriver, key: GenKey, ids: Iterable<number> | AsyncIterable<number> | DecodedLoadInput, options?: {
|
|
658
|
+
export declare function bulkLoadCrbmGeneration(driver: IStorageDriver, key: GenKey, ids: Iterable<number> | AsyncIterable<number> | DecodedLoadInput | ChunkLoadInput, options?: {
|
|
659
659
|
registry?: IRegistryDriver;
|
|
660
660
|
keystore?: IKeystore;
|
|
661
661
|
requireEncryption?: boolean;
|
|
@@ -724,7 +724,7 @@ export interface LoadAhead {
|
|
|
724
724
|
* {@link bulkLoadCrbmGeneration} for a load whose number, and perhaps its key, are still being fetched: the same
|
|
725
725
|
* options, with the same meaning, and the same writes. The number is joined only where the object is written.
|
|
726
726
|
*/
|
|
727
|
-
export declare function bulkLoadAhead(driver: IStorageDriver, ref: SegmentRef, ahead: LoadAhead, ids:
|
|
727
|
+
export declare function bulkLoadAhead(driver: IStorageDriver, ref: SegmentRef, ahead: LoadAhead, ids: Parameters<typeof bulkLoadCrbmGeneration>[2], options?: NonNullable<Parameters<typeof bulkLoadCrbmGeneration>[3]>): Promise<BulkLoadResult>;
|
|
728
728
|
/**
|
|
729
729
|
* Open a {@link CrbmReader} on one generation over the storage driver's range/tail reads (decrypting if `crypto`).
|
|
730
730
|
* The reader the write paths use to re-read what they wrote, and the erasure rewrite uses to stream the old
|
package/dist/core/engine.d.ts
CHANGED
|
@@ -78,6 +78,11 @@ export interface CombineOptions extends IdRange {
|
|
|
78
78
|
*/
|
|
79
79
|
readonly allowAbsentOperands?: boolean;
|
|
80
80
|
}
|
|
81
|
+
/** One chunk of a combine's result: the bitmap of its remainders, owned by whoever reads it. */
|
|
82
|
+
export interface CombinedChunk {
|
|
83
|
+
readonly chunkKey: number;
|
|
84
|
+
readonly bitmap: CodecBitmap;
|
|
85
|
+
}
|
|
81
86
|
export declare class SegmentEngine {
|
|
82
87
|
private readonly storage;
|
|
83
88
|
private readonly cache;
|
|
@@ -99,6 +104,8 @@ export declare class SegmentEngine {
|
|
|
99
104
|
* they deliver, as a read whose entry an invalidation dropped does not (streams hold no `openReads` entry).
|
|
100
105
|
*/
|
|
101
106
|
private readonly openStreams;
|
|
107
|
+
/** How many times {@link invalidate} has been called: a read compares it with the count it began under. */
|
|
108
|
+
private invalidations;
|
|
102
109
|
constructor(deps: EngineDeps);
|
|
103
110
|
/** Membership: one chunk lookup — the cache, else one Storage fetch of that chunk. */
|
|
104
111
|
has(seg: SegmentRef, id: number): Promise<boolean>;
|
|
@@ -215,6 +222,20 @@ export declare class SegmentEngine {
|
|
|
215
222
|
unionBatches(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<Uint32Array>;
|
|
216
223
|
/** {@link andNot}, one `Uint32Array` per chunk — see {@link iterateBatches}. */
|
|
217
224
|
andNotBatches(seg: SegmentRef, excludes: readonly SegmentRef[], options?: Omit<CombineOptions, 'exclude'>): AsyncGenerator<Uint32Array>;
|
|
225
|
+
/**
|
|
226
|
+
* {@link intersect}, as the chunks the result is made of: `{ chunkKey, bitmap }` in ascending key order, each a
|
|
227
|
+
* non-empty bitmap of the chunk's 16-bit remainders, cut to the read's range. What a materialisation writes
|
|
228
|
+
* straight into its new generation, with no id built on the way.
|
|
229
|
+
*
|
|
230
|
+
* **Each bitmap belongs to the caller, who may change it:** it is the combine's own accumulator or a bitmap built
|
|
231
|
+
* for the range's edge, never a chunk the cache holds or another operand's. The reads, the budget, the metric and the
|
|
232
|
+
* errors are the id reads' own.
|
|
233
|
+
*/
|
|
234
|
+
intersectChunks(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<CombinedChunk>;
|
|
235
|
+
/** {@link union}, as chunks: see {@link intersectChunks}. */
|
|
236
|
+
unionChunks(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<CombinedChunk>;
|
|
237
|
+
/** {@link andNot}, as chunks: see {@link intersectChunks}. */
|
|
238
|
+
andNotChunks(seg: SegmentRef, excludes: readonly SegmentRef[], options?: Omit<CombineOptions, 'exclude'>): AsyncGenerator<CombinedChunk>;
|
|
218
239
|
/**
|
|
219
240
|
* Everything a combine decides before it reads a chunk: validation, each operand's keys and generation, the
|
|
220
241
|
* absent-operand refusal, key alignment, the budget and the metric. `null` when the range is empty. The per-id
|
|
@@ -223,6 +244,10 @@ export declare class SegmentEngine {
|
|
|
223
244
|
private combinePlan;
|
|
224
245
|
private combine;
|
|
225
246
|
private combineBatches;
|
|
247
|
+
/** The chunk read: the plan every combine shares, then each key's result, cut to the range at its edges. */
|
|
248
|
+
private combineChunks;
|
|
249
|
+
/** An edge chunk's bitmap cut to the window, as a new bitmap: the remainders `edgeIds` would yield. */
|
|
250
|
+
private cutToWindow;
|
|
226
251
|
/**
|
|
227
252
|
* The AND (or OR) of one chunk key across all operands, minus the excludes that hold it, or `null` if empty.
|
|
228
253
|
* Operand chunks are fetched **in parallel** (the spec's parallel byte-range reads).
|
|
@@ -239,16 +264,21 @@ export declare class SegmentEngine {
|
|
|
239
264
|
*/
|
|
240
265
|
private operandChunk;
|
|
241
266
|
/**
|
|
242
|
-
*
|
|
243
|
-
* `getChunks`, a segment with no generation, or nothing to read.
|
|
244
|
-
*
|
|
245
|
-
* requested until the first chunk is asked for.
|
|
267
|
+
* `keys` of `seg` as a stream of coalesced ranges, or `undefined` when the read is per key: a source with no
|
|
268
|
+
* `getChunks`, a segment with no generation, or nothing to read. Nothing is requested, and no cache lookup made, until
|
|
269
|
+
* the first chunk is asked for (see {@link StreamedChunks}).
|
|
246
270
|
*/
|
|
247
271
|
private openStreamed;
|
|
272
|
+
/**
|
|
273
|
+
* Open the stream of `streamed`, at its first uncached chunk `from`: over that chunk and the later keys the cache does
|
|
274
|
+
* not hold. Each later key is counted as a cache hit or miss here, so a key is counted once whichever way it is served.
|
|
275
|
+
*/
|
|
276
|
+
private startStream;
|
|
248
277
|
private closeStreamed;
|
|
249
278
|
/**
|
|
250
|
-
* The decoded chunk at `chunkKey` of a streamed read: the
|
|
251
|
-
*
|
|
279
|
+
* The decoded chunk at `chunkKey` of a streamed read: from the cache while the stream is unopened and the cache holds
|
|
280
|
+
* it; else the next chunk of its stream, or, for a chunk the cache held when the stream opened, the cached instance
|
|
281
|
+
* while it is still there, and a read of that chunk alone once it is not.
|
|
252
282
|
*/
|
|
253
283
|
private streamedChunk;
|
|
254
284
|
/**
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* All of it runs before the load's first request, so a malformed input costs no round trip and writes nothing.
|
|
11
11
|
*/
|
|
12
12
|
import type { CodecBitmap, CodecInterface } from './codec.js';
|
|
13
|
+
import type { CombinedChunk } from './engine.js';
|
|
13
14
|
/**
|
|
14
15
|
* Anything that serializes itself to portable Roaring: `roaring`'s `RoaringBitmap32` is one. Typed by shape, so
|
|
15
16
|
* no codec's own class appears in a signature. A bitmap that can also say how large its serialization is
|
|
@@ -44,6 +45,14 @@ export declare class DecodedLoadInput {
|
|
|
44
45
|
readonly bitmap: CodecBitmap;
|
|
45
46
|
constructor(bitmap: CodecBitmap);
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* A combine's result as the bitmaps of its chunks, ascending by key: written as they are, never id by id. Made only by
|
|
50
|
+
* {@link loadSegmentChunks}, never by {@link prepareLoadInput}, so no input a load is handed can be one.
|
|
51
|
+
*/
|
|
52
|
+
export declare class ChunkLoadInput {
|
|
53
|
+
readonly chunks: AsyncIterable<CombinedChunk>;
|
|
54
|
+
constructor(chunks: AsyncIterable<CombinedChunk>);
|
|
55
|
+
}
|
|
47
56
|
/**
|
|
48
57
|
* Check a load's input and decode a bitmap input, before the load makes any request.
|
|
49
58
|
*
|
package/dist/core/load.d.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* object, so it stays, and collection takes it like any other generation once one above it is current.
|
|
21
21
|
*/
|
|
22
22
|
import { type IAuditSink } from './audit.js';
|
|
23
|
-
import { type CodecInterface } from './codec.js';
|
|
23
|
+
import { type CodecBitmap, type CodecInterface } from './codec.js';
|
|
24
24
|
import type { Clock, Rng } from './determinism.js';
|
|
25
25
|
import type { IKeystore } from './crypto.js';
|
|
26
26
|
import { type LoadInput } from './load-input.js';
|
|
@@ -174,3 +174,22 @@ export interface LoadResult {
|
|
|
174
174
|
* interesting cases (a guard tripped, a racing writer won) are operational facts rather than faults.
|
|
175
175
|
*/
|
|
176
176
|
export declare function loadSegment(ref: SegmentRef, input: LoadInput, deps: LoadDeps, options?: LoadOptions): Promise<LoadResult>;
|
|
177
|
+
/**
|
|
178
|
+
* {@link loadSegment} for the result of a combine, as the chunks it is made of: each `{ chunkKey, bitmap }` is the
|
|
179
|
+
* bitmap of one 16-bit chunk's remainders, and they ascend by key. The chunks are written as they are, so no id is built
|
|
180
|
+
* for a value. What the `*Into` verbs call; the unwired form of them, as {@link loadSegment} is of `store.load`.
|
|
181
|
+
*
|
|
182
|
+
* Every chunk is checked before anything is written. Its bitmap must be one the `codec` made (`codec.owns`, so a codec
|
|
183
|
+
* without it refuses every chunk), its key an integer in `[0, 65535]` above the one before, and its values 16-bit;
|
|
184
|
+
* anything else is a {@link ValidationError}. An empty bitmap is left out, as no empty chunk is stored.
|
|
185
|
+
*
|
|
186
|
+
* **The load consumes the bitmaps it is given.** Writing a chunk may re-encode its bitmap in place for size, which
|
|
187
|
+
* changes its representation and never its members, so pass bitmaps you made for this call and do not reuse them. A
|
|
188
|
+
* bitmap the chunk cache holds, or one a caller still reads, is not one to pass.
|
|
189
|
+
*
|
|
190
|
+
* Everything else is `loadSegment`'s, the options, the result and the refusals included.
|
|
191
|
+
*/
|
|
192
|
+
export declare function loadSegmentChunks(ref: SegmentRef, chunks: AsyncIterable<{
|
|
193
|
+
readonly chunkKey: number;
|
|
194
|
+
readonly bitmap: CodecBitmap;
|
|
195
|
+
}>, deps: LoadDeps, options?: LoadOptions): Promise<LoadResult>;
|
package/dist/core/ports.d.ts
CHANGED
|
@@ -412,6 +412,23 @@ export interface NewRegistryRecord {
|
|
|
412
412
|
* `currentGen` the row will have.
|
|
413
413
|
*/
|
|
414
414
|
export type RegistryPatch = Partial<Pick<RegistryRecord, 'currentGen' | 'wrappedDeks' | 'keyId' | 'status' | 'retention' | 'residency' | 'summary'>>;
|
|
415
|
+
/**
|
|
416
|
+
* What a caller passes {@link IRegistryDriver.create} or {@link IRegistryDriver.compareAndSwap} besides the write itself.
|
|
417
|
+
*/
|
|
418
|
+
export interface RegistryWriteOptions {
|
|
419
|
+
/**
|
|
420
|
+
* The row the caller read, and is writing against: the record a {@link IRegistryDriver.get} of this registry returned
|
|
421
|
+
* to it, or `null` when that read found no row. A driver that would read the row to learn the version its conditional
|
|
422
|
+
* write is fenced on can use the one it observed then, and send the write at once.
|
|
423
|
+
*
|
|
424
|
+
* It is a hint, and never the fence: the write is still conditioned on the store's own version of the row, so a row
|
|
425
|
+
* that has changed since makes it fail as a lost race does, with {@link WriteConflictError}. A driver reads the row
|
|
426
|
+
* as it does without a hint when it does not recognise the record (one it did not return, or one that is not of
|
|
427
|
+
* this row), or when `held` and the `expected` token of a `compareAndSwap` disagree. A write that gets no answer is
|
|
428
|
+
* settled by reading the row, never by this record. A driver that ignores the option reads the row.
|
|
429
|
+
*/
|
|
430
|
+
readonly held?: RegistryRecord | null;
|
|
431
|
+
}
|
|
415
432
|
/** Capabilities a registry driver advertises; validated fail-fast at wiring time. */
|
|
416
433
|
export interface RegCaps {
|
|
417
434
|
/** REQUIRED — `currentGen` feeds read correctness + the publish CAS, so reads must be strongly consistent. */
|
|
@@ -560,12 +577,20 @@ export interface IRegistryDriver {
|
|
|
560
577
|
* serving what it holds and asks again shortly, while any other error reaches the read that met it.
|
|
561
578
|
*/
|
|
562
579
|
get(ref: SegmentRef): Promise<RegistryRecord | null>;
|
|
563
|
-
/**
|
|
564
|
-
|
|
580
|
+
/**
|
|
581
|
+
* Create the row; throws {@link WriteConflictError} if it already exists (use CAS to mutate). With
|
|
582
|
+
* `options.held` of `null`, the caller found no row: a driver may send the create-only write without reading first,
|
|
583
|
+
* and one that then loses it reads the row, as it does without the hint.
|
|
584
|
+
*/
|
|
585
|
+
create(ref: SegmentRef, record: NewRegistryRecord, options?: RegistryWriteOptions): Promise<{
|
|
565
586
|
token: Token;
|
|
566
587
|
}>;
|
|
567
|
-
/**
|
|
568
|
-
|
|
588
|
+
/**
|
|
589
|
+
* Server-side compare-and-set: apply `patch` iff the stored token equals `expected`, else `WriteConflictError`.
|
|
590
|
+
* `options.held` is the row the caller read, which a driver may use to skip its own read; see
|
|
591
|
+
* {@link RegistryWriteOptions}.
|
|
592
|
+
*/
|
|
593
|
+
compareAndSwap(ref: SegmentRef, expected: Token, patch: RegistryPatch, options?: RegistryWriteOptions): Promise<{
|
|
569
594
|
token: Token;
|
|
570
595
|
}>;
|
|
571
596
|
/**
|
package/dist/driver-kit.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
validateRegistryPatch,
|
|
29
29
|
validateSegmentRef,
|
|
30
30
|
webCryptoEntropy
|
|
31
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-LFUNPMXZ.js";
|
|
32
32
|
|
|
33
33
|
// src/drivers/_shared/object-registry-keys.ts
|
|
34
34
|
var REGISTRY_SUFFIX = ".reg";
|
|
@@ -135,29 +135,48 @@ var ObjectStoreRegistry = class {
|
|
|
135
135
|
removesRows() {
|
|
136
136
|
return this.store.conditionalDelete === true && typeof this.store.delete === "function";
|
|
137
137
|
}
|
|
138
|
+
/** The object each record `get` returned was read from, to fence a write made against it ({@link RegistryWriteOptions.held}). */
|
|
139
|
+
observed = /* @__PURE__ */ new WeakMap();
|
|
138
140
|
async get(ref) {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
+
const key = registryObjectKey(this.prefix, ref);
|
|
142
|
+
const row = await this.readRow(key);
|
|
143
|
+
if (row === null || row.env.deleted) return null;
|
|
144
|
+
this.observed.set(row.env.record, { key, bytes: row.bytes, version: row.version });
|
|
145
|
+
return row.env.record;
|
|
141
146
|
}
|
|
142
|
-
async create(ref, record) {
|
|
147
|
+
async create(ref, record, options) {
|
|
143
148
|
const checked = validateNewRegistryRecord(record);
|
|
144
149
|
const key = registryObjectKey(this.prefix, ref);
|
|
150
|
+
let lost;
|
|
151
|
+
if (options?.held === null) {
|
|
152
|
+
try {
|
|
153
|
+
return await this.createOver(key, ref, checked, void 0);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (!isWriteConflictError(err)) throw err;
|
|
156
|
+
lost = err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
145
159
|
const current = await this.readRow(key);
|
|
146
160
|
if (current !== null && !current.env.deleted) {
|
|
147
161
|
throw new WriteConflictError(`registry row already exists for segment ${ref.segment}`);
|
|
148
162
|
}
|
|
149
|
-
|
|
163
|
+
if (lost !== void 0 && current === null) throw lost;
|
|
164
|
+
return this.createOver(key, ref, checked, current ?? void 0);
|
|
165
|
+
}
|
|
166
|
+
/** Write a new incarnation of the row: over `tombstone` under its version, or create-only when there is none. */
|
|
167
|
+
async createOver(key, ref, checked, tombstone) {
|
|
168
|
+
const token = newIncarnationToken(this.entropy, tombstone?.env.record);
|
|
150
169
|
const env = {
|
|
151
170
|
deleted: false,
|
|
152
171
|
record: recordFromNew(ref, checked, this.now(), token)
|
|
153
172
|
};
|
|
154
|
-
await this.putRow(key, env,
|
|
173
|
+
await this.putRow(key, env, tombstone ? { version: tombstone.version } : "absent");
|
|
155
174
|
return { token };
|
|
156
175
|
}
|
|
157
|
-
async compareAndSwap(ref, expected, patch) {
|
|
176
|
+
async compareAndSwap(ref, expected, patch, options) {
|
|
158
177
|
const checked = validateRegistryPatch(patch);
|
|
159
178
|
const key = registryObjectKey(this.prefix, ref);
|
|
160
|
-
const current = await this.readRow(key);
|
|
179
|
+
const current = this.observedRow(key, expected, options?.held) ?? await this.readRow(key);
|
|
161
180
|
if (current === null || current.env.deleted || current.env.record.token !== expected) {
|
|
162
181
|
throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);
|
|
163
182
|
}
|
|
@@ -169,6 +188,17 @@ var ObjectStoreRegistry = class {
|
|
|
169
188
|
await this.putRow(key, env, { version: current.version });
|
|
170
189
|
return { token };
|
|
171
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* The row a caller's `held` record was read as, decoded again from the bytes `get` saw so that nothing the caller did
|
|
193
|
+
* to its copy reaches the write, or `undefined` when `held` is not a record this registry returned for this row under
|
|
194
|
+
* the token the write expects. The version it carries is the store's fence, so a row that has moved on since fails
|
|
195
|
+
* the write.
|
|
196
|
+
*/
|
|
197
|
+
observedRow(key, expected, held) {
|
|
198
|
+
const seen = held ? this.observed.get(held) : void 0;
|
|
199
|
+
if (seen === void 0 || seen.key !== key || held?.token !== expected) return void 0;
|
|
200
|
+
return this.decodeRow(key, seen);
|
|
201
|
+
}
|
|
172
202
|
async *list(namespace) {
|
|
173
203
|
const prefix = registryListPrefix(this.prefix, namespace);
|
|
174
204
|
let page = [];
|
|
@@ -233,7 +263,10 @@ var ObjectStoreRegistry = class {
|
|
|
233
263
|
*/
|
|
234
264
|
async readRow(objectKey) {
|
|
235
265
|
const row = await this.readRaced(objectKey);
|
|
236
|
-
|
|
266
|
+
return row === null ? null : this.decodeRow(objectKey, row);
|
|
267
|
+
}
|
|
268
|
+
/** Check and parse the bytes of a registry object. */
|
|
269
|
+
decodeRow(objectKey, row) {
|
|
237
270
|
if (row.bytes.length > MAX_ROW_BYTES) {
|
|
238
271
|
throw new IntegrityError(
|
|
239
272
|
`registry object ${row.bytes.length}B exceeds cap ${MAX_ROW_BYTES}B`
|
|
@@ -245,7 +278,7 @@ var ObjectStoreRegistry = class {
|
|
|
245
278
|
);
|
|
246
279
|
}
|
|
247
280
|
const env = parseRegistryEnvelope(new TextDecoder().decode(row.bytes), objectKey);
|
|
248
|
-
return { env, version: row.version };
|
|
281
|
+
return { env, bytes: row.bytes, version: row.version };
|
|
249
282
|
}
|
|
250
283
|
/** {@link ObjectRegistryStore.read}, retrying the bounded number of times a mid-read overwrite allows. */
|
|
251
284
|
async readRaced(objectKey) {
|
package/dist/driver-kit.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/drivers/_shared/object-registry-keys.ts", "../src/drivers/_shared/object-registry.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * The registry object layout, shared by every object-store registry (S3, GCS, Azure Blob).\n *\n * One tiny JSON object per segment at `<prefix>registry/<ns>/<segment>.reg`, **registry-first** \u2014 separate\n * from the namespace-first storage layout (`<prefix><ns>/segments/\u2026`) \u2014 so discovery is a single list over\n * `registry/` for all namespaces, or `registry/<ns>/` for one, never entangled with the `.crbm` payloads.\n *\n * It lives here rather than in a driver because all three object stores encode names the same way\n * (`encodeNameForKey`, `namespaceKeyPart`) and build byte-identical keys. Three copies of this would be\n * three chances for one cloud's layout to drift from the others \u2014 and a drifted key is not a visible bug, it\n * is a segment that quietly cannot be found by a store pointed at the same bucket.\n */\nimport { ValidationError } from '@/core/errors';\nimport { validateSegmentRef } from '@/core/validate';\nimport type { SegmentRef } from '@/core/ports';\nimport { DEFAULT_NAMESPACE, decodeNameFromKey, encodeNameForKey, namespaceKeyPart } from './keys';\n\nconst REGISTRY_SUFFIX = '.reg';\n\n/** Normalize an optional caller prefix to either `''` or `trimmed/` (no leading/trailing slashes). */\nexport function prefixPart(prefix: string | undefined): string {\n if (prefix === undefined) return '';\n const trimmed = prefix.replace(/^\\/+|\\/+$/g, '');\n return trimmed === '' ? '' : `${trimmed}/`;\n}\n\n/**\n * Validate a caller-supplied key prefix (trusted config, but make it a real containment boundary): reject\n * control characters and `.`/`..` path segments so a prefix cannot traverse out of its intended space.\n * Returns it unchanged \u2014 the key builders normalize slashes.\n *\n * The traversal check covers three spellings, because the tools that walk these buckets do. A bare `..` is\n * the obvious one. A **backslash** segment matters on ADLS Gen2 and to Windows-side tooling, which treat it\n * as a separator this code otherwise would not. And a percent-encoded `..` matters because `gsutil`,\n * `s3fs`, `gcsfuse` and `azcopy` decode a key on their way to a local path \u2014 the same reasoning\n * `name-codec.ts` already applies to segment names, which a prefix has no reason to be exempt from.\n */\nexport function normalizeObjectPrefix(prefix: string | undefined): string | undefined {\n if (prefix === undefined) return undefined;\n for (const ch of prefix) {\n const code = ch.charCodeAt(0);\n // C0 plus DEL: a bare `< 0x20` lets U+007F through, and it is as unprintable as the rest.\n if (code < 0x20 || code === 0x7f) {\n throw new ValidationError('prefix must not contain control characters');\n }\n }\n if (prefix.includes('\\\\')) {\n throw new ValidationError(\n `prefix must not contain backslashes (a path separator on some backends): ${JSON.stringify(prefix)}`,\n );\n }\n // Check the decoded spelling too, so an encoded `..` cannot slip past the literal comparison.\n let decoded: string;\n try {\n decoded = decodeURIComponent(prefix);\n } catch {\n decoded = prefix; // not valid percent-encoding; the literal check still applies\n }\n for (const source of decoded === prefix ? [prefix] : [prefix, decoded]) {\n for (const segment of source.split('/')) {\n if (segment === '.' || segment === '..') {\n throw new ValidationError(\n `prefix must not contain \".\" or \"..\" path segments: ${JSON.stringify(prefix)}`,\n );\n }\n }\n }\n return prefix;\n}\n\n/** The key prefix under which every registry object lives: `<prefix>registry/`. */\nexport function registryPrefix(prefix: string | undefined): string {\n return `${prefixPart(prefix)}registry/`;\n}\n\n/** The full key of one segment's registry object: `<prefix>registry/<ns>/<segment>.reg`. */\nexport function registryObjectKey(prefix: string | undefined, ref: SegmentRef): string {\n validateSegmentRef(ref);\n return `${registryPrefix(prefix)}${namespaceKeyPart(ref.namespace)}/${encodeNameForKey(ref.segment)}${REGISTRY_SUFFIX}`;\n}\n\n/** The list prefix for discovery: registry-wide, or scoped to one namespace. */\nexport function registryListPrefix(prefix: string | undefined, namespace?: string): string {\n const base = registryPrefix(prefix);\n return namespace === undefined ? base : `${base}${namespaceKeyPart(namespace)}/`;\n}\n\n/**\n * Parse a `<prefix>registry/<ns>/<segment>.reg` key back to its {@link SegmentRef}, or `null` if it does not\n * match \u2014 a stray or foreign object under the prefix, or one whose parsed ref fails the round-trip check.\n * `_default` maps back to the absent namespace. A name is percent-encoded on the way in, so no encoded name\n * can contain `/` and the split stays unambiguous whatever the caller named their segment.\n */\nexport function parseRegistryKey(prefix: string | undefined, objectKey: string): SegmentRef | null {\n const base = registryPrefix(prefix);\n if (!objectKey.startsWith(base) || !objectKey.endsWith(REGISTRY_SUFFIX)) return null;\n const rest = objectKey.slice(base.length, objectKey.length - REGISTRY_SUFFIX.length);\n const slash = rest.indexOf('/');\n if (slash < 0) return null;\n const nsPart = rest.slice(0, slash);\n const encodedSegment = rest.slice(slash + 1);\n if (encodedSegment.length === 0 || encodedSegment.includes('/')) return null;\n const segment = decodeNameFromKey(encodedSegment);\n const namespace = nsPart === DEFAULT_NAMESPACE ? undefined : decodeNameFromKey(nsPart);\n // The encoding must ROUND-TRIP, not merely decode: a foreign object placed under our prefix could spell a\n // name two ways, and reporting both would hand a sweep one segment under two identities.\n if (encodeNameForKey(segment) !== encodedSegment) return null;\n if (namespace !== undefined && encodeNameForKey(namespace) !== nsPart) return null;\n const ref: SegmentRef = { segment, namespace };\n try {\n validateSegmentRef(ref); // reject a hostile/foreign key that isn't a valid ref\n } catch {\n return null;\n }\n return ref;\n}\n", "/**\n * `ObjectStoreRegistry` \u2014 an {@link IRegistryDriver} over any object store with conditional writes.\n *\n * Lets a **read-mostly deployment run on one bucket alone** \u2014 storage `.crbm` generations plus the registry in\n * the same place, with no separate database. One tiny JSON object per segment at\n * `<prefix>registry/<ns>/<segment>.reg` holding the `{ deleted, record }` envelope (the same shape LocalFs\n * persists). The OCC token is a random incarnation id drawn when the row is created, a counter advanced on every\n * mutation, and a random part drawn for every write, so with overwhelming probability a deleted-then-recreated row, or\n * one restored from a backup, never re-issues an old token (ABA-safe) \u2014 identical semantics to the LocalFs registry, so it passes the same\n * conformance suite.\n *\n * **`delete` removes a row for good where the store vouches for a conditional delete** (`If-Match: <etag>`,\n * `ifGenerationMatch: <generation>`), and only a row whose token carries an incarnation id: a re-create draws a new\n * incarnation, so nothing of the old row is needed to keep its tokens apart, and a full `list()` no longer reads a\n * row for every name it ever held. Every other row is **tombstoned**: the object stays, its counter advanced, and a\n * re-create carries the counter on. A row a release before 0.12 wrote has a bare decimal token and is always\n * tombstoned, since a process still on that release, re-creating the name over nothing, would start its counter at 0\n * again and issue the old row's tokens.\n *\n * **The atomic swap is offloaded to the store's conditional writes.** `create` writes only if absent (or\n * over a tombstone under its version), and `compareAndSwap`/`delete` write (or delete) only if the object still\n * carries the version we read \u2014 so a concurrent writer between our read and our write loses, and the loss surfaces\n * as {@link WriteConflictError}. No in-process lock is needed (unlike LocalFs): the precondition fences\n * writers *across processes*.\n *\n * **Why this is shared rather than written per cloud.** Every object store worth using has the same two\n * primitives under different names \u2014 S3 `If-None-Match: *` / `If-Match: <etag>`, GCS `ifGenerationMatch: 0`\n * / `ifGenerationMatch: <generation>`, Azure `If-None-Match: *` / `If-Match: <etag>` \u2014 so the only thing\n * that differs is a few I/O calls, which is what {@link ObjectRegistryStore} abstracts. The parts that are\n * genuinely hard (the ABA-safe token, the delete and its tombstone, the bounded delete retry, the round-trip key check)\n * exist once. Three copies would be three chances for one cloud to drift, and a drifted registry is not a\n * visible bug \u2014 it is a lost `currentGen` swap.\n *\n * **Deployment requirements** (a backend that violates these silently corrupts the registry):\n * - The store **must honor the conditional write**. One that accepts the precondition header and ignores it\n * degrades compare-and-swap to last-write-wins \u2192 lost `currentGen` swaps.\n * - Reads must be **strongly consistent** \u2014 true of S3 (since 2020), GCS and Azure Blob \u2014 since the registry\n * advertises `strongRead` and generation resolution depends on it.\n * - **Do not apply a lifecycle-expiration rule to the `registry/` prefix that expires a current version.**\n * Expiring a live row loses its pointer. A tombstone `delete` left keeps the counter for a re-create; one expired\n * by a rule is no worse than a row removed for good, since a recreate draws a fresh incarnation and its tokens are\n * new but for a collision of probability 2^-128 per pair of incarnations, but a rule cannot tell a tombstone from a\n * live row.\n * - **A store's conditional delete must be honoured by its backend** before the store sets `conditionalDelete`.\n * One that ignores the precondition lets two sweepers and a re-create delete a live row.\n *\n * **`list()` is fail-closed, and one bad object stops it for everyone.** An object under the `registry/`\n * prefix whose key parses but whose body does not aborts the whole enumeration \u2014 every namespace, not just\n * the affected segment \u2014 and it stays that way until an operator removes the object. The error names the\n * offending key.\n *\n * That is the deliberate choice, and it is the *safer* one rather than the more convenient one. Skipping the\n * unreadable row would keep discovery running, but `list()` is what tells orphan generation collection which\n * segments exist: a row missing from the enumeration makes its storage `.crbm` generations look unreferenced,\n * and the next GC pass would delete them. Silently skipping turns a parse error into data loss. Refusing to\n * enumerate keeps every sweep off a set it cannot vouch for (invariant 5 \u2014 bytes from the tier are\n * untrusted). **Recovery:** read the named object, and either restore a valid envelope or delete it; nothing\n * else is damaged, and discovery resumes on its own.\n */\nimport {\n IntegrityError,\n TransientError,\n ValidationError,\n WriteConflictError,\n isWriteConflictError,\n} from '@/core/errors';\nimport type {\n IRegistryDriver,\n NewRegistryRecord,\n RegCaps,\n RegistryPatch,\n RegistryRecord,\n SegmentRef,\n Token,\n} from '@/core/ports';\nimport type { Entropy } from '@/core/determinism';\nimport { mapWithConcurrency } from '@/core/concurrency';\nimport {\n applyRegistryPatch,\n incarnationOf,\n newIncarnationToken,\n nextRegistryToken,\n parseRegistryEnvelope,\n recordFromNew,\n serializeRegistryEnvelope,\n validateNewRegistryRecord,\n validateRegistryPatch,\n type RegistryEnvelope,\n} from './registry';\nimport { entropyIsAvailable, webCryptoEntropy } from './entropy';\nimport { parseRegistryKey, registryListPrefix, registryObjectKey } from './object-registry-keys';\n\n/** Defensive cap on a single registry object read from storage, before allocation (rows are tiny; ~1 KB). */\nexport const MAX_ROW_BYTES = 1 * 1024 * 1024;\n/** Bounded retry for `delete`'s read then delete or tombstone, under cross-process contention (then fails typed). */\nconst MAX_DELETE_ATTEMPTS = 8;\n/** Bounded re-read when a store's pinned version is overwritten mid-read (see {@link ObjectVersionRaced}). */\nconst MAX_READ_ATTEMPTS = 8;\n/** In-flight reads per `list()` page \u2014 turns the serial N+1 into one list plus bounded parallel reads. */\nconst LIST_READ_CONCURRENCY = 48;\n\n/**\n * Raised by a store when the version it pinned for a read vanished before it could fetch the bytes \u2014\n * a concurrent writer overwrote or deleted the object in between.\n *\n * This is deliberately **neither** of the two things it superficially resembles. It is not absence: the row\n * is very likely still live, just one version further on. And it is not a write conflict: the caller may be\n * a read-only `get()`, which has nothing to conflict with. Either is an easy misreading for a store that reads a\n * row in two calls, metadata and then bytes pinned to it, which is why the signal is part of the port rather than\n * left to each store's judgement. {@link ObjectStoreRegistry} answers it the only way that is correct for every\n * caller: it reads again.\n *\n * Stores whose read is atomic can never raise it, and every shipped store's is: S3 serves bytes and `ETag` from one\n * `GetObject`, GCS bytes and `generation` from one GET, and Azure Blob bytes and `ETag` from one GET.\n */\nexport class ObjectVersionRaced extends Error {\n constructor(objectKey: string) {\n super(`registry object was overwritten mid-read: ${objectKey}`);\n this.name = 'ObjectVersionRaced';\n }\n}\n\n/** One object as read, with the opaque version that fences a later conditional write. */\nexport interface ObjectRow {\n readonly bytes: Uint8Array;\n /** The store's version fence \u2014 an S3/Azure ETag, a GCS generation. Opaque to this class. */\n readonly version: string;\n}\n\n/**\n * The I/O calls an object store must provide to host a registry: three, and an optional conditional delete. Everything\n * else \u2014 the OCC token, tombstones, the delete retry, key parsing \u2014 is implemented once above.\n */\nexport interface ObjectRegistryStore {\n /** A label for error messages, e.g. `GCS` or `Azure Blob`. */\n readonly label: string;\n /**\n * Read one object, or `null` when it is absent.\n *\n * **`null` means the object does not exist** \u2014 nothing weaker. A store that reads in two round trips\n * (metadata for the version fence, then the bytes pinned to it) can find that version already gone; it\n * MUST report that with {@link ObjectVersionRaced} rather than `null` or {@link WriteConflictError}, and\n * the caller re-reads. Retryable faults throw {@link TransientError}.\n *\n * `bytes` and `version` MUST describe the **same** observation of the object: `version` is the fence a\n * later conditional write is conditioned on, so a pair drawn from two different versions would either\n * fail a write that should have succeeded or, worse, pass one that should not.\n */\n read(key: string): Promise<ObjectRow | null>;\n /**\n * Write one object under a precondition: `'absent'` means create-only, a version means\n * compare-and-swap against exactly that version. A lost race MUST throw {@link WriteConflictError}.\n *\n * A store must not replay this write after a lost response without telling the replay apart: the replay meets\n * the row it just wrote and its precondition fails, which would read as a lost race. Send it once, or store a\n * write id with the row and treat a conflict whose stored row carries it as success. Retryable faults throw\n * {@link TransientError}.\n */\n write(key: string, body: Uint8Array, expect: 'absent' | { version: string }): Promise<void>;\n /** Every object key under `prefix`, paginated internally. */\n listKeys(prefix: string): AsyncIterable<string>;\n /**\n * Optional: delete one object, and only while it is still at exactly `expect.version`, by a precondition the\n * backend applies (`If-Match: <etag>`, `ifGenerationMatch: <generation>`). An object that was overwritten, or is\n * gone, MUST throw {@link WriteConflictError} and delete nothing. Retryable faults throw {@link TransientError}.\n *\n * Sending it again after a lost response is safe, unlike a write: the precondition names one version, so a second\n * copy can remove nothing the first was not allowed to. A copy that meets its own landed delete fails the\n * precondition and reports a conflict; the registry re-reads, and finds the row gone.\n *\n * The registry calls it only when {@link conditionalDelete} is `true`.\n */\n delete?(key: string, expect: { version: string }): Promise<void>;\n /**\n * Whether {@link delete} may be relied on: `true` only where the backend is known to apply the precondition. A\n * backend that accepts the header and ignores it would let a delete remove a row written after the delete read it,\n * a live one included. Absent or `false`, the registry never calls `delete` and tombstones every row instead.\n */\n readonly conditionalDelete?: boolean;\n}\n\nexport class ObjectStoreRegistry implements IRegistryDriver {\n /**\n * `entropy` draws the random parts of every token: a new row's incarnation id, and each write's own part. It\n * defaults to the platform's Web Crypto. Inject one only to make a test replayable: a seeded source in production\n * gives every process the same ids.\n */\n constructor(\n private readonly store: ObjectRegistryStore,\n private readonly prefix: string | undefined,\n private readonly now: () => number,\n private readonly entropy: Entropy = webCryptoEntropy,\n ) {}\n\n capabilities(): RegCaps {\n const conditionalDelete = this.removesRows();\n return entropyIsAvailable(this.entropy)\n ? { strongRead: true, conditionalDelete }\n : { strongRead: true, canWrite: false, conditionalDelete };\n }\n\n /** Whether a delete removes a row born with an incarnation id: the store has a delete, and vouches for it. */\n private removesRows(): boolean {\n return this.store.conditionalDelete === true && typeof this.store.delete === 'function';\n }\n\n async get(ref: SegmentRef): Promise<RegistryRecord | null> {\n const current = await this.readRow(registryObjectKey(this.prefix, ref));\n return current && !current.env.deleted ? current.env.record : null;\n }\n\n async create(ref: SegmentRef, record: NewRegistryRecord): Promise<{ token: Token }> {\n const checked = validateNewRegistryRecord(record);\n const key = registryObjectKey(this.prefix, ref);\n const current = await this.readRow(key);\n if (current !== null && !current.env.deleted) {\n throw new WriteConflictError(`registry row already exists for segment ${ref.segment}`);\n }\n // A new incarnation, whose counter continues across a tombstone, so with overwhelming probability a recreate never re-issues an old token.\n const token = newIncarnationToken(this.entropy, current?.env.record);\n const env: RegistryEnvelope = {\n deleted: false,\n record: recordFromNew(ref, checked, this.now(), token),\n };\n // Create-only when truly absent; overwrite the tombstone under its version when recreating. Either way\n // a concurrent create loses the precondition and surfaces as a conflict.\n await this.putRow(key, env, current ? { version: current.version } : 'absent');\n return { token };\n }\n\n async compareAndSwap(\n ref: SegmentRef,\n expected: Token,\n patch: RegistryPatch,\n ): Promise<{ token: Token }> {\n const checked = validateRegistryPatch(patch);\n const key = registryObjectKey(this.prefix, ref);\n const current = await this.readRow(key);\n if (current === null || current.env.deleted || current.env.record.token !== expected) {\n throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);\n }\n const token = nextRegistryToken(current.env.record, this.entropy);\n const env: RegistryEnvelope = {\n deleted: false,\n record: applyRegistryPatch(current.env.record, checked, this.now(), token),\n };\n // The version we read fences a concurrent writer between that read and this write.\n await this.putRow(key, env, { version: current.version });\n return { token };\n }\n\n async *list(namespace?: string): AsyncIterable<RegistryRecord> {\n const prefix = registryListPrefix(this.prefix, namespace);\n // One list plus bounded parallel reads. A serial await-in-loop made this O(N) sequential round trips.\n let page: string[] = [];\n const flush = async function* (\n this: ObjectStoreRegistry,\n keys: string[],\n ): AsyncIterable<RegistryRecord> {\n const rows = await mapWithConcurrency(keys, LIST_READ_CONCURRENCY, (key) =>\n this.readRow(key),\n );\n for (const current of rows) {\n if (current && !current.env.deleted) yield current.env.record;\n }\n }.bind(this);\n\n for await (const key of this.store.listKeys(prefix)) {\n if (parseRegistryKey(this.prefix, key) === null) continue; // stray or foreign object\n page.push(key);\n if (page.length >= LIST_READ_CONCURRENCY) {\n yield* flush(page);\n page = [];\n }\n }\n if (page.length > 0) yield* flush(page);\n }\n\n async delete(ref: SegmentRef, expected?: Token): Promise<void> {\n const key = registryObjectKey(this.prefix, ref);\n // A row born with an incarnation id is removed from the store, by a delete the store applies only while the\n // object is still the version read here: a later create draws a new incarnation, so nothing of this row is needed\n // to keep its tokens apart. Any other row is tombstoned (the counter advanced, the object kept), so a re-create\n // carries the counter on. Retry the read and the write on a cross-process race; it converges, then fails typed\n // rather than silently leaving the row live.\n for (let attempt = 0; attempt < MAX_DELETE_ATTEMPTS; attempt++) {\n const current = await this.readRow(key);\n if (expected !== undefined) {\n // Fenced: the row must still carry the token the caller read. Re-checked on every attempt, so a row\n // that was swapped, or deleted and re-created, between two attempts is refused rather than tombstoned.\n if (current === null || current.env.deleted || current.env.record.token !== expected) {\n throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);\n }\n } else if (current === null || current.env.deleted) {\n return; // idempotent \u2014 already gone\n }\n try {\n const remove = this.removesRows() ? this.store.delete : undefined;\n if (remove !== undefined && incarnationOf(current.env.record.token) !== undefined) {\n // The version read above is the row the caller's token names, so this removes that row and no later one.\n await remove.call(this.store, key, { version: current.version });\n return;\n }\n const token = nextRegistryToken(current.env.record, this.entropy);\n const env: RegistryEnvelope = {\n deleted: true,\n record: { ...current.env.record, token, updatedAt: this.now() },\n };\n await this.putRow(key, env, { version: current.version });\n return;\n } catch (err) {\n if (isWriteConflictError(err)) continue; // raced \u2014 re-read and try again\n throw err;\n }\n }\n throw new WriteConflictError(`registry delete: contention deleting \"${ref.segment}\" \u2014 retry`);\n }\n\n /**\n * Read + parse a registry object, returning its envelope and version fence, or null if absent.\n *\n * A store that pins a version to read consistently ({@link ObjectVersionRaced}) loses that pin whenever a\n * concurrent writer lands mid-read. Re-reading is the whole answer \u2014 the next read simply observes the\n * newer version \u2014 and it is bounded so a row under permanent write saturation fails typed instead of\n * spinning. Only a store that reads in two calls gets here; none of the shipped stores does.\n */\n private async readRow(\n objectKey: string,\n ): Promise<{ env: RegistryEnvelope; version: string } | null> {\n const row = await this.readRaced(objectKey);\n if (row === null) return null;\n if (row.bytes.length > MAX_ROW_BYTES) {\n throw new IntegrityError(\n `registry object ${row.bytes.length}B exceeds cap ${MAX_ROW_BYTES}B`,\n );\n }\n if (row.version.length === 0) {\n // The version is the OCC fence for CAS; a backend that omits it cannot be used safely as a registry.\n throw new IntegrityError(\n `registry object has no version fence (needed for compare-and-swap): ${objectKey}`,\n );\n }\n const env = parseRegistryEnvelope(new TextDecoder().decode(row.bytes), objectKey);\n return { env, version: row.version };\n }\n\n /** {@link ObjectRegistryStore.read}, retrying the bounded number of times a mid-read overwrite allows. */\n private async readRaced(objectKey: string): Promise<ObjectRow | null> {\n for (let attempt = 0; attempt < MAX_READ_ATTEMPTS; attempt++) {\n try {\n return await this.store.read(objectKey);\n } catch (err) {\n if (!(err instanceof ObjectVersionRaced)) throw err;\n }\n }\n throw new TransientError(\n `registry read: ${this.store.label} object \"${objectKey}\" was overwritten on every one of ` +\n `${MAX_READ_ATTEMPTS} attempts \u2014 retry`,\n );\n }\n\n /** Conditional write of an envelope. The store maps a lost precondition to {@link WriteConflictError}. */\n private async putRow(\n objectKey: string,\n env: RegistryEnvelope,\n expect: 'absent' | { version: string },\n ): Promise<void> {\n const body = new TextEncoder().encode(serializeRegistryEnvelope(env));\n if (body.length > MAX_ROW_BYTES) {\n // Belt-and-braces: the governance/DEK fields are already capped by validate*, so this cannot fire for\n // valid input \u2014 but never write a row that would later be unreadable.\n throw new ValidationError(`registry object ${body.length}B exceeds cap ${MAX_ROW_BYTES}B`);\n }\n await this.store.write(objectKey, body, expect);\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiBA,IAAM,kBAAkB;AAGjB,SAAS,WAAW,QAAoC;AAC7D,MAAI,WAAW,OAAW,QAAO;AACjC,QAAM,UAAU,OAAO,QAAQ,cAAc,EAAE;AAC/C,SAAO,YAAY,KAAK,KAAK,GAAG,OAAO;AACzC;AAaO,SAAS,sBAAsB,QAAgD;AACpF,MAAI,WAAW,OAAW,QAAO;AACjC,aAAW,MAAM,QAAQ;AACvB,UAAM,OAAO,GAAG,WAAW,CAAC;AAE5B,QAAI,OAAO,MAAQ,SAAS,KAAM;AAChC,YAAM,IAAI,gBAAgB,4CAA4C;AAAA,IACxE;AAAA,EACF;AACA,MAAI,OAAO,SAAS,IAAI,GAAG;AACzB,UAAM,IAAI;AAAA,MACR,4EAA4E,KAAK,UAAU,MAAM,CAAC;AAAA,IACpG;AAAA,EACF;AAEA,MAAI;AACJ,MAAI;AACF,cAAU,mBAAmB,MAAM;AAAA,EACrC,QAAQ;AACN,cAAU;AAAA,EACZ;AACA,aAAW,UAAU,YAAY,SAAS,CAAC,MAAM,IAAI,CAAC,QAAQ,OAAO,GAAG;AACtE,eAAW,WAAW,OAAO,MAAM,GAAG,GAAG;AACvC,UAAI,YAAY,OAAO,YAAY,MAAM;AACvC,cAAM,IAAI;AAAA,UACR,sDAAsD,KAAK,UAAU,MAAM,CAAC;AAAA,QAC9E;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAGO,SAAS,eAAe,QAAoC;AACjE,SAAO,GAAG,WAAW,MAAM,CAAC;AAC9B;AAGO,SAAS,kBAAkB,QAA4B,KAAyB;AACrF,qBAAmB,GAAG;AACtB,SAAO,GAAG,eAAe,MAAM,CAAC,GAAG,iBAAiB,IAAI,SAAS,CAAC,IAAI,iBAAiB,IAAI,OAAO,CAAC,GAAG,eAAe;AACvH;AAGO,SAAS,mBAAmB,QAA4B,WAA4B;AACzF,QAAM,OAAO,eAAe,MAAM;AAClC,SAAO,cAAc,SAAY,OAAO,GAAG,IAAI,GAAG,iBAAiB,SAAS,CAAC;AAC/E;AAQO,SAAS,iBAAiB,QAA4B,WAAsC;AACjG,QAAM,OAAO,eAAe,MAAM;AAClC,MAAI,CAAC,UAAU,WAAW,IAAI,KAAK,CAAC,UAAU,SAAS,eAAe,EAAG,QAAO;AAChF,QAAM,OAAO,UAAU,MAAM,KAAK,QAAQ,UAAU,SAAS,gBAAgB,MAAM;AACnF,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,MAAI,QAAQ,EAAG,QAAO;AACtB,QAAM,SAAS,KAAK,MAAM,GAAG,KAAK;AAClC,QAAM,iBAAiB,KAAK,MAAM,QAAQ,CAAC;AAC3C,MAAI,eAAe,WAAW,KAAK,eAAe,SAAS,GAAG,EAAG,QAAO;AACxE,QAAM,UAAU,kBAAkB,cAAc;AAChD,QAAM,YAAY,WAAW,oBAAoB,SAAY,kBAAkB,MAAM;AAGrF,MAAI,iBAAiB,OAAO,MAAM,eAAgB,QAAO;AACzD,MAAI,cAAc,UAAa,iBAAiB,SAAS,MAAM,OAAQ,QAAO;AAC9E,QAAM,MAAkB,EAAE,SAAS,UAAU;AAC7C,MAAI;AACF,uBAAmB,GAAG;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,SAAO;AACT;;;
|
|
4
|
+
"sourcesContent": ["/**\n * The registry object layout, shared by every object-store registry (S3, GCS, Azure Blob).\n *\n * One tiny JSON object per segment at `<prefix>registry/<ns>/<segment>.reg`, **registry-first** \u2014 separate\n * from the namespace-first storage layout (`<prefix><ns>/segments/\u2026`) \u2014 so discovery is a single list over\n * `registry/` for all namespaces, or `registry/<ns>/` for one, never entangled with the `.crbm` payloads.\n *\n * It lives here rather than in a driver because all three object stores encode names the same way\n * (`encodeNameForKey`, `namespaceKeyPart`) and build byte-identical keys. Three copies of this would be\n * three chances for one cloud's layout to drift from the others \u2014 and a drifted key is not a visible bug, it\n * is a segment that quietly cannot be found by a store pointed at the same bucket.\n */\nimport { ValidationError } from '@/core/errors';\nimport { validateSegmentRef } from '@/core/validate';\nimport type { SegmentRef } from '@/core/ports';\nimport { DEFAULT_NAMESPACE, decodeNameFromKey, encodeNameForKey, namespaceKeyPart } from './keys';\n\nconst REGISTRY_SUFFIX = '.reg';\n\n/** Normalize an optional caller prefix to either `''` or `trimmed/` (no leading/trailing slashes). */\nexport function prefixPart(prefix: string | undefined): string {\n if (prefix === undefined) return '';\n const trimmed = prefix.replace(/^\\/+|\\/+$/g, '');\n return trimmed === '' ? '' : `${trimmed}/`;\n}\n\n/**\n * Validate a caller-supplied key prefix (trusted config, but make it a real containment boundary): reject\n * control characters and `.`/`..` path segments so a prefix cannot traverse out of its intended space.\n * Returns it unchanged \u2014 the key builders normalize slashes.\n *\n * The traversal check covers three spellings, because the tools that walk these buckets do. A bare `..` is\n * the obvious one. A **backslash** segment matters on ADLS Gen2 and to Windows-side tooling, which treat it\n * as a separator this code otherwise would not. And a percent-encoded `..` matters because `gsutil`,\n * `s3fs`, `gcsfuse` and `azcopy` decode a key on their way to a local path \u2014 the same reasoning\n * `name-codec.ts` already applies to segment names, which a prefix has no reason to be exempt from.\n */\nexport function normalizeObjectPrefix(prefix: string | undefined): string | undefined {\n if (prefix === undefined) return undefined;\n for (const ch of prefix) {\n const code = ch.charCodeAt(0);\n // C0 plus DEL: a bare `< 0x20` lets U+007F through, and it is as unprintable as the rest.\n if (code < 0x20 || code === 0x7f) {\n throw new ValidationError('prefix must not contain control characters');\n }\n }\n if (prefix.includes('\\\\')) {\n throw new ValidationError(\n `prefix must not contain backslashes (a path separator on some backends): ${JSON.stringify(prefix)}`,\n );\n }\n // Check the decoded spelling too, so an encoded `..` cannot slip past the literal comparison.\n let decoded: string;\n try {\n decoded = decodeURIComponent(prefix);\n } catch {\n decoded = prefix; // not valid percent-encoding; the literal check still applies\n }\n for (const source of decoded === prefix ? [prefix] : [prefix, decoded]) {\n for (const segment of source.split('/')) {\n if (segment === '.' || segment === '..') {\n throw new ValidationError(\n `prefix must not contain \".\" or \"..\" path segments: ${JSON.stringify(prefix)}`,\n );\n }\n }\n }\n return prefix;\n}\n\n/** The key prefix under which every registry object lives: `<prefix>registry/`. */\nexport function registryPrefix(prefix: string | undefined): string {\n return `${prefixPart(prefix)}registry/`;\n}\n\n/** The full key of one segment's registry object: `<prefix>registry/<ns>/<segment>.reg`. */\nexport function registryObjectKey(prefix: string | undefined, ref: SegmentRef): string {\n validateSegmentRef(ref);\n return `${registryPrefix(prefix)}${namespaceKeyPart(ref.namespace)}/${encodeNameForKey(ref.segment)}${REGISTRY_SUFFIX}`;\n}\n\n/** The list prefix for discovery: registry-wide, or scoped to one namespace. */\nexport function registryListPrefix(prefix: string | undefined, namespace?: string): string {\n const base = registryPrefix(prefix);\n return namespace === undefined ? base : `${base}${namespaceKeyPart(namespace)}/`;\n}\n\n/**\n * Parse a `<prefix>registry/<ns>/<segment>.reg` key back to its {@link SegmentRef}, or `null` if it does not\n * match \u2014 a stray or foreign object under the prefix, or one whose parsed ref fails the round-trip check.\n * `_default` maps back to the absent namespace. A name is percent-encoded on the way in, so no encoded name\n * can contain `/` and the split stays unambiguous whatever the caller named their segment.\n */\nexport function parseRegistryKey(prefix: string | undefined, objectKey: string): SegmentRef | null {\n const base = registryPrefix(prefix);\n if (!objectKey.startsWith(base) || !objectKey.endsWith(REGISTRY_SUFFIX)) return null;\n const rest = objectKey.slice(base.length, objectKey.length - REGISTRY_SUFFIX.length);\n const slash = rest.indexOf('/');\n if (slash < 0) return null;\n const nsPart = rest.slice(0, slash);\n const encodedSegment = rest.slice(slash + 1);\n if (encodedSegment.length === 0 || encodedSegment.includes('/')) return null;\n const segment = decodeNameFromKey(encodedSegment);\n const namespace = nsPart === DEFAULT_NAMESPACE ? undefined : decodeNameFromKey(nsPart);\n // The encoding must ROUND-TRIP, not merely decode: a foreign object placed under our prefix could spell a\n // name two ways, and reporting both would hand a sweep one segment under two identities.\n if (encodeNameForKey(segment) !== encodedSegment) return null;\n if (namespace !== undefined && encodeNameForKey(namespace) !== nsPart) return null;\n const ref: SegmentRef = { segment, namespace };\n try {\n validateSegmentRef(ref); // reject a hostile/foreign key that isn't a valid ref\n } catch {\n return null;\n }\n return ref;\n}\n", "/**\n * `ObjectStoreRegistry` \u2014 an {@link IRegistryDriver} over any object store with conditional writes.\n *\n * Lets a **read-mostly deployment run on one bucket alone** \u2014 storage `.crbm` generations plus the registry in\n * the same place, with no separate database. One tiny JSON object per segment at\n * `<prefix>registry/<ns>/<segment>.reg` holding the `{ deleted, record }` envelope (the same shape LocalFs\n * persists). The OCC token is a random incarnation id drawn when the row is created, a counter advanced on every\n * mutation, and a random part drawn for every write, so with overwhelming probability a deleted-then-recreated row, or\n * one restored from a backup, never re-issues an old token (ABA-safe) \u2014 identical semantics to the LocalFs registry, so it passes the same\n * conformance suite.\n *\n * **`delete` removes a row for good where the store vouches for a conditional delete** (`If-Match: <etag>`,\n * `ifGenerationMatch: <generation>`), and only a row whose token carries an incarnation id: a re-create draws a new\n * incarnation, so nothing of the old row is needed to keep its tokens apart, and a full `list()` no longer reads a\n * row for every name it ever held. Every other row is **tombstoned**: the object stays, its counter advanced, and a\n * re-create carries the counter on. A row a release before 0.12 wrote has a bare decimal token and is always\n * tombstoned, since a process still on that release, re-creating the name over nothing, would start its counter at 0\n * again and issue the old row's tokens.\n *\n * **The atomic swap is offloaded to the store's conditional writes.** `create` writes only if absent (or\n * over a tombstone under its version), and `compareAndSwap`/`delete` write (or delete) only if the object still\n * carries the version we read \u2014 so a concurrent writer between our read and our write loses, and the loss surfaces\n * as {@link WriteConflictError}. No in-process lock is needed (unlike LocalFs): the precondition fences\n * writers *across processes*.\n *\n * **A caller that already read the row can spare the read before its write.** `get` remembers the version of the\n * object it read, against the record it returned. A `compareAndSwap` handed that very record as `held` conditions its\n * write on that version and sends it at once, and a `create` handed `null` (the caller found no row) sends its\n * create-only write at once. The store's condition stays the fence: a row that changed since fails the write as a lost\n * race, so a stale `held` can lose a write and never land one. A record this registry did not return (a copy, another\n * registry's) is read for as without the hint.\n *\n * **Why this is shared rather than written per cloud.** Every object store worth using has the same two\n * primitives under different names \u2014 S3 `If-None-Match: *` / `If-Match: <etag>`, GCS `ifGenerationMatch: 0`\n * / `ifGenerationMatch: <generation>`, Azure `If-None-Match: *` / `If-Match: <etag>` \u2014 so the only thing\n * that differs is a few I/O calls, which is what {@link ObjectRegistryStore} abstracts. The parts that are\n * genuinely hard (the ABA-safe token, the delete and its tombstone, the bounded delete retry, the round-trip key check)\n * exist once. Three copies would be three chances for one cloud to drift, and a drifted registry is not a\n * visible bug \u2014 it is a lost `currentGen` swap.\n *\n * **Deployment requirements** (a backend that violates these silently corrupts the registry):\n * - The store **must honor the conditional write**. One that accepts the precondition header and ignores it\n * degrades compare-and-swap to last-write-wins \u2192 lost `currentGen` swaps.\n * - Reads must be **strongly consistent** \u2014 true of S3 (since 2020), GCS and Azure Blob \u2014 since the registry\n * advertises `strongRead` and generation resolution depends on it.\n * - **Do not apply a lifecycle-expiration rule to the `registry/` prefix that expires a current version.**\n * Expiring a live row loses its pointer. A tombstone `delete` left keeps the counter for a re-create; one expired\n * by a rule is no worse than a row removed for good, since a recreate draws a fresh incarnation and its tokens are\n * new but for a collision of probability 2^-128 per pair of incarnations, but a rule cannot tell a tombstone from a\n * live row.\n * - **A store's conditional delete must be honoured by its backend** before the store sets `conditionalDelete`.\n * One that ignores the precondition lets two sweepers and a re-create delete a live row.\n *\n * **`list()` is fail-closed, and one bad object stops it for everyone.** An object under the `registry/`\n * prefix whose key parses but whose body does not aborts the whole enumeration \u2014 every namespace, not just\n * the affected segment \u2014 and it stays that way until an operator removes the object. The error names the\n * offending key.\n *\n * That is the deliberate choice, and it is the *safer* one rather than the more convenient one. Skipping the\n * unreadable row would keep discovery running, but `list()` is what tells orphan generation collection which\n * segments exist: a row missing from the enumeration makes its storage `.crbm` generations look unreferenced,\n * and the next GC pass would delete them. Silently skipping turns a parse error into data loss. Refusing to\n * enumerate keeps every sweep off a set it cannot vouch for (invariant 5 \u2014 bytes from the tier are\n * untrusted). **Recovery:** read the named object, and either restore a valid envelope or delete it; nothing\n * else is damaged, and discovery resumes on its own.\n */\nimport {\n IntegrityError,\n TransientError,\n ValidationError,\n WriteConflictError,\n isWriteConflictError,\n} from '@/core/errors';\nimport type {\n IRegistryDriver,\n NewRegistryRecord,\n RegCaps,\n RegistryPatch,\n RegistryRecord,\n RegistryWriteOptions,\n SegmentRef,\n Token,\n} from '@/core/ports';\nimport type { Entropy } from '@/core/determinism';\nimport { mapWithConcurrency } from '@/core/concurrency';\nimport {\n applyRegistryPatch,\n incarnationOf,\n newIncarnationToken,\n nextRegistryToken,\n parseRegistryEnvelope,\n recordFromNew,\n serializeRegistryEnvelope,\n validateNewRegistryRecord,\n validateRegistryPatch,\n type RegistryEnvelope,\n} from './registry';\nimport { entropyIsAvailable, webCryptoEntropy } from './entropy';\nimport { parseRegistryKey, registryListPrefix, registryObjectKey } from './object-registry-keys';\n\n/** Defensive cap on a single registry object read from storage, before allocation (rows are tiny; ~1 KB). */\nexport const MAX_ROW_BYTES = 1 * 1024 * 1024;\n/** Bounded retry for `delete`'s read then delete or tombstone, under cross-process contention (then fails typed). */\nconst MAX_DELETE_ATTEMPTS = 8;\n/** Bounded re-read when a store's pinned version is overwritten mid-read (see {@link ObjectVersionRaced}). */\nconst MAX_READ_ATTEMPTS = 8;\n/** In-flight reads per `list()` page \u2014 turns the serial N+1 into one list plus bounded parallel reads. */\nconst LIST_READ_CONCURRENCY = 48;\n\n/**\n * Raised by a store when the version it pinned for a read vanished before it could fetch the bytes \u2014\n * a concurrent writer overwrote or deleted the object in between.\n *\n * This is deliberately **neither** of the two things it superficially resembles. It is not absence: the row\n * is very likely still live, just one version further on. And it is not a write conflict: the caller may be\n * a read-only `get()`, which has nothing to conflict with. Either is an easy misreading for a store that reads a\n * row in two calls, metadata and then bytes pinned to it, which is why the signal is part of the port rather than\n * left to each store's judgement. {@link ObjectStoreRegistry} answers it the only way that is correct for every\n * caller: it reads again.\n *\n * Stores whose read is atomic can never raise it, and every shipped store's is: S3 serves bytes and `ETag` from one\n * `GetObject`, GCS bytes and `generation` from one GET, and Azure Blob bytes and `ETag` from one GET.\n */\nexport class ObjectVersionRaced extends Error {\n constructor(objectKey: string) {\n super(`registry object was overwritten mid-read: ${objectKey}`);\n this.name = 'ObjectVersionRaced';\n }\n}\n\n/** One object as read, with the opaque version that fences a later conditional write. */\nexport interface ObjectRow {\n readonly bytes: Uint8Array;\n /** The store's version fence \u2014 an S3/Azure ETag, a GCS generation. Opaque to this class. */\n readonly version: string;\n}\n\n/**\n * The I/O calls an object store must provide to host a registry: three, and an optional conditional delete. Everything\n * else \u2014 the OCC token, tombstones, the delete retry, key parsing \u2014 is implemented once above.\n */\nexport interface ObjectRegistryStore {\n /** A label for error messages, e.g. `GCS` or `Azure Blob`. */\n readonly label: string;\n /**\n * Read one object, or `null` when it is absent.\n *\n * **`null` means the object does not exist** \u2014 nothing weaker. A store that reads in two round trips\n * (metadata for the version fence, then the bytes pinned to it) can find that version already gone; it\n * MUST report that with {@link ObjectVersionRaced} rather than `null` or {@link WriteConflictError}, and\n * the caller re-reads. Retryable faults throw {@link TransientError}.\n *\n * `bytes` and `version` MUST describe the **same** observation of the object: `version` is the fence a\n * later conditional write is conditioned on, so a pair drawn from two different versions would either\n * fail a write that should have succeeded or, worse, pass one that should not.\n */\n read(key: string): Promise<ObjectRow | null>;\n /**\n * Write one object under a precondition: `'absent'` means create-only, a version means\n * compare-and-swap against exactly that version. A lost race MUST throw {@link WriteConflictError}.\n *\n * A store must not replay this write after a lost response without telling the replay apart: the replay meets\n * the row it just wrote and its precondition fails, which would read as a lost race. Send it once, or store a\n * write id with the row and treat a conflict whose stored row carries it as success. Retryable faults throw\n * {@link TransientError}.\n */\n write(key: string, body: Uint8Array, expect: 'absent' | { version: string }): Promise<void>;\n /** Every object key under `prefix`, paginated internally. */\n listKeys(prefix: string): AsyncIterable<string>;\n /**\n * Optional: delete one object, and only while it is still at exactly `expect.version`, by a precondition the\n * backend applies (`If-Match: <etag>`, `ifGenerationMatch: <generation>`). An object that was overwritten, or is\n * gone, MUST throw {@link WriteConflictError} and delete nothing. Retryable faults throw {@link TransientError}.\n *\n * Sending it again after a lost response is safe, unlike a write: the precondition names one version, so a second\n * copy can remove nothing the first was not allowed to. A copy that meets its own landed delete fails the\n * precondition and reports a conflict; the registry re-reads, and finds the row gone.\n *\n * The registry calls it only when {@link conditionalDelete} is `true`.\n */\n delete?(key: string, expect: { version: string }): Promise<void>;\n /**\n * Whether {@link delete} may be relied on: `true` only where the backend is known to apply the precondition. A\n * backend that accepts the header and ignores it would let a delete remove a row written after the delete read it,\n * a live one included. Absent or `false`, the registry never calls `delete` and tombstones every row instead.\n */\n readonly conditionalDelete?: boolean;\n}\n\n/** A row as read: its envelope, the bytes it was parsed from, and the version that fences a write to it. */\ninterface ReadRow extends ObjectRow {\n readonly env: RegistryEnvelope;\n}\n\n/** What a record `get` returned was read from. */\ninterface ObservedRow extends ObjectRow {\n readonly key: string;\n}\n\nexport class ObjectStoreRegistry implements IRegistryDriver {\n /**\n * `entropy` draws the random parts of every token: a new row's incarnation id, and each write's own part. It\n * defaults to the platform's Web Crypto. Inject one only to make a test replayable: a seeded source in production\n * gives every process the same ids.\n */\n constructor(\n private readonly store: ObjectRegistryStore,\n private readonly prefix: string | undefined,\n private readonly now: () => number,\n private readonly entropy: Entropy = webCryptoEntropy,\n ) {}\n\n capabilities(): RegCaps {\n const conditionalDelete = this.removesRows();\n return entropyIsAvailable(this.entropy)\n ? { strongRead: true, conditionalDelete }\n : { strongRead: true, canWrite: false, conditionalDelete };\n }\n\n /** Whether a delete removes a row born with an incarnation id: the store has a delete, and vouches for it. */\n private removesRows(): boolean {\n return this.store.conditionalDelete === true && typeof this.store.delete === 'function';\n }\n\n /** The object each record `get` returned was read from, to fence a write made against it ({@link RegistryWriteOptions.held}). */\n private readonly observed = new WeakMap<RegistryRecord, ObservedRow>();\n\n async get(ref: SegmentRef): Promise<RegistryRecord | null> {\n const key = registryObjectKey(this.prefix, ref);\n const row = await this.readRow(key);\n if (row === null || row.env.deleted) return null;\n this.observed.set(row.env.record, { key, bytes: row.bytes, version: row.version });\n return row.env.record;\n }\n\n async create(\n ref: SegmentRef,\n record: NewRegistryRecord,\n options?: RegistryWriteOptions,\n ): Promise<{ token: Token }> {\n const checked = validateNewRegistryRecord(record);\n const key = registryObjectKey(this.prefix, ref);\n let lost: unknown;\n if (options?.held === null) {\n // The caller found no row: send the create-only write without reading. One that loses to a row, or to a tombstone\n // (which a create-only write cannot go over), is told apart by the read below.\n try {\n return await this.createOver(key, ref, checked, undefined);\n } catch (err) {\n if (!isWriteConflictError(err)) throw err;\n lost = err;\n }\n }\n const current = await this.readRow(key);\n if (current !== null && !current.env.deleted) {\n throw new WriteConflictError(`registry row already exists for segment ${ref.segment}`);\n }\n // No row at all now: the create-only write lost to something that has gone since, and it is not sent again.\n if (lost !== undefined && current === null) throw lost;\n return this.createOver(key, ref, checked, current ?? undefined);\n }\n\n /** Write a new incarnation of the row: over `tombstone` under its version, or create-only when there is none. */\n private async createOver(\n key: string,\n ref: SegmentRef,\n checked: NewRegistryRecord,\n tombstone: ReadRow | undefined,\n ): Promise<{ token: Token }> {\n // A new incarnation, whose counter continues across a tombstone, so with overwhelming probability a recreate never re-issues an old token.\n const token = newIncarnationToken(this.entropy, tombstone?.env.record);\n const env: RegistryEnvelope = {\n deleted: false,\n record: recordFromNew(ref, checked, this.now(), token),\n };\n // Create-only when truly absent; overwrite the tombstone under its version when recreating. Either way\n // a concurrent create loses the precondition and surfaces as a conflict.\n await this.putRow(key, env, tombstone ? { version: tombstone.version } : 'absent');\n return { token };\n }\n\n async compareAndSwap(\n ref: SegmentRef,\n expected: Token,\n patch: RegistryPatch,\n options?: RegistryWriteOptions,\n ): Promise<{ token: Token }> {\n const checked = validateRegistryPatch(patch);\n const key = registryObjectKey(this.prefix, ref);\n // The row the caller read, as this registry read it, when it is the row `expected` names; otherwise read it now.\n const current = this.observedRow(key, expected, options?.held) ?? (await this.readRow(key));\n if (current === null || current.env.deleted || current.env.record.token !== expected) {\n throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);\n }\n const token = nextRegistryToken(current.env.record, this.entropy);\n const env: RegistryEnvelope = {\n deleted: false,\n record: applyRegistryPatch(current.env.record, checked, this.now(), token),\n };\n // The version we read fences a concurrent writer between that read and this write.\n await this.putRow(key, env, { version: current.version });\n return { token };\n }\n\n /**\n * The row a caller's `held` record was read as, decoded again from the bytes `get` saw so that nothing the caller did\n * to its copy reaches the write, or `undefined` when `held` is not a record this registry returned for this row under\n * the token the write expects. The version it carries is the store's fence, so a row that has moved on since fails\n * the write.\n */\n private observedRow(\n key: string,\n expected: Token,\n held: RegistryRecord | null | undefined,\n ): ReadRow | undefined {\n const seen = held ? this.observed.get(held) : undefined;\n if (seen === undefined || seen.key !== key || held?.token !== expected) return undefined;\n return this.decodeRow(key, seen);\n }\n\n async *list(namespace?: string): AsyncIterable<RegistryRecord> {\n const prefix = registryListPrefix(this.prefix, namespace);\n // One list plus bounded parallel reads. A serial await-in-loop made this O(N) sequential round trips.\n let page: string[] = [];\n const flush = async function* (\n this: ObjectStoreRegistry,\n keys: string[],\n ): AsyncIterable<RegistryRecord> {\n const rows = await mapWithConcurrency(keys, LIST_READ_CONCURRENCY, (key) =>\n this.readRow(key),\n );\n for (const current of rows) {\n if (current && !current.env.deleted) yield current.env.record;\n }\n }.bind(this);\n\n for await (const key of this.store.listKeys(prefix)) {\n if (parseRegistryKey(this.prefix, key) === null) continue; // stray or foreign object\n page.push(key);\n if (page.length >= LIST_READ_CONCURRENCY) {\n yield* flush(page);\n page = [];\n }\n }\n if (page.length > 0) yield* flush(page);\n }\n\n async delete(ref: SegmentRef, expected?: Token): Promise<void> {\n const key = registryObjectKey(this.prefix, ref);\n // A row born with an incarnation id is removed from the store, by a delete the store applies only while the\n // object is still the version read here: a later create draws a new incarnation, so nothing of this row is needed\n // to keep its tokens apart. Any other row is tombstoned (the counter advanced, the object kept), so a re-create\n // carries the counter on. Retry the read and the write on a cross-process race; it converges, then fails typed\n // rather than silently leaving the row live.\n for (let attempt = 0; attempt < MAX_DELETE_ATTEMPTS; attempt++) {\n const current = await this.readRow(key);\n if (expected !== undefined) {\n // Fenced: the row must still carry the token the caller read. Re-checked on every attempt, so a row\n // that was swapped, or deleted and re-created, between two attempts is refused rather than tombstoned.\n if (current === null || current.env.deleted || current.env.record.token !== expected) {\n throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);\n }\n } else if (current === null || current.env.deleted) {\n return; // idempotent \u2014 already gone\n }\n try {\n const remove = this.removesRows() ? this.store.delete : undefined;\n if (remove !== undefined && incarnationOf(current.env.record.token) !== undefined) {\n // The version read above is the row the caller's token names, so this removes that row and no later one.\n await remove.call(this.store, key, { version: current.version });\n return;\n }\n const token = nextRegistryToken(current.env.record, this.entropy);\n const env: RegistryEnvelope = {\n deleted: true,\n record: { ...current.env.record, token, updatedAt: this.now() },\n };\n await this.putRow(key, env, { version: current.version });\n return;\n } catch (err) {\n if (isWriteConflictError(err)) continue; // raced \u2014 re-read and try again\n throw err;\n }\n }\n throw new WriteConflictError(`registry delete: contention deleting \"${ref.segment}\" \u2014 retry`);\n }\n\n /**\n * Read + parse a registry object, returning its envelope and version fence, or null if absent.\n *\n * A store that pins a version to read consistently ({@link ObjectVersionRaced}) loses that pin whenever a\n * concurrent writer lands mid-read. Re-reading is the whole answer \u2014 the next read simply observes the\n * newer version \u2014 and it is bounded so a row under permanent write saturation fails typed instead of\n * spinning. Only a store that reads in two calls gets here; none of the shipped stores does.\n */\n private async readRow(objectKey: string): Promise<ReadRow | null> {\n const row = await this.readRaced(objectKey);\n return row === null ? null : this.decodeRow(objectKey, row);\n }\n\n /** Check and parse the bytes of a registry object. */\n private decodeRow(objectKey: string, row: ObjectRow): ReadRow {\n if (row.bytes.length > MAX_ROW_BYTES) {\n throw new IntegrityError(\n `registry object ${row.bytes.length}B exceeds cap ${MAX_ROW_BYTES}B`,\n );\n }\n if (row.version.length === 0) {\n // The version is the OCC fence for CAS; a backend that omits it cannot be used safely as a registry.\n throw new IntegrityError(\n `registry object has no version fence (needed for compare-and-swap): ${objectKey}`,\n );\n }\n const env = parseRegistryEnvelope(new TextDecoder().decode(row.bytes), objectKey);\n return { env, bytes: row.bytes, version: row.version };\n }\n\n /** {@link ObjectRegistryStore.read}, retrying the bounded number of times a mid-read overwrite allows. */\n private async readRaced(objectKey: string): Promise<ObjectRow | null> {\n for (let attempt = 0; attempt < MAX_READ_ATTEMPTS; attempt++) {\n try {\n return await this.store.read(objectKey);\n } catch (err) {\n if (!(err instanceof ObjectVersionRaced)) throw err;\n }\n }\n throw new TransientError(\n `registry read: ${this.store.label} object \"${objectKey}\" was overwritten on every one of ` +\n `${MAX_READ_ATTEMPTS} attempts \u2014 retry`,\n );\n }\n\n /** Conditional write of an envelope. The store maps a lost precondition to {@link WriteConflictError}. */\n private async putRow(\n objectKey: string,\n env: RegistryEnvelope,\n expect: 'absent' | { version: string },\n ): Promise<void> {\n const body = new TextEncoder().encode(serializeRegistryEnvelope(env));\n if (body.length > MAX_ROW_BYTES) {\n // Belt-and-braces: the governance/DEK fields are already capped by validate*, so this cannot fire for\n // valid input \u2014 but never write a row that would later be unreadable.\n throw new ValidationError(`registry object ${body.length}B exceeds cap ${MAX_ROW_BYTES}B`);\n }\n await this.store.write(objectKey, body, expect);\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IRegistryDriver, NewRegistryRecord, RegCaps, RegistryPatch, RegistryRecord, SegmentRef, Token } from '../../core/ports.js';
|
|
1
|
+
import type { IRegistryDriver, NewRegistryRecord, RegCaps, RegistryPatch, RegistryRecord, RegistryWriteOptions, SegmentRef, Token } from '../../core/ports.js';
|
|
2
2
|
import type { Entropy } from '../../core/determinism.js';
|
|
3
3
|
/** Defensive cap on a single registry object read from storage, before allocation (rows are tiny; ~1 KB). */
|
|
4
4
|
export declare const MAX_ROW_BYTES: number;
|
|
@@ -94,13 +94,24 @@ export declare class ObjectStoreRegistry implements IRegistryDriver {
|
|
|
94
94
|
capabilities(): RegCaps;
|
|
95
95
|
/** Whether a delete removes a row born with an incarnation id: the store has a delete, and vouches for it. */
|
|
96
96
|
private removesRows;
|
|
97
|
+
/** The object each record `get` returned was read from, to fence a write made against it ({@link RegistryWriteOptions.held}). */
|
|
98
|
+
private readonly observed;
|
|
97
99
|
get(ref: SegmentRef): Promise<RegistryRecord | null>;
|
|
98
|
-
create(ref: SegmentRef, record: NewRegistryRecord): Promise<{
|
|
100
|
+
create(ref: SegmentRef, record: NewRegistryRecord, options?: RegistryWriteOptions): Promise<{
|
|
99
101
|
token: Token;
|
|
100
102
|
}>;
|
|
101
|
-
|
|
103
|
+
/** Write a new incarnation of the row: over `tombstone` under its version, or create-only when there is none. */
|
|
104
|
+
private createOver;
|
|
105
|
+
compareAndSwap(ref: SegmentRef, expected: Token, patch: RegistryPatch, options?: RegistryWriteOptions): Promise<{
|
|
102
106
|
token: Token;
|
|
103
107
|
}>;
|
|
108
|
+
/**
|
|
109
|
+
* The row a caller's `held` record was read as, decoded again from the bytes `get` saw so that nothing the caller did
|
|
110
|
+
* to its copy reaches the write, or `undefined` when `held` is not a record this registry returned for this row under
|
|
111
|
+
* the token the write expects. The version it carries is the store's fence, so a row that has moved on since fails
|
|
112
|
+
* the write.
|
|
113
|
+
*/
|
|
114
|
+
private observedRow;
|
|
104
115
|
list(namespace?: string): AsyncIterable<RegistryRecord>;
|
|
105
116
|
delete(ref: SegmentRef, expected?: Token): Promise<void>;
|
|
106
117
|
/**
|
|
@@ -112,6 +123,8 @@ export declare class ObjectStoreRegistry implements IRegistryDriver {
|
|
|
112
123
|
* spinning. Only a store that reads in two calls gets here; none of the shipped stores does.
|
|
113
124
|
*/
|
|
114
125
|
private readRow;
|
|
126
|
+
/** Check and parse the bytes of a registry object. */
|
|
127
|
+
private decodeRow;
|
|
115
128
|
/** {@link ObjectRegistryStore.read}, retrying the bounded number of times a mid-read overwrite allows. */
|
|
116
129
|
private readRaced;
|
|
117
130
|
/** Conditional write of an envelope. The store maps a lost precondition to {@link WriteConflictError}. */
|
|
@@ -22,6 +22,10 @@ export declare class LocalFsRegistryDriver implements IRegistryDriver {
|
|
|
22
22
|
create(ref: SegmentRef, record: NewRegistryRecord): Promise<{
|
|
23
23
|
token: Token;
|
|
24
24
|
}>;
|
|
25
|
+
/**
|
|
26
|
+
* A caller's `held` row (see `RegistryWriteOptions`) has no read to spare here: the row's file is read under its lock and its token compared
|
|
27
|
+
* there, a local read and no request, so a stale one fails exactly as a lost race does.
|
|
28
|
+
*/
|
|
25
29
|
compareAndSwap(ref: SegmentRef, expected: Token, patch: RegistryPatch): Promise<{
|
|
26
30
|
token: Token;
|
|
27
31
|
}>;
|
package/dist/drivers/memory.d.ts
CHANGED
|
@@ -26,6 +26,10 @@ export declare class MemoryRegistryDriver implements IRegistryDriver {
|
|
|
26
26
|
create(ref: SegmentRef, record: NewRegistryRecord): Promise<{
|
|
27
27
|
token: Token;
|
|
28
28
|
}>;
|
|
29
|
+
/**
|
|
30
|
+
* A caller's `held` row (see `RegistryWriteOptions`) has no read to spare here: the token is compared with the stored row in the same step
|
|
31
|
+
* as the write, in memory, so a stale one fails exactly as a lost race does.
|
|
32
|
+
*/
|
|
29
33
|
compareAndSwap(ref: SegmentRef, expected: Token, patch: RegistryPatch): Promise<{
|
|
30
34
|
token: Token;
|
|
31
35
|
}>;
|
package/dist/index.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export { CrbmStorageChunkSource } from './core/crbm-storage-source.js';
|
|
|
35
35
|
export type { CrbmStorageChunkSourceOptions, PinnedObject } from './core/crbm-storage-source.js';
|
|
36
36
|
export { PinnedStorageChunkSource } from './core/pinned-storage-source.js';
|
|
37
37
|
export type { PinnedAt } from './core/pinned-storage-source.js';
|
|
38
|
-
export { loadSegment } from './core/load.js';
|
|
38
|
+
export { loadSegment, loadSegmentChunks } from './core/load.js';
|
|
39
39
|
export type { LoadDeps, LoadOptions, LoadGuard, LoadResult, LoadRefusal } from './core/load.js';
|
|
40
40
|
export type { LoadInput, PortableBitmap } from './core/load-input.js';
|
|
41
41
|
export { listGenerations, rollbackSegment } from './core/rollback.js';
|
|
@@ -44,7 +44,7 @@ export { eraseIdFromSegment } from './core/erase-id.js';
|
|
|
44
44
|
export type { EraseIdDeps, EraseIdResult } from './core/erase-id.js';
|
|
45
45
|
export type { CodecInterface, CodecBitmap, EncodedChunk } from './core/codec.js';
|
|
46
46
|
export type { Clock, Entropy, Rng } from './core/determinism.js';
|
|
47
|
-
export type { StorageChunkSource, IStorageDriver, StorageCaps, ChunkRef, ChunkRead, ReadChunksOptions, SegmentRef, GenKey, IRegistryDriver, StorageBackend, RegistryRecord, NewRegistryRecord, RegistryPatch, RegistryStatus, RegistrySummary, ClearRegistrySummary, SealedRegistrySummary, GenerationMetadata, GenerationSummary, RegCaps, GovernanceMeta, SegmentSize, } from './core/ports.js';
|
|
47
|
+
export type { StorageChunkSource, IStorageDriver, StorageCaps, ChunkRef, ChunkRead, ReadChunksOptions, SegmentRef, GenKey, IRegistryDriver, StorageBackend, RegistryRecord, NewRegistryRecord, RegistryPatch, RegistryStatus, RegistrySummary, RegistryWriteOptions, ClearRegistrySummary, SealedRegistrySummary, GenerationMetadata, GenerationSummary, RegCaps, GovernanceMeta, SegmentSize, } from './core/ports.js';
|
|
48
48
|
export { CloudRoaringError, ValidationError, WriteConflictError, IntegrityError, NotFoundError, UnsupportedError, CapabilityError, TransientError, KeyUnavailableError, BudgetExceededError, isCloudRoaringError, isWriteConflictError, isTransientError, isNotFoundError, isIntegrityError, isValidationError, } from './core/errors.js';
|
|
49
49
|
export type { Aead, AeadSealed, IKeystore, WrappedDek, CrbmCrypto } from './core/crypto.js';
|
|
50
50
|
export { aadFor } from './core/crypto.js';
|