@cloudbitmaps/core 0.8.2 → 0.10.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/NOTICE +4 -4
- package/README.md +28 -6
- package/dist/chunk-BUPDGZRR.js +461 -0
- package/dist/chunk-BUPDGZRR.js.map +7 -0
- package/dist/core/audit.d.ts +127 -0
- package/dist/core/bit-route.d.ts +21 -0
- package/dist/core/blob.d.ts +34 -0
- package/dist/core/budget.d.ts +66 -0
- package/dist/core/codec.d.ts +114 -0
- package/dist/core/concurrency.d.ts +15 -0
- package/dist/core/consistency.d.ts +73 -0
- package/dist/core/cooperative.d.ts +78 -0
- package/dist/core/cost.d.ts +102 -0
- package/dist/core/crbm/crc32c.d.ts +10 -0
- package/dist/core/crbm/format.d.ts +116 -0
- package/dist/core/crbm/reader.d.ts +69 -0
- package/dist/core/crbm/varint.d.ts +13 -0
- package/dist/core/crbm/writer.d.ts +39 -0
- package/dist/core/crbm-storage-source.d.ts +432 -0
- package/dist/core/crypto.d.ts +80 -0
- package/dist/core/determinism.d.ts +42 -0
- package/dist/core/discover.d.ts +75 -0
- package/dist/core/due-index.d.ts +55 -0
- package/dist/core/engine.d.ts +204 -0
- package/dist/core/erase-id.d.ts +141 -0
- package/dist/core/erasure.d.ts +179 -0
- package/dist/core/errors.d.ts +120 -0
- package/dist/core/generation-gc.d.ts +52 -0
- package/dist/core/keys.d.ts +23 -0
- package/dist/core/load.d.ts +102 -0
- package/dist/core/lru.d.ts +69 -0
- package/dist/core/metrics.d.ts +132 -0
- package/dist/core/name-codec.d.ts +79 -0
- package/dist/core/pinned-storage-source.d.ts +69 -0
- package/dist/core/ports.d.ts +340 -0
- package/dist/core/registry-scan.d.ts +38 -0
- package/dist/core/retention-sweep.d.ts +190 -0
- package/dist/core/retention.d.ts +67 -0
- package/dist/core/retry.d.ts +65 -0
- package/dist/core/rollback.d.ts +71 -0
- package/dist/core/validate.d.ts +20 -0
- package/dist/driver-kit.d.ts +10 -0
- package/dist/driver-kit.js +314 -0
- package/dist/driver-kit.js.map +7 -0
- package/dist/drivers/_shared/aws-errors.d.ts +18 -0
- package/dist/drivers/_shared/keys.d.ts +27 -0
- package/dist/drivers/_shared/object-registry-keys.d.ts +28 -0
- package/dist/drivers/_shared/object-registry.d.ts +84 -0
- package/dist/drivers/_shared/registry.d.ts +62 -0
- package/dist/drivers/backends.d.ts +49 -0
- package/dist/drivers/crypto.d.ts +38 -0
- package/dist/drivers/localfs/fs-util.d.ts +20 -0
- package/dist/drivers/localfs/paths.d.ts +33 -0
- package/dist/drivers/localfs/registry.d.ts +28 -0
- package/dist/drivers/localfs/storage.d.ts +19 -0
- package/dist/drivers/memory.d.ts +57 -0
- package/dist/drivers/retry/retrying-drivers.d.ts +90 -0
- package/dist/export/index.d.ts +92 -0
- package/dist/index.d.ts +85 -2534
- package/dist/index.js +3076 -2978
- package/dist/index.js.map +7 -1
- package/dist/testing/fuzz-core.d.ts +14 -0
- package/package.json +19 -115
- package/dist/azure/index.cjs +0 -368
- package/dist/azure/index.cjs.map +0 -1
- package/dist/azure/index.d.cts +0 -48
- package/dist/azure/index.d.ts +0 -48
- package/dist/azure/index.js +0 -304
- package/dist/azure/index.js.map +0 -1
- package/dist/cassandra/index.cjs +0 -265
- package/dist/cassandra/index.cjs.map +0 -1
- package/dist/cassandra/index.d.cts +0 -52
- package/dist/cassandra/index.d.ts +0 -52
- package/dist/cassandra/index.js +0 -204
- package/dist/cassandra/index.js.map +0 -1
- package/dist/chunk-7LMLYSVJ.js +0 -43
- package/dist/chunk-7LMLYSVJ.js.map +0 -1
- package/dist/chunk-L54FL5DW.js +0 -203
- package/dist/chunk-L54FL5DW.js.map +0 -1
- package/dist/chunk-NUIDEEFZ.js +0 -91
- package/dist/chunk-NUIDEEFZ.js.map +0 -1
- package/dist/chunk-SNJVZ227.js +0 -35
- package/dist/chunk-SNJVZ227.js.map +0 -1
- package/dist/chunk-YEHMG6W2.js +0 -6
- package/dist/chunk-YEHMG6W2.js.map +0 -1
- package/dist/dynamodb/index.cjs +0 -740
- package/dist/dynamodb/index.cjs.map +0 -1
- package/dist/dynamodb/index.d.cts +0 -106
- package/dist/dynamodb/index.d.ts +0 -106
- package/dist/dynamodb/index.js +0 -469
- package/dist/dynamodb/index.js.map +0 -1
- package/dist/gcs/index.cjs +0 -343
- package/dist/gcs/index.cjs.map +0 -1
- package/dist/gcs/index.d.cts +0 -46
- package/dist/gcs/index.d.ts +0 -46
- package/dist/gcs/index.js +0 -279
- package/dist/gcs/index.js.map +0 -1
- package/dist/index.cjs +0 -4608
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -2535
- package/dist/mongodb/index.cjs +0 -260
- package/dist/mongodb/index.cjs.map +0 -1
- package/dist/mongodb/index.d.cts +0 -45
- package/dist/mongodb/index.d.ts +0 -45
- package/dist/mongodb/index.js +0 -199
- package/dist/mongodb/index.js.map +0 -1
- package/dist/mysql/index.cjs +0 -281
- package/dist/mysql/index.cjs.map +0 -1
- package/dist/mysql/index.d.cts +0 -56
- package/dist/mysql/index.d.ts +0 -56
- package/dist/mysql/index.js +0 -211
- package/dist/mysql/index.js.map +0 -1
- package/dist/ports-5BQTGKze.d.cts +0 -356
- package/dist/ports-5BQTGKze.d.ts +0 -356
- package/dist/postgres/index.cjs +0 -273
- package/dist/postgres/index.cjs.map +0 -1
- package/dist/postgres/index.d.cts +0 -54
- package/dist/postgres/index.d.ts +0 -54
- package/dist/postgres/index.js +0 -203
- package/dist/postgres/index.js.map +0 -1
- package/dist/redis/index.cjs +0 -257
- package/dist/redis/index.cjs.map +0 -1
- package/dist/redis/index.d.cts +0 -42
- package/dist/redis/index.d.ts +0 -42
- package/dist/redis/index.js +0 -197
- package/dist/redis/index.js.map +0 -1
- package/dist/s3/index.cjs +0 -891
- package/dist/s3/index.cjs.map +0 -1
- package/dist/s3/index.d.cts +0 -112
- package/dist/s3/index.d.ts +0 -112
- package/dist/s3/index.js +0 -555
- package/dist/s3/index.js.map +0 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { MAX_NAME_LENGTH } from './validate.js';
|
|
2
|
+
import type { RegistryRecord, SegmentRef } from './ports.js';
|
|
3
|
+
/** Namespace prefix for due-index rows. */
|
|
4
|
+
export declare const DUE_NAMESPACE_PREFIX = "cbm.due.";
|
|
5
|
+
/** Bucket width. One day: small enough that a cycle reads little, coarse enough that the index stays tiny. */
|
|
6
|
+
export declare const DUE_BUCKET_MS = 86400000;
|
|
7
|
+
/**
|
|
8
|
+
* The name-length cap, and an index row encodes **both** of the original ref's parts into one name — so a ref
|
|
9
|
+
* whose encoding would exceed it is simply not indexed (see {@link canIndex}).
|
|
10
|
+
*
|
|
11
|
+
* Re-exported from the validator rather than restated. The two were separate constants with the same value
|
|
12
|
+
* until the validator started measuring the **encoded** length; `canIndex` went on counting raw characters and
|
|
13
|
+
* began answering `true` for refs the registry then refused, turning a documented graceful degradation into a
|
|
14
|
+
* guaranteed throw on the retention fast path.
|
|
15
|
+
*/
|
|
16
|
+
export { MAX_NAME_LENGTH };
|
|
17
|
+
/** Which bucket an expiry falls in. Total and reversible; no calendar, no timezone, no ambient time. */
|
|
18
|
+
export declare function dueBucket(expiresAt: number): number;
|
|
19
|
+
/** The namespace holding one bucket's pointers. */
|
|
20
|
+
export declare function dueNamespace(bucket: number): string;
|
|
21
|
+
/** Is this record a due-index pointer rather than a segment? */
|
|
22
|
+
export declare function isDueIndexRow(record: Pick<RegistryRecord, 'namespace'>): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Encode a ref into one index-row name, unambiguously.
|
|
25
|
+
*
|
|
26
|
+
* `${namespaceLength}.${namespace}${segment}` — a decimal length, a dot, then the two parts concatenated. The
|
|
27
|
+
* length prefix is what makes it reversible: every character the grammar allows (`.`, `-`, `_`, `:`, alphanumerics)
|
|
28
|
+
* is legal *inside* a name, so no separator character could ever be unambiguous on its own. Reading the digits
|
|
29
|
+
* up to the first dot tells the parser exactly where the namespace ends.
|
|
30
|
+
*/
|
|
31
|
+
export declare function encodeDueName(ref: SegmentRef): string;
|
|
32
|
+
/**
|
|
33
|
+
* Can this ref be indexed at all? False when the encoded name would exceed the grammar's 256-character cap —
|
|
34
|
+
* possible only for a ref whose namespace and segment are together near the limit.
|
|
35
|
+
*
|
|
36
|
+
* **Not indexable is not "not retired".** The repair scan still sees the segment's own row, so the consequence
|
|
37
|
+
* is that it expires on the repair cadence instead of the fast one. Callers must not treat `false` as an error.
|
|
38
|
+
*/
|
|
39
|
+
export declare function canIndex(ref: SegmentRef): boolean;
|
|
40
|
+
/** The registry ref of the pointer for `ref` in `bucket`. Throws if the ref cannot be encoded — check first. */
|
|
41
|
+
export declare function dueIndexRef(bucket: number, ref: SegmentRef): SegmentRef;
|
|
42
|
+
/**
|
|
43
|
+
* Recover the original ref from an index row's name. `null` for anything we did not write — a foreign row in
|
|
44
|
+
* the reserved namespace is ignored rather than acted upon.
|
|
45
|
+
*/
|
|
46
|
+
export declare function decodeDueName(name: string): SegmentRef | null;
|
|
47
|
+
/**
|
|
48
|
+
* Which buckets a cycle must read at `now`: everything from `since` up to and including the current one.
|
|
49
|
+
*
|
|
50
|
+
* Past buckets are included because a sweep that did not run — a scaled-to-zero worker, a failed deploy, a
|
|
51
|
+
* paused schedule — must not leave its buckets stranded. `lookbackBuckets` bounds how far back a first run
|
|
52
|
+
* reaches, so a long outage costs a bounded number of list calls per cycle rather than one per day since the
|
|
53
|
+
* epoch; anything older is the repair scan's job.
|
|
54
|
+
*/
|
|
55
|
+
export declare function dueBucketsAt(now: number, lookbackBuckets: number): number[];
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { CodecBitmap, CodecInterface } from './codec.js';
|
|
2
|
+
import type { Budget, BudgetOption } from './budget.js';
|
|
3
|
+
import type { Clock } from './determinism.js';
|
|
4
|
+
import type { BoundedLru } from './lru.js';
|
|
5
|
+
import type { IMetricsSink } from './metrics.js';
|
|
6
|
+
import type { StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
|
|
7
|
+
export interface EngineDeps {
|
|
8
|
+
readonly storage: StorageChunkSource;
|
|
9
|
+
/** Optional cache of decoded (immutable) Storage chunks. */
|
|
10
|
+
readonly cache?: BoundedLru<string, CodecBitmap>;
|
|
11
|
+
readonly maxBitmapBytes?: number;
|
|
12
|
+
/**
|
|
13
|
+
* The bitmap codec — **required**. `core/` is codec-agnostic: it can have no default, because the concrete codec
|
|
14
|
+
* lives in a *flavor* package that depends on core (a default here would invert that arrow). A flavor's facade
|
|
15
|
+
* injects it — `@cloudbitmaps/roaring` passes `roaringCodec` — so applications never see this.
|
|
16
|
+
*/
|
|
17
|
+
readonly codec: CodecInterface;
|
|
18
|
+
/** Time source for the `storage.get` latency metric only; defaults to a clock that reads 0. */
|
|
19
|
+
readonly clock?: Pick<Clock, 'now'>;
|
|
20
|
+
/** Observability sink; defaults to a no-op. Assumed exception-safe (the facade wraps it). */
|
|
21
|
+
readonly metrics?: IMetricsSink;
|
|
22
|
+
/**
|
|
23
|
+
* Per-op denial-of-wallet budget, already resolved by the facade: a {@link Budget} to enforce, or `null` to
|
|
24
|
+
* disable. Undefined ⇒ {@link DEFAULT_BUDGET} (a direct engine construction still gets the generous default).
|
|
25
|
+
* Read ops refuse before fan-out if they'd exceed it.
|
|
26
|
+
*/
|
|
27
|
+
readonly budget?: Budget | null;
|
|
28
|
+
}
|
|
29
|
+
/** Options common to the chunk-aligned combines. */
|
|
30
|
+
export interface CombineOptions {
|
|
31
|
+
/** Max chunk keys resolved concurrently — bounds the Storage footprint. A positive integer. */
|
|
32
|
+
readonly concurrency?: number;
|
|
33
|
+
/** Override the store's per-op budget for this call (`false` lifts it). */
|
|
34
|
+
readonly budget?: BudgetOption;
|
|
35
|
+
/** Segments whose ids are subtracted from the result. */
|
|
36
|
+
readonly exclude?: readonly SegmentRef[];
|
|
37
|
+
/**
|
|
38
|
+
* Allow an operand that names a segment which does not exist. Default `false` — a combine refuses one,
|
|
39
|
+
* because a misspelled or mis-namespaced operand is indistinguishable from a correct one in the result.
|
|
40
|
+
* Set `true` when you genuinely intend to combine against a name that may not have been created yet.
|
|
41
|
+
*/
|
|
42
|
+
readonly allowAbsentOperands?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export declare class SegmentEngine {
|
|
45
|
+
private readonly storage;
|
|
46
|
+
private readonly cache;
|
|
47
|
+
private readonly codec;
|
|
48
|
+
private readonly maxBitmapBytes;
|
|
49
|
+
private readonly clock;
|
|
50
|
+
private readonly metrics;
|
|
51
|
+
private readonly metricsOn;
|
|
52
|
+
/** Resolved per-op budget (null = disabled); undefined deps ⇒ the generous default. */
|
|
53
|
+
private readonly budget;
|
|
54
|
+
constructor(deps: EngineDeps);
|
|
55
|
+
/** Membership: one chunk lookup — the cache, else one Storage fetch of that chunk. */
|
|
56
|
+
has(seg: SegmentRef, id: number): Promise<boolean>;
|
|
57
|
+
/**
|
|
58
|
+
* Cardinality. When the Storage source can serve per-chunk cardinality from its `.crbm` index (the `.crbm`
|
|
59
|
+
* source can), the count is summed from the index with **zero payload reads**. A source without that
|
|
60
|
+
* capability (the in-memory source) falls back to fetching every chunk.
|
|
61
|
+
*/
|
|
62
|
+
count(seg: SegmentRef): Promise<number>;
|
|
63
|
+
/** Whether the Storage source can measure segment size (for grounded cost); false ⇒ storage isn't grounded. */
|
|
64
|
+
get supportsStorageSize(): boolean;
|
|
65
|
+
/** Grounded Storage size of a segment's current generation (cost reporting), or null if it has no generation. */
|
|
66
|
+
segmentSize(seg: SegmentRef): Promise<SegmentSize | null>;
|
|
67
|
+
/** Every id, ascending, one chunk at a time. */
|
|
68
|
+
iterate(seg: SegmentRef): AsyncGenerator<number>;
|
|
69
|
+
/**
|
|
70
|
+
* `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
|
|
71
|
+
*
|
|
72
|
+
* The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
|
|
73
|
+
* afterwards (intersect into a temp segment, then subtract) writes an intermediate segment that nobody wants;
|
|
74
|
+
* applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
|
|
75
|
+
* operand is read **only at the keys that survived the intersection** — the cost is bounded by the
|
|
76
|
+
* audience's surviving key count and **never scales with the size of the suppression list**. Subtracting a
|
|
77
|
+
* 61,000-chunk global opt-out list costs at most one read per surviving key, not 61,000.
|
|
78
|
+
*
|
|
79
|
+
* ---
|
|
80
|
+
*
|
|
81
|
+
* Chunk-skipping intersection. Aligns each segment's chunk-key set (read from the `.crbm` index, no payload),
|
|
82
|
+
* keeps only keys present in **all** operands (a key missing from any operand can't contribute → its Storage
|
|
83
|
+
* chunks are never fetched — the core saving), then for each surviving key fetches the operands' chunks in
|
|
84
|
+
* parallel and hands them to the codec for the AND, streaming results through a bounded in-flight window.
|
|
85
|
+
*
|
|
86
|
+
* **Memory:** the Storage payload footprint is bounded by the window (`concurrency × operands × chunk`), not by
|
|
87
|
+
* segment size — that's the Lambda-friendly property.
|
|
88
|
+
*
|
|
89
|
+
* Generation-consistent within the call (normal case): each operand's current generation is resolved **once**
|
|
90
|
+
* up front (before the fan-out) and threaded into every chunk read, so a concurrent load can't corrupt or tear
|
|
91
|
+
* the result — every chunk read is a whole, checksum-verified, immutable generation. The edge a *long* call can
|
|
92
|
+
* hit: if it straddles a mid-call `cache.genTtlMs` boundary and a load has published, an operand's not-yet-read
|
|
93
|
+
* chunks may re-resolve forward to the newer generation (a generation hop within one long call) — the call
|
|
94
|
+
* never crashes or returns a torn object, but may mix generations. A shorter call is unaffected **unless the
|
|
95
|
+
* reader cache evicts an operand mid-call** (`maxOpenSegments`): the re-read re-resolves fresh (bypassing the
|
|
96
|
+
* TTL), which can hop generations even sub-TTL — still whole/immutable per read, never torn.
|
|
97
|
+
*/
|
|
98
|
+
intersect(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<number>;
|
|
99
|
+
/**
|
|
100
|
+
* `segs[0] ∪ segs[1] ∪ …`, minus every id in `options.exclude` — streamed ascending.
|
|
101
|
+
*
|
|
102
|
+
* **Union is the one composite read with no chunk-skipping**, and that is a property of the operation rather
|
|
103
|
+
* than of this implementation: an id in *any* operand belongs to the result, so every chunk of every operand
|
|
104
|
+
* has to be read. `intersect` can prune a key absent from any operand; union cannot prune anything. It is
|
|
105
|
+
* budgeted exactly like `intersect` so a wide union is refused rather than quietly billed.
|
|
106
|
+
*/
|
|
107
|
+
union(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<number>;
|
|
108
|
+
/**
|
|
109
|
+
* `seg \ (excludes[0] ∪ excludes[1] ∪ …)` — streamed ascending. Suppression on its own.
|
|
110
|
+
*
|
|
111
|
+
* Reads every chunk of `seg` (any of them may survive) but each exclude **only where it overlaps `seg`**, so
|
|
112
|
+
* the cost scales with the segment being filtered rather than with the size of the suppression list.
|
|
113
|
+
*/
|
|
114
|
+
andNot(seg: SegmentRef, excludes: readonly SegmentRef[], options?: Omit<CombineOptions, 'exclude'>): AsyncGenerator<number>;
|
|
115
|
+
private combine;
|
|
116
|
+
/**
|
|
117
|
+
* The AND (or OR) of one chunk key across all operands, minus the excludes that hold it, or `null` if empty.
|
|
118
|
+
* Operand chunks are fetched **in parallel** (the spec's parallel byte-range reads).
|
|
119
|
+
*
|
|
120
|
+
* The accumulator is a **clone** of the first operand's chunk, and that clone is load-bearing: every other
|
|
121
|
+
* bitmap here is the cached, shared Storage instance and must never be mutated, or the cache is poisoned for
|
|
122
|
+
* every later reader. The remaining operands and the excludes are only *read* by the in-place ops, so they are
|
|
123
|
+
* used as-is — one clone per key, not one per operand.
|
|
124
|
+
*/
|
|
125
|
+
private combineChunk;
|
|
126
|
+
/** The segment's chunk keys, ascending — a shape read off the index, no payload. Keys are untrusted (invariant 5). */
|
|
127
|
+
private chunkKeys;
|
|
128
|
+
/**
|
|
129
|
+
* Tier-derived keys are untrusted (invariant 5) — fail fast on a corrupt/out-of-range key rather than
|
|
130
|
+
* letting it flow into the id-routing step and produce a bogus id.
|
|
131
|
+
*/
|
|
132
|
+
private assertChunkKeyInRange;
|
|
133
|
+
/**
|
|
134
|
+
* The other half of invariant 5: a chunk payload holds **remainders** — 16-bit offsets within one chunk — so
|
|
135
|
+
* every value must be `<= 0xffff`.
|
|
136
|
+
*
|
|
137
|
+
* Nothing upstream establishes that. The byte cap bounds *size*, and CRC/AEAD prove the bytes are the bytes
|
|
138
|
+
* that were written — which anyone able to write the bucket satisfies trivially. A value `>= 65536` would then
|
|
139
|
+
* reach the id-routing step, which masks it (`remainder & 0xffff`) and emits a **fabricated id belonging to a different
|
|
140
|
+
* chunk's id space**: indistinguishable from real data, inflating `count()` and creating spurious `intersect`
|
|
141
|
+
* matches.
|
|
142
|
+
*
|
|
143
|
+
* Costs one `maximum()` per chunk, not per id — `maximum` is optional on the codec seam precisely so a codec
|
|
144
|
+
* that cannot answer in O(1) opts out instead of making the read path walk every value.
|
|
145
|
+
*/
|
|
146
|
+
private assertChunkPayloadInRange;
|
|
147
|
+
/**
|
|
148
|
+
* Refuse a combine whose operand names a segment that was never created.
|
|
149
|
+
*
|
|
150
|
+
* A segment that resolves to nothing is ambiguous in a way that matters only here. Read it directly and
|
|
151
|
+
* "empty" is the right answer either way. Pass it as an **operand** and the two states diverge: a
|
|
152
|
+
* suppression list with nobody on it correctly suppresses nothing, while one whose namespace you omitted
|
|
153
|
+
* *silently* suppresses nothing — and the result is not empty and obviously wrong, it is the full audience
|
|
154
|
+
* and plausibly right. That direction is the dangerous one: the failure mode is mailing the people who
|
|
155
|
+
* opted out.
|
|
156
|
+
*
|
|
157
|
+
* The asymmetry is why this is checked rather than left to the caller. A mistyped **include** collapses an
|
|
158
|
+
* intersect to nothing, which you notice; a mistyped **exclude** removes a safeguard, which you do not. Both
|
|
159
|
+
* are refused, because both are wiring errors and one rule is easier to trust than two.
|
|
160
|
+
*
|
|
161
|
+
* **Costs nothing on a normal combine.** `exists` is consulted only for an operand that resolved to zero
|
|
162
|
+
* chunks — a segment with data is self-evidently registered — so the check is a registry read exactly when
|
|
163
|
+
* a combine was about to do something suspicious, and no reads at all otherwise. A source that cannot answer
|
|
164
|
+
* `exists` (it has no notion of registration) skips the check rather than guessing.
|
|
165
|
+
*/
|
|
166
|
+
private refuseAbsentOperands;
|
|
167
|
+
/**
|
|
168
|
+
* Drop every piece of state this engine derived from `ref`, and tell the Storage source to do the same.
|
|
169
|
+
*
|
|
170
|
+
* The decoded-chunk cache is keyed by generation, which handles a *publish* (the new generation misses) but
|
|
171
|
+
* not a *destruction*: a segment that was erased from, dropped, shredded or retired has no newer generation
|
|
172
|
+
* whose key would miss, so its decoded chunks stay resident and keep answering. That is why the erasing
|
|
173
|
+
* process itself could report `erased: true` and then still answer `true` for the same id, out of RAM, with
|
|
174
|
+
* no storage read to intercept.
|
|
175
|
+
*
|
|
176
|
+
* Called by the destructive verbs on the facade. Synchronous and best-effort — forgetting cannot fail.
|
|
177
|
+
*/
|
|
178
|
+
invalidate(ref: SegmentRef): void;
|
|
179
|
+
/** The segment's current generation, resolved once per op (`undefined` ⇒ source can't report it). */
|
|
180
|
+
private currentGen;
|
|
181
|
+
/**
|
|
182
|
+
* The cache-key component identifying **which bytes** this op will read: the source's `currentVersion` when
|
|
183
|
+
* it has one, else the generation alone.
|
|
184
|
+
*
|
|
185
|
+
* The generation alone is not an identity. `nextGeneration` restarts at 0 once a row is purged and the
|
|
186
|
+
* bucket emptied, so a retired-and-re-created name serves different data at the same `currentGen` — and a
|
|
187
|
+
* decoded chunk cached under `(segment, chunk, 0)` is handed straight back to the new incarnation. That is an
|
|
188
|
+
* erased id reappearing with no read to intercept it, which is why this is keyed on the version rather than
|
|
189
|
+
* the number.
|
|
190
|
+
*/
|
|
191
|
+
private cacheVersion;
|
|
192
|
+
/**
|
|
193
|
+
* Decode a Storage chunk. `gen` is the segment's current generation, resolved **once per op** by the caller (not
|
|
194
|
+
* per chunk — that would put a registry re-resolve on every chunk of a count/intersect). The cache is
|
|
195
|
+
* keyed by it, so a load that advances the generation misses the cache and re-reads the new bytes instead of
|
|
196
|
+
* serving a stale decoded chunk (an erased id can't resurrect from a cached superseded chunk). `gen === null`
|
|
197
|
+
* ⇒ the source reports no current generation ⇒ no storage bytes for any chunk, so skip the fetch entirely.
|
|
198
|
+
* `gen === undefined` ⇒ the source can't report a generation (pins one for its lifetime) ⇒ the key stays
|
|
199
|
+
* generation-free. Superseded-generation entries age out under the LRU ceiling — no active purge.
|
|
200
|
+
*
|
|
201
|
+
* The returned instance is **shared** (it may be the cached one): callers read it or clone it, never mutate it.
|
|
202
|
+
*/
|
|
203
|
+
private storageChunk;
|
|
204
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subject erasure on a loaded segment (GDPR Art. 17): remove **one id** from a segment by rewriting its current
|
|
3
|
+
* generation without that id.
|
|
4
|
+
*
|
|
5
|
+
* There is no per-id delete on an immutable object, and there is no mutable tier to hold a tombstone, so erasure
|
|
6
|
+
* is what every other write in this library is: a new generation. The current generation is streamed chunk by
|
|
7
|
+
* chunk through the ascending writer — every chunk copied through, the one chunk holding the id re-encoded with
|
|
8
|
+
* that bit cleared — then published fenced on the generation it streamed, and that generation is collected immediately
|
|
9
|
+
* (`keep: 0`), so the bit is **physically gone from the bucket when this returns**. Constant memory: one chunk in
|
|
10
|
+
* flight, never the whole segment.
|
|
11
|
+
*
|
|
12
|
+
* What it does NOT reach: backups, replicas and noncurrent object versions hold the old object until their own
|
|
13
|
+
* lifecycle removes it. For an at-rest guarantee that survives those, use encryption plus crypto-shred
|
|
14
|
+
* (`destroySegment`), which is per segment — a per-id shred is infeasible because one DEK covers the whole segment.
|
|
15
|
+
*
|
|
16
|
+
* Concurrency. This is a **read-modify-write**, not a plain forward-only publish, and that distinction is where
|
|
17
|
+
* its correctness lives: the new generation is `from` minus one bit, so it is a valid successor to `from` and to
|
|
18
|
+
* nothing else. The publish is therefore fenced on `from` **and on the row it was read from**
|
|
19
|
+
* (`publishGeneration`'s `expectFrom` + `expectToken`) and lands only while the pointer is still exactly there,
|
|
20
|
+
* on that same row. Both halves are needed: a generation number identifies a generation only within one
|
|
21
|
+
* incarnation of a name, and a name that is retired and re-created restarts at `0`, so the number alone matched
|
|
22
|
+
* a different segment entirely. Anything else — a load that published in the meantime, or another
|
|
23
|
+
* erasure that got there first — is reported as `reason: 'superseded'`, `erased: false`, and the caller re-runs
|
|
24
|
+
* against the new generation. A writer that claims the same generation *number* first surfaces as a
|
|
25
|
+
* `WriteConflictError` (write-once).
|
|
26
|
+
*
|
|
27
|
+
* The same answer covers the read half, and for the same reason. An erasure collects with `keep: 0`, which takes
|
|
28
|
+
* every generation below its new pointer — so a racing erasure can delete the generation this call is streaming,
|
|
29
|
+
* and (once it is below the winner's pointer) the object this call just wrote. All three exposed round trips (the
|
|
30
|
+
* reader open, any chunk of the whole-segment rewrite, the verify) therefore report a *reason* rather than a bare
|
|
31
|
+
* `NotFoundError`, which was the same event wearing an unactionable face: through `eraseSubject` it became a note
|
|
32
|
+
* the facade documents as ambiguous about which side of the publish it landed on, telling an operator to triage
|
|
33
|
+
* where the truth was to re-run.
|
|
34
|
+
*
|
|
35
|
+
* Which reason comes from **re-reading the row**, not from assuming a supersession: a moved pointer is
|
|
36
|
+
* `'superseded'`, a tombstoned row `'destroyed'`, a purged row `'absent'`, a row with no pointer
|
|
37
|
+
* `'no-generation'`. A pointer still on `from` means the object it names is genuinely gone — the forbidden
|
|
38
|
+
* `missing-storage-generation` state — and that throws, because no re-run fixes it. That `NotFoundError` is the only
|
|
39
|
+
* signal of that state, so a faulting re-read rethrows it rather than replacing it with a transient-looking
|
|
40
|
+
* registry error.
|
|
41
|
+
*
|
|
42
|
+
* **No orphan is left behind by a refused rewrite**, and it is worth saying why, because nothing else collects
|
|
43
|
+
* above the pointer: `putImmutable` commits atomically, so a rewrite whose stream throws leaves no object at all;
|
|
44
|
+
* and if the write did complete, the winner's generation is necessarily higher, which puts ours below its pointer
|
|
45
|
+
* where its `keep: 0` takes it. The two orderings are mutually exclusive.
|
|
46
|
+
*
|
|
47
|
+
* Without that fence the failure was silent and severe, and both halves were reproduced: `nextGeneration` picks a
|
|
48
|
+
* number above everything in the bucket, so a forward-only publish here always won — discarding a concurrent
|
|
49
|
+
* load's whole set, and letting two concurrent erasures each return `erased: true` while the second one's
|
|
50
|
+
* generation put the first one's id back and collected the generation that had evidenced its removal. A false
|
|
51
|
+
* Art. 17 receipt is the worst output this module can produce.
|
|
52
|
+
*
|
|
53
|
+
* Readers pinned to the collected generation re-resolve on their next read; that is the documented cost of
|
|
54
|
+
* physical deletion on return. **Do not re-load the id while erasing it**: a load that lands after this rewrite
|
|
55
|
+
* carries whatever its source held, and the library cannot know that source was meant to exclude the id.
|
|
56
|
+
*/
|
|
57
|
+
import { type IAuditSink } from './audit.js';
|
|
58
|
+
import type { CodecInterface } from './codec.js';
|
|
59
|
+
import type { Yielder } from './cooperative.js';
|
|
60
|
+
import type { IKeystore } from './crypto.js';
|
|
61
|
+
import type { IStorageDriver, IRegistryDriver, SegmentRef } from './ports.js';
|
|
62
|
+
/** What {@link eraseIdFromSegment} needs: the objects, the pointer, the codec, and the key material if encrypted. */
|
|
63
|
+
export interface EraseIdDeps {
|
|
64
|
+
readonly storage: IStorageDriver;
|
|
65
|
+
readonly registry: IRegistryDriver;
|
|
66
|
+
/**
|
|
67
|
+
* Bitmap codec — the rewrite decodes and re-encodes every chunk through it. Optional in the type so this stays
|
|
68
|
+
* call-compatible public API; a **flavor** package binds it (see {@link requireCodec}).
|
|
69
|
+
*/
|
|
70
|
+
readonly codec?: CodecInterface;
|
|
71
|
+
/**
|
|
72
|
+
* Keystore for an **encrypted** segment: its registry record carries wrapped DEK(s), and the rewrite reuses
|
|
73
|
+
* that DEK — unwrapping it to decrypt the old generation and encrypt the new one under a `(segment, generation)`
|
|
74
|
+
* -bound AAD. Rewriting an encrypted segment without a keystore throws {@link KeyUnavailableError}.
|
|
75
|
+
*/
|
|
76
|
+
readonly keystore?: IKeystore;
|
|
77
|
+
/** When true, refuse to rewrite a **cleartext** segment (the same guard the read path and bulk-load offer). */
|
|
78
|
+
readonly requireEncryption?: boolean;
|
|
79
|
+
/** Supplying a clock that can yield makes the rewrite cooperative — see `bulkLoadCrbmGeneration`. */
|
|
80
|
+
readonly clock?: Yielder;
|
|
81
|
+
/** Per-chunk decode ceiling (invariant 5); defaults to 1 MiB. */
|
|
82
|
+
readonly maxBitmapBytes?: number;
|
|
83
|
+
}
|
|
84
|
+
export interface EraseIdResult {
|
|
85
|
+
readonly segment: string;
|
|
86
|
+
readonly namespace?: string;
|
|
87
|
+
/**
|
|
88
|
+
* True iff the id **was** a member and a generation without it is now the segment's current generation — and
|
|
89
|
+
* the generation that held it has been deleted (see `collected`). This is the field an erasure ledger records.
|
|
90
|
+
*/
|
|
91
|
+
readonly erased: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Why nothing was rewritten, when `erased` is false. `'absent'` (no registry row), `'destroyed'` (a crypto-shred
|
|
94
|
+
* tombstone — already unreadable), `'no-generation'` (a row with no Storage data yet), `'not-member'` (the id is not
|
|
95
|
+
* in the current generation — the common case across a fleet scan), or `'superseded'`.
|
|
96
|
+
*
|
|
97
|
+
* **`'superseded'` means this call did not erase the id, not that the id is still there.** Another writer — a
|
|
98
|
+
* load, or another erasure — moved the pointer off the generation this rewrite was derived from, so the
|
|
99
|
+
* rewrite is not a valid successor to what is now current. Re-run against the new generation: if the id is
|
|
100
|
+
* still present it is erased then; if the racing writer was another erasure of the *same* id, the re-run
|
|
101
|
+
* reports `'not-member'` because it is already gone. Either way the re-run settles it, which is why it is the
|
|
102
|
+
* documented action for this reason and for no other.
|
|
103
|
+
*
|
|
104
|
+
* The first four reasons are also returned when the row changes *underneath* a rewrite already in flight,
|
|
105
|
+
* not only when it is read up front — a concurrent `dropSegment` gives `'destroyed'`, a retention sweep that
|
|
106
|
+
* purges the row gives `'absent'`. A caller branching on `reason` never has to care which it was.
|
|
107
|
+
*/
|
|
108
|
+
readonly reason?: 'absent' | 'destroyed' | 'no-generation' | 'not-member' | 'superseded';
|
|
109
|
+
/** The generation the id was found in (present whenever it was read). */
|
|
110
|
+
readonly fromGeneration?: number;
|
|
111
|
+
/** The generation written without the id (present whenever one was written, even if `superseded`). */
|
|
112
|
+
readonly generation?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Generations deleted after the publish — normally `[fromGeneration]`, plus any older orphans. Empty when nothing
|
|
115
|
+
* was rewritten. **It is what THIS call deleted, not the proof that the id is gone** — those differ: a
|
|
116
|
+
* concurrent collector can take the generation holding the id first, and then `erased: true` is returned with
|
|
117
|
+
* `collected` empty, because the claim is about the bucket rather than about who emptied it. Treat a
|
|
118
|
+
* non-empty `collected` as evidence and an empty one as "someone else got there", never as a failure.
|
|
119
|
+
*
|
|
120
|
+
* A call that cannot establish the claim **throws** rather than report `erased: true` over bytes that are
|
|
121
|
+
* still there. Three ways that happens: a Storage `delete` fault, a collection pass that could not prove the
|
|
122
|
+
* segment was still the same one (`WriteConflictError`, which an ordinary retirement landing mid-call is
|
|
123
|
+
* enough to cause), and the same refusal on the collect-only path below, where nothing was published at all.
|
|
124
|
+
*
|
|
125
|
+
* **Re-run it**, and read what the re-run says rather than assuming it finished the job. The pointer has
|
|
126
|
+
* moved, so the re-run looks for the id in the *superseded* generations as well as the current one, and there
|
|
127
|
+
* are three outcomes:
|
|
128
|
+
*
|
|
129
|
+
* - `erased: true` naming the superseded generation it found the id in and collected — the ordinary case,
|
|
130
|
+
* and the one that gives you the receipt the failed call could not;
|
|
131
|
+
* - `'not-member'` — a racing collector took that generation first. The bit is gone, but **no run reports a
|
|
132
|
+
* receipt for it**, so keep the failed call's error alongside your ledger if you need the audit trail;
|
|
133
|
+
* - `'absent'` — the segment's registry row is gone, so it is no longer a segment at all and
|
|
134
|
+
* `store.eraseSubject` will not even scan it. Anything left in the bucket is an **orphan**: find it with
|
|
135
|
+
* `checkConsistency()` and collect it with `gcOrphanGenerations`.
|
|
136
|
+
*/
|
|
137
|
+
readonly collected: readonly number[];
|
|
138
|
+
}
|
|
139
|
+
export declare function eraseIdFromSegment(ref: SegmentRef, id: number, deps: EraseIdDeps, options?: {
|
|
140
|
+
audit?: IAuditSink;
|
|
141
|
+
}): Promise<EraseIdResult>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whole-segment erasure: crypto-shred (`destroySegment`, `eraseNamespace`) and disposal (`dropSegment`).
|
|
3
|
+
*
|
|
4
|
+
* GDPR "right to erasure" on immutable, backed-up storage: you can't delete a `.crbm` object from every
|
|
5
|
+
* backup, but you can **delete its key**. `destroySegment` drops the segment's wrapped DEK(s) from the registry
|
|
6
|
+
* (a CAS to a `destroyed` tombstone) — the encrypted Storage bytes are then permanently unreadable, everywhere,
|
|
7
|
+
* forever. **Immediate + irreversible**, so it's gated behind an explicit confirmation (name the exact
|
|
8
|
+
* segment/namespace). A `destroyed` segment reads as empty (its DEK is gone). Only works on an **encrypted**
|
|
9
|
+
* segment; a cleartext segment has no key to shred.
|
|
10
|
+
*
|
|
11
|
+
* The tombstone is also the fence every writer respects: `publishGeneration` and `bulkLoadCrbmGeneration` refuse
|
|
12
|
+
* a `destroyed` row, so a load racing an erasure cannot resurrect the segment. A single id's erasure is a different
|
|
13
|
+
* operation — `eraseIdFromSegment` rewrites the generation without it.
|
|
14
|
+
*/
|
|
15
|
+
import { type IAuditSink } from './audit.js';
|
|
16
|
+
import type { IStorageDriver, IRegistryDriver, SegmentRef } from './ports.js';
|
|
17
|
+
export interface EraseDeps {
|
|
18
|
+
readonly registry: IRegistryDriver;
|
|
19
|
+
}
|
|
20
|
+
export interface DestroyResult {
|
|
21
|
+
readonly segment: string;
|
|
22
|
+
readonly namespace?: string;
|
|
23
|
+
/** True iff the segment is now a `destroyed` tombstone (incl. the idempotent already-destroyed case). */
|
|
24
|
+
readonly destroyed: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* True iff **this call** performed a genuine crypto-shred — an encrypted segment whose wrapped DEK(s) were
|
|
27
|
+
* dropped, so its Storage bytes are now permanently unreadable. False for a cleartext tombstone (bytes remain
|
|
28
|
+
* readable), for the idempotent already-destroyed case, and for an absent segment. This is what the
|
|
29
|
+
* `segment.erase` audit event keys off — a cleartext "erase" is not an irreversible destruction.
|
|
30
|
+
*/
|
|
31
|
+
readonly cryptoShredded: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Why this segment was not a fresh crypto-shred. `'absent'` (no registry row), `'already'` (already
|
|
34
|
+
* destroyed), `'cleartext'` (no DEK to shred) — and from `eraseNamespace` only, where per-segment faults are
|
|
35
|
+
* isolated so one failure cannot discard the ledger: `'contended'` (the row was rewritten during every CAS
|
|
36
|
+
* attempt) or `` `failed: <message>` `` (any other fault).
|
|
37
|
+
*
|
|
38
|
+
* The last two come with `destroyed: false` and mean **the segment still holds data**. A namespace erase
|
|
39
|
+
* returns them rather than throwing, so its entries have to be inspected — see {@link eraseNamespace}.
|
|
40
|
+
*/
|
|
41
|
+
readonly reason?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Crypto-shred one segment. **Irreversible.** `confirmSegment` must equal `ref.segment` (a guard against an
|
|
45
|
+
* accidental destroy — you must name the exact segment). A non-encrypted segment is rejected unless you opt in
|
|
46
|
+
* via `allowCleartext` (there's no key to shred; its Storage bytes stay readable — the tombstone only stops the
|
|
47
|
+
* segment resolving).
|
|
48
|
+
*/
|
|
49
|
+
export declare function destroySegment(ref: SegmentRef, deps: EraseDeps, options: {
|
|
50
|
+
confirmSegment: string;
|
|
51
|
+
allowCleartext?: boolean;
|
|
52
|
+
audit?: IAuditSink;
|
|
53
|
+
}): Promise<DestroyResult>;
|
|
54
|
+
/**
|
|
55
|
+
* Crypto-shred every segment in a namespace. **Irreversible.** `confirmNamespace` must equal `namespace`.
|
|
56
|
+
* Returns a per-segment result (skips cleartext segments unless `allowCleartext`).
|
|
57
|
+
*/
|
|
58
|
+
export declare function eraseNamespace(namespace: string, deps: EraseDeps, options: {
|
|
59
|
+
confirmNamespace: string;
|
|
60
|
+
allowCleartext?: boolean;
|
|
61
|
+
audit?: IAuditSink;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
destroyed: DestroyResult[];
|
|
64
|
+
}>;
|
|
65
|
+
/**
|
|
66
|
+
* Deps for {@link dropSegment}. Adds `storage`, because unlike a crypto-shred this one deletes the objects.
|
|
67
|
+
*/
|
|
68
|
+
export interface DropDeps extends EraseDeps {
|
|
69
|
+
readonly storage: IStorageDriver;
|
|
70
|
+
}
|
|
71
|
+
export interface DropResult {
|
|
72
|
+
readonly segment: string;
|
|
73
|
+
readonly namespace?: string;
|
|
74
|
+
/**
|
|
75
|
+
* **True iff this segment is now empty as a result of this call — or already was.** This is the field a
|
|
76
|
+
* retention job should branch on: `false` means the call found nothing to do and your ref is probably wrong.
|
|
77
|
+
*/
|
|
78
|
+
readonly dropped: boolean;
|
|
79
|
+
/** Storage generations physically deleted, ascending. Empty on a dry run — see {@link DropResult.wouldDelete}. */
|
|
80
|
+
readonly generationsDeleted: readonly number[];
|
|
81
|
+
/**
|
|
82
|
+
* Generations still present in Storage when the sweep gave up, ascending. **Empty is the normal outcome** — a
|
|
83
|
+
* non-empty value means the storage was NOT fully reclaimed and the drop should be re-run.
|
|
84
|
+
*
|
|
85
|
+
* This field exists because its absence was a defect. A drop used to return `dropped: true` with a populated
|
|
86
|
+
* `generationsDeleted` and no `reason` even when an object holding the **complete set** had just been left in
|
|
87
|
+
* the bucket by a writer that was already mid-write when the tombstone landed (its publish is refused, but the
|
|
88
|
+
* object survives). For a cleartext segment those bytes are readable — and `gcOrphanGenerations` only collects
|
|
89
|
+
* a destroyed segment's generations when something runs it. The result was indistinguishable from a clean
|
|
90
|
+
* drop, so an operator got no signal to re-run. Now they do.
|
|
91
|
+
*/
|
|
92
|
+
readonly generationsRemaining: readonly number[];
|
|
93
|
+
/**
|
|
94
|
+
* On a dry run, the generations that **would** be deleted. `undefined` on a real run.
|
|
95
|
+
*
|
|
96
|
+
* This exists because the confirmation guard the erasure calls use — naming the segment twice — protects a
|
|
97
|
+
* hand-typed literal and nothing else. In the loop this operation is actually *for*
|
|
98
|
+
* (`for (const day of expired) drop({segment: day}, {confirmSegment: day})`) the same variable appears twice
|
|
99
|
+
* and the guard is pure ceremony. A dry run is the guard that still works when a machine is calling.
|
|
100
|
+
*
|
|
101
|
+
* It is a **snapshot**: a later real run can see a different set.
|
|
102
|
+
*/
|
|
103
|
+
readonly wouldDelete?: readonly number[];
|
|
104
|
+
/**
|
|
105
|
+
* On a dry run, whether the real drop would **also** crypto-shred (i.e. the segment is encrypted).
|
|
106
|
+
* `undefined` on a real run. This is the one fact in the preview that is irreversible *everywhere*, backups
|
|
107
|
+
* included — a dry run that did not surface it was previewing the recoverable half only.
|
|
108
|
+
*/
|
|
109
|
+
readonly wouldCryptoShred?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* True iff the segment was encrypted and its wrapped DEK(s) were dropped as part of this call — so its bytes
|
|
112
|
+
* are unreadable *everywhere*, backups included, not merely deleted from the bucket. Deleting an object does
|
|
113
|
+
* not reach a noncurrent version, a replica, or a PITR snapshot; discarding the key does.
|
|
114
|
+
*/
|
|
115
|
+
readonly cryptoShredded: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* How the segment got to its current state. Absent on a fresh, ordinary drop (tombstone written, Storage swept).
|
|
118
|
+
*
|
|
119
|
+
* - `'already'` — it was already a tombstone. Idempotent; pairs with `dropped: true`. A re-drop is **not** a
|
|
120
|
+
* no-op — it re-sweeps Storage, so it is how a residual in `generationsRemaining` is collected.
|
|
121
|
+
* - `'absent'` — **nothing existed.** No registry row and no Storage objects. Pairs with `dropped: false`, and it
|
|
122
|
+
* is the one value worth alerting on: the usual cause is a mistyped name or an omitted `namespace`, both of
|
|
123
|
+
* which address a *different* segment than you meant.
|
|
124
|
+
*/
|
|
125
|
+
readonly reason?: string;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* **Dispose of a segment: tombstone it, then delete its Storage objects.** Irreversible.
|
|
129
|
+
*
|
|
130
|
+
* WHY THIS EXISTS, given {@link destroySegment} already erases. Because `destroySegment` answers a *compliance*
|
|
131
|
+
* question and this one answers an *operational* question, and they are not the same:
|
|
132
|
+
*
|
|
133
|
+
* - `destroySegment` **crypto-shreds** — it discards the key so the bytes are unreadable everywhere including
|
|
134
|
+
* immutable backups, which is the only erasure that survives WORM. But it **leaves the objects in the
|
|
135
|
+
* bucket**, still billed, and it *requires* encryption because a cleartext segment has no key to discard.
|
|
136
|
+
* - `dropSegment` **removes the storage**. It works on a cleartext segment, and on an encrypted one it *also*
|
|
137
|
+
* drops the DEKs, so it is a strict superset there.
|
|
138
|
+
*
|
|
139
|
+
* Before this existed there was no supported way to delete a segment and stop paying for it, and the obvious
|
|
140
|
+
* workaround — an object-store lifecycle rule on the key prefix — deletes the bytes while the registry still
|
|
141
|
+
* points at them. That is exactly the `missing-storage-generation` state the DR runbook says not to serve traffic
|
|
142
|
+
* on, and it surfaces *intermittently*, because a read consults the cache before Storage: cached chunks answer
|
|
143
|
+
* correctly and evicted ones throw. The whole value of this function is that the ordering below cannot be got
|
|
144
|
+
* wrong by a caller.
|
|
145
|
+
*
|
|
146
|
+
* **THE ORDER IS THE CONTRACT — registry first, then Storage.**
|
|
147
|
+
*
|
|
148
|
+
* 1. **Registry first.** After the tombstone nothing resolves a generation for this segment, so no reader can
|
|
149
|
+
* reach for bytes that are about to disappear, and no writer can publish onto it (`publishGeneration` and
|
|
150
|
+
* `bulkLoadCrbmGeneration` refuse a `destroyed` row).
|
|
151
|
+
* 2. **Storage second, best-effort, and re-swept.** Once the pointer is a tombstone the segment resolves as empty,
|
|
152
|
+
* so a failure part-way through leaves **orphaned bytes, not a wrong answer.** Orphans cost money and are
|
|
153
|
+
* cleaned up by re-running; a torn pointer costs correctness and is not self-healing. Given the choice, leak
|
|
154
|
+
* bytes — but say so: whatever survives the sweep is reported in {@link DropResult.generationsRemaining}.
|
|
155
|
+
*
|
|
156
|
+
* **Why step 2 sweeps more than once.** A load that was already writing its object when the tombstone landed
|
|
157
|
+
* still finishes the write — its publish is then refused, but the object survives, and it holds the complete
|
|
158
|
+
* set. A single list-then-delete misses it entirely. The re-sweep converges because the tombstone *is* a hard
|
|
159
|
+
* fence on **publishing**, so only already-in-flight writes can appear and they are finite.
|
|
160
|
+
*
|
|
161
|
+
* **When "reads as empty" starts being true.** Not instantly, for a store that has already read this segment: a
|
|
162
|
+
* resolved generation is cached and decoded chunks sit in the cache, so an in-flight reader can answer from
|
|
163
|
+
* cache for a window. A fresh store, or any reader that had not touched the segment, sees empty at once.
|
|
164
|
+
*
|
|
165
|
+
* That window is bounded by `cache.genTtlMs` (default 2 s) **only for a reader whose storage source has both a clock
|
|
166
|
+
* and a registry and a positive TTL** — expiry needs all three. A source built without a clock, or with
|
|
167
|
+
* `cache.genTtlMs: 0` (documented as "pin forever"), holds its resolved snapshot for its own lifetime; because a
|
|
168
|
+
* cache-LRU hit never reaches Storage, such a reader can answer `true` for a dropped segment **indefinitely** and must
|
|
169
|
+
* be restarted. This is the *same* caching that makes the delete-bytes-first ordering fail intermittently rather
|
|
170
|
+
* than loudly — it cuts both ways.
|
|
171
|
+
*
|
|
172
|
+
* `confirmSegment` must equal `ref.segment`, matching `destroySegment`/`eraseNamespace`. For an automated
|
|
173
|
+
* caller that guard is ceremony — use `dryRun` first, which reports what would go without touching anything.
|
|
174
|
+
*/
|
|
175
|
+
export declare function dropSegment(ref: SegmentRef, deps: DropDeps, options: {
|
|
176
|
+
confirmSegment: string;
|
|
177
|
+
dryRun?: boolean;
|
|
178
|
+
audit?: IAuditSink;
|
|
179
|
+
}): Promise<DropResult>;
|