@cloudbitmaps/core 0.10.0 → 0.11.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/README.md +32 -36
- package/dist/{chunk-BUPDGZRR.js → chunk-5OMC5U7U.js} +87 -40
- package/dist/chunk-5OMC5U7U.js.map +7 -0
- package/dist/core/audit.d.ts +15 -14
- package/dist/core/budget.d.ts +5 -3
- package/dist/core/codec.d.ts +18 -10
- package/dist/core/consistency.d.ts +3 -3
- package/dist/core/cooperative.d.ts +9 -9
- package/dist/core/cost.d.ts +167 -15
- package/dist/core/crbm/format.d.ts +7 -9
- package/dist/core/crbm/reader.d.ts +18 -1
- package/dist/core/crbm/writer.d.ts +2 -2
- package/dist/core/crbm-storage-source.d.ts +150 -60
- package/dist/core/determinism.d.ts +1 -1
- package/dist/core/discover.d.ts +7 -5
- package/dist/core/due-index.d.ts +3 -5
- package/dist/core/engine.d.ts +55 -13
- package/dist/core/erase-id.d.ts +76 -41
- package/dist/core/erasure.d.ts +22 -16
- package/dist/core/errors.d.ts +13 -20
- package/dist/core/generation-gc.d.ts +9 -5
- package/dist/core/keys.d.ts +2 -2
- package/dist/core/load.d.ts +28 -9
- package/dist/core/metrics.d.ts +8 -4
- package/dist/core/name-codec.d.ts +21 -31
- package/dist/core/pinned-storage-source.d.ts +29 -8
- package/dist/core/ports.d.ts +142 -59
- package/dist/core/reader-defaults.d.ts +18 -0
- package/dist/core/registry-scan.d.ts +15 -6
- package/dist/core/retention-sweep.d.ts +26 -18
- package/dist/core/retry.d.ts +8 -4
- package/dist/core/rollback.d.ts +11 -7
- package/dist/core/validate.d.ts +27 -3
- package/dist/driver-kit.d.ts +2 -3
- package/dist/driver-kit.js +13 -52
- package/dist/driver-kit.js.map +3 -3
- package/dist/drivers/_shared/keys.d.ts +4 -5
- package/dist/drivers/_shared/object-registry.d.ts +10 -5
- package/dist/drivers/_shared/registry.d.ts +9 -5
- package/dist/drivers/backends.d.ts +10 -16
- package/dist/drivers/localfs/fs-util.d.ts +3 -2
- package/dist/drivers/localfs/registry.d.ts +4 -4
- package/dist/drivers/memory.d.ts +2 -10
- package/dist/drivers/retry/retrying-chunk-source.d.ts +59 -0
- package/dist/export/index.d.ts +7 -6
- package/dist/index.d.ts +15 -24
- package/dist/index.js +1014 -373
- package/dist/index.js.map +4 -4
- package/dist/testing/fuzz-core.d.ts +1 -1
- package/package.json +1 -1
- package/dist/chunk-BUPDGZRR.js.map +0 -7
- package/dist/drivers/_shared/aws-errors.d.ts +0 -18
- package/dist/drivers/retry/retrying-drivers.d.ts +0 -90
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* publish part-way through an export, a send or a reconciliation means the second half of the job describes a
|
|
7
7
|
* different instant than the first — and nothing in the result says so.
|
|
8
8
|
*
|
|
9
|
-
* **A pin is a generation
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* **A pin is a generation number and the object it named, not a captured reader.** The reader behind it can live
|
|
10
|
+
* in the shared bounded LRU and be evicted freely: re-opening at the same number reopens the same object, or, if
|
|
11
|
+
* the name was purged and loaded again since, finds another one, which the pin's fingerprint refuses. Holding the
|
|
12
|
+
* reader instead put every live pin outside the library's memory ceiling.
|
|
12
13
|
*
|
|
13
14
|
* **Other segments are not pinned, and that is the contract, not an omission.** `snap.intersect([other])` reads
|
|
14
15
|
* `snap` at its pinned generation and `other` at whatever is current. Pinning the whole query means pinning
|
|
@@ -22,10 +23,18 @@
|
|
|
22
23
|
*/
|
|
23
24
|
import type { ChunkRef, StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
|
|
24
25
|
import type { CrbmStorageChunkSource } from './crbm-storage-source.js';
|
|
25
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* What a pin holds for one segment: the generation, the version identifying those exact bytes, and the pinned
|
|
28
|
+
* object's fingerprint, so a read can tell that the generation it opens is still that object.
|
|
29
|
+
*
|
|
30
|
+
* `fingerprint` is optional so that a pin built by hand, before there was one, still compiles. Nothing checks the
|
|
31
|
+
* object such a pin reads, so it cannot tell a name purged and loaded again from the segment it pinned.
|
|
32
|
+
* `seg.pin()` always records it.
|
|
33
|
+
*/
|
|
26
34
|
export interface PinnedAt {
|
|
27
35
|
readonly generation: number | null;
|
|
28
36
|
readonly version: string | null;
|
|
37
|
+
readonly fingerprint?: string | null;
|
|
29
38
|
}
|
|
30
39
|
export declare class PinnedStorageChunkSource implements StorageChunkSource {
|
|
31
40
|
private readonly inner;
|
|
@@ -44,6 +53,8 @@ export declare class PinnedStorageChunkSource implements StorageChunkSource {
|
|
|
44
53
|
* wrong answer this exists to prevent.
|
|
45
54
|
*/
|
|
46
55
|
pins: ReadonlyMap<string, PinnedAt>);
|
|
56
|
+
/** The unpinned segments refresh as the store's own source does; a pinned one never does. */
|
|
57
|
+
get pointerRefreshMs(): number;
|
|
47
58
|
/**
|
|
48
59
|
* The pin for this segment, or `undefined` if it is not pinned here.
|
|
49
60
|
*
|
|
@@ -58,10 +69,20 @@ export declare class PinnedStorageChunkSource implements StorageChunkSource {
|
|
|
58
69
|
cardinalities(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
|
|
59
70
|
currentGeneration(ref: SegmentRef): Promise<number | null>;
|
|
60
71
|
/**
|
|
61
|
-
* A pinned segment reports the version captured when it was pinned, so its decoded chunks
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* `
|
|
72
|
+
* A pinned segment reports the version captured when it was pinned, marked as a pin's, so its decoded chunks
|
|
73
|
+
* are cached under keys that no live read writes. A live read keys its fetches by the version it resolved when
|
|
74
|
+
* it began, but is served whatever generation the live source holds when each fetch lands — after a publish
|
|
75
|
+
* and a lapsed `cache.genTtlMs`, a reader-cache eviction, a sweep that heals the read forward, or an
|
|
76
|
+
* invalidation, a different one — so an entry under a live version can hold another generation's chunk.
|
|
77
|
+
* Invariant 3 lets that live call see it, and later live reads resolve the current version and never look the
|
|
78
|
+
* entry up; a pin sharing the key would be handed it, and return a read that mixed two generations. A pinned
|
|
79
|
+
* read fetches exactly its own generation, so the entries it fills are always that generation's.
|
|
80
|
+
*
|
|
81
|
+
* The pin still shares the store's chunk cache, and its memory ceiling; what it gives up is a hit on a chunk a
|
|
82
|
+
* live read of the same version cached, which costs a pin one GET per such chunk. Live reads make no call for it,
|
|
83
|
+
* though a pin's entries share the cache's bound with theirs, so under a small `cache.maxChunks` each can evict
|
|
84
|
+
* the other. The version, not the bare generation, is still what the key carries: sharing on a generation-only
|
|
85
|
+
* key was how a pinned read could resurrect an id that `eraseIdFromSegment` had reported physically gone.
|
|
65
86
|
*/
|
|
66
87
|
currentVersion(ref: SegmentRef): Promise<string | null>;
|
|
67
88
|
exists(ref: SegmentRef): Promise<boolean>;
|
package/dist/core/ports.d.ts
CHANGED
|
@@ -11,6 +11,17 @@ import type { BlobSink } from './blob.js';
|
|
|
11
11
|
import type { WrappedDek } from './crypto.js';
|
|
12
12
|
/** Opaque optimistic-concurrency token — unique per write, compared by equality only. */
|
|
13
13
|
export type Token = string;
|
|
14
|
+
/**
|
|
15
|
+
* The address of one segment: a `namespace` and a `segment` name, both any non-empty string of at most 256
|
|
16
|
+
* characters once encoded for storage. Omitting the namespace addresses a different segment from one that names it.
|
|
17
|
+
*
|
|
18
|
+
* **A namespace starting with `cbm.due.` is reserved**, and every call of the store, and of the free functions
|
|
19
|
+
* behind it, that takes a ref or a `namespace` option throws `ValidationError` for one. The library keeps the due
|
|
20
|
+
* index there (one pointer row per expiring segment), and every fleet-wide scan skips those rows as bookkeeping, so a
|
|
21
|
+
* segment in such a namespace would be invisible to an erasure, a consistency check, an export and a retention sweep.
|
|
22
|
+
* A driver takes the prefix, since the due index's own rows go through it. Only that exact prefix is reserved:
|
|
23
|
+
* `cbm.dueX` and `cbmdue.eu` are ordinary namespaces, and a segment name is never restricted.
|
|
24
|
+
*/
|
|
14
25
|
export interface SegmentRef {
|
|
15
26
|
readonly namespace?: string;
|
|
16
27
|
readonly segment: string;
|
|
@@ -39,6 +50,12 @@ export interface StorageChunkSource {
|
|
|
39
50
|
* payload reads), or `null` if the segment has no Storage generation. Powers the grounded `costReport()`.
|
|
40
51
|
*/
|
|
41
52
|
sizeOf?(ref: SegmentRef): Promise<SegmentSize | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Optional: how often this source re-reads a segment's pointer while the segment is being read, in ms, or 0 if
|
|
55
|
+
* it never does. The grounded `costReport()` prices the pointer refresh at this interval; a source that omits it
|
|
56
|
+
* is priced at the store's default.
|
|
57
|
+
*/
|
|
58
|
+
readonly pointerRefreshMs?: number;
|
|
42
59
|
/**
|
|
43
60
|
* Optional: the current generation's **per-chunk cardinality** (`chunkKey → count`), read from the
|
|
44
61
|
* already-parsed `.crbm` index with **no payload reads**, or `null` if the segment has no Storage generation.
|
|
@@ -52,8 +69,8 @@ export interface StorageChunkSource {
|
|
|
52
69
|
* keys its chunk cache by this so a generation bump (a load's publish) is observed — a new generation
|
|
53
70
|
* misses the cache instead of serving a stale decoded chunk, and an erased id can't resurrect from a cached
|
|
54
71
|
* superseded chunk. Cheap: served from the source's own (short-TTL-refreshed) snapshot, **not** a fresh
|
|
55
|
-
* backend read per call. A source that
|
|
56
|
-
*
|
|
72
|
+
* backend read per call. A source that only ever serves one immutable generation may omit this — the engine
|
|
73
|
+
* then keys the cache without a generation.
|
|
57
74
|
*/
|
|
58
75
|
currentGeneration?(ref: SegmentRef): Promise<number | null>;
|
|
59
76
|
/**
|
|
@@ -64,8 +81,8 @@ export interface StorageChunkSource {
|
|
|
64
81
|
* `currentGen`**. Neither covers an event that *destroys* what the cache was derived from: an erasure that
|
|
65
82
|
* deletes the generation holding the bit, a `dropSegment`, a crypto-shred, a retirement. After one of those
|
|
66
83
|
* a source that had already resolved the segment keeps answering from memory — with no backend read at all,
|
|
67
|
-
* so no storage-side control can close the window — until its TTL lapses, and
|
|
68
|
-
* or `cache.genTtlMs: 0
|
|
84
|
+
* so no storage-side control can close the window — until its TTL lapses, and with no bound at all if it has
|
|
85
|
+
* no registry, no clock or `cache.genTtlMs: 0`.
|
|
69
86
|
*
|
|
70
87
|
* Callers that destroy or retire a segment must call this. It is synchronous and best-effort: dropping
|
|
71
88
|
* memoized state cannot fail, and a source that memoizes nothing may omit the method entirely.
|
|
@@ -88,7 +105,7 @@ export interface StorageChunkSource {
|
|
|
88
105
|
* the generation *and* the incarnation of the name it belongs to.
|
|
89
106
|
*
|
|
90
107
|
* {@link StorageChunkSource.currentGeneration} is not enough to key a decoded-chunk cache, and the gap is not
|
|
91
|
-
* theoretical:
|
|
108
|
+
* theoretical: a load numbers its generation `max(currentGen, highest object) + 1`, so the number **restarts at 0** once
|
|
92
109
|
* a registry row is purged and the bucket emptied. A retired, re-created name therefore serves different
|
|
93
110
|
* data at the same `currentGen`, and a cache keyed on `(segment, chunk, generation)` hands back the previous
|
|
94
111
|
* incarnation's ids — an erased id reappearing, with no read to intercept.
|
|
@@ -96,8 +113,8 @@ export interface StorageChunkSource {
|
|
|
96
113
|
* Note that putting the incarnation in the *object key* would not fix this: the new incarnation still starts
|
|
97
114
|
* at generation 0, so the cache key collides either way. The identity has to reach the cache.
|
|
98
115
|
*
|
|
99
|
-
* Returns `null` when the segment resolves to no generation. A source that
|
|
100
|
-
*
|
|
116
|
+
* Returns `null` when the segment resolves to no generation. A source that only ever serves one immutable
|
|
117
|
+
* generation may omit this, and the engine falls back to the generation alone.
|
|
101
118
|
*/
|
|
102
119
|
currentVersion?(ref: SegmentRef): Promise<string | null>;
|
|
103
120
|
}
|
|
@@ -114,35 +131,77 @@ export interface StorageCaps {
|
|
|
114
131
|
* Immutable object storage for `.crbm` generations. A "dumb byte mover": it
|
|
115
132
|
* understands neither roaring nor the `.crbm` layout, only opaque bytes addressed by a {@link GenKey}.
|
|
116
133
|
* The core never reuses a key, so puts are write-once.
|
|
134
|
+
*
|
|
135
|
+
* **What a driver must do** — callers rely on each of these, and the storage conformance suite holds every
|
|
136
|
+
* shipped driver to them:
|
|
137
|
+
*
|
|
138
|
+
* - **`putImmutable` is write-once and reports a collision as {@link WriteConflictError}.** It never overwrites a
|
|
139
|
+
* stored object. `load` reads that error as a lost race for the generation number (`superseded`), so a driver
|
|
140
|
+
* that threw anything else for an existing key would surface as a failed load.
|
|
141
|
+
* - **A missing object makes `getRange` and `getTail` throw {@link NotFoundError}** — never an empty or short
|
|
142
|
+
* result. Heal-forward (a read whose generation a sweep collected re-resolves), the erasure's holder probe and
|
|
143
|
+
* its verify of the rewrite, and a pin's replaced-object check all branch on that one error. (A zero-length
|
|
144
|
+
* `getRange` may answer empty without reaching the backend, so it proves neither that the object exists nor
|
|
145
|
+
* that the offset lies inside it: the cloud drivers answer it empty, the memory and local-filesystem drivers
|
|
146
|
+
* throw for a missing object or an offset past the end.)
|
|
147
|
+
* - **An out-of-range read throws {@link ValidationError}**: a range that runs past the end of the object, or a
|
|
148
|
+
* negative or non-integer offset or length. Never a clamped, short or adjacent read. A pin whose object
|
|
149
|
+
* was purged and loaded again as a smaller one asks for a range past its end, and this error is what sends it
|
|
150
|
+
* to check that the object is still the one it pinned.
|
|
151
|
+
* - **`getTail` reports the object's true total size**, not the number of bytes it returned.
|
|
152
|
+
* - **`delete` is idempotent.** Deleting an absent key is a no-op, not an error: collection passes race each
|
|
153
|
+
* other and retry.
|
|
154
|
+
* - **`list` is strongly consistent, read-after-delete.** Once `delete` has resolved, `list` no longer yields
|
|
155
|
+
* that generation, and once `putImmutable` has resolved it does. The erasure's re-check for a generation still
|
|
156
|
+
* holding the id, `dropSegment`'s `generationsRemaining`, the retention sweep's check that a tombstone's storage
|
|
157
|
+
* is gone, and rollback's post-move check prove a deletion or a presence by listing, so a listing that lags
|
|
158
|
+
* reports an erasure as incomplete or a stale object as gone.
|
|
159
|
+
* - **Never replay a conditional write without telling the replay apart.** A `putImmutable` that lands and then
|
|
160
|
+
* loses its response, sent again, finds its own object and would report a collision. Either send each write
|
|
161
|
+
* once, with the backend client's retry off for that request, or recognise your own write when the
|
|
162
|
+
* precondition fails by reading an id you stored with it back, and treat a match as success. The first is
|
|
163
|
+
* what the S3 driver and the single-request GCS upload do; the second is what the Azure Blob driver and the
|
|
164
|
+
* resumable GCS upload do.
|
|
165
|
+
* - **Raise a transient fault as {@link TransientError}** (throttling, a 5xx, a dropped connection), so the read
|
|
166
|
+
* retry can ride it out and a write's caller can tell it from a deterministic failure.
|
|
117
167
|
*/
|
|
118
168
|
export interface IStorageDriver {
|
|
119
169
|
capabilities(): StorageCaps;
|
|
120
170
|
/**
|
|
121
171
|
* Stream a new immutable generation. The driver opens a destination, hands `write` a {@link BlobSink},
|
|
122
|
-
* then atomically commits (and computes the content hash). Throws if the key
|
|
172
|
+
* then atomically commits (and computes the content hash). Throws {@link WriteConflictError} if the key
|
|
173
|
+
* already exists (write-once).
|
|
123
174
|
*/
|
|
124
175
|
putImmutable(key: GenKey, write: (sink: BlobSink) => Promise<void>): Promise<{
|
|
125
176
|
size: number;
|
|
126
177
|
sha256: string;
|
|
127
178
|
}>;
|
|
128
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* Range read; the caller bounds-checks. Out-of-range throws {@link ValidationError}, never a short/adjacent
|
|
181
|
+
* read, and a missing object throws {@link NotFoundError}.
|
|
182
|
+
*/
|
|
129
183
|
getRange(key: GenKey, offset: number, length: number): Promise<Uint8Array>;
|
|
130
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Speculative tail read: the last `min(maxBytes, size)` bytes + the object's **true total size**. A missing
|
|
186
|
+
* object throws {@link NotFoundError}.
|
|
187
|
+
*/
|
|
131
188
|
getTail(key: GenKey, maxBytes: number): Promise<{
|
|
132
189
|
bytes: Uint8Array;
|
|
133
190
|
size: number;
|
|
134
191
|
}>;
|
|
192
|
+
/** Idempotent: deleting an absent key is a no-op, not an error. */
|
|
135
193
|
delete(key: GenKey): Promise<void>;
|
|
136
|
-
/**
|
|
194
|
+
/**
|
|
195
|
+
* Enumerate the generations present for a segment (orphan sweep / latest-gen resolution). Strongly
|
|
196
|
+
* consistent, read-after-delete and read-after-put.
|
|
197
|
+
*/
|
|
137
198
|
list(ref: SegmentRef): AsyncIterable<GenKey>;
|
|
138
199
|
}
|
|
139
200
|
/**
|
|
140
201
|
* Lifecycle status of a segment. `active` is the steady state; `destroyed` is the post-crypto-shred tombstone
|
|
141
|
-
* (the row is kept for audit but the segment is logically gone).
|
|
142
|
-
* stored and round-tripped so a row written by an earlier build still reads, but no writer in this build sets
|
|
143
|
-
* them.
|
|
202
|
+
* (the row is kept for audit but the segment is logically gone).
|
|
144
203
|
*/
|
|
145
|
-
export type RegistryStatus = 'active' | '
|
|
204
|
+
export type RegistryStatus = 'active' | 'destroyed';
|
|
146
205
|
/**
|
|
147
206
|
* Free-form, JSON-serializable governance metadata — a **plain object** (both registry boundaries reject `null`,
|
|
148
207
|
* an array or a primitive, because a reader that tests `'key' in meta` would throw an untyped `TypeError`).
|
|
@@ -218,29 +277,27 @@ export interface RegCaps {
|
|
|
218
277
|
* Brand for {@link StorageBackend}.
|
|
219
278
|
*
|
|
220
279
|
* `Symbol.for` rather than a class check, for the same reason the error brands are: a class object is only
|
|
221
|
-
* shared
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* symbol is identity-stable across all of them, and across realms.
|
|
280
|
+
* shared while one copy of core is loaded, so a backend built from one copy and checked by another — after a
|
|
281
|
+
* version skew, or behind a duplicating bundler — would fail an `instanceof`. A registered symbol is
|
|
282
|
+
* identity-stable across all of them, and across realms.
|
|
225
283
|
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* constraint, so it uses the current name.
|
|
284
|
+
* A brand string is part of that identity contract, so once a release carries one it never changes: a changed
|
|
285
|
+
* string would stop matching the copies already installed.
|
|
229
286
|
*/
|
|
230
287
|
declare const STORAGE_BACKEND_BRAND: unique symbol;
|
|
231
288
|
/**
|
|
232
289
|
* Where a store keeps everything: the generations, and the pointer saying which one is current.
|
|
233
290
|
*
|
|
234
291
|
* **Branded on purpose.** The two fields alone are not enough to qualify, and that is the whole point. This
|
|
235
|
-
* shape is also {@link LoadDeps}
|
|
292
|
+
* shape is also {@link LoadDeps}, so before the brand any `{ storage, registry }`
|
|
236
293
|
* object literal satisfied it — including one assembling halves from two *unrelated* stores, which the store
|
|
237
294
|
* accepted and then answered **empty** for a segment that holds data, because it read a pointer from a place
|
|
238
295
|
* nothing had ever been written. That is the exact silent-empty failure one-class-per-backend exists to
|
|
239
296
|
* remove, and it was reachable in five lines.
|
|
240
297
|
*
|
|
241
298
|
* So a backend comes from `MemoryStorage`, `LocalFsStorage`, `S3Storage`, `GcsStorage` or
|
|
242
|
-
* `AzureBlobStorage` — each deriving both halves from one bucket and one prefix
|
|
243
|
-
*
|
|
299
|
+
* `AzureBlobStorage` — each deriving both halves from one bucket and one prefix. A driver author composing halves
|
|
300
|
+
* of their own says so on purpose, with {@link brandAsBackend}. The halves stay readable as `.storage` /
|
|
244
301
|
* `.registry`, because the free functions genuinely need them.
|
|
245
302
|
*
|
|
246
303
|
* **What this stops is the accident, not a determined caller.** The brand is a registered symbol, so it can
|
|
@@ -256,51 +313,43 @@ export interface StorageBackend {
|
|
|
256
313
|
readonly registry: IRegistryDriver;
|
|
257
314
|
}
|
|
258
315
|
/**
|
|
259
|
-
*
|
|
316
|
+
* The brand key, for the type-only `declare` field a backend class carries.
|
|
317
|
+
*
|
|
318
|
+
* Exported from `@cloudbitmaps/core/driver-kit` and part of the driver contract: a third-party storage
|
|
319
|
+
* package needs it to declare the field that {@link brandAsBackend} then stamps at runtime.
|
|
320
|
+
*/
|
|
321
|
+
export declare const STORAGE_BACKEND: typeof STORAGE_BACKEND_BRAND;
|
|
322
|
+
/**
|
|
323
|
+
* Stamp the brand on a backend, **non-enumerably**, after checking both halves are drivers. Returns `target`.
|
|
260
324
|
*
|
|
261
|
-
*
|
|
262
|
-
* `
|
|
263
|
-
*
|
|
325
|
+
* This is the driver author's door, and it works on a backend class (`brandAsBackend(this)` as the last line of
|
|
326
|
+
* its constructor) and on a plain `{ storage, registry }` object alike — the latter is how a backend of your
|
|
327
|
+
* own halves is built: a driver wrapped for auditing, metrics, tenant scoping or client-side encryption, or
|
|
328
|
+
* paired with a registry in a database you already run. An application does not need it: it gets both halves
|
|
329
|
+
* from one backend class, which derives them from one bucket and one prefix so they cannot disagree.
|
|
264
330
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* `{ storage, registry }` literal is refused precisely because it let the two halves come from unrelated
|
|
269
|
-
* places by accident, and answer **empty** rather than fail.
|
|
331
|
+
* It throws {@link ValidationError} when `storage` has no `putImmutable` or `registry` has no `compareAndSwap`,
|
|
332
|
+
* so a half that is not a driver is refused here rather than at its first write, and when `target` is frozen or
|
|
333
|
+
* non-extensible, since the brand is stamped on it in place.
|
|
270
334
|
*
|
|
271
335
|
* It cannot check that the halves agree: `IStorageDriver` and `IRegistryDriver` do not expose a location, so
|
|
272
|
-
* nothing here can compare one.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
readonly storage: IStorageDriver;
|
|
276
|
-
readonly registry: IRegistryDriver;
|
|
277
|
-
}): StorageBackend;
|
|
278
|
-
/**
|
|
279
|
-
* Stamp the brand on a backend, **non-enumerably**.
|
|
336
|
+
* nothing here can compare one. Branding two unrelated halves is you taking that on — the plain
|
|
337
|
+
* `{ storage, registry }` literal is refused by the store precisely because it let the two halves come from
|
|
338
|
+
* unrelated places by accident, and answer **empty** rather than fail.
|
|
280
339
|
*
|
|
281
340
|
* Non-enumerable is load-bearing, not tidiness. A class field (`readonly [BRAND] = true`) emits an
|
|
282
341
|
* *enumerable* own property, and spread and `Object.assign` copy exactly those — so
|
|
283
342
|
* `{ ...backend, registry: other.registry }` carried the brand and was accepted, reproducing the silent-empty
|
|
284
|
-
* bug
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* This does not make the brand unforgeable — the symbol is registered, so anyone who wants it can write
|
|
288
|
-
* `Symbol.for('cloudbitmaps.storage-backend')`. That is deliberate effort equivalent to calling
|
|
289
|
-
* `createBackend`, and it is not what the check is for. The check is for the accident.
|
|
290
|
-
*/
|
|
291
|
-
/**
|
|
292
|
-
* The brand key, for the type-only `declare` field a backend class carries.
|
|
293
|
-
*
|
|
294
|
-
* Exported from `@cloudbitmaps/core/driver-kit` and part of the driver contract: a third-party storage
|
|
295
|
-
* package needs it to declare the field that {@link brandAsBackend} then stamps at runtime.
|
|
343
|
+
* bug the brand exists to close. This does not make the brand unforgeable — the symbol is registered, so anyone
|
|
344
|
+
* who wants it can write `Symbol.for('cloudbitmaps.storage-backend')`. That is deliberate effort equivalent to
|
|
345
|
+
* calling this function, and it is not what the check is for. The check is for the accident.
|
|
296
346
|
*/
|
|
297
|
-
export declare const STORAGE_BACKEND: typeof STORAGE_BACKEND_BRAND;
|
|
298
347
|
export declare function brandAsBackend<T extends {
|
|
299
348
|
storage: IStorageDriver;
|
|
300
349
|
registry: IRegistryDriver;
|
|
301
350
|
}>(target: T): T & StorageBackend;
|
|
302
351
|
/**
|
|
303
|
-
* Is this a branded backend — from a backend class or from {@link
|
|
352
|
+
* Is this a branded backend — from a backend class or from {@link brandAsBackend}?
|
|
304
353
|
*
|
|
305
354
|
* Checks the brand, not the shape — see {@link StorageBackend} for why the shape alone is not enough.
|
|
306
355
|
*/
|
|
@@ -309,10 +358,34 @@ export declare function isStorageBackend(value: unknown): value is StorageBacken
|
|
|
309
358
|
* Per-segment registry — the authoritative source of `currentGen`, the discovery index, and the wrapped-DEK
|
|
310
359
|
* holder. One row per segment under OCC: a never-reused, equality-compared {@link Token} (ABA-safe across
|
|
311
360
|
* delete→recreate).
|
|
361
|
+
*
|
|
362
|
+
* **What a driver must do** — callers rely on each of these, and the registry conformance suite holds every
|
|
363
|
+
* shipped driver to them:
|
|
364
|
+
*
|
|
365
|
+
* - **`create` and `compareAndSwap` are atomic conditional writes.** A `create` over a live row, and a
|
|
366
|
+
* `compareAndSwap` whose token is not the stored one, throw {@link WriteConflictError} and change nothing.
|
|
367
|
+
* Two racing writers have exactly one winner.
|
|
368
|
+
* - **Tokens are never reused**, not even across `delete` then `create`: a delete leaves a tombstone (or a
|
|
369
|
+
* global counter) so a recreated row always carries a fresh token and a stale holder cannot swap into it.
|
|
370
|
+
* - **`delete` is idempotent** without an `expected` token: deleting an absent row is a no-op, not an error.
|
|
371
|
+
* **With `expected` it is fenced**: it lands only while the row still carries that token, and otherwise throws
|
|
372
|
+
* {@link WriteConflictError} and leaves the row.
|
|
373
|
+
* - **Reads are strongly consistent** (`RegCaps.strongRead`), and `list` yields every existing row,
|
|
374
|
+
* `destroyed` tombstones included, with every field (see {@link IRegistryDriver.list}).
|
|
375
|
+
* - **Never replay a conditional write without telling the replay apart.** A `create` or `compareAndSwap`
|
|
376
|
+
* that lands and then loses its response, sent again, finds its own write and would report a conflict.
|
|
377
|
+
* Either send each write once, with the backend client's retry off for that request, or recognise your own
|
|
378
|
+
* write when the precondition fails by reading an id you stored with it back. The shipped registries do
|
|
379
|
+
* one or the other.
|
|
380
|
+
* - **Raise a transient fault as {@link TransientError}.**
|
|
312
381
|
*/
|
|
313
382
|
export interface IRegistryDriver {
|
|
314
383
|
capabilities(): RegCaps;
|
|
315
|
-
/**
|
|
384
|
+
/**
|
|
385
|
+
* The segment's record, or `null` if it doesn't exist (or was deleted). Throw {@link TransientError} for a fault
|
|
386
|
+
* worth riding out (throttling, a 5xx, a dropped connection): a reader whose pointer refresh meets one keeps
|
|
387
|
+
* serving what it holds and asks again shortly, while any other error reaches the read that met it.
|
|
388
|
+
*/
|
|
316
389
|
get(ref: SegmentRef): Promise<RegistryRecord | null>;
|
|
317
390
|
/** Create the row; throws {@link WriteConflictError} if it already exists (use CAS to mutate). */
|
|
318
391
|
create(ref: SegmentRef, record: NewRegistryRecord): Promise<{
|
|
@@ -334,7 +407,17 @@ export interface IRegistryDriver {
|
|
|
334
407
|
* paying a `get()` per segment, so a `list()` that drops the field means nothing ever expires, silently.
|
|
335
408
|
*/
|
|
336
409
|
list(namespace?: string): AsyncIterable<RegistryRecord>;
|
|
337
|
-
/**
|
|
338
|
-
|
|
410
|
+
/**
|
|
411
|
+
* Remove the row (tombstoned for ABA-safety — a later `create` still gets a fresh, greater token).
|
|
412
|
+
*
|
|
413
|
+
* Without `expected`, idempotent: deleting an absent row is a no-op.
|
|
414
|
+
*
|
|
415
|
+
* With `expected`, the delete is **fenced like a {@link IRegistryDriver.compareAndSwap}**: it lands only while
|
|
416
|
+
* the row still carries exactly that token, and otherwise — the token differs, or the row is absent or already
|
|
417
|
+
* deleted — throws {@link WriteConflictError} and leaves the row. Pass the token of the row you read when you
|
|
418
|
+
* decided to delete it, so a delete that is delayed, replayed, or racing a re-create cannot tombstone a row
|
|
419
|
+
* created after the decision. A third-party driver that ignores `expected` keeps the unfenced behaviour.
|
|
420
|
+
*/
|
|
421
|
+
delete(ref: SegmentRef, expected?: Token): Promise<void>;
|
|
339
422
|
}
|
|
340
423
|
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage reader's defaults, stated once for the two modules that need them: the reader, which applies them,
|
|
3
|
+
* and the cost model, which prices what they cause. The cost model once imported them from the reader itself,
|
|
4
|
+
* which pulled the whole read path into a module that is otherwise arithmetic.
|
|
5
|
+
*/
|
|
6
|
+
/** Default TTL (ms) for re-resolving a segment's `currentGen` — the bound on post-publish read staleness. */
|
|
7
|
+
export declare const DEFAULT_CURRENT_GEN_TTL_MS = 2000;
|
|
8
|
+
/**
|
|
9
|
+
* How long (ms) a reader keeps serving after a pointer refresh failed transiently, before the next read asks the
|
|
10
|
+
* registry again. Short, so an outage ends the stale serving soon after the registry answers; fixed, so a registry
|
|
11
|
+
* that is down is asked at most once per this interval per segment being read (the refresh is coalesced per
|
|
12
|
+
* segment). Never longer than the TTL itself.
|
|
13
|
+
*/
|
|
14
|
+
export declare const REFRESH_RETRY_MS = 500;
|
|
15
|
+
/** Default ceiling on cached segment readers (each holds a parsed `.crbm` index) — the steady-state count bound. */
|
|
16
|
+
export declare const DEFAULT_MAX_OPEN_SEGMENTS = 1024;
|
|
17
|
+
/** Default aggregate ceiling (bytes) on resident parsed indices in the reader cache — the steady-state byte bound. */
|
|
18
|
+
export declare const DEFAULT_MAX_OPEN_INDEX_BYTES: number;
|
|
@@ -9,14 +9,24 @@ export declare function validateMaxScanSegments(value: number, op: string): void
|
|
|
9
9
|
/**
|
|
10
10
|
* Drain every registry record (optionally one namespace) into an array, refusing past `maxScanSegments`.
|
|
11
11
|
*
|
|
12
|
-
* The bound is checked **before** each push, so the array never exceeds the ceiling the caller agreed to
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The bound is checked **before** each push, so the array never exceeds the ceiling the caller agreed to. Pushing
|
|
13
|
+
* first and then throwing would leave the very row that broke the budget resident, and a caller sizing a container
|
|
14
|
+
* against `maxScanSegments` off by one row at the worst possible moment.
|
|
15
15
|
*/
|
|
16
16
|
export declare function drainRegistry(registry: IRegistryDriver, options: {
|
|
17
17
|
namespace?: string;
|
|
18
18
|
maxScanSegments: number;
|
|
19
19
|
op: string;
|
|
20
|
+
/**
|
|
21
|
+
* How a caller of `op` raises the ceiling, completing "Narrow it with `namespace`, or …" in the refusal.
|
|
22
|
+
* Defaults to raising `maxScanSegments`, which is right wherever `op` takes that option itself.
|
|
23
|
+
*/
|
|
24
|
+
raise?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the caller could scope the scan down to a `namespace`. Defaults to true; `eraseNamespace` is already
|
|
27
|
+
* one namespace, so it passes false and the refusal does not tell it to narrow what it cannot.
|
|
28
|
+
*/
|
|
29
|
+
narrowable?: boolean;
|
|
20
30
|
}): Promise<RegistryRecord[]>;
|
|
21
31
|
/**
|
|
22
32
|
* Is this record **bookkeeping** rather than a segment? Covers every reserved family — today the due-index
|
|
@@ -24,9 +34,8 @@ export declare function drainRegistry(registry: IRegistryDriver, options: {
|
|
|
24
34
|
* the export/eject scan, and the all-namespaces GDPR paths.
|
|
25
35
|
*
|
|
26
36
|
* This is the ONE place a reserved family is declared, and a new one belongs here rather than at the call
|
|
27
|
-
* sites.
|
|
28
|
-
*
|
|
29
|
-
* each site is a check that cannot fire.
|
|
37
|
+
* sites. A comparison inlined per site is easy to miss at one of them — at `subjectReport`, the rows would consume
|
|
38
|
+
* an Art. 15 request's per-op budget. A filter you have to remember at each site is a check that cannot fire.
|
|
30
39
|
*/
|
|
31
40
|
export declare function isReservedRow(record: Pick<RegistryRecord, 'namespace'>): boolean;
|
|
32
41
|
/**
|
|
@@ -27,10 +27,12 @@
|
|
|
27
27
|
* It also **purges the tombstones its own retirements leave behind**. A retired segment that had a registry row
|
|
28
28
|
* gets a `destroyed` row, and one dead row per retired daily bucket — or per retired dedup wave — is exactly the
|
|
29
29
|
* registry litter `dropSegment` already refuses to create for a row-less accumulator. Purging is narrow on
|
|
30
|
-
* purpose: only a tombstone
|
|
31
|
-
* a
|
|
30
|
+
* purpose: only a tombstone carrying the **stamp this sweep writes on its own retirements** (so it is attributably
|
|
31
|
+
* ours: a crypto-shred's tombstone does not carry it, unless the shred lands between this sweep's re-read of the
|
|
32
|
+
* row and its drop, which then reports the tombstone dropped and stamps it), only after a grace period, and only
|
|
33
|
+
* once Storage is provably empty for it —
|
|
32
34
|
* because deleting the row is what makes the name reusable and takes the segment out of reach of
|
|
33
|
-
*
|
|
35
|
+
* the generation collection.
|
|
34
36
|
*/
|
|
35
37
|
import { type IAuditSink } from './audit.js';
|
|
36
38
|
import type { DropDeps, DropResult } from './erasure.js';
|
|
@@ -39,7 +41,7 @@ export declare const DEFAULT_RETIRE_LIMIT = 100;
|
|
|
39
41
|
/**
|
|
40
42
|
* Default delay before a retirement's own tombstone row is purged: 24 h.
|
|
41
43
|
*
|
|
42
|
-
* The row is a fence — while it exists,
|
|
44
|
+
* The row is a fence — while it exists, a load refuses the segment, so
|
|
43
45
|
* a load that was mid-write when the drop landed cannot resurrect it. That window is
|
|
44
46
|
* seconds to minutes in practice; a day of margin costs one tiny row and removes any need to reason about it.
|
|
45
47
|
*/
|
|
@@ -69,10 +71,10 @@ export interface RetireExpiredOptions {
|
|
|
69
71
|
* expiring**. Each candidate's live row is still re-read before anything is decided, so a stale pointer
|
|
70
72
|
* costs one read and retires nothing.
|
|
71
73
|
*
|
|
72
|
-
* **`'index'` is not a drop-in replacement for `'fleet'`; it is the fast half of a pair.** A policy
|
|
73
|
-
*
|
|
74
|
+
* **`'index'` is not a drop-in replacement for `'fleet'`; it is the fast half of a pair.** A policy whose
|
|
75
|
+
* pointer write failed (`indexed: false`), or whose ref is too long to index, has no pointer — so a
|
|
74
76
|
* deployment that *only* ever runs `'index'` will never retire those. Run `'fleet'` periodically as the
|
|
75
|
-
* repair pass. The default
|
|
77
|
+
* repair pass. The default is `'fleet'` because it is complete by construction.
|
|
76
78
|
*/
|
|
77
79
|
readonly scan?: 'fleet' | 'index';
|
|
78
80
|
/**
|
|
@@ -93,12 +95,18 @@ export interface RetireExpiredOptions {
|
|
|
93
95
|
readonly lookbackBuckets?: number;
|
|
94
96
|
/**
|
|
95
97
|
* Whether to delete the tombstone rows this sweep's own past retirements left (default `true`). Set `false` to
|
|
96
|
-
* keep every
|
|
97
|
-
* `destroyed` row as an attestation.
|
|
98
|
+
* keep every one of them — the right choice if something outside this library treats the presence of a
|
|
99
|
+
* `destroyed` row as an attestation. That includes the row of a retirement whose drop found no Storage
|
|
100
|
+
* generation to delete and left none behind: with the default that row is deleted in the same pass, since it
|
|
101
|
+
* would do nothing but fence the name against every writer, and with `false` it stays, stamped like any other
|
|
102
|
+
* of this sweep's tombstones, so a later sweep with purging on deletes it once `tombstoneGraceMs` has passed.
|
|
103
|
+
* While it stays, it fences the name like every kept tombstone. What records a retirement either way is its
|
|
104
|
+
* ledger entry, and its `segment.dispose` event (`generationsDeleted: 0` for an empty one) when an `audit` sink
|
|
105
|
+
* is passed.
|
|
98
106
|
*
|
|
99
107
|
* Two knobs rather than one `number | 'never'`, deliberately: `0` would have had to mean "purge immediately"
|
|
100
|
-
* here while `cache.genTtlMs: 0` in this same library means "
|
|
101
|
-
* opposite of another's is a reading hazard for whoever tunes both.
|
|
108
|
+
* here while `cache.genTtlMs: 0` in this same library means "never refresh on a timer", and one option whose
|
|
109
|
+
* zero is the opposite of another's is a reading hazard for whoever tunes both.
|
|
102
110
|
*/
|
|
103
111
|
readonly purgeTombstones?: boolean;
|
|
104
112
|
/** How long a retirement's tombstone must age before this sweep deletes it (default 24 h). */
|
|
@@ -141,7 +149,7 @@ export type RetireEntry = {
|
|
|
141
149
|
* expiring is the silence that costs a retention commitment.
|
|
142
150
|
* `'limit'` — eligible, but this cycle's `limit` was already spent. Re-run to continue.
|
|
143
151
|
* `'tombstone-not-empty'` — a tombstone whose Storage generations are not gone even after a GC
|
|
144
|
-
* attempt, so its row is kept: the row is what keeps the segment reachable by
|
|
152
|
+
* attempt, so its row is kept: the row is what keeps the segment reachable by the generation collection and
|
|
145
153
|
* refused by every writer. Several causes, all self-healing: the storage really could not be reclaimed,
|
|
146
154
|
* the collection *declined* because the row changed underneath it (`WriteConflictError`, which this
|
|
147
155
|
* sweep swallows deliberately), or this was a `dryRun`, which reports the reason without attempting the
|
|
@@ -161,7 +169,7 @@ export interface RetireExpiredResult {
|
|
|
161
169
|
readonly eligible: number;
|
|
162
170
|
/**
|
|
163
171
|
* Segments **actually retired**. Zero under `dryRun` — see `wouldRetire`. Kept honest because this is the field
|
|
164
|
-
* most likely to end up on a dashboard
|
|
172
|
+
* most likely to end up on a dashboard: a counter that means "deleted" in one mode and
|
|
165
173
|
* "would delete" in another produces phantom deletions on any graph that does not also join on `dryRun`.
|
|
166
174
|
*/
|
|
167
175
|
readonly retired: number;
|
|
@@ -175,6 +183,11 @@ export interface RetireExpiredResult {
|
|
|
175
183
|
/** Per-segment ledger. Inspect it: a `skipped` or failed entry is a segment that still holds data. */
|
|
176
184
|
readonly entries: readonly RetireEntry[];
|
|
177
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* How many past buckets an index scan reads besides the current one. A week: long enough that a weekend outage
|
|
188
|
+
* or a paused schedule recovers on its own, short enough that a cycle is eight list calls rather than hundreds.
|
|
189
|
+
*/
|
|
190
|
+
export declare const DEFAULT_LOOKBACK_BUCKETS = 7;
|
|
178
191
|
/**
|
|
179
192
|
* Retire every segment whose retention policy has expired, and clean up the tombstones earlier sweeps left.
|
|
180
193
|
*
|
|
@@ -182,9 +195,4 @@ export interface RetireExpiredResult {
|
|
|
182
195
|
* argument, and for a fleet larger than `maxScanSegments` — a scan that cannot be held in memory is a fail-loud
|
|
183
196
|
* condition, not a partial result to be mistaken for a complete sweep.
|
|
184
197
|
*/
|
|
185
|
-
/**
|
|
186
|
-
* How many past buckets an index scan reads besides the current one. A week: long enough that a weekend outage
|
|
187
|
-
* or a paused schedule recovers on its own, short enough that a cycle is eight list calls rather than hundreds.
|
|
188
|
-
*/
|
|
189
|
-
export declare const DEFAULT_LOOKBACK_BUCKETS = 7;
|
|
190
198
|
export declare function retireExpired(deps: DropDeps, options: RetireExpiredOptions): Promise<RetireExpiredResult>;
|
package/dist/core/retry.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* It deliberately does **not** retry deterministic failures ({@link WriteConflictError},
|
|
12
12
|
* {@link ValidationError}, {@link IntegrityError}, {@link NotFoundError}, …): retrying those either can't
|
|
13
13
|
* help or would be incorrect. A pointer conflict is retried by a *separate* loop inside
|
|
14
|
-
*
|
|
14
|
+
* the publish, because each attempt must re-read the row and re-decide — a blind replay of the same
|
|
15
15
|
* compare-and-swap would either fail again on a stale token or, worse, advance a pointer whose state has
|
|
16
16
|
* changed underneath it. A write-once object collision is never replayed at all: the generation number is
|
|
17
17
|
* taken, so the caller has to pick a new one.
|
|
@@ -35,15 +35,19 @@ export interface RetryPolicy {
|
|
|
35
35
|
/**
|
|
36
36
|
* Conservative defaults: 4 attempts, 50ms → 100 → 200 (×2), capped at 2s, full jitter. Tuned for a cloud
|
|
37
37
|
* backend's brief throttle/5xx blip — enough to ride out a transient fault without turning a hard outage
|
|
38
|
-
* into a long hang. Override per store
|
|
38
|
+
* into a long hang. Override per store (`retry`) or per read source (`RetryingStorageChunkSource`) if your latency
|
|
39
|
+
* budget differs.
|
|
39
40
|
*/
|
|
40
41
|
export declare const DEFAULT_RETRY_POLICY: RetryPolicy;
|
|
41
42
|
export interface RetryDeps {
|
|
42
43
|
readonly clock: Clock;
|
|
43
44
|
readonly rng: Rng;
|
|
44
|
-
/** Override which errors are retryable. Default: any {@link TransientError}
|
|
45
|
+
/** Override which errors are retryable. Default: any {@link TransientError}. */
|
|
45
46
|
readonly isRetryable?: (err: unknown) => boolean;
|
|
46
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Optional hook (observability) fired before each backoff wait. `attempt` is 1-based (the one that failed).
|
|
49
|
+
* Best-effort: an error it throws is swallowed, so it can neither stop the retry nor change the error thrown.
|
|
50
|
+
*/
|
|
47
51
|
readonly onRetry?: (info: {
|
|
48
52
|
attempt: number;
|
|
49
53
|
delayMs: number;
|