@cloudbitmaps/core 0.16.0 → 0.17.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-LFUNPMXZ.js → chunk-CV7GJO3B.js} +363 -23
- package/dist/chunk-CV7GJO3B.js.map +7 -0
- package/dist/core/cost.d.ts +3 -3
- package/dist/core/crbm-storage-source.d.ts +73 -65
- package/dist/core/engine.d.ts +23 -0
- package/dist/core/erasure.d.ts +2 -1
- package/dist/core/errors.d.ts +23 -0
- package/dist/core/generation-gc.d.ts +69 -45
- package/dist/core/kept-generations.d.ts +40 -0
- package/dist/core/leases.d.ts +119 -0
- package/dist/core/load-input.d.ts +12 -0
- package/dist/core/load.d.ts +13 -11
- package/dist/core/metrics.d.ts +24 -0
- package/dist/core/ports.d.ts +99 -2
- package/dist/core/registry-reap.d.ts +22 -0
- package/dist/core/retention.d.ts +2 -1
- package/dist/core/rollback.d.ts +2 -1
- package/dist/driver-kit.d.ts +1 -0
- package/dist/driver-kit.js +100 -1
- package/dist/driver-kit.js.map +2 -2
- package/dist/drivers/_shared/object-registry.d.ts +19 -1
- package/dist/drivers/_shared/registry.d.ts +7 -9
- package/dist/export/index.d.ts +20 -8
- package/dist/index.d.ts +9 -3
- package/dist/index.js +527 -86
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/dist/chunk-LFUNPMXZ.js.map +0 -7
package/dist/core/load.d.ts
CHANGED
|
@@ -47,10 +47,10 @@ export interface LoadDeps {
|
|
|
47
47
|
*/
|
|
48
48
|
readonly readRetry?: ReadRetry;
|
|
49
49
|
/**
|
|
50
|
-
* Collect by listing the segment's objects after the publish, whatever `keep` is. Absent, a load
|
|
51
|
-
*
|
|
52
|
-
* out of the window, and lists every sixteenth generation to take what that leaves; the generations
|
|
53
|
-
*
|
|
50
|
+
* Collect by listing the segment's objects after the publish, whatever `keep` is. Absent, a load whose row records
|
|
51
|
+
* the generations it keeps and that found nothing above its pointer deletes by name the generations its publish
|
|
52
|
+
* pushed out of the window, and lists every sixteenth generation to take what that leaves; the generations no list
|
|
53
|
+
* names wait for that listing. Set it for a caller whose `keep` promises every generation below the new
|
|
54
54
|
* one beyond the window is gone when the call returns: the `*Into` verbs, whose `keep` is how an operator clears
|
|
55
55
|
* a destination that earlier materialisations kept in full.
|
|
56
56
|
*/
|
|
@@ -95,12 +95,14 @@ export interface LoadOptions {
|
|
|
95
95
|
* longest pinned job runs, on every writer that loads the segment — see "Generations and `keep`" in the loading
|
|
96
96
|
* guide.
|
|
97
97
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* take whatever that pass leaves, such as
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
98
|
+
* The segment's row records the generations a load keeps, up to 64, so a load that found nothing above the pointer
|
|
99
|
+
* collects without listing: it deletes the generations its publish pushed out of the window, and lists the segment's
|
|
100
|
+
* objects on every sixteenth generation to take whatever that pass leaves, such as an object a crashed load left
|
|
101
|
+
* below the pointer. A `keep` above 64 records no list and lists on every load, and so does the first load of a
|
|
102
|
+
* row that records none, which then records it; so does a load whose check found the current generation's object
|
|
103
|
+
* gone: the guard's read of it, or, when the guard took the size from the row's summary, one zero-byte read made
|
|
104
|
+
* before a `keep` of 1 or more takes a name. A `keep` at least the generation published collects nothing and asks
|
|
105
|
+
* for nothing.
|
|
104
106
|
*/
|
|
105
107
|
readonly keep?: number;
|
|
106
108
|
/**
|
|
@@ -154,7 +156,7 @@ export interface LoadResult {
|
|
|
154
156
|
/**
|
|
155
157
|
* The superseded generations collected after publishing. Empty when nothing was published.
|
|
156
158
|
*
|
|
157
|
-
* When collection deleted by name (see {@link LoadOptions.keep})
|
|
159
|
+
* When collection deleted by name (see {@link LoadOptions.keep}) these are the names it deleted, and one
|
|
158
160
|
* may have been gone already: a delete of an absent object succeeds on every backend and does not say so, so the
|
|
159
161
|
* list names what the pass asked the bucket to delete, not what it found there. A listing pass names the
|
|
160
162
|
* generations it found and deleted. Neither is a receipt, since a concurrent collector may have taken a generation
|
package/dist/core/metrics.d.ts
CHANGED
|
@@ -92,6 +92,30 @@ export type MetricEvent = {
|
|
|
92
92
|
readonly kind: 'op';
|
|
93
93
|
readonly name: MetricOpName;
|
|
94
94
|
readonly ms: number;
|
|
95
|
+
} | {
|
|
96
|
+
/**
|
|
97
|
+
* A once-only note that a setting looks too small for how the store is used. It is not a fault: nothing failed.
|
|
98
|
+
* Emitted by a backend that can read the setting, never on a request's path after the first, and silent for
|
|
99
|
+
* anything the backend cannot read, so no event does not mean the setting is large enough.
|
|
100
|
+
*
|
|
101
|
+
* `'socket-pool-below-window'`: the S3 client's pool is smaller than `threshold` sockets, twice `concurrency`.
|
|
102
|
+
* `concurrency` is the default window of a combine (32), since a combine's own `concurrency` is chosen per call
|
|
103
|
+
* and the backend cannot see it: a store that always passes a lower one can ignore the event. `maxSockets` is
|
|
104
|
+
* the `maxSockets` of the agent for the scheme of the client's endpoint, or, when the client does not expose an
|
|
105
|
+
* endpoint, the larger of the agents its handler has made (the event fires only when all are below `threshold`).
|
|
106
|
+
* `driver` and `bucket` say which backend the pool
|
|
107
|
+
* belongs to; they never carry a credential, an endpoint or a key.
|
|
108
|
+
*
|
|
109
|
+
* A sink that switches on `kind` with an exhaustive `never` check stops compiling at this variant: add a case
|
|
110
|
+
* for `'advisory'` (ignoring it is fine). A sink with a `default` branch needs no change.
|
|
111
|
+
*/
|
|
112
|
+
readonly kind: 'advisory';
|
|
113
|
+
readonly code: 'socket-pool-below-window';
|
|
114
|
+
readonly driver: string;
|
|
115
|
+
readonly bucket: string;
|
|
116
|
+
readonly maxSockets: number;
|
|
117
|
+
readonly threshold: number;
|
|
118
|
+
readonly concurrency: number;
|
|
95
119
|
};
|
|
96
120
|
/**
|
|
97
121
|
* The sink you plug in. One method, synchronous, fire-and-forget. It must never throw back into the
|
package/dist/core/ports.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { BlobSink } from './blob.js';
|
|
11
11
|
import type { WrappedDek } from './crypto.js';
|
|
12
|
+
import type { IMetricsSink } from './metrics.js';
|
|
12
13
|
/** Opaque optimistic-concurrency token — unique per write, compared by equality only. */
|
|
13
14
|
export type Token = string;
|
|
14
15
|
/**
|
|
@@ -334,6 +335,20 @@ export interface SealedRegistrySummary {
|
|
|
334
335
|
*/
|
|
335
336
|
readonly sealed: string;
|
|
336
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* One lease on a generation. It names no fingerprint: a collector spares a generation by number and cannot compare an
|
|
340
|
+
* object without reading it, and a purge and re-create drops the whole list with the row. A pin's own fingerprint,
|
|
341
|
+
* which its reads check, stops a number retaken after a rollback being read as the leased object; the entry then spares a
|
|
342
|
+
* different object from collection until it ends, at most the longest lease.
|
|
343
|
+
*/
|
|
344
|
+
export interface LeaseEntry {
|
|
345
|
+
/** 16 lowercase hex digits drawn once per pin call; names the holder, so a retried write is idempotent. */
|
|
346
|
+
readonly holder: string;
|
|
347
|
+
/** The generation held. */
|
|
348
|
+
readonly generation: number;
|
|
349
|
+
/** Epoch-ms the lease runs to on the holder's clock; a collector holds it for the skew margin longer. */
|
|
350
|
+
readonly until: number;
|
|
351
|
+
}
|
|
337
352
|
/**
|
|
338
353
|
* One registry row — the authoritative per-segment record. Exactly one per segment.
|
|
339
354
|
*/
|
|
@@ -376,6 +391,21 @@ export interface RegistryRecord extends SegmentRef {
|
|
|
376
391
|
* correct, and a reader then opens the generation instead. Trusted only for the generation it names.
|
|
377
392
|
*/
|
|
378
393
|
readonly summary?: RegistrySummary;
|
|
394
|
+
/**
|
|
395
|
+
* The generations below `currentGen` that the last publish kept as its grace window, ascending. Optional: absent
|
|
396
|
+
* means the row does not know which generations are kept, and a load then lists the bucket to collect and records
|
|
397
|
+
* them; an empty list means none are. Trusted only while every entry is below `currentGen`. A patch that moves
|
|
398
|
+
* `currentGen` without naming it drops it.
|
|
399
|
+
*/
|
|
400
|
+
readonly keptGens?: readonly number[];
|
|
401
|
+
/**
|
|
402
|
+
* The leases on this segment's generations: each keeps one generation out of a load's collection until it ends.
|
|
403
|
+
* Optional; absent means none. Unlike {@link keptGens} it does not follow the pointer: a patch that moves
|
|
404
|
+
* `currentGen` leaves it as it was, and only a patch that names it, or the row going away, changes it. Entries
|
|
405
|
+
* past their end are ignored by every collector and pruned by the next write that names the list. A row's identity
|
|
406
|
+
* is its token, so a purged and re-created segment starts with none.
|
|
407
|
+
*/
|
|
408
|
+
readonly leases?: readonly LeaseEntry[];
|
|
379
409
|
/** Epoch-ms of creation / last mutation (from the driver's injected clock). */
|
|
380
410
|
readonly createdAt: number;
|
|
381
411
|
readonly updatedAt: number;
|
|
@@ -402,6 +432,8 @@ export interface NewRegistryRecord {
|
|
|
402
432
|
readonly residency?: GovernanceMeta;
|
|
403
433
|
/** Must name `currentGen` when given. */
|
|
404
434
|
readonly summary?: RegistrySummary;
|
|
435
|
+
/** Every entry below `currentGen`, ascending; see {@link RegistryRecord.keptGens}. */
|
|
436
|
+
readonly keptGens?: readonly number[];
|
|
405
437
|
}
|
|
406
438
|
/**
|
|
407
439
|
* Fields a {@link IRegistryDriver.compareAndSwap} may mutate (identity + audit + token are off-limits).
|
|
@@ -409,9 +441,10 @@ export interface NewRegistryRecord {
|
|
|
409
441
|
* Presence-based: a field the patch does not mention is left as it was. `summary` is the one exception, because it
|
|
410
442
|
* describes the current generation: a patch that moves `currentGen` and does not mention `summary` drops the old
|
|
411
443
|
* one rather than keep a description of another generation. A `summary` the patch gives must name the
|
|
412
|
-
* `currentGen` the row will have.
|
|
444
|
+
* `currentGen` the row will have. `keptGens` follows the pointer the same way: a patch that moves `currentGen` and does
|
|
445
|
+
* not mention it drops it, and one that gives it must name only generations below the `currentGen` the row will have.
|
|
413
446
|
*/
|
|
414
|
-
export type RegistryPatch = Partial<Pick<RegistryRecord, 'currentGen' | 'wrappedDeks' | 'keyId' | 'status' | 'retention' | 'residency' | 'summary'>>;
|
|
447
|
+
export type RegistryPatch = Partial<Pick<RegistryRecord, 'currentGen' | 'wrappedDeks' | 'keyId' | 'status' | 'retention' | 'residency' | 'summary' | 'keptGens' | 'leases'>>;
|
|
415
448
|
/**
|
|
416
449
|
* What a caller passes {@link IRegistryDriver.create} or {@link IRegistryDriver.compareAndSwap} besides the write itself.
|
|
417
450
|
*/
|
|
@@ -453,6 +486,47 @@ export interface RegCaps {
|
|
|
453
486
|
*/
|
|
454
487
|
readonly conditionalDelete?: boolean;
|
|
455
488
|
}
|
|
489
|
+
/** What {@link IRegistryDriver.reapLegacyTombstones} is asked to do. */
|
|
490
|
+
export interface ReapLegacyTombstonesOptions {
|
|
491
|
+
/** Only rows in this namespace; every namespace, the library's bookkeeping namespaces included, when absent. */
|
|
492
|
+
readonly namespace?: string;
|
|
493
|
+
/** Count what would be removed and remove nothing. */
|
|
494
|
+
readonly dryRun: boolean;
|
|
495
|
+
/** The most rows to remove (or, in a dry run, to count as removable) in this call. */
|
|
496
|
+
readonly limit: number;
|
|
497
|
+
}
|
|
498
|
+
/** What a reap of the tombstones a registry holds found. Every count is of rows, not requests. */
|
|
499
|
+
export interface ReapLegacyTombstonesResult {
|
|
500
|
+
/** The call was a dry run: nothing was removed, and `wouldReap` says what a real run removes. */
|
|
501
|
+
readonly dryRun: boolean;
|
|
502
|
+
/** Rows read. Rows the `limit` stopped the run before reading are not counted. */
|
|
503
|
+
readonly examined: number;
|
|
504
|
+
/** Rows removed. Always 0 in a dry run. */
|
|
505
|
+
readonly reaped: number;
|
|
506
|
+
/** Rows a dry run found removable. Always 0 in a real run. */
|
|
507
|
+
readonly wouldReap: number;
|
|
508
|
+
/**
|
|
509
|
+
* The `limit` was spent and the listing had more keys: rows were left unread, and they may or may not be removable,
|
|
510
|
+
* so run again. A run is not resumable: each lists and reads from the start, so a dry run with a `limit` reports the
|
|
511
|
+
* same first rows every time, and `wouldReap` is a total only when this is `false`.
|
|
512
|
+
*/
|
|
513
|
+
readonly limited: boolean;
|
|
514
|
+
/** Rows read and left, by why. */
|
|
515
|
+
readonly skipped: {
|
|
516
|
+
/** A live (`active`, not deleted) row. */
|
|
517
|
+
readonly live: number;
|
|
518
|
+
/** A `destroyed` row that is not `deleted`: a crypto-shred's or a drop's tombstone, kept as the attestation of an erasure. */
|
|
519
|
+
readonly destroyed: number;
|
|
520
|
+
/**
|
|
521
|
+
* A deleted row that carries an incarnation id, written by a 0.12 or later release. On a registry that removes rows,
|
|
522
|
+
* `delete` and the sweep remove such a row themselves; one left while the registry could not remove rows (GCS by
|
|
523
|
+
* default, S3 on a custom endpoint) stays, and this call, which refuses on such a registry, does not remove it.
|
|
524
|
+
*/
|
|
525
|
+
readonly incarnated: number;
|
|
526
|
+
/** A row that changed, or was already gone (a 404), between its read and its delete, so the delete (fenced on the version read) refused. */
|
|
527
|
+
readonly raced: number;
|
|
528
|
+
};
|
|
529
|
+
}
|
|
456
530
|
/**
|
|
457
531
|
* Refuse, with `UnsupportedError`, a write that would write an object before its row when the registry reports it
|
|
458
532
|
* cannot write a row (see {@link RegCaps.canWrite}). Called before the write's first request.
|
|
@@ -496,6 +570,14 @@ export interface StorageBackend {
|
|
|
496
570
|
readonly storage: IStorageDriver;
|
|
497
571
|
/** Where the `currentGen` pointer, the discovery index and the wrapped DEKs live. */
|
|
498
572
|
readonly registry: IRegistryDriver;
|
|
573
|
+
/**
|
|
574
|
+
* Optional. A store with a metrics sink calls this once, as it is built, with that sink (already safe to call: it
|
|
575
|
+
* never throws back). A backend that has something to say about its own setup, such as a pool it can read and finds
|
|
576
|
+
* small, keeps the sink and emits an `advisory` event to it later. A backend without it is simply never told. One backend
|
|
577
|
+
* may be handed to several stores, and is called once by each, with each store's own sink. It must not throw: an
|
|
578
|
+
* exception propagates from the store's constructor, so a backend's bug fails fast rather than going unseen.
|
|
579
|
+
*/
|
|
580
|
+
attachMetrics?(sink: IMetricsSink): void;
|
|
499
581
|
}
|
|
500
582
|
/**
|
|
501
583
|
* The brand key, for the type-only `declare` field a backend class carries.
|
|
@@ -619,5 +701,20 @@ export interface IRegistryDriver {
|
|
|
619
701
|
* created after the decision. A third-party driver that ignores `expected` keeps the unfenced behaviour.
|
|
620
702
|
*/
|
|
621
703
|
delete(ref: SegmentRef, expected?: Token): Promise<void>;
|
|
704
|
+
/**
|
|
705
|
+
* Optional: remove the `deleted: true` rows with no incarnation id, which a release before 0.12 left, or a 0.12 or
|
|
706
|
+
* later release left by deleting a row born before 0.12, so that no later full `list` reads them. Only a registry that
|
|
707
|
+
* keeps such rows in its backend implements it; the object registries do.
|
|
708
|
+
*
|
|
709
|
+
* It removes a row only if it is `deleted: true` with no incarnation id (whatever its `status`), and only by a delete
|
|
710
|
+
* the backend applies under the version it read (see {@link RegCaps.conditionalDelete}), so a `create` that lands over
|
|
711
|
+
* the row first wins. A registry that cannot delete under that precondition throws `CapabilityError` before its first
|
|
712
|
+
* request. It never touches a live row, a `destroyed` row that is not `deleted`, or a deleted row with an incarnation
|
|
713
|
+
* id, so it does not remove every row a full `list` reads. If `conditionalDelete` was set on a backend that ignores the
|
|
714
|
+
* precondition, the delete is unfenced.
|
|
715
|
+
*
|
|
716
|
+
* Not resumable: every call lists and reads from the start (R reads for R rows, whatever the `limit`).
|
|
717
|
+
*/
|
|
718
|
+
reapLegacyTombstones?(options: ReapLegacyTombstonesOptions): Promise<ReapLegacyTombstonesResult>;
|
|
622
719
|
}
|
|
623
720
|
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IRegistryDriver, ReapLegacyTombstonesResult } from './ports.js';
|
|
2
|
+
/** Rows one call removes when no `limit` says otherwise. */
|
|
3
|
+
export declare const DEFAULT_REAP_LIMIT = 1000;
|
|
4
|
+
/** The keys `reapRegistryTombstones` takes. */
|
|
5
|
+
export interface ReapRegistryTombstonesOptions {
|
|
6
|
+
/** Only rows in this namespace. Omit to cover every namespace, the library's own bookkeeping rows included. */
|
|
7
|
+
readonly namespace?: string;
|
|
8
|
+
/** Count what a real run would remove, and remove nothing. Needs no `confirmNoLegacyWriters`. */
|
|
9
|
+
readonly dryRun?: boolean;
|
|
10
|
+
/** Required `true` for a real run: no process on a release before 0.12 writes this registry. */
|
|
11
|
+
readonly confirmNoLegacyWriters?: boolean;
|
|
12
|
+
/** The most rows to remove in this call (default {@link DEFAULT_REAP_LIMIT}); a run that stops on it says `limited`. */
|
|
13
|
+
readonly limit?: number;
|
|
14
|
+
}
|
|
15
|
+
/** What a reap found and did: see {@link ReapLegacyTombstonesResult}. */
|
|
16
|
+
export type ReapRegistryTombstonesResult = ReapLegacyTombstonesResult;
|
|
17
|
+
/**
|
|
18
|
+
* Remove the `deleted: true` rows with no incarnation id from `registry`, or count them (`dryRun`).
|
|
19
|
+
*
|
|
20
|
+
* Cost over R rows read and E removed: `ceil(R / 1000)` LIST requests, R GETs and E DELETEs.
|
|
21
|
+
*/
|
|
22
|
+
export declare function reapRegistryTombstones(registry: IRegistryDriver, options?: ReapRegistryTombstonesOptions): Promise<ReapRegistryTombstonesResult>;
|
package/dist/core/retention.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChurnDeps } from './leases.js';
|
|
1
2
|
import type { GovernanceMeta, IRegistryDriver, SegmentRef } from './ports.js';
|
|
2
3
|
/**
|
|
3
4
|
* The smallest accepted `expiresAt`: 2001-09-09, the point below which an epoch-**seconds** value would land.
|
|
@@ -18,7 +19,7 @@ export interface RetentionPolicy {
|
|
|
18
19
|
*/
|
|
19
20
|
readonly expiresAt: number;
|
|
20
21
|
}
|
|
21
|
-
export interface RetentionDeps {
|
|
22
|
+
export interface RetentionDeps extends ChurnDeps {
|
|
22
23
|
readonly registry: IRegistryDriver;
|
|
23
24
|
}
|
|
24
25
|
export interface SetRetentionResult {
|
package/dist/core/rollback.d.ts
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { type IAuditSink } from './audit.js';
|
|
20
20
|
import type { IKeystore } from './crypto.js';
|
|
21
|
+
import { type ChurnDeps } from './leases.js';
|
|
21
22
|
import type { IStorageDriver, GenerationMetadata, IRegistryDriver, SegmentRef } from './ports.js';
|
|
22
23
|
/** What the generation helpers need: the objects, and the pointer that says which one is current. */
|
|
23
|
-
export interface GenerationListDeps {
|
|
24
|
+
export interface GenerationListDeps extends ChurnDeps {
|
|
24
25
|
readonly storage: IStorageDriver;
|
|
25
26
|
readonly registry: IRegistryDriver;
|
|
26
27
|
/**
|
package/dist/driver-kit.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { GenKey, IRegistryDriver, IStorageDriver, SegmentRef, StorageBackend, StorageCaps, } from './core/ports.js';
|
|
2
2
|
export { brandAsBackend, STORAGE_BACKEND } from './core/ports.js';
|
|
3
|
+
export type { IMetricsSink, MetricEvent } from './core/metrics.js';
|
|
3
4
|
export { IntegrityError, isNotFoundError, isValidationError, isWriteConflictError, NotFoundError, TransientError, ValidationError, WriteConflictError, } from './core/errors.js';
|
|
4
5
|
export { validateSegmentRef } from './core/validate.js';
|
|
5
6
|
export type { BlobSink } from './core/blob.js';
|
package/dist/driver-kit.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CapabilityError,
|
|
2
3
|
DEFAULT_NAMESPACE,
|
|
3
4
|
IntegrityError,
|
|
4
5
|
NotFoundError,
|
|
@@ -28,7 +29,7 @@ import {
|
|
|
28
29
|
validateRegistryPatch,
|
|
29
30
|
validateSegmentRef,
|
|
30
31
|
webCryptoEntropy
|
|
31
|
-
} from "./chunk-
|
|
32
|
+
} from "./chunk-CV7GJO3B.js";
|
|
32
33
|
|
|
33
34
|
// src/drivers/_shared/object-registry-keys.ts
|
|
34
35
|
var REGISTRY_SUFFIX = ".reg";
|
|
@@ -253,6 +254,104 @@ var ObjectStoreRegistry = class {
|
|
|
253
254
|
}
|
|
254
255
|
throw new WriteConflictError(`registry delete: contention deleting "${ref.segment}" \u2014 retry`);
|
|
255
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Remove the `deleted: true` rows with no incarnation id (the envelopes a release before 0.12 left), or count them.
|
|
259
|
+
* One listing, a read per key and a conditional delete per envelope, so a row that a create wrote over since it was
|
|
260
|
+
* read keeps its new version and the delete is refused. Refuses with {@link CapabilityError}, before any request,
|
|
261
|
+
* where the store's delete is not known to apply its precondition.
|
|
262
|
+
*
|
|
263
|
+
* An object that cannot be read or parsed stops the run with the error that names its key, and removes nothing
|
|
264
|
+
* more: it may be a newer release's row. Rows removed before the stop stay removed, and a re-run is safe.
|
|
265
|
+
*
|
|
266
|
+
* Not resumable: it lists and reads from the start on every call. A delete refused as a conflict, including one for a
|
|
267
|
+
* row already gone, is counted as `raced`.
|
|
268
|
+
*/
|
|
269
|
+
async reapLegacyTombstones(options) {
|
|
270
|
+
await this.store.resolveCapabilities?.();
|
|
271
|
+
const remove = this.removesRows() ? this.store.delete : void 0;
|
|
272
|
+
if (remove === void 0) {
|
|
273
|
+
throw new CapabilityError(
|
|
274
|
+
`reapRegistryTombstones: this registry's delete is not conditional (capabilities().conditionalDelete is false), so a removal could take a row a create has just written over the envelope. Nothing was requested`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const { dryRun, limit } = options;
|
|
278
|
+
const counts = {
|
|
279
|
+
examined: 0,
|
|
280
|
+
reaped: 0,
|
|
281
|
+
wouldReap: 0,
|
|
282
|
+
live: 0,
|
|
283
|
+
destroyed: 0,
|
|
284
|
+
incarnated: 0,
|
|
285
|
+
raced: 0
|
|
286
|
+
};
|
|
287
|
+
let limited = false;
|
|
288
|
+
const removable = () => counts.reaped + counts.wouldReap;
|
|
289
|
+
const handle = async (keys) => {
|
|
290
|
+
const rows = await mapWithConcurrency(
|
|
291
|
+
keys,
|
|
292
|
+
LIST_READ_CONCURRENCY,
|
|
293
|
+
(key) => this.readRow(key).then((row) => ({ key, row }))
|
|
294
|
+
);
|
|
295
|
+
const found = [];
|
|
296
|
+
for (const { key, row } of rows) {
|
|
297
|
+
if (row === null) continue;
|
|
298
|
+
counts.examined += 1;
|
|
299
|
+
const { deleted, record } = row.env;
|
|
300
|
+
if (!deleted) {
|
|
301
|
+
if (record.status === "destroyed") counts.destroyed += 1;
|
|
302
|
+
else counts.live += 1;
|
|
303
|
+
} else if (incarnationOf(record.token) !== void 0) {
|
|
304
|
+
counts.incarnated += 1;
|
|
305
|
+
} else if (removable() + found.length >= limit) {
|
|
306
|
+
limited = true;
|
|
307
|
+
} else {
|
|
308
|
+
found.push({ key, version: row.version });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (dryRun) {
|
|
312
|
+
counts.wouldReap += found.length;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
await mapWithConcurrency(found, LIST_READ_CONCURRENCY, async ({ key, version }) => {
|
|
316
|
+
try {
|
|
317
|
+
await remove.call(this.store, key, { version });
|
|
318
|
+
counts.reaped += 1;
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (!isWriteConflictError(err)) throw err;
|
|
321
|
+
counts.raced += 1;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
let page = [];
|
|
326
|
+
for await (const key of this.store.listKeys(
|
|
327
|
+
registryListPrefix(this.prefix, options.namespace)
|
|
328
|
+
)) {
|
|
329
|
+
if (parseRegistryKey(this.prefix, key) === null) continue;
|
|
330
|
+
if (removable() >= limit) {
|
|
331
|
+
limited = true;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
page.push(key);
|
|
335
|
+
if (page.length >= LIST_READ_CONCURRENCY) {
|
|
336
|
+
await handle(page);
|
|
337
|
+
page = [];
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (page.length > 0) await handle(page);
|
|
341
|
+
return {
|
|
342
|
+
dryRun,
|
|
343
|
+
examined: counts.examined,
|
|
344
|
+
reaped: counts.reaped,
|
|
345
|
+
wouldReap: counts.wouldReap,
|
|
346
|
+
limited,
|
|
347
|
+
skipped: {
|
|
348
|
+
live: counts.live,
|
|
349
|
+
destroyed: counts.destroyed,
|
|
350
|
+
incarnated: counts.incarnated,
|
|
351
|
+
raced: counts.raced
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
256
355
|
/**
|
|
257
356
|
* Read + parse a registry object, returning its envelope and version fence, or null if absent.
|
|
258
357
|
*
|
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 * **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": "
|
|
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 CapabilityError,\n IntegrityError,\n TransientError,\n ValidationError,\n WriteConflictError,\n isWriteConflictError,\n} from '@/core/errors';\nimport type {\n IRegistryDriver,\n NewRegistryRecord,\n ReapLegacyTombstonesOptions,\n ReapLegacyTombstonesResult,\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 * Optional: settle {@link conditionalDelete} without a request to the backend, for a store that learns it from its\n * client rather than knowing it at construction. A caller that must refuse before its first request awaits it first.\n */\n resolveCapabilities?(): Promise<void>;\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 * Remove the `deleted: true` rows with no incarnation id (the envelopes a release before 0.12 left), or count them.\n * One listing, a read per key and a conditional delete per envelope, so a row that a create wrote over since it was\n * read keeps its new version and the delete is refused. Refuses with {@link CapabilityError}, before any request,\n * where the store's delete is not known to apply its precondition.\n *\n * An object that cannot be read or parsed stops the run with the error that names its key, and removes nothing\n * more: it may be a newer release's row. Rows removed before the stop stay removed, and a re-run is safe.\n *\n * Not resumable: it lists and reads from the start on every call. A delete refused as a conflict, including one for a\n * row already gone, is counted as `raced`.\n */\n async reapLegacyTombstones(\n options: ReapLegacyTombstonesOptions,\n ): Promise<ReapLegacyTombstonesResult> {\n await this.store.resolveCapabilities?.();\n const remove = this.removesRows() ? this.store.delete : undefined;\n if (remove === undefined) {\n throw new CapabilityError(\n `reapRegistryTombstones: this registry's delete is not conditional (capabilities().conditionalDelete is false), ` +\n 'so a removal could take a row a create has just written over the envelope. Nothing was requested',\n );\n }\n const { dryRun, limit } = options;\n const counts = {\n examined: 0,\n reaped: 0,\n wouldReap: 0,\n live: 0,\n destroyed: 0,\n incarnated: 0,\n raced: 0,\n };\n let limited = false;\n const removable = (): number => counts.reaped + counts.wouldReap;\n\n const handle = async (keys: string[]): Promise<void> => {\n const rows = await mapWithConcurrency(keys, LIST_READ_CONCURRENCY, (key) =>\n this.readRow(key).then((row) => ({ key, row })),\n );\n const found: Array<{ key: string; version: string }> = [];\n for (const { key, row } of rows) {\n if (row === null) continue; // gone between the listing and the read\n counts.examined += 1;\n const { deleted, record } = row.env;\n if (!deleted) {\n if (record.status === 'destroyed') counts.destroyed += 1;\n else counts.live += 1;\n } else if (incarnationOf(record.token) !== undefined) {\n counts.incarnated += 1;\n } else if (removable() + found.length >= limit) {\n limited = true;\n } else {\n found.push({ key, version: row.version });\n }\n }\n if (dryRun) {\n counts.wouldReap += found.length;\n return;\n }\n await mapWithConcurrency(found, LIST_READ_CONCURRENCY, async ({ key, version }) => {\n try {\n await remove.call(this.store, key, { version });\n counts.reaped += 1;\n } catch (err) {\n if (!isWriteConflictError(err)) throw err;\n counts.raced += 1; // the row changed (a create, another sweeper) or is already gone\n }\n });\n };\n\n let page: string[] = [];\n for await (const key of this.store.listKeys(\n registryListPrefix(this.prefix, options.namespace),\n )) {\n if (parseRegistryKey(this.prefix, key) === null) continue; // stray or foreign object\n if (removable() >= limit) {\n limited = true; // spent: stop listing, and say rows may remain\n break;\n }\n page.push(key);\n if (page.length >= LIST_READ_CONCURRENCY) {\n await handle(page);\n page = [];\n }\n }\n if (page.length > 0) await handle(page);\n return {\n dryRun,\n examined: counts.examined,\n reaped: counts.reaped,\n wouldReap: counts.wouldReap,\n limited,\n skipped: {\n live: counts.live,\n destroyed: counts.destroyed,\n incarnated: counts.incarnated,\n raced: counts.raced,\n },\n };\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
|
}
|