@cloudbitmaps/roaring 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/PRIVACY.md +139 -73
- package/README.md +80 -154
- package/dist/bin/export-segments.js +784 -320
- package/dist/bin/export-segments.js.map +4 -4
- package/dist/index.d.ts +357 -204
- package/dist/index.js +815 -294
- package/dist/index.js.map +4 -4
- package/dist/open-charging-storage.d.ts +34 -0
- package/dist/option-keys.d.ts +19 -0
- package/dist/portable/decode.d.ts +6 -7
- package/dist/portable/layout.d.ts +32 -0
- package/dist/reserved-namespace.d.ts +3 -0
- package/dist/roaring-codec.d.ts +13 -6
- package/dist/system-clock.d.ts +3 -4
- package/dist/testing/conformance.d.ts +21 -1
- package/dist/testing/fuzz-codec.d.ts +19 -5
- package/package.json +2 -3
- package/dist/codec-bound.d.ts +0 -38
- package/dist/moved-options.d.ts +0 -14
|
@@ -1,21 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
-
var __export = (target, all) => {
|
|
7
|
-
for (var name in all)
|
|
8
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
-
};
|
|
10
|
-
var __copyProps = (to, from, except, desc) => {
|
|
11
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
-
for (let key of __getOwnPropNames(from))
|
|
13
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
-
}
|
|
16
|
-
return to;
|
|
17
|
-
};
|
|
18
|
-
var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
|
|
19
2
|
|
|
20
3
|
// src/bin/export-segments.ts
|
|
21
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -23,20 +6,9 @@ import { realpathSync } from "node:fs";
|
|
|
23
6
|
import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
|
|
24
7
|
import { join } from "node:path";
|
|
25
8
|
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { encodeNameForPath, namespacePathPart } from "@cloudbitmaps/core/driver-kit";
|
|
26
10
|
|
|
27
11
|
// src/index.ts
|
|
28
|
-
var src_exports = {};
|
|
29
|
-
__export(src_exports, {
|
|
30
|
-
CloudRoaring: () => CloudRoaring,
|
|
31
|
-
SafeBitmap: () => SafeBitmap,
|
|
32
|
-
Segment: () => Segment,
|
|
33
|
-
VERSION: () => VERSION,
|
|
34
|
-
bulkLoadCrbmGeneration: () => bulkLoadCrbmGeneration,
|
|
35
|
-
eraseIdFromSegment: () => eraseIdFromSegment,
|
|
36
|
-
loadSegment: () => loadSegment,
|
|
37
|
-
roaringCodec: () => roaringCodec,
|
|
38
|
-
runExport: () => runExport
|
|
39
|
-
});
|
|
40
12
|
import {
|
|
41
13
|
BoundedLru,
|
|
42
14
|
PinnedStorageChunkSource,
|
|
@@ -44,44 +16,251 @@ import {
|
|
|
44
16
|
CrbmStorageChunkSource,
|
|
45
17
|
DEFAULT_BUDGET,
|
|
46
18
|
DEFAULT_RETRY_POLICY,
|
|
19
|
+
withRetry,
|
|
47
20
|
isStorageBackend,
|
|
48
21
|
NOOP_METRICS,
|
|
49
22
|
RetryingStorageChunkSource,
|
|
50
23
|
SegmentEngine,
|
|
51
24
|
UnsupportedError,
|
|
52
|
-
ValidationError,
|
|
25
|
+
ValidationError as ValidationError2,
|
|
53
26
|
WriteConflictError,
|
|
54
27
|
MIN_EXPIRES_AT_MS,
|
|
28
|
+
checkBudget,
|
|
55
29
|
collectWithinBudget,
|
|
56
30
|
excludingReservedRows,
|
|
57
31
|
dropSegment,
|
|
32
|
+
eraseIdFromSegment,
|
|
58
33
|
estimateCost,
|
|
59
34
|
groundedReport,
|
|
35
|
+
loadSegment,
|
|
60
36
|
mapWithConcurrency,
|
|
61
37
|
resolveBudget,
|
|
62
38
|
resolvePerOpBudget,
|
|
63
39
|
retireExpired,
|
|
64
40
|
runConsistencyCheck,
|
|
65
|
-
runExport
|
|
41
|
+
runExport,
|
|
66
42
|
setSegmentRetention,
|
|
67
43
|
clearSegmentRetention,
|
|
68
44
|
getSegmentRetention,
|
|
69
45
|
safeMetrics,
|
|
70
|
-
splitId
|
|
71
|
-
validateSegmentRef
|
|
46
|
+
splitId
|
|
72
47
|
} from "@cloudbitmaps/core";
|
|
48
|
+
import { validateSegmentRef } from "@cloudbitmaps/core/driver-kit";
|
|
73
49
|
|
|
74
|
-
// src/
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
50
|
+
// src/open-charging-storage.ts
|
|
51
|
+
var OpenChargingStorage = class {
|
|
52
|
+
/**
|
|
53
|
+
* @param inner the driver the erasure would otherwise use
|
|
54
|
+
* @param current the generation the segment's row named when it was listed, or `null` when it named none
|
|
55
|
+
* @param charge called once per charged open; it throws to refuse
|
|
56
|
+
*/
|
|
57
|
+
constructor(inner, current, charge) {
|
|
58
|
+
this.inner = inner;
|
|
59
|
+
this.charge = charge;
|
|
60
|
+
if (current !== null) this.free.add(current);
|
|
61
|
+
}
|
|
62
|
+
inner;
|
|
63
|
+
charge;
|
|
64
|
+
free = /* @__PURE__ */ new Set();
|
|
65
|
+
capabilities() {
|
|
66
|
+
return this.inner.capabilities();
|
|
67
|
+
}
|
|
68
|
+
async putImmutable(key, write) {
|
|
69
|
+
const written = await this.inner.putImmutable(key, write);
|
|
70
|
+
this.free.add(key.generation);
|
|
71
|
+
return written;
|
|
72
|
+
}
|
|
73
|
+
getRange(key, offset, length) {
|
|
74
|
+
return this.inner.getRange(key, offset, length);
|
|
75
|
+
}
|
|
76
|
+
/** One `getTail` is one open: a reader takes the footer and the index from it, and reads chunks with `getRange`. */
|
|
77
|
+
async getTail(key, maxBytes) {
|
|
78
|
+
if (!this.free.has(key.generation)) this.charge();
|
|
79
|
+
return this.inner.getTail(key, maxBytes);
|
|
80
|
+
}
|
|
81
|
+
delete(key) {
|
|
82
|
+
return this.inner.delete(key);
|
|
83
|
+
}
|
|
84
|
+
list(ref) {
|
|
85
|
+
return this.inner.list(ref);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/index.ts
|
|
90
|
+
import { listGenerations, rollbackSegment } from "@cloudbitmaps/core";
|
|
91
|
+
import { listSegments, segmentExists } from "@cloudbitmaps/core";
|
|
92
|
+
|
|
93
|
+
// src/reserved-namespace.ts
|
|
94
|
+
import { ValidationError } from "@cloudbitmaps/core";
|
|
95
|
+
var RESERVED_NAMESPACE_PREFIX = "cbm.due.";
|
|
96
|
+
function refuseReservedNamespace(namespace) {
|
|
97
|
+
if (namespace !== void 0 && namespace.startsWith(RESERVED_NAMESPACE_PREFIX)) {
|
|
98
|
+
throw new ValidationError(
|
|
99
|
+
`namespace "${namespace}" is reserved: names starting with "${RESERVED_NAMESPACE_PREFIX}" hold the library's own bookkeeping rows, which every fleet-wide scan skips, so a segment there would be invisible to an erasure, a consistency check, an export and a retention sweep. Choose another namespace.`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
81
103
|
|
|
82
104
|
// src/roaring-codec.ts
|
|
83
105
|
import roaring from "roaring";
|
|
106
|
+
import { IntegrityError as IntegrityError2 } from "@cloudbitmaps/core";
|
|
107
|
+
|
|
108
|
+
// src/portable/layout.ts
|
|
84
109
|
import { IntegrityError } from "@cloudbitmaps/core";
|
|
110
|
+
var SERIAL_COOKIE_NO_RUNCONTAINER = 12346;
|
|
111
|
+
var SERIAL_COOKIE = 12347;
|
|
112
|
+
var NO_OFFSET_THRESHOLD = 4;
|
|
113
|
+
var ARRAY_MAX_CARDINALITY = 4096;
|
|
114
|
+
var BITMAP_CONTAINER_BYTES = 8192;
|
|
115
|
+
var MAX_LOW = 65535;
|
|
116
|
+
var ARRAY = 0;
|
|
117
|
+
var BITMAP = 1;
|
|
118
|
+
var RUN = 2;
|
|
119
|
+
function overrun(bytes, offset, length, what) {
|
|
120
|
+
return new IntegrityError(
|
|
121
|
+
`portable roaring: ${what} needs bytes [${offset}, ${offset + length}) of a ${bytes.byteLength}-byte buffer`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
function checkPortableLayout(bytes) {
|
|
125
|
+
walk(bytes, null);
|
|
126
|
+
}
|
|
127
|
+
function walk(bytes, out) {
|
|
128
|
+
const length = bytes.byteLength;
|
|
129
|
+
if (length === 0) return;
|
|
130
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, length);
|
|
131
|
+
if (length < 4) throw overrun(bytes, 0, 4, "the cookie");
|
|
132
|
+
const cookie = view.getUint32(0, true);
|
|
133
|
+
let count;
|
|
134
|
+
let runFlagsAt = -1;
|
|
135
|
+
let pos;
|
|
136
|
+
if (cookie === SERIAL_COOKIE_NO_RUNCONTAINER) {
|
|
137
|
+
if (length < 8) throw overrun(bytes, 4, 4, "the container count");
|
|
138
|
+
count = view.getUint32(4, true);
|
|
139
|
+
pos = 8;
|
|
140
|
+
} else if ((cookie & 65535) === SERIAL_COOKIE) {
|
|
141
|
+
count = (cookie >>> 16) + 1;
|
|
142
|
+
runFlagsAt = 4;
|
|
143
|
+
const flagBytes = count + 7 >>> 3;
|
|
144
|
+
if (runFlagsAt + flagBytes > length) {
|
|
145
|
+
throw overrun(bytes, runFlagsAt, flagBytes, "the run-container bitmap");
|
|
146
|
+
}
|
|
147
|
+
pos = runFlagsAt + flagBytes;
|
|
148
|
+
} else {
|
|
149
|
+
throw new IntegrityError(
|
|
150
|
+
`portable roaring: unrecognized cookie 0x${cookie.toString(16)}, not a portable-format bitmap`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const descriptiveAt = pos;
|
|
154
|
+
if (pos + count * 4 > length) throw overrun(bytes, pos, count * 4, "the descriptive header");
|
|
155
|
+
pos += count * 4;
|
|
156
|
+
let offsetsAt = -1;
|
|
157
|
+
if (runFlagsAt < 0 || count >= NO_OFFSET_THRESHOLD) {
|
|
158
|
+
if (pos + count * 4 > length) throw overrun(bytes, pos, count * 4, "the offset header");
|
|
159
|
+
offsetsAt = pos;
|
|
160
|
+
pos += count * 4;
|
|
161
|
+
}
|
|
162
|
+
let previousKey = -1;
|
|
163
|
+
for (let i = 0; i < count; i++) {
|
|
164
|
+
const key = view.getUint16(descriptiveAt + i * 4, true);
|
|
165
|
+
if (key <= previousKey) {
|
|
166
|
+
throw new IntegrityError(
|
|
167
|
+
`portable roaring: container keys are not strictly ascending (${previousKey} then ${key} at index ${i})`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
previousKey = key;
|
|
171
|
+
const cardinality = view.getUint16(descriptiveAt + i * 4 + 2, true) + 1;
|
|
172
|
+
const isRun = runFlagsAt >= 0 && (bytes[runFlagsAt + (i >>> 3)] & 1 << (i & 7)) !== 0;
|
|
173
|
+
const kind = isRun ? RUN : cardinality > ARRAY_MAX_CARDINALITY ? BITMAP : ARRAY;
|
|
174
|
+
if (offsetsAt >= 0) {
|
|
175
|
+
const stated = view.getUint32(offsetsAt + i * 4, true);
|
|
176
|
+
if (stated !== pos) {
|
|
177
|
+
throw new IntegrityError(
|
|
178
|
+
`portable roaring: the offset header puts container ${i} at byte ${stated}, but it starts at ${pos}`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
let size;
|
|
183
|
+
if (kind === ARRAY) {
|
|
184
|
+
size = cardinality * 2;
|
|
185
|
+
if (pos + size > length) throw overrun(bytes, pos, size, `container ${i} (array)`);
|
|
186
|
+
checkArray(view, pos, cardinality, i);
|
|
187
|
+
} else if (kind === BITMAP) {
|
|
188
|
+
size = BITMAP_CONTAINER_BYTES;
|
|
189
|
+
if (pos + size > length) throw overrun(bytes, pos, size, `container ${i} (bitmap)`);
|
|
190
|
+
const bits = popcount(view, pos);
|
|
191
|
+
if (bits !== cardinality) {
|
|
192
|
+
throw new IntegrityError(
|
|
193
|
+
`portable roaring: container ${i} (bitmap) holds ${bits} values, but its header says ${cardinality}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
if (pos + 2 > length) throw overrun(bytes, pos, 2, `container ${i} (run count)`);
|
|
198
|
+
const runs = view.getUint16(pos, true);
|
|
199
|
+
size = 2 + runs * 4;
|
|
200
|
+
if (pos + size > length) throw overrun(bytes, pos, size, `container ${i} (run)`);
|
|
201
|
+
const covered = checkRuns(view, pos + 2, runs, i);
|
|
202
|
+
if (covered !== cardinality) {
|
|
203
|
+
throw new IntegrityError(
|
|
204
|
+
`portable roaring: container ${i} (run) covers ${covered} values, but its header says ${cardinality}`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
out?.push({ key, cardinality, kind, offset: pos });
|
|
209
|
+
pos += size;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function checkArray(view, at, cardinality, index) {
|
|
213
|
+
let previous = -1;
|
|
214
|
+
let p = at;
|
|
215
|
+
const end = at + cardinality * 2;
|
|
216
|
+
for (; p + 4 <= end; p += 4) {
|
|
217
|
+
const pair = view.getUint32(p, true);
|
|
218
|
+
const first = pair & 65535;
|
|
219
|
+
const second = pair >>> 16;
|
|
220
|
+
if (first <= previous || second <= first) throw unordered(index, (p - at) / 2);
|
|
221
|
+
previous = second;
|
|
222
|
+
}
|
|
223
|
+
if (p < end && view.getUint16(p, true) <= previous) throw unordered(index, (p - at) / 2);
|
|
224
|
+
}
|
|
225
|
+
function unordered(index, element) {
|
|
226
|
+
return new IntegrityError(
|
|
227
|
+
`portable roaring: container ${index} (array) is not strictly ascending at or after element ${element}`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
function checkRuns(view, at, runs, index) {
|
|
231
|
+
let covered = 0;
|
|
232
|
+
let lowest = 0;
|
|
233
|
+
for (let r = 0; r < runs; r++) {
|
|
234
|
+
const run = view.getUint32(at + r * 4, true);
|
|
235
|
+
const start = run & 65535;
|
|
236
|
+
const last = start + (run >>> 16);
|
|
237
|
+
if (start < lowest) {
|
|
238
|
+
throw new IntegrityError(
|
|
239
|
+
`portable roaring: container ${index} (run) has run ${r} out of order, overlapping or touching the one before`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (last > MAX_LOW) {
|
|
243
|
+
throw new IntegrityError(
|
|
244
|
+
`portable roaring: container ${index} (run) has run ${r} running past the end of the container`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
covered += last - start + 1;
|
|
248
|
+
lowest = last + 2;
|
|
249
|
+
}
|
|
250
|
+
return covered;
|
|
251
|
+
}
|
|
252
|
+
function popcount(view, at) {
|
|
253
|
+
let bits = 0;
|
|
254
|
+
for (let p = at, end = at + BITMAP_CONTAINER_BYTES; p < end; p += 4) {
|
|
255
|
+
let w = view.getUint32(p, true);
|
|
256
|
+
w -= w >>> 1 & 1431655765;
|
|
257
|
+
w = (w & 858993459) + (w >>> 2 & 858993459);
|
|
258
|
+
bits += Math.imul(w + (w >>> 4) & 252645135, 16843009) >>> 24;
|
|
259
|
+
}
|
|
260
|
+
return bits;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/roaring-codec.ts
|
|
85
264
|
var { RoaringBitmap32, SerializationFormat, DeserializationFormat } = roaring;
|
|
86
265
|
var SafeBitmap = class _SafeBitmap {
|
|
87
266
|
bitmap;
|
|
@@ -95,18 +274,24 @@ var SafeBitmap = class _SafeBitmap {
|
|
|
95
274
|
return new _SafeBitmap(new RoaringBitmap32(values));
|
|
96
275
|
}
|
|
97
276
|
/**
|
|
98
|
-
*
|
|
99
|
-
* Throws `IntegrityError` if the input exceeds `maxBytes
|
|
100
|
-
* native addon is never handed unbounded or unsafe-format input.
|
|
277
|
+
* Cap the size, check the structure, then deserialize with the **portable** format.
|
|
278
|
+
* Throws `IntegrityError` if the input exceeds `maxBytes`, is not a well-formed portable bitmap, or fails to
|
|
279
|
+
* decode — the native addon is never handed unbounded, malformed or unsafe-format input.
|
|
280
|
+
*
|
|
281
|
+
* The structural check is what makes the decoded bitmap's answers mean anything. The native deserializer
|
|
282
|
+
* bounds its reads and checks nothing else, so bytes of the right length and the wrong shape (containers or
|
|
283
|
+
* values out of order, overlapping runs, a cardinality that disagrees with the bits) decode into a bitmap
|
|
284
|
+
* whose `maximum()`, `has()` and `size` are wrong, and some of those shapes crash the process when used.
|
|
101
285
|
*/
|
|
102
286
|
static safeDeserialize(bytes, maxBytes) {
|
|
103
287
|
if (bytes.length > maxBytes) {
|
|
104
|
-
throw new
|
|
288
|
+
throw new IntegrityError2(`serialized bitmap is ${bytes.length}B, exceeds cap ${maxBytes}B`);
|
|
105
289
|
}
|
|
290
|
+
checkPortableLayout(bytes);
|
|
106
291
|
try {
|
|
107
292
|
return new _SafeBitmap(RoaringBitmap32.deserialize(bytes, DeserializationFormat.portable));
|
|
108
293
|
} catch (err) {
|
|
109
|
-
throw new
|
|
294
|
+
throw new IntegrityError2(`failed to deserialize bitmap: ${err.message}`);
|
|
110
295
|
}
|
|
111
296
|
}
|
|
112
297
|
serialize() {
|
|
@@ -134,8 +319,10 @@ var SafeBitmap = class _SafeBitmap {
|
|
|
134
319
|
this.bitmap.runOptimize();
|
|
135
320
|
}
|
|
136
321
|
/**
|
|
137
|
-
* Largest value, or `undefined` when empty. O(1) — roaring reads it off the container
|
|
138
|
-
* per-chunk range assertion costs one call per chunk rather than a walk per id.
|
|
322
|
+
* Largest value, or `undefined` when empty. O(1) — roaring reads it off the last container, so the engine's
|
|
323
|
+
* per-chunk range assertion costs one call per chunk rather than a walk per id. The last container holds the
|
|
324
|
+
* largest value only because {@link SafeBitmap.safeDeserialize} refuses bytes whose containers or values are
|
|
325
|
+
* out of order.
|
|
139
326
|
*/
|
|
140
327
|
maximum() {
|
|
141
328
|
return this.bitmap.isEmpty ? void 0 : this.bitmap.maximum();
|
|
@@ -210,7 +397,7 @@ var SystemClock = class {
|
|
|
210
397
|
* A long CPU-bound loop in `core/` (bulk-load is the one that matters) periodically hands the loop back so a
|
|
211
398
|
* co-resident HTTP server keeps answering. Measured on the **synthetic yield-primitive benchmark** — a
|
|
212
399
|
* 61,035-iteration loop yielding every 1,024, isolating the primitive rather than timing a real load (the
|
|
213
|
-
* end-to-end figures are
|
|
400
|
+
* end-to-end figures are 519 ms → 24 ms; see `cooperative.ts`, which lists all three experiments):
|
|
214
401
|
*
|
|
215
402
|
* ```text
|
|
216
403
|
* no yield 568 ms wall 568.0 ms worst event-loop gap
|
|
@@ -234,66 +421,54 @@ var SystemClock = class {
|
|
|
234
421
|
}
|
|
235
422
|
};
|
|
236
423
|
|
|
237
|
-
// src/
|
|
238
|
-
var
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
{ ...deps, codec: deps.codec ?? roaringCodec, clock: deps.clock ?? new SystemClock() },
|
|
247
|
-
options
|
|
248
|
-
);
|
|
249
|
-
var runExport = (reader, registry, sink, options = {}) => coreRunExport(reader, registry, sink, { ...options, codec: options.codec ?? roaringCodec });
|
|
250
|
-
var loadSegment = (ref, ids, deps, options = {}) => coreLoadSegment(
|
|
251
|
-
ref,
|
|
252
|
-
ids,
|
|
253
|
-
{ ...deps, codec: deps.codec ?? roaringCodec, clock: deps.clock ?? new SystemClock() },
|
|
254
|
-
options
|
|
255
|
-
);
|
|
256
|
-
|
|
257
|
-
// src/index.ts
|
|
258
|
-
import { listGenerations, rollbackSegment } from "@cloudbitmaps/core";
|
|
259
|
-
import { listSegments, segmentExists } from "@cloudbitmaps/core";
|
|
260
|
-
|
|
261
|
-
// src/moved-options.ts
|
|
262
|
-
var MOVED_OPTIONS = [
|
|
263
|
-
["cacheMaxChunks", "cache.maxChunks", "group"],
|
|
264
|
-
["cacheTtlMs", "cache.ttlMs", "group"],
|
|
265
|
-
// The `cold*` spellings are what `0.9.x` ACTUALLY had. The `storage*` ones below never appeared in any
|
|
266
|
-
// release — they existed between two unreleased commits of this cycle — and for a while they were the only
|
|
267
|
-
// ones here, so the guard written to catch an upgrader missed every real upgrader and caught nobody.
|
|
268
|
-
["coldGenTtlMs", "cache.genTtlMs", "group"],
|
|
269
|
-
["coldReaderCacheMax", "cache.readerMax", "group"],
|
|
270
|
-
["coldReaderCacheMaxBytes", "cache.readerMaxBytes", "group"],
|
|
271
|
-
["storageGenTtlMs", "cache.genTtlMs", "group"],
|
|
272
|
-
["storageReaderCacheMax", "cache.readerMax", "group"],
|
|
273
|
-
["storageReaderCacheMaxBytes", "cache.readerMaxBytes", "group"],
|
|
274
|
-
["keystore", "encryption.keystore", "group"],
|
|
275
|
-
["requireEncryption", "encryption.required", "group"],
|
|
276
|
-
["onRetry", "retry.onRetry", "group"],
|
|
277
|
-
["clock", "seams.clock", "group"],
|
|
278
|
-
["rng", "seams.rng", "group"],
|
|
279
|
-
[
|
|
280
|
-
"registry",
|
|
281
|
-
"the backend passed as `storage` (S3Storage, GcsStorage, \u2026), which carries it",
|
|
282
|
-
"renamed"
|
|
283
|
-
],
|
|
284
|
-
["cold", "storage", "renamed"],
|
|
285
|
-
// The live (warm) tier and its knobs. `warm` was REQUIRED in `0.9.x`, so every upgrader passes one — and
|
|
286
|
-
// an upgrader who fixes `cold` first meets this next. None has a successor, so none is offered.
|
|
287
|
-
["warm", "the live tier is gone", "gone"],
|
|
288
|
-
["warmReadConsistency", "it tuned the live tier", "gone"],
|
|
289
|
-
["maxWarmScanBytes", "it tuned the live tier", "gone"],
|
|
290
|
-
["writeConcurrency", "it bounded the live tier\u2019s flusher", "gone"],
|
|
291
|
-
["occBackoff", "it tuned the live tier\u2019s read-modify-write", "gone"]
|
|
292
|
-
];
|
|
424
|
+
// src/option-keys.ts
|
|
425
|
+
var OPTION_KEYS = {
|
|
426
|
+
top: ["storage", "cache", "encryption", "retry", "metrics", "budget", "seams"],
|
|
427
|
+
cache: ["maxChunks", "ttlMs", "genTtlMs", "readerMax", "readerMaxBytes"],
|
|
428
|
+
encryption: ["keystore", "required"],
|
|
429
|
+
retry: ["maxAttempts", "baseDelayMs", "maxDelayMs", "backoffFactor", "jitter", "onRetry"],
|
|
430
|
+
budget: ["maxRequests"],
|
|
431
|
+
seams: ["clock", "rng"]
|
|
432
|
+
};
|
|
293
433
|
|
|
294
434
|
// src/index.ts
|
|
295
|
-
|
|
296
|
-
|
|
435
|
+
import {
|
|
436
|
+
MemoryStorage,
|
|
437
|
+
LocalFsStorage,
|
|
438
|
+
CrbmStorageChunkSource as CrbmStorageChunkSource2,
|
|
439
|
+
destroySegment,
|
|
440
|
+
eraseNamespace,
|
|
441
|
+
excludingReservedRows as excludingReservedRows2,
|
|
442
|
+
readRetentionPolicy,
|
|
443
|
+
MIN_EXPIRES_AT_MS as MIN_EXPIRES_AT_MS2,
|
|
444
|
+
InProcessKeystore,
|
|
445
|
+
NodeAead,
|
|
446
|
+
aadFor,
|
|
447
|
+
CloudRoaringError,
|
|
448
|
+
ValidationError as ValidationError3,
|
|
449
|
+
WriteConflictError as WriteConflictError2,
|
|
450
|
+
IntegrityError as IntegrityError3,
|
|
451
|
+
NotFoundError,
|
|
452
|
+
UnsupportedError as UnsupportedError2,
|
|
453
|
+
CapabilityError,
|
|
454
|
+
TransientError,
|
|
455
|
+
KeyUnavailableError,
|
|
456
|
+
BudgetExceededError,
|
|
457
|
+
isCloudRoaringError,
|
|
458
|
+
isWriteConflictError,
|
|
459
|
+
isTransientError,
|
|
460
|
+
isNotFoundError,
|
|
461
|
+
isIntegrityError,
|
|
462
|
+
isValidationError,
|
|
463
|
+
CrbmReader,
|
|
464
|
+
BufferReader,
|
|
465
|
+
CountingMetricsSink,
|
|
466
|
+
RecordingAuditSink,
|
|
467
|
+
DEFAULT_RETRY_POLICY as DEFAULT_RETRY_POLICY2,
|
|
468
|
+
AWS_US_EAST_1_ONDEMAND,
|
|
469
|
+
ELASTICACHE_REDIS_US_EAST_1_ONDEMAND,
|
|
470
|
+
ONE_REDIS_HA_CLUSTER
|
|
471
|
+
} from "@cloudbitmaps/core";
|
|
297
472
|
var SystemRng = class {
|
|
298
473
|
/** A float in `[0, 1)`. Jitter only — never key material, never anything a caller can observe. */
|
|
299
474
|
next() {
|
|
@@ -304,12 +479,13 @@ var DEFAULT_CACHE_MAX_CHUNKS = 1024;
|
|
|
304
479
|
var DEFAULT_ADMIN_CONCURRENCY = 8;
|
|
305
480
|
function validateConcurrency(concurrency) {
|
|
306
481
|
if (concurrency !== void 0 && (!Number.isInteger(concurrency) || concurrency < 1)) {
|
|
307
|
-
throw new
|
|
482
|
+
throw new ValidationError2(`concurrency must be a positive integer; got ${concurrency}`);
|
|
308
483
|
}
|
|
309
484
|
}
|
|
310
485
|
function requireScope(options, op) {
|
|
486
|
+
refuseReservedNamespace(options.namespace);
|
|
311
487
|
if (options.namespace === void 0 && options.allNamespaces !== true) {
|
|
312
|
-
throw new
|
|
488
|
+
throw new ValidationError2(
|
|
313
489
|
`${op} scans the global id space across all namespaces \u2014 pass an explicit \`namespace\`, or \`{ allNamespaces: true }\` to intentionally sweep the whole fleet`
|
|
314
490
|
);
|
|
315
491
|
}
|
|
@@ -317,7 +493,7 @@ function requireScope(options, op) {
|
|
|
317
493
|
function resolveStorageSource(options, clock) {
|
|
318
494
|
const storage = options.storage;
|
|
319
495
|
if (storage === null || typeof storage !== "object") {
|
|
320
|
-
throw new
|
|
496
|
+
throw new ValidationError2(
|
|
321
497
|
"`storage` must be a StorageBackend, an IStorageDriver, or a StorageChunkSource"
|
|
322
498
|
);
|
|
323
499
|
}
|
|
@@ -327,15 +503,15 @@ function resolveStorageSource(options, clock) {
|
|
|
327
503
|
const hasPutImmutable = typeof storage.putImmutable === "function";
|
|
328
504
|
const isBackend = isStorageBackend(storage);
|
|
329
505
|
if (isBackend && (hasGetChunk || hasPutImmutable)) {
|
|
330
|
-
throw new
|
|
506
|
+
throw new ValidationError2(
|
|
331
507
|
`\`storage\` is a StorageBackend that also exposes ${hasGetChunk ? "`getChunk`" : "`putImmutable`"} \u2014 ambiguous. A backend must not also be a driver or a source; pass whichever one you mean.`
|
|
332
508
|
);
|
|
333
509
|
}
|
|
334
510
|
const hasStorageHalf = isObject(asBackend.storage);
|
|
335
511
|
const hasRegistryHalf = isObject(asBackend.registry);
|
|
336
512
|
if (!isBackend && hasPutImmutable && hasRegistryHalf) {
|
|
337
|
-
throw new
|
|
338
|
-
"`storage` looks like a driver that also carries a `registry`. Passed as a bare driver it would have no pointer at all \u2014 generations would resolve by list-scan, so reads could serve a generation that was written but never published. If you meant a backend,
|
|
513
|
+
throw new ValidationError2(
|
|
514
|
+
"`storage` looks like a driver that also carries a `registry`. Passed as a bare driver it would have no pointer at all \u2014 generations would resolve by list-scan, so reads could serve a generation that was written but never published. If you meant a backend, use a backend class \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage \u2014 or, to pair a driver of your own with a registry, brand the pair with `brandAsBackend` from `@cloudbitmaps/core/driver-kit`."
|
|
339
515
|
);
|
|
340
516
|
}
|
|
341
517
|
if (!isBackend && !hasGetChunk && !hasPutImmutable && hasStorageHalf && hasRegistryHalf) {
|
|
@@ -343,12 +519,12 @@ function resolveStorageSource(options, clock) {
|
|
|
343
519
|
const registryOk = typeof asBackend.registry.compareAndSwap === "function";
|
|
344
520
|
if (!storageOk || !registryOk) {
|
|
345
521
|
const bad = !storageOk ? "`storage` half is not an IStorageDriver (no `putImmutable`)" : "`registry` half is not an IRegistryDriver (no `compareAndSwap`)";
|
|
346
|
-
throw new
|
|
347
|
-
"`storage` looks like a backend but its " + bad + " \u2014
|
|
522
|
+
throw new ValidationError2(
|
|
523
|
+
"`storage` looks like a backend but its " + bad + " \u2014 use a backend class, or brand a pair of drivers with `brandAsBackend` from `@cloudbitmaps/core/driver-kit`."
|
|
348
524
|
);
|
|
349
525
|
}
|
|
350
|
-
throw new
|
|
351
|
-
"`storage` must be a backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. An object with `.storage` and `.registry` is not one: a backend builds both halves from a single bucket and prefix, so they cannot disagree, and hand-assembling them re-opens exactly that mismatch \u2014 a store whose pointer and generations live in different places reads as empty rather than failing.
|
|
526
|
+
throw new ValidationError2(
|
|
527
|
+
"`storage` must be a backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. An object with `.storage` and `.registry` is not one: a backend builds both halves from a single bucket and prefix, so they cannot disagree, and hand-assembling them re-opens exactly that mismatch \u2014 a store whose pointer and generations live in different places reads as empty rather than failing. A driver author who genuinely wants halves of their own \u2014 an instrumented driver, a registry in a database you already run \u2014 brands the pair with `brandAsBackend` from `@cloudbitmaps/core/driver-kit`, which is taking on that they agree."
|
|
352
528
|
);
|
|
353
529
|
}
|
|
354
530
|
if (isBackend) {
|
|
@@ -368,18 +544,18 @@ function resolveStorageSource(options, clock) {
|
|
|
368
544
|
};
|
|
369
545
|
}
|
|
370
546
|
if (hasGetChunk && hasPutImmutable) {
|
|
371
|
-
throw new
|
|
547
|
+
throw new ValidationError2(
|
|
372
548
|
"`storage` exposes both `getChunk` and `putImmutable` \u2014 ambiguous; pass a StorageBackend, an IStorageDriver, or a StorageChunkSource, not a hybrid"
|
|
373
549
|
);
|
|
374
550
|
}
|
|
375
551
|
if (!hasGetChunk && !hasPutImmutable) {
|
|
376
|
-
throw new
|
|
552
|
+
throw new ValidationError2(
|
|
377
553
|
"`storage` must be a StorageBackend, an IStorageDriver, or a StorageChunkSource"
|
|
378
554
|
);
|
|
379
555
|
}
|
|
380
556
|
if (hasGetChunk) {
|
|
381
557
|
if (options.encryption?.keystore !== void 0 || options.encryption?.required === true) {
|
|
382
|
-
throw new
|
|
558
|
+
throw new ValidationError2(
|
|
383
559
|
"keystore/requireEncryption apply only when the store builds its own read path; configure them on the StorageChunkSource you passed instead"
|
|
384
560
|
);
|
|
385
561
|
}
|
|
@@ -399,9 +575,20 @@ function resolveStorageSource(options, clock) {
|
|
|
399
575
|
registry: void 0
|
|
400
576
|
};
|
|
401
577
|
}
|
|
578
|
+
var NOT_A_GROUP = {
|
|
579
|
+
Null: "null",
|
|
580
|
+
Array: "an array",
|
|
581
|
+
Map: "a Map",
|
|
582
|
+
Set: "a Set",
|
|
583
|
+
Boolean: "a boxed boolean",
|
|
584
|
+
Number: "a boxed number",
|
|
585
|
+
String: "a boxed string"
|
|
586
|
+
};
|
|
402
587
|
var CloudRoaring = class _CloudRoaring {
|
|
403
588
|
engine;
|
|
404
589
|
cache;
|
|
590
|
+
/** The store's retry options, for a pinned engine to read through as the store's own does; undefined when off. */
|
|
591
|
+
retryOptions;
|
|
405
592
|
crbmSource;
|
|
406
593
|
clock;
|
|
407
594
|
metrics;
|
|
@@ -415,39 +602,49 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
415
602
|
/** Resolved store-level per-op budget (null = disabled); the admin scans use it, with a per-op override. */
|
|
416
603
|
budget;
|
|
417
604
|
/**
|
|
418
|
-
* Refuse
|
|
605
|
+
* Refuse any option key the store does not take, at the top level or inside a group, naming each one.
|
|
419
606
|
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
607
|
+
* An option the store ignored would do nothing and look as if it had — a typo'd `cache.maxChunk`, a key from
|
|
608
|
+
* another library's config spread into this one — so every key is checked against {@link OPTION_KEYS}.
|
|
422
609
|
*/
|
|
423
|
-
static
|
|
610
|
+
static rejectUnknownOptions(options) {
|
|
424
611
|
if (options === null || options === void 0 || typeof options !== "object") {
|
|
425
|
-
throw new
|
|
612
|
+
throw new ValidationError2(
|
|
426
613
|
"CloudRoaring needs an options object with a `storage` key \u2014 got " + (options === null ? "null" : typeof options)
|
|
427
614
|
);
|
|
428
615
|
}
|
|
429
616
|
const bag = options;
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
617
|
+
const unknown = [];
|
|
618
|
+
for (const key of Object.keys(bag)) {
|
|
619
|
+
if (!OPTION_KEYS.top.includes(key)) unknown.push({ group: null, key });
|
|
620
|
+
}
|
|
621
|
+
const groups = Object.keys(OPTION_KEYS).filter((g) => g !== "top");
|
|
622
|
+
for (const group of groups) {
|
|
623
|
+
const value = bag[group];
|
|
624
|
+
if (value === void 0) continue;
|
|
625
|
+
const offOk = group === "retry" || group === "budget";
|
|
626
|
+
if (value === false && offOk) continue;
|
|
627
|
+
const tag = Object.prototype.toString.call(value).slice(8, -1);
|
|
628
|
+
if (typeof value !== "object" || value === null || NOT_A_GROUP[tag] !== void 0) {
|
|
629
|
+
const got = typeof value !== "object" ? typeof value : NOT_A_GROUP[tag];
|
|
630
|
+
throw new ValidationError2(
|
|
631
|
+
`CloudRoaring's \`${group}\` must be an object${offOk ? " or `false`" : ""} \u2014 got ${got}`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
const known = OPTION_KEYS[group];
|
|
635
|
+
for (const key of Object.keys(value)) if (!known.includes(key)) unknown.push({ group, key });
|
|
636
|
+
}
|
|
637
|
+
if (unknown.length === 0) return;
|
|
638
|
+
const takes = (keys) => keys.map((k) => `\`${k}\``).join(", ");
|
|
639
|
+
const named = [...new Set(unknown.map((u) => u.group))];
|
|
640
|
+
throw new ValidationError2(
|
|
641
|
+
`CloudRoaring does not take ${takes(unknown.map((u) => u.group === null ? u.key : `${u.group}.${u.key}`))}. ` + named.map(
|
|
642
|
+
(g) => g === null ? `The store takes ${takes(OPTION_KEYS.top)}.` : `\`${g}\` takes ${takes(OPTION_KEYS[g])}.`
|
|
643
|
+
).join(" ") + " https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/api-reference.md#build-a-store--new-cloudroaringoptions"
|
|
447
644
|
);
|
|
448
645
|
}
|
|
449
646
|
constructor(options) {
|
|
450
|
-
_CloudRoaring.
|
|
647
|
+
_CloudRoaring.rejectUnknownOptions(options);
|
|
451
648
|
const clock = options.seams?.clock ?? new SystemClock();
|
|
452
649
|
const rng = options.seams?.rng ?? new SystemRng();
|
|
453
650
|
const metrics = safeMetrics(options.metrics ?? NOOP_METRICS);
|
|
@@ -483,6 +680,7 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
483
680
|
}
|
|
484
681
|
};
|
|
485
682
|
storage = new RetryingStorageChunkSource(storage, retryOpts);
|
|
683
|
+
this.retryOptions = retryOpts;
|
|
486
684
|
}
|
|
487
685
|
this.budget = resolveBudget(options.budget, DEFAULT_BUDGET);
|
|
488
686
|
const deps = {
|
|
@@ -509,17 +707,16 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
509
707
|
* the store to have been constructed with a **backend**, which supplies both halves: an `IStorageDriver` to
|
|
510
708
|
* write generations through (a pre-built `StorageChunkSource` has none) and the registry holding the pointer
|
|
511
709
|
* every write publishes.
|
|
512
|
-
* Out-of-process callers use the free functions with explicit deps.
|
|
513
710
|
*/
|
|
514
711
|
lifecycleDeps(op) {
|
|
515
712
|
if (this.storageDriver === void 0) {
|
|
516
713
|
throw new UnsupportedError(
|
|
517
|
-
`${op} needs the store built with a storage backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. A pre-built StorageChunkSource is read-only: it has no IStorageDriver underneath to write generations through.
|
|
714
|
+
`${op} needs the store built with a storage backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. A pre-built StorageChunkSource is read-only: it has no IStorageDriver underneath to write generations through. Build the store on one of those backend classes to write.`
|
|
518
715
|
);
|
|
519
716
|
}
|
|
520
717
|
if (this.registry === void 0) {
|
|
521
718
|
throw new UnsupportedError(
|
|
522
|
-
`${op} needs a storage backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. A bare IStorageDriver has no generation pointer to publish through, and there is no
|
|
719
|
+
`${op} needs a storage backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. A bare IStorageDriver has no generation pointer to publish through, and there is no separate \`registry\` option: a backend carries it.`
|
|
523
720
|
);
|
|
524
721
|
}
|
|
525
722
|
return {
|
|
@@ -536,77 +733,86 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
536
733
|
segment(name, options) {
|
|
537
734
|
const ref = { segment: name, namespace: options?.namespace };
|
|
538
735
|
validateSegmentRef(ref);
|
|
736
|
+
refuseReservedNamespace(ref.namespace);
|
|
539
737
|
const expiresAt = options?.expiresAt;
|
|
540
738
|
if (expiresAt !== void 0) {
|
|
541
739
|
if (!Number.isFinite(expiresAt) || !Number.isInteger(expiresAt) || expiresAt < MIN_EXPIRES_AT_MS) {
|
|
542
|
-
throw new
|
|
740
|
+
throw new ValidationError2(
|
|
543
741
|
`segment: expiresAt must be an integer epoch-MILLISECONDS >= ${MIN_EXPIRES_AT_MS}; got ${expiresAt} (a value in seconds lands in 1970 and would make the segment read as permanently empty)`
|
|
544
742
|
);
|
|
545
743
|
}
|
|
546
744
|
}
|
|
547
|
-
return
|
|
548
|
-
this.engine,
|
|
745
|
+
return makeSegment({
|
|
746
|
+
engine: this.engine,
|
|
549
747
|
ref,
|
|
550
|
-
this.clock,
|
|
551
|
-
this.metrics,
|
|
552
|
-
(dest, ids, op, options2) => this.materialize(dest, ids, op, options2),
|
|
553
|
-
(r, e) => this.pinSegment(r, e),
|
|
554
|
-
(handles) => this.engineForCombine(handles),
|
|
748
|
+
clock: this.clock,
|
|
749
|
+
metrics: this.metrics,
|
|
750
|
+
materialize: (dest, ids, op, options2) => this.materialize(dest, ids, op, options2),
|
|
751
|
+
pinned: (r, e) => this.pinSegment(r, e),
|
|
752
|
+
combineEngine: (handles) => this.engineForCombine(handles),
|
|
555
753
|
expiresAt
|
|
556
|
-
);
|
|
754
|
+
});
|
|
557
755
|
}
|
|
558
756
|
/**
|
|
559
|
-
* Write `ids` as a **new generation of `dest`** and publish it
|
|
560
|
-
*
|
|
757
|
+
* Write `ids` as a **new generation of `dest`** and publish it — the shared body of the `*Into` verbs. An `*Into`
|
|
758
|
+
* materialisation is a load, and publishes the same way (hard invariant 1): every load that finds a row fences its
|
|
759
|
+
* publish on the row's token; a guarded load (the default, since the empty refusal reads the current generation)
|
|
760
|
+
* also fences on the pointer it judged (`expectFrom`), and one that found no row fences on that absence instead.
|
|
761
|
+
* Only an unguarded load (`allowEmpty: true` and no `guard.minRetained`) onto a segment with no row publishes bare
|
|
762
|
+
* forward-only. The destination's previous generation stays readable until the publish lands (readers re-resolve
|
|
561
763
|
* within `cache.genTtlMs`).
|
|
562
764
|
*
|
|
563
765
|
* This routes through `loadSegment` rather than writing the generation itself, and that is the whole point
|
|
564
766
|
* of it. A materialisation is a load whose ids happen to come from a combine instead of from upstream, so
|
|
565
767
|
* everything `load()` learned the hard way applies unchanged: the generation is written UNPUBLISHED, the
|
|
566
|
-
* guard runs while the old generation is still authoritative, the publish is fenced
|
|
567
|
-
*
|
|
568
|
-
* object is reclaimed only after re-reading the row and finding the same incarnation (hard invariant 1:
|
|
768
|
+
* guard runs while the old generation is still authoritative, the publish is fenced as above, and a refused
|
|
769
|
+
* object is reclaimed only when a re-read finds the row unchanged (the same token) or gone (hard invariant 1:
|
|
569
770
|
* deleting it after a purge-and-recreate would put a live row over a missing generation).
|
|
570
771
|
*
|
|
571
772
|
* That last check narrows the window rather than closing it: the row read and the delete are two round
|
|
572
|
-
* trips, and `IStorageDriver` has no conditional delete to make them one.
|
|
573
|
-
* the same residual
|
|
773
|
+
* trips, and `IStorageDriver` has no conditional delete to make them one. the collection a load runs carries
|
|
774
|
+
* the same residual. The failure it leaves is an orphan object, which costs storage until
|
|
574
775
|
* something collects it — deliberately the cheaper side of the trade.
|
|
575
776
|
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
777
|
+
* Written and published in one step, with no guard, an empty combine — a typo'd operand, an `exclude` that
|
|
778
|
+
* swallowed everything, an operand that had not loaded yet — would silently replace `dest` with an empty
|
|
779
|
+
* generation: the same failure `load()`'s guard exists to prevent, on the same data, reachable without passing
|
|
780
|
+
* any option at all.
|
|
580
781
|
*
|
|
581
|
-
* **A lost race
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
* carries it.
|
|
782
|
+
* **A lost race throws.** `loadSegment` reports one as `reason: 'superseded'`; the `*Into` verbs throw
|
|
783
|
+
* {@link WriteConflictError} for it, so a caller can `catch (WriteConflictError)`. That one refusal is
|
|
784
|
+
* translated into the throw, and `MaterializeResult.reason` never carries it.
|
|
585
785
|
*
|
|
586
786
|
* **A `WriteConflictError` does not by itself mean nothing was published**, and that is worth knowing
|
|
587
|
-
* before you write the retry. `'superseded'` covers
|
|
588
|
-
* the pointer moved, the row's token changed, the row was purged —
|
|
589
|
-
* "somebody beat us" the name suggests. A token can also change on a write
|
|
590
|
-
* all, such as a `setRetention` on the destination. On top of that, the collection pass that runs AFTER a
|
|
787
|
+
* before you write the retry. `'superseded'` covers five different causes — the write-once PUT collided,
|
|
788
|
+
* the pointer moved, a row appeared where the load found none, the row's token changed, the row was purged —
|
|
789
|
+
* and only the first three are the "somebody beat us" the name suggests. A token can also change on a write
|
|
790
|
+
* that is not a supersession at all, such as a `setRetention` on the destination. On top of that, the collection pass that runs AFTER a
|
|
591
791
|
* successful publish can raise the same error. So: treat it as "re-read the destination and decide",
|
|
592
792
|
* never as "the write did not happen".
|
|
593
793
|
*
|
|
594
|
-
* **And it
|
|
794
|
+
* **And by default it collects nothing**, unlike `load()`. See the `keep` default below.
|
|
595
795
|
*/
|
|
596
796
|
async materialize(dest, ids, op, options) {
|
|
597
797
|
const deps = this.lifecycleDeps(op);
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
798
|
+
let result;
|
|
799
|
+
try {
|
|
800
|
+
result = await loadSegment(dest, ids, deps, {
|
|
801
|
+
...options?.allowEmpty === void 0 ? {} : { allowEmpty: options.allowEmpty },
|
|
802
|
+
...options?.guard === void 0 ? {} : { guard: options.guard },
|
|
803
|
+
// COLLECT NOTHING by default, which `loadSegment` does not — it keeps a grace window of 1 and deletes
|
|
804
|
+
// the rest. By default a materialisation collects nothing: the guide states "**It deletes nothing**,
|
|
805
|
+
// unlike `load()`. The destination's previous generations stay in the bucket until you collect them", and
|
|
806
|
+
// the ownership table
|
|
807
|
+
// puts that call on the operator. Inheriting `load()`'s collection would silently delete the
|
|
808
|
+
// generations an operator's recovery story depends on — `rollbackSegment` refuses a collected target —
|
|
809
|
+
// as a side effect of adding a guard whose entire purpose is preventing data loss. Opt in with `keep`.
|
|
810
|
+
keep: options?.keep ?? KEEP_EVERY_GENERATION,
|
|
811
|
+
...options?.audit === void 0 ? {} : { audit: options.audit }
|
|
812
|
+
});
|
|
813
|
+
} finally {
|
|
814
|
+
this.engine.invalidate(dest);
|
|
815
|
+
}
|
|
610
816
|
const reason = result.reason;
|
|
611
817
|
if (reason === "superseded") {
|
|
612
818
|
const wrote = result.size > 0 ? `generation ${result.generation} was written and did not become current` : `nothing was written \u2014 another writer took generation ${result.generation} first`;
|
|
@@ -629,10 +835,16 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
629
835
|
* **Subject access (GDPR Art. 15 / CCPA right-to-know): which segments is this id a member of?**
|
|
630
836
|
*
|
|
631
837
|
* Enumerates the **registered** segments (via the store's own `registry`) and does a `has(id)` on each — no
|
|
632
|
-
* drivers to re-pass. Complete only over registered segments (every loaded segment has a row, so
|
|
633
|
-
*
|
|
634
|
-
* load for a rare request; this admin scan is `O(registered segments)` and touches no hot path.
|
|
635
|
-
*
|
|
838
|
+
* drivers to re-pass. Complete only over registered segments (every loaded segment has a row, so build the
|
|
839
|
+
* store on the backend the loads used). There is deliberately **no `id → segments` reverse index** — that would
|
|
840
|
+
* tax every load for a rare request; this admin scan is `O(registered segments)` and touches no hot path. Needs
|
|
841
|
+
* a storage backend (throws {@link UnsupportedError} otherwise).
|
|
842
|
+
*
|
|
843
|
+
* **Current as of the registry row, not as of the reader's cache.** Each segment's resolved generation is
|
|
844
|
+
* compared with the row the scan listed, and a segment whose cached generation differs is re-resolved before the
|
|
845
|
+
* read, so a load or an erasure from another process shows at once — whatever `cache.genTtlMs` is, `0` included.
|
|
846
|
+
* The comparison is on the generation **and** the row's token, so a segment retired, purged and loaded again from
|
|
847
|
+
* generation 0 while this store held its old generation 0 is told apart too.
|
|
636
848
|
*/
|
|
637
849
|
async subjectReport(id, options = {}) {
|
|
638
850
|
const registry = this.requireRegistry("subjectReport");
|
|
@@ -641,7 +853,7 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
641
853
|
const budget = resolvePerOpBudget(options.budget, this.budget);
|
|
642
854
|
splitId(id);
|
|
643
855
|
const recs = await collectWithinBudget(
|
|
644
|
-
// A subject cannot be in a
|
|
856
|
+
// A subject cannot be in a due-index row, and charging this request's budget for them would refuse a
|
|
645
857
|
// GDPR Art. 15 report for a reason unrelated to the subject.
|
|
646
858
|
excludingReservedRows(registry.list(options.namespace)),
|
|
647
859
|
budget,
|
|
@@ -653,6 +865,11 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
653
865
|
async (rec) => {
|
|
654
866
|
if (rec.status === "destroyed") return null;
|
|
655
867
|
const ref = { segment: rec.segment, namespace: rec.namespace };
|
|
868
|
+
if (this.crbmSource !== void 0) {
|
|
869
|
+
const held = await this.crbmSource.currentVersion(ref);
|
|
870
|
+
const listed = rec.currentGen === null ? null : `${rec.currentGen}:${String(rec.token)}`;
|
|
871
|
+
if (held !== listed) this.engine.invalidate(ref);
|
|
872
|
+
}
|
|
656
873
|
return await this.engine.has(ref, id) ? { segment: rec.segment, namespace: rec.namespace } : null;
|
|
657
874
|
}
|
|
658
875
|
);
|
|
@@ -665,44 +882,59 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
665
882
|
*
|
|
666
883
|
* For each **registered** segment the id is a member of, `eraseIdFromSegment` rewrites the current generation
|
|
667
884
|
* without the id — every chunk streamed through, one bit cleared — publishes the rewrite **fenced on the
|
|
668
|
-
* generation it was derived from**, and
|
|
669
|
-
*
|
|
885
|
+
* generation it was derived from**, and collects the generation that held the bit. A segment whose current
|
|
886
|
+
* generation does not hold the id is searched anyway, every generation in its bucket: a retained superseded one
|
|
887
|
+
* can still hold it, and so can one above the pointer after a {@link CloudRoaring.rollback}, which the rollback
|
|
888
|
+
* could make current again. Each holder is deleted — below the pointer by a `keep: 0` collection, above it one
|
|
889
|
+
* by one, re-proved against the row first — and the generations above the pointer that never held the id stay
|
|
890
|
+
* as rollback targets. An entry says `erased: true` only once the call has listed the bucket and read what is
|
|
891
|
+
* left: **no generation of the segment holds the id**. The returned per-segment record is your **erasure ledger** —
|
|
670
892
|
* persist it / route it to your audit sink as the proof of deletion (a `segment.rewrite` audit event is also
|
|
671
893
|
* emitted per rewrite when you pass `audit`).
|
|
672
894
|
*
|
|
673
895
|
* Uses the backend's **own** two halves, so the membership check and the rewrite provably run
|
|
674
896
|
* over the same generation. Requires the store built with a **backend** (throws
|
|
675
897
|
* {@link UnsupportedError} otherwise; a pre-built `StorageChunkSource` store has no `IStorageDriver` to write
|
|
676
|
-
* through
|
|
898
|
+
* through; build the store on a backend instead).
|
|
677
899
|
*
|
|
678
900
|
* **One contract remains** (an integrator obligation the library cannot check): **do not load the segment
|
|
679
901
|
* while erasing from it.** A load that lands after the rewrite carries whatever its source held, and the
|
|
680
902
|
* library cannot know that source was meant to exclude the id. Quiesce loads of the affected segments for the
|
|
681
903
|
* duration, or fix the source first and load after. A writer that lands *during* the rewrite is caught: the
|
|
682
|
-
* rewrite's publish is refused **by the fence** —
|
|
904
|
+
* rewrite's publish is refused **by the fence** — the publish lands its compare-and-swap only
|
|
683
905
|
* while the pointer is still on the generation the rewrite streamed — and the entry says `note: 'superseded'`,
|
|
684
|
-
* so re-run. Forward-only alone would NOT refuse it:
|
|
906
|
+
* so re-run. Forward-only alone would NOT refuse it: the rewrite's number goes above everything in the bucket,
|
|
685
907
|
* so the rewrite would out-rank the newer generation and then collect it.
|
|
686
908
|
*
|
|
687
909
|
* A racing **erasure** is caught before that, and reported the same way. It collects with `keep: 0`, taking
|
|
688
910
|
* every generation below its new pointer — the one this rewrite is streaming, and the object this rewrite
|
|
689
|
-
* just wrote — so the loser can find its own inputs deleted mid-flight. That surfaces as
|
|
690
|
-
* an error, read off the row: a moved pointer is `'superseded'
|
|
691
|
-
* a retention sweep
|
|
911
|
+
* just wrote — so the loser can find its own inputs deleted mid-flight. That surfaces as an outcome rather than
|
|
912
|
+
* an error, read off the row: a moved pointer is a `'superseded'` entry, which a re-run settles, and a segment
|
|
913
|
+
* that a concurrent `dropSegment` tombstoned or a retention sweep purged is left out of the ledger, as a fresh
|
|
914
|
+
* call would leave it out.
|
|
692
915
|
*
|
|
693
916
|
* **Read `note` on any `erased: false` entry — the two reasons mean different things.** `'superseded'` means
|
|
694
|
-
* another writer (a load, or
|
|
695
|
-
* the id. Re-run: it erases the id if it is still there, and lists nothing for the segment if a racing
|
|
917
|
+
* another writer (a load, another erasure, or a rollback) moved the pointer mid-call, so **this call** did not
|
|
918
|
+
* erase the id. Re-run: it erases the id if it is still there, and lists nothing for the segment if a racing
|
|
696
919
|
* erasure of the same id already removed it. Do not read `'superseded'` as "the id is still present" —
|
|
697
920
|
* read it as "not done by this call, and the re-run settles it".
|
|
698
921
|
* `` `error: …` `` is a per-segment fault (caught so one segment can't discard the whole ledger) and it can land
|
|
699
922
|
* on either side of the publish: if the rewrite had not published, the id is still there and a re-run erases
|
|
700
923
|
* it; if the publish succeeded and only the **collection** of the old generation failed, the id is already
|
|
701
|
-
* absent from every read and what remains is an object in the bucket that still contains the bit.
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
* `
|
|
924
|
+
* absent from every read and what remains is an object in the bucket that still contains the bit. The same
|
|
925
|
+
* note, after a publish, can mean a rollback moved the pointer back onto a generation that still holds the id
|
|
926
|
+
* while the collection ran — then the id is served again, and the entry says so rather than `erased: true`. A
|
|
927
|
+
* re-run searches every generation in the bucket, so it rewrites or collects what is left and usually reports
|
|
928
|
+
* `erased: true`; {@link SubjectErasureEntry.note} says what else a re-run can report. Re-running is otherwise safe
|
|
929
|
+
* and idempotent: a segment the id is no longer in is not listed. Admin-only path;
|
|
930
|
+
* `O(registered segments + superseded generations)`, no hot-path cost: a segment whose current generation lacks
|
|
931
|
+
* the id is searched generation by generation, one open for each other generation in its bucket, and with the
|
|
932
|
+
* keep-everything default of the `*Into` verbs that is one per generation the segment ever had. The per-op
|
|
933
|
+
* `budget` is charged one unit for each segment and one for each generation opened beyond the one its row names,
|
|
934
|
+
* so a fleet with long histories can exhaust it where the segment count alone would not. A segment that does is
|
|
935
|
+
* reported `erased: false` with an `error:` note, before it deletes anything (the one refusal that can come after a
|
|
936
|
+
* rewrite is the last check for a generation a concurrent writer left behind), and the
|
|
937
|
+
* rest of the scan continues: the call itself does not throw for it. Per-subject crypto-shred is infeasible (a subject's bit is
|
|
706
938
|
* co-mingled in a shared container), so this is the single-subject erasure route; whole-segment/tenant erasure
|
|
707
939
|
* is `dropSegment` / the `destroySegment`/`eraseNamespace` free functions.
|
|
708
940
|
*/
|
|
@@ -717,6 +949,11 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
717
949
|
budget,
|
|
718
950
|
"eraseSubject"
|
|
719
951
|
);
|
|
952
|
+
let opens = 0;
|
|
953
|
+
const charge = () => {
|
|
954
|
+
opens += 1;
|
|
955
|
+
checkBudget(budget, recs.length + opens, "eraseSubject");
|
|
956
|
+
};
|
|
720
957
|
const entries = await mapWithConcurrency(
|
|
721
958
|
recs,
|
|
722
959
|
options.concurrency ?? DEFAULT_ADMIN_CONCURRENCY,
|
|
@@ -724,7 +961,12 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
724
961
|
if (rec.status === "destroyed") return null;
|
|
725
962
|
const ref = { segment: rec.segment, namespace: rec.namespace };
|
|
726
963
|
try {
|
|
727
|
-
const result = await eraseIdFromSegment(
|
|
964
|
+
const result = await eraseIdFromSegment(
|
|
965
|
+
ref,
|
|
966
|
+
id,
|
|
967
|
+
budget === null ? deps : { ...deps, storage: new OpenChargingStorage(deps.storage, rec.currentGen, charge) },
|
|
968
|
+
{ audit: options.audit }
|
|
969
|
+
);
|
|
728
970
|
if (result.reason === "not-member" || result.reason === "absent") return null;
|
|
729
971
|
if (result.reason === "no-generation") return null;
|
|
730
972
|
if (result.reason === "destroyed") return null;
|
|
@@ -759,7 +1001,7 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
759
1001
|
*
|
|
760
1002
|
* What the bucket holds, not what the segment has ever been — collection deletes superseded objects, so this is
|
|
761
1003
|
* the grace window plus whatever has not been collected yet. It is the set {@link CloudRoaring.rollback} can
|
|
762
|
-
* choose from, which is the reason to look at it. One
|
|
1004
|
+
* choose from, which is the reason to look at it. One registry read and one listing; it does not open the objects.
|
|
763
1005
|
*
|
|
764
1006
|
* Needs a backend.
|
|
765
1007
|
*/
|
|
@@ -785,7 +1027,7 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
785
1027
|
* a handle carrying an expired `expiresAt` reads empty by a rule that lives on the handle, not the row.
|
|
786
1028
|
*
|
|
787
1029
|
* Not a lock: the answer can change the moment it returns. If it has to hold, use the fence built for that —
|
|
788
|
-
* `load`'s `guard`, or `expectFrom`/`expectToken` on a publish. Needs a
|
|
1030
|
+
* `load`'s `guard`, or `expectFrom`/`expectToken` on a publish. Needs a storage backend.
|
|
789
1031
|
*
|
|
790
1032
|
* ```ts
|
|
791
1033
|
* if (!(await store.exists({ segment: 'users' }))) {
|
|
@@ -810,13 +1052,13 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
810
1052
|
* Scoping to a namespace narrows the LIST prefix, so it really is the difference between reading one tenant
|
|
811
1053
|
* and reading all of them.
|
|
812
1054
|
*
|
|
813
|
-
* It streams, and stopping the iteration stops the scan
|
|
814
|
-
*
|
|
815
|
-
*
|
|
1055
|
+
* It streams, and stopping the iteration stops the scan. It reads the registry directly and is not retried: a
|
|
1056
|
+
* transient fault part-way through ends the loop with that error, and calling `segments()` again scans from the
|
|
1057
|
+
* start.
|
|
816
1058
|
*
|
|
817
1059
|
* Yields `destroyed` tombstones and rows with `currentGen: null`, because a filtered enumeration that looks
|
|
818
1060
|
* complete is worse than an honest one — filter on `status`/`currentGen` yourself, or ask
|
|
819
|
-
* {@link CloudRoaring.exists} the narrower question. Needs a
|
|
1061
|
+
* {@link CloudRoaring.exists} the narrower question. Needs a storage backend.
|
|
820
1062
|
*
|
|
821
1063
|
* ```ts
|
|
822
1064
|
* for await (const s of store.segments({ namespace: 'active-daily' })) {
|
|
@@ -827,6 +1069,7 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
827
1069
|
segments(options = {}) {
|
|
828
1070
|
if (options.namespace !== void 0) {
|
|
829
1071
|
validateSegmentRef({ segment: "x", namespace: options.namespace });
|
|
1072
|
+
refuseReservedNamespace(options.namespace);
|
|
830
1073
|
}
|
|
831
1074
|
return listSegments(this.requireRegistry("segments"), options);
|
|
832
1075
|
}
|
|
@@ -847,9 +1090,16 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
847
1090
|
* current is a no-op that reports itself.
|
|
848
1091
|
*
|
|
849
1092
|
* It deletes nothing. The generations above the new pointer stay put — which is what makes this reversible —
|
|
850
|
-
* and are then *above* `currentGen`, where collection never looks
|
|
851
|
-
*
|
|
852
|
-
*
|
|
1093
|
+
* and are then *above* `currentGen`, where collection never looks. They remain until a load numbers above them
|
|
1094
|
+
* (collection then keeps the newest `keep` of what is below its pointer), {@link CloudRoaring.dropSegment}
|
|
1095
|
+
* deletes them, or {@link CloudRoaring.eraseSubject} does: all of those present when it rewrites, and only those holding
|
|
1096
|
+
* the id when the current generation does not. An operator who has just undone a bad load should not have the
|
|
1097
|
+
* evidence collected out from under them, while a rollback target that still holds erased data would make the
|
|
1098
|
+
* erasure undoable.
|
|
1099
|
+
*
|
|
1100
|
+
* A target **above** the pointer needs `{ allowForward: true }`, and is refused with {@link ValidationError}
|
|
1101
|
+
* without it: that is also where objects live that were never published, such as a load that wrote its object
|
|
1102
|
+
* and died before the publish. Undoing an earlier rollback is what the opt-in is for.
|
|
853
1103
|
*
|
|
854
1104
|
* Needs a backend.
|
|
855
1105
|
*/
|
|
@@ -871,8 +1121,8 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
871
1121
|
* attention to and everybody pays for.
|
|
872
1122
|
*
|
|
873
1123
|
* ```ts
|
|
874
|
-
* const r = await store.load('audience:active', idsFromWarehouse, {
|
|
875
|
-
* guard: {
|
|
1124
|
+
* const r = await store.load({ segment: 'audience:active' }, idsFromWarehouse, {
|
|
1125
|
+
* guard: { minRetained: 0.5 }, // refuse a load that would drop more than half the segment
|
|
876
1126
|
* });
|
|
877
1127
|
* if (!r.published) console.warn(`load refused: ${r.reason}`);
|
|
878
1128
|
* ```
|
|
@@ -890,15 +1140,22 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
890
1140
|
* `eraseIdFromSegment` instead, because three of its four refusals are expected guard outcomes rather than
|
|
891
1141
|
* faults.
|
|
892
1142
|
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
* incarnation's live object, so it leaves the orphan rather than risk deleting live data.
|
|
1143
|
+
* `'superseded'` means another writer got there first: another load took the same generation number, so this
|
|
1144
|
+
* one wrote nothing (`size: 0`), or the segment's registry row changed while the load was writing — another load
|
|
1145
|
+
* published, a retention change, a rollback or an erasure wrote the row, or the row was deleted.
|
|
897
1146
|
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
901
|
-
*
|
|
1147
|
+
* A refused load deletes the object it wrote before returning — it sits above `currentGen`, where generation
|
|
1148
|
+
* collection deliberately never looks — but only while the segment's registry row is unchanged or gone. Once
|
|
1149
|
+
* another write has changed the row, the generation number it holds may name another incarnation's live object,
|
|
1150
|
+
* so it leaves the orphan rather than risk deleting live data. The orphan is an ordinary generation once a later
|
|
1151
|
+
* one is current above it, and collection counts it within `keep`.
|
|
1152
|
+
*
|
|
1153
|
+
* What it **throws** for is a fault rather than an outcome: invalid options or ids, and a crypto-shredded
|
|
1154
|
+
* segment (`ValidationError`); a key the keystore cannot provide (`KeyUnavailableError`); a current generation
|
|
1155
|
+
* that will not open when a guard reads its size (`IntegrityError`); a driver failure; and a collection pass
|
|
1156
|
+
* that could not prove the segment was still the same one (`WriteConflictError`). The last can be raised
|
|
1157
|
+
* **after** the publish already landed, so a throw does not by itself mean the load did not take effect —
|
|
1158
|
+
* re-read the pointer rather than assuming.
|
|
902
1159
|
*
|
|
903
1160
|
* Needs a backend (throws {@link UnsupportedError} otherwise).
|
|
904
1161
|
*/
|
|
@@ -941,10 +1198,14 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
941
1198
|
* could not remove rather than returning a result that looks like a clean drop.
|
|
942
1199
|
*
|
|
943
1200
|
* Reads become empty within `cache.genTtlMs` (default 2 s), not instantly: a store that had already read this
|
|
944
|
-
* segment may answer from its cached generation + cached chunks until that window lapses
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
1201
|
+
* segment may answer from its cached generation + cached chunks until that window lapses, or, while the registry
|
|
1202
|
+
* cannot be read because of a transient fault, until a retry 500 ms apart reaches it again. A reader that never
|
|
1203
|
+
* touched it sees empty at once. **That bound needs a registry and `cache.genTtlMs > 0`.** A store with no registry
|
|
1204
|
+
* (a bare `IStorageDriver`), with `cache.genTtlMs: 0`, or on a storage source built with no clock, has no timed
|
|
1205
|
+
* refresh. It notices the drop only when a read has to fetch
|
|
1206
|
+
* from a deleted generation or its reader cache evicts the segment, and until then a chunk it has cached
|
|
1207
|
+
* answers without reaching storage, so it can keep answering `true` for a dropped segment indefinitely. Tell
|
|
1208
|
+
* it with {@link invalidate}, or restart it.
|
|
948
1209
|
*
|
|
949
1210
|
* Needs the store built with a **backend** (throws {@link UnsupportedError} otherwise),
|
|
950
1211
|
* because it has to enumerate and delete generations — a pre-built `StorageChunkSource` only reads.
|
|
@@ -981,8 +1242,8 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
981
1242
|
*
|
|
982
1243
|
* A value in the past is legal and means "eligible on the next sweep" — backfilling a policy onto existing
|
|
983
1244
|
* buckets is normal. A value below `MIN_EXPIRES_AT_MS` (2001-09-09) is rejected: it is almost certainly epoch
|
|
984
|
-
* **seconds**, which would read as long-expired and retire the segment on the next pass. Needs a
|
|
985
|
-
*
|
|
1245
|
+
* **seconds**, which would read as long-expired and retire the segment on the next pass. Needs a storage
|
|
1246
|
+
* backend (throws {@link UnsupportedError} otherwise), and refuses a crypto-shredded segment.
|
|
986
1247
|
*/
|
|
987
1248
|
async setRetention(ref, policy) {
|
|
988
1249
|
validateSegmentRef(ref);
|
|
@@ -1021,7 +1282,7 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
1021
1282
|
* ```ts
|
|
1022
1283
|
* // In your scheduled handler. Start with a preview in a new deployment.
|
|
1023
1284
|
* const preview = await store.retireExpired({ namespace: 'active-daily', dryRun: true });
|
|
1024
|
-
* console.log(`would retire ${preview.
|
|
1285
|
+
* console.log(`would retire ${preview.wouldRetire} of ${preview.scanned} (limited: ${preview.limited})`);
|
|
1025
1286
|
*
|
|
1026
1287
|
* const swept = await store.retireExpired({ namespace: 'active-daily' });
|
|
1027
1288
|
* for (const e of swept.entries) {
|
|
@@ -1047,8 +1308,9 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
1047
1308
|
* first, since nothing else ever would for a tombstoned segment. Attribution is a **positive marker the sweep
|
|
1048
1309
|
* stamps on its own retirements**, not an inference from "destroyed + an expired policy": a crypto-shred leaves
|
|
1049
1310
|
* `retention` untouched, so setting a policy and then honouring a right-to-erasure request mid-window produces
|
|
1050
|
-
* exactly that row, and deleting it would destroy the Art. 17 attestation and un-fence the name.
|
|
1051
|
-
*
|
|
1311
|
+
* exactly that row, and deleting it would destroy the Art. 17 attestation and un-fence the name. By default a
|
|
1312
|
+
* segment that held nothing has its row deleted in the pass that retires it, since that row would only fence the name.
|
|
1313
|
+
* Pass `purgeTombstones: false` to keep every tombstone, that row included.
|
|
1052
1314
|
*
|
|
1053
1315
|
* Needs the store built with a **backend** (throws {@link UnsupportedError} otherwise),
|
|
1054
1316
|
* because retiring a segment deletes its storage objects. `now` defaults to the store's clock.
|
|
@@ -1075,37 +1337,42 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
1075
1337
|
* that advances `currentGen`**, which the snapshot TTL notices and the generation-keyed cache misses on.
|
|
1076
1338
|
* Neither notices an event that *destroys* what they were derived from.
|
|
1077
1339
|
*
|
|
1078
|
-
* The
|
|
1079
|
-
* what they touch. This method is for the cases they cannot see:
|
|
1340
|
+
* The store's own writes handle themselves: `load`, `rollback`, `eraseSubject`, `dropSegment` and `retireExpired`
|
|
1341
|
+
* here, and a segment's `*Into` verbs, invalidate what they touch. This method is for the cases they cannot see:
|
|
1080
1342
|
*
|
|
1081
1343
|
* - **`destroySegment` / `eraseNamespace`**, which are free functions over raw drivers rather than methods
|
|
1082
|
-
* here, so a crypto-shred performed beside this store
|
|
1083
|
-
*
|
|
1344
|
+
* here, so a crypto-shred performed beside this store invalidates nothing in it. It keeps its open reader and
|
|
1345
|
+
* the unwrapped DEK, and decrypts with them, chunks it had never fetched before the shred included, until its
|
|
1346
|
+
* snapshot re-resolves: within `cache.genTtlMs` when the store has a registry and a `genTtlMs` above 0, and
|
|
1347
|
+
* otherwise only when its caches let the segment go or a read has to fetch from a generation a sweep collected.
|
|
1348
|
+
* A pin answers with no timed bound, until it is told or both caches let it go.
|
|
1084
1349
|
* - **Another process.** Erasing on one box invalidates nothing on the others; each store bounds its own
|
|
1085
|
-
* staleness by `cache.genTtlMs`, and a store
|
|
1086
|
-
*
|
|
1087
|
-
* this is the call to make when your own fan-out delivers.
|
|
1350
|
+
* staleness by `cache.genTtlMs`, and a store with no registry (a bare `IStorageDriver`), with
|
|
1351
|
+
* `cache.genTtlMs: 0`, or on a storage source built with no clock has no bound at all: it converges only when a read happens to miss its caches. If a compliance deadline depends on every
|
|
1352
|
+
* reader converging, you need to signal them — this is the call to make when your own fan-out delivers.
|
|
1353
|
+
*
|
|
1354
|
+
* It also forgets any replacement this store found of a pin's object, so once it is called after a restore puts that
|
|
1355
|
+
* object back, the pin reads it again.
|
|
1088
1356
|
*
|
|
1089
1357
|
* Synchronous, best-effort, and safe to call for a segment this store has never read.
|
|
1090
1358
|
*
|
|
1091
1359
|
* ```ts
|
|
1092
|
-
* await destroySegment(ref, {
|
|
1360
|
+
* await destroySegment(ref, { registry: backend.registry }, { confirmSegment: ref.segment });
|
|
1093
1361
|
* store.invalidate(ref); // this process
|
|
1094
1362
|
* await bus.publish('cloudbitmaps.invalidate', ref); // and every other one
|
|
1095
1363
|
* ```
|
|
1096
1364
|
*/
|
|
1097
1365
|
invalidate(ref) {
|
|
1098
1366
|
validateSegmentRef(ref);
|
|
1367
|
+
refuseReservedNamespace(ref.namespace);
|
|
1099
1368
|
this.engine.invalidate(ref);
|
|
1100
1369
|
}
|
|
1101
1370
|
/**
|
|
1102
1371
|
* Build a handle held at the generation `ref` resolves to right now. See {@link Segment.pin}.
|
|
1103
1372
|
*
|
|
1104
|
-
* The pinned handle gets its own engine but **shares the store's chunk cache**,
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
* Sharing it on a generation-only key was how a pinned read could resurrect an id already reported
|
|
1108
|
-
* physically gone.
|
|
1373
|
+
* The pinned handle gets its own engine but **shares the store's chunk cache**, under keys of its own: the
|
|
1374
|
+
* pinned view reports the version captured at pin time, marked as a pin's, so its decoded chunks are never
|
|
1375
|
+
* those of a live read that fetched across a publish (see {@link PinnedStorageChunkSource.currentVersion}).
|
|
1109
1376
|
*/
|
|
1110
1377
|
async pinSegment(ref, expiresAt) {
|
|
1111
1378
|
const crbm = this.crbmSource;
|
|
@@ -1114,27 +1381,30 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
1114
1381
|
"pin() needs the `.crbm` storage source \u2014 pass a backend or a raw IStorageDriver as `storage` (a pre-built StorageChunkSource that cannot resolve a generation has nothing to pin)"
|
|
1115
1382
|
);
|
|
1116
1383
|
}
|
|
1117
|
-
const at = await crbm.pinGeneration(ref);
|
|
1118
|
-
const pinnedAt = {
|
|
1384
|
+
const at = await this.withRetries(() => crbm.pinGeneration(ref));
|
|
1385
|
+
const pinnedAt = {
|
|
1386
|
+
generation: at?.generation ?? null,
|
|
1387
|
+
version: at?.version ?? null,
|
|
1388
|
+
fingerprint: at?.fingerprint ?? null
|
|
1389
|
+
};
|
|
1119
1390
|
const pins = /* @__PURE__ */ new Map([[segmentKey(ref), pinnedAt]]);
|
|
1120
|
-
return
|
|
1121
|
-
this.engineWithPins(pins),
|
|
1391
|
+
return makeSegment({
|
|
1392
|
+
engine: this.engineWithPins(pins),
|
|
1122
1393
|
ref,
|
|
1123
|
-
this.clock,
|
|
1124
|
-
this.metrics,
|
|
1125
|
-
(dest, ids, op, options) => this.materialize(dest, ids, op, options),
|
|
1126
|
-
(r, e) => this.pinSegment(r, e),
|
|
1127
|
-
(handles) => this.engineForCombine(handles),
|
|
1394
|
+
clock: this.clock,
|
|
1395
|
+
metrics: this.metrics,
|
|
1396
|
+
materialize: (dest, ids, op, options) => this.materialize(dest, ids, op, options),
|
|
1397
|
+
pinned: (r, e) => this.pinSegment(r, e),
|
|
1398
|
+
combineEngine: (handles) => this.engineForCombine(handles),
|
|
1128
1399
|
expiresAt,
|
|
1129
1400
|
pinnedAt
|
|
1130
|
-
);
|
|
1401
|
+
});
|
|
1131
1402
|
}
|
|
1132
1403
|
/**
|
|
1133
|
-
* An engine reading every segment in `pins` at its pinned generation and everything else live
|
|
1404
|
+
* An engine reading every segment in `pins` at its pinned generation and everything else live, with the store's
|
|
1405
|
+
* retries, so a transient fault on a pinned read is retried as it would be on a live one.
|
|
1134
1406
|
*
|
|
1135
|
-
* It **shares the store's chunk cache
|
|
1136
|
-
* version rather than the generation number: a pinned view reports the version captured at pin time, so its
|
|
1137
|
-
* decoded chunks cannot collide with the live generation's.
|
|
1407
|
+
* It **shares the store's chunk cache and its memory bound**, under keys of its own for each pinned segment.
|
|
1138
1408
|
*/
|
|
1139
1409
|
engineWithPins(pins) {
|
|
1140
1410
|
const crbm = this.crbmSource;
|
|
@@ -1143,8 +1413,9 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
1143
1413
|
"pin() needs the `.crbm` storage source \u2014 pass a backend or a raw IStorageDriver as `storage` (a pre-built StorageChunkSource that cannot resolve a generation has nothing to pin)"
|
|
1144
1414
|
);
|
|
1145
1415
|
}
|
|
1416
|
+
const pinned = new PinnedStorageChunkSource(crbm, pins);
|
|
1146
1417
|
return new SegmentEngine({
|
|
1147
|
-
storage: new
|
|
1418
|
+
storage: this.retryOptions === void 0 ? pinned : new RetryingStorageChunkSource(pinned, this.retryOptions),
|
|
1148
1419
|
cache: this.cache,
|
|
1149
1420
|
codec: roaringCodec,
|
|
1150
1421
|
clock: this.clock,
|
|
@@ -1160,20 +1431,42 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
1160
1431
|
* `other.intersect([snap])` silently does not, and the two are the same question. So the combine collects
|
|
1161
1432
|
* every pin in play and runs on an engine that honours all of them. With no pins anywhere this is the
|
|
1162
1433
|
* store's own engine and costs nothing.
|
|
1434
|
+
*
|
|
1435
|
+
* One engine reads a segment at one generation, so a combine that holds the same segment at two — two pins of
|
|
1436
|
+
* it, or a pin and a live handle — is refused. Keeping one of them would answer the question for the wrong
|
|
1437
|
+
* instant, and say nothing: diffing two snapshots of a segment returned no ids at all.
|
|
1163
1438
|
*/
|
|
1164
1439
|
engineForCombine(handles) {
|
|
1440
|
+
if (!handles.some((h) => h.pinnedAt !== void 0)) return void 0;
|
|
1165
1441
|
const pins = /* @__PURE__ */ new Map();
|
|
1442
|
+
const held = /* @__PURE__ */ new Map();
|
|
1166
1443
|
for (const h of handles) {
|
|
1444
|
+
const key = h.key();
|
|
1167
1445
|
const at = h.pinnedAt;
|
|
1168
|
-
if (
|
|
1446
|
+
if (held.has(key)) {
|
|
1447
|
+
const before = held.get(key);
|
|
1448
|
+
if (!samePin(before, at)) {
|
|
1449
|
+
throw new ValidationError2(
|
|
1450
|
+
`the same segment is in this combine twice, ${twoPins(before, at)}: one call reads a segment at one generation, so materialise one of them into a segment of its own first, with \`intersectInto(dest, [])\``
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
} else {
|
|
1454
|
+
held.set(key, at);
|
|
1455
|
+
}
|
|
1456
|
+
if (at !== void 0) pins.set(key, at);
|
|
1169
1457
|
}
|
|
1170
|
-
return
|
|
1458
|
+
return this.engineWithPins(pins);
|
|
1459
|
+
}
|
|
1460
|
+
/** `op` under the store's retries, as the store's own reads run; bare when the store has retries off. */
|
|
1461
|
+
withRetries(op) {
|
|
1462
|
+
const r = this.retryOptions;
|
|
1463
|
+
return r === void 0 ? op() : withRetry(op, r.policy ?? DEFAULT_RETRY_POLICY, r);
|
|
1171
1464
|
}
|
|
1172
1465
|
/** The store's registry, or a typed error naming the operation that needs one. */
|
|
1173
1466
|
requireRegistry(op) {
|
|
1174
1467
|
if (this.registry === void 0) {
|
|
1175
1468
|
throw new UnsupportedError(
|
|
1176
|
-
`${op} needs a storage backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. A bare IStorageDriver has no generation pointer to publish through, and there is no
|
|
1469
|
+
`${op} needs a storage backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. A bare IStorageDriver has no generation pointer to publish through, and there is no separate \`registry\` option: a backend carries it.`
|
|
1177
1470
|
);
|
|
1178
1471
|
}
|
|
1179
1472
|
return this.registry;
|
|
@@ -1182,11 +1475,16 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
1182
1475
|
* **Cross-tier DR consistency check.** After a restore/failover, verify every registered segment's `currentGen`
|
|
1183
1476
|
* actually has its `.crbm` present in Storage — catching a **torn restore** where the registry (`currentGen`) came
|
|
1184
1477
|
* back ahead of the object store, so a pointer references a generation that isn't there (reads would then
|
|
1185
|
-
* throw). Read-only, bounded fan-out; run it at startup after a restore. Returns `{ checked, inconsistent
|
|
1186
|
-
* `inconsistent` empty ⇒ coherent; otherwise it names the segments to recover (restore the object
|
|
1187
|
-
* roll the registry back to a coherent point). Needs the store built with a **backend**
|
|
1478
|
+
* throw). Read-only, bounded fan-out; run it at startup after a restore. Returns `{ checked, inconsistent,
|
|
1479
|
+
* errored }` — `inconsistent` empty ⇒ coherent; otherwise it names the segments to recover (restore the object
|
|
1480
|
+
* store, or roll the registry back to a coherent point). Needs the store built with a **backend**
|
|
1188
1481
|
* (throws {@link UnsupportedError} otherwise). `destroyed` (crypto-shredded) segments are skipped. Pair it with
|
|
1189
1482
|
* the DR runbook (docs/guide/disaster-recovery.md).
|
|
1483
|
+
*
|
|
1484
|
+
* It holds the registry rows it enumerates resident, at most 250,000 of them, and throws
|
|
1485
|
+
* `BudgetExceededError` past that rather than report a partial scan as a whole one. This method takes no
|
|
1486
|
+
* ceiling of its own: narrow the scan with `namespace`, or call `runConsistencyCheck` over the backend's
|
|
1487
|
+
* `storage` and `registry` with a higher `maxScanSegments`.
|
|
1190
1488
|
*/
|
|
1191
1489
|
async checkConsistency(options = {}) {
|
|
1192
1490
|
const deps = this.lifecycleDeps("checkConsistency");
|
|
@@ -1206,7 +1504,7 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
1206
1504
|
*/
|
|
1207
1505
|
async exportSegments(sink, options = {}) {
|
|
1208
1506
|
const registry = this.requireRegistry("exportSegments");
|
|
1209
|
-
return
|
|
1507
|
+
return runExport(this, registry, sink, {
|
|
1210
1508
|
...options,
|
|
1211
1509
|
codec: options.codec ?? roaringCodec
|
|
1212
1510
|
});
|
|
@@ -1219,24 +1517,34 @@ var CloudRoaring = class _CloudRoaring {
|
|
|
1219
1517
|
return estimateCost(input);
|
|
1220
1518
|
}
|
|
1221
1519
|
};
|
|
1520
|
+
var failing = (err) => ({
|
|
1521
|
+
[Symbol.asyncIterator]: () => ({ next: () => Promise.reject(err) })
|
|
1522
|
+
});
|
|
1523
|
+
var samePin = (a, b) => a === void 0 || b === void 0 ? a === b : a.generation === b.generation && a.fingerprint === b.fingerprint;
|
|
1524
|
+
var twoPins = (a, b) => {
|
|
1525
|
+
const one = (p) => p === void 0 ? "live" : `pinned at generation ${p.generation ?? "none"}`;
|
|
1526
|
+
return a !== void 0 && b !== void 0 && a.generation === b.generation ? `pinned twice at generation ${a.generation ?? "none"}, as two different objects` : `${one(a)} and ${one(b)}`;
|
|
1527
|
+
};
|
|
1528
|
+
var rangeOf = (options) => ({
|
|
1529
|
+
after: options.after,
|
|
1530
|
+
through: options.through
|
|
1531
|
+
});
|
|
1532
|
+
var readOptions = (options) => ({
|
|
1533
|
+
...rangeOf(options),
|
|
1534
|
+
concurrency: options.concurrency,
|
|
1535
|
+
budget: options.budget,
|
|
1536
|
+
allowAbsentOperands: options.allowAbsentOperands
|
|
1537
|
+
});
|
|
1538
|
+
var NO_SEGMENTS = [];
|
|
1222
1539
|
var EMPTY_IDS = {
|
|
1223
1540
|
async *[Symbol.asyncIterator]() {
|
|
1224
1541
|
}
|
|
1225
1542
|
};
|
|
1226
1543
|
var KEEP_EVERY_GENERATION = Number.MAX_SAFE_INTEGER;
|
|
1227
|
-
var
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
this.clock = clock;
|
|
1232
|
-
this.metrics = metrics;
|
|
1233
|
-
this.materialize = materialize;
|
|
1234
|
-
this.pinned = pinned;
|
|
1235
|
-
this.combineEngine = combineEngine;
|
|
1236
|
-
this.expiresAt = expiresAt;
|
|
1237
|
-
this.pinnedAt = pinnedAt;
|
|
1238
|
-
this.metricsOn = metrics !== NOOP_METRICS;
|
|
1239
|
-
}
|
|
1544
|
+
var makeSegment;
|
|
1545
|
+
var minting = false;
|
|
1546
|
+
var Segment = class _Segment {
|
|
1547
|
+
metricsOn;
|
|
1240
1548
|
engine;
|
|
1241
1549
|
ref;
|
|
1242
1550
|
clock;
|
|
@@ -1244,9 +1552,44 @@ var Segment = class {
|
|
|
1244
1552
|
materialize;
|
|
1245
1553
|
pinned;
|
|
1246
1554
|
combineEngine;
|
|
1555
|
+
/** Absolute epoch-ms deadline from {@link SegmentOptions.expiresAt}; `undefined` ⇒ this handle never expires. */
|
|
1247
1556
|
expiresAt;
|
|
1557
|
+
/**
|
|
1558
|
+
* The generation this handle is held at, when it came from {@link Segment.pin}. Read by the store so a
|
|
1559
|
+
* pinned handle passed as an **operand** is still read at its pin rather than live.
|
|
1560
|
+
*/
|
|
1248
1561
|
pinnedAt;
|
|
1249
|
-
|
|
1562
|
+
static {
|
|
1563
|
+
makeSegment = (parts) => {
|
|
1564
|
+
minting = true;
|
|
1565
|
+
try {
|
|
1566
|
+
return new _Segment(parts);
|
|
1567
|
+
} finally {
|
|
1568
|
+
minting = false;
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Not constructible: a handle comes from {@link CloudRoaring.segment}, which wires it to the store's engine,
|
|
1574
|
+
* caches and write path. The constructor takes those internals, so it is not part of the surface.
|
|
1575
|
+
*/
|
|
1576
|
+
constructor(parts) {
|
|
1577
|
+
if (!minting) {
|
|
1578
|
+
throw new ValidationError2(
|
|
1579
|
+
"a Segment is not constructed directly; call `store.segment(name)`"
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
this.engine = parts.engine;
|
|
1583
|
+
this.ref = parts.ref;
|
|
1584
|
+
this.clock = parts.clock;
|
|
1585
|
+
this.metrics = parts.metrics;
|
|
1586
|
+
this.materialize = parts.materialize;
|
|
1587
|
+
this.pinned = parts.pinned;
|
|
1588
|
+
this.combineEngine = parts.combineEngine;
|
|
1589
|
+
this.expiresAt = parts.expiresAt;
|
|
1590
|
+
this.pinnedAt = parts.pinnedAt;
|
|
1591
|
+
this.metricsOn = parts.metrics !== NOOP_METRICS;
|
|
1592
|
+
}
|
|
1250
1593
|
/**
|
|
1251
1594
|
* **Hold this segment at the generation that is current right now**, for as long as you keep the handle.
|
|
1252
1595
|
*
|
|
@@ -1265,15 +1608,54 @@ var Segment = class {
|
|
|
1265
1608
|
* `other` at whatever is current — pin each segment if you want the whole query held. And a pinned handle
|
|
1266
1609
|
* used as an *operand* is still read at its pin, never live.
|
|
1267
1610
|
*
|
|
1268
|
-
* **
|
|
1611
|
+
* **One call reads a segment at one generation**, so a combine that holds this segment at two —
|
|
1612
|
+
* `snap0.andNot([snap1])`, or `live.intersect([snap])` — throws {@link ValidationError} when it is read, and an
|
|
1613
|
+
* `*Into` of one throws before it reads anything. Materialise one side first, with `intersectInto(dest, [])`.
|
|
1614
|
+
*
|
|
1615
|
+
* **A pin knows its object, not only its number.** A name purged and loaded again starts again at generation
|
|
1616
|
+
* 0, so a pin of the old segment never reads the new one: what it has already read still answers, as the
|
|
1617
|
+
* instant it pinned, and anything it would have to fetch fails with `NotFoundError`, as a swept pin's does.
|
|
1618
|
+
*
|
|
1619
|
+
* **It is a hold, not a lease.** Nothing here stops a collection deleting the generation underneath
|
|
1269
1620
|
* you: a pinned read deliberately does **not** heal forward, because silently serving a different generation
|
|
1270
1621
|
* is the one thing a pin exists to prevent, so it fails instead. Size `keep` to cover your longest pinned
|
|
1271
|
-
* job — see [
|
|
1622
|
+
* job — see [Generations and `keep`](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/loading.md#generations-and-keep) — or take the pin on a segment
|
|
1272
1623
|
* you are not collecting.
|
|
1273
1624
|
*
|
|
1274
|
-
* A segment with no current generation pins nothing and reads empty, exactly as it would unpinned. A
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1625
|
+
* A segment with no current generation pins nothing and reads empty, exactly as it would unpinned. A pinned
|
|
1626
|
+
* segment whose row is later dropped or destroyed fails once it must open its object again, rather than go empty
|
|
1627
|
+
* part-way through a call. **A pin
|
|
1628
|
+
* keeps the key its reader unwrapped for as long as that reader stays open, and answers from the chunks it
|
|
1629
|
+
* decoded for as long as they stay cached.** This store invalidates a pin:
|
|
1630
|
+
*
|
|
1631
|
+
* - a `load`, a `rollback` or an `*Into` invalidates a pin of the segment it writes;
|
|
1632
|
+
* - `dropSegment` invalidates a pin of the segment it drops, and `retireExpired` a pin of each segment its ledger
|
|
1633
|
+
* lists, retired or not, neither on a dry run;
|
|
1634
|
+
* - `eraseSubject` invalidates a pin of each segment it scans that is not already destroyed.
|
|
1635
|
+
*
|
|
1636
|
+
* An invalidated pin opens its object again, and fails if that object is gone or replaced, or its row is gone or
|
|
1637
|
+
* destroyed.
|
|
1638
|
+
* Anything else leaves the pin as it is: after a `destroySegment` beside this store, or an erasure, a drop or a
|
|
1639
|
+
* retirement through another store, in this process or another, the pin answers from what it holds until this
|
|
1640
|
+
* store's reader cache evicts the pin's reader and its chunk cache evicts the chunks the pin decoded, or
|
|
1641
|
+
* {@link CloudRoaring.invalidate} is called on this store. Where the
|
|
1642
|
+
* object the pin reads has been deleted, by an erasure, a drop or a sweep, a chunk the pin has not cached fails at
|
|
1643
|
+
* once. No timed refresh bounds any of that, as none bounds anything else a pin holds.
|
|
1644
|
+
*
|
|
1645
|
+
* A replacement a pin has found is remembered, so its later chunk reads fail with no request, until the store
|
|
1646
|
+
* forgets it: an invalidation of the segment does, as does a later `pin()` of the same version that opens the object
|
|
1647
|
+
* again, and the store remembers at most `cache.readerMax` of them. So once a restore puts the pinned object back,
|
|
1648
|
+
* call {@link CloudRoaring.invalidate} on this store: the pin then reads the object again.
|
|
1649
|
+
*
|
|
1650
|
+
* `pin()` reads the registry row and opens the generation at once, so the pin knows its object before its first
|
|
1651
|
+
* read. With a registry, pins of one generation taken while its row is unchanged share one reader while the store
|
|
1652
|
+
* keeps it open, pins taken at the same moment included: the first costs a tail read, and a key unwrap for an
|
|
1653
|
+
* encrypted segment, whether or not it is read, and the rest cost the row read alone. Without a registry every
|
|
1654
|
+
* `pin()` lists the segment's objects and makes the tail read, since only the object can tell two incarnations of
|
|
1655
|
+
* a name apart there. It retries a
|
|
1656
|
+
* transient fault as the store's reads do, and heals a generation swept between the two, but it fails where the
|
|
1657
|
+
* generation cannot be opened: `NotFoundError` for a pointer at a missing object, `IntegrityError` for a damaged
|
|
1658
|
+
* or misfiled one.
|
|
1277
1659
|
*
|
|
1278
1660
|
* Needs a store built on the `.crbm` storage source (the default when you pass a backend or a raw driver). Throws
|
|
1279
1661
|
* {@link UnsupportedError} on a store wired with a pre-built source that cannot pin.
|
|
@@ -1281,7 +1663,12 @@ var Segment = class {
|
|
|
1281
1663
|
async pin() {
|
|
1282
1664
|
return this.pinned(this.ref, this.expiresAt);
|
|
1283
1665
|
}
|
|
1284
|
-
/**
|
|
1666
|
+
/**
|
|
1667
|
+
* An opaque string that names this handle's segment, namespace included: two handles of one segment have the
|
|
1668
|
+
* same key, a live handle and its pins among them. Use it as a `Map` key or a log field when you track handles
|
|
1669
|
+
* and have not kept the name you made them with. The format is not specified and is not a storage key, so
|
|
1670
|
+
* compare keys, never parse one.
|
|
1671
|
+
*/
|
|
1285
1672
|
key() {
|
|
1286
1673
|
return segmentKey(this.ref);
|
|
1287
1674
|
}
|
|
@@ -1300,15 +1687,15 @@ var Segment = class {
|
|
|
1300
1687
|
*
|
|
1301
1688
|
* On the read verbs an expired handle answers empty, which is the point of lazy expiry. On a *write* the same
|
|
1302
1689
|
* rule would be destructive in a way nobody asks for: `a.intersectInto(dest, [b])` where `b`'s deadline has
|
|
1303
|
-
* quietly passed
|
|
1304
|
-
* the cause (a deadline on a handle somewhere) nowhere in the result. Reads degrade to empty; writes must not.
|
|
1690
|
+
* quietly passed would, unrefused, replace `dest` with an **empty generation** — a wipe, reported as a successful
|
|
1691
|
+
* write, with the cause (a deadline on a handle somewhere) nowhere in the result. Reads degrade to empty; writes must not.
|
|
1305
1692
|
*
|
|
1306
1693
|
* So every handle in the call is checked, `dest` included: an expired `dest` does not change the bytes written,
|
|
1307
1694
|
* but a caller who put a deadline on the thing they are writing into has said something contradictory and is
|
|
1308
1695
|
* better told than guessed at. Open a handle without `expiresAt` to write, or drop the deadline.
|
|
1309
1696
|
*
|
|
1310
1697
|
* (The broader guard — refusing to publish an empty or implausible generation over a non-empty one, with an
|
|
1311
|
-
* `allowEmpty` override —
|
|
1698
|
+
* `allowEmpty` override — covers these verbs too: they route through the same guarded write path as
|
|
1312
1699
|
* {@link CloudRoaring.load}. The two stay separate because they differ in kind. That one is a REPORTED
|
|
1313
1700
|
* refusal a caller may legitimately override; an expired handle is a wiring mistake, so it THROWS, before
|
|
1314
1701
|
* any object is written — and `allowEmpty: true` does not reach it.)
|
|
@@ -1324,7 +1711,7 @@ var Segment = class {
|
|
|
1324
1711
|
stale.push(seg.ref.namespace ? `${seg.ref.namespace}/${seg.ref.segment}` : seg.ref.segment);
|
|
1325
1712
|
}
|
|
1326
1713
|
if (stale.length > 0) {
|
|
1327
|
-
throw new
|
|
1714
|
+
throw new ValidationError2(
|
|
1328
1715
|
`${op}: refusing to publish a generation while these handles have expired \u2014 ${[...new Set(stale)].join(", ")}. An expired handle reads as empty, so this would write an empty (or short) generation over "${dest.ref.segment}". Open the handles without \`expiresAt\` if you meant to materialise, or drop the deadline.`
|
|
1329
1716
|
);
|
|
1330
1717
|
}
|
|
@@ -1347,24 +1734,71 @@ var Segment = class {
|
|
|
1347
1734
|
if (this.expired()) return Promise.resolve(false);
|
|
1348
1735
|
return this.timed("has", () => this.engine.has(this.ref, id));
|
|
1349
1736
|
}
|
|
1350
|
-
/**
|
|
1737
|
+
/**
|
|
1738
|
+
* Cardinality — summed from the `.crbm` index with **zero payload reads** on a loaded segment.
|
|
1739
|
+
*
|
|
1740
|
+
* What this trusts: the answer is the sum of the per-chunk cardinalities the index records, and no payload is
|
|
1741
|
+
* decoded to confirm it. The index is checked for internal consistency when the object is opened (each key in
|
|
1742
|
+
* range and ascending, each cardinality in `1..65536`, each payload inside the payload region, and, on an
|
|
1743
|
+
* unencrypted object, the footer's chunk count and total agreeing with the index), and a corrupt index that is
|
|
1744
|
+
* still internally consistent yields a wrong count. `iterate()` and the combines decode the payloads, whose
|
|
1745
|
+
* structure is checked.
|
|
1746
|
+
*/
|
|
1351
1747
|
count() {
|
|
1352
1748
|
if (this.expired()) return Promise.resolve(0);
|
|
1353
1749
|
return this.timed("count", () => this.engine.count(this.ref));
|
|
1354
1750
|
}
|
|
1355
|
-
/**
|
|
1356
|
-
|
|
1751
|
+
/**
|
|
1752
|
+
* Every id, ascending, streamed one chunk at a time. Pass a range to read part of the segment: the ids in
|
|
1753
|
+
* `(after, through]`, fetching only the chunks the range overlaps. The per-op budget is charged for every chunk in
|
|
1754
|
+
* the range, so give a page `through` as well as `after`. On a pinned handle it reads the pinned generation, as a
|
|
1755
|
+
* full read does.
|
|
1756
|
+
*
|
|
1757
|
+
* ```ts
|
|
1758
|
+
* // Keyset paging: each page resumes where the one before ended, and stops at its window's end.
|
|
1759
|
+
* for await (const id of seg.iterate({ after: previousEnd, through: windowEnd })) await send(id);
|
|
1760
|
+
* ```
|
|
1761
|
+
*
|
|
1762
|
+
* Each bound is optional and an integer in `0..4294967295`; a bad one throws {@link ValidationError} when the
|
|
1763
|
+
* stream is first read. `after >= through` is an empty range, which reads nothing. An expired handle reads empty
|
|
1764
|
+
* without checking its options, as every read of one does.
|
|
1765
|
+
*/
|
|
1766
|
+
iterate(options) {
|
|
1357
1767
|
if (this.expired()) return EMPTY_IDS;
|
|
1358
|
-
|
|
1768
|
+
const range = options == null ? void 0 : rangeOf(options);
|
|
1769
|
+
const none = range === void 0 || range.after === void 0 && range.through === void 0;
|
|
1770
|
+
return this.engine.iterate(this.ref, none ? void 0 : range);
|
|
1359
1771
|
}
|
|
1360
1772
|
/**
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1773
|
+
* The options a combine hands the engine, read once, when it is called ({@link readOptions}), with the `exclude`
|
|
1774
|
+
* it was given, already reduced to its live handles ({@link liveExcludes}) and mapped down to the plain refs
|
|
1775
|
+
* `core` takes. A method rather than a module function because `ref` is class-private — the encapsulation is
|
|
1776
|
+
* worth more than the free function.
|
|
1777
|
+
*
|
|
1778
|
+
* Each field is named rather than copied with a rest spread: a rest copy takes only own enumerable properties, so
|
|
1779
|
+
* a bound held in a getter or inherited from a prototype was dropped, and the read silently widened to the whole
|
|
1780
|
+
* segment.
|
|
1363
1781
|
*/
|
|
1364
|
-
refsIn(options) {
|
|
1365
|
-
if (options
|
|
1366
|
-
|
|
1367
|
-
|
|
1782
|
+
refsIn(options, exclude) {
|
|
1783
|
+
if (options == null) return void 0;
|
|
1784
|
+
return {
|
|
1785
|
+
...readOptions(options),
|
|
1786
|
+
exclude: exclude.length > 0 ? exclude.map((o) => o.ref) : void 0
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* The handles in `options.exclude` that have not expired. **An expired exclusion excludes nothing**, in every
|
|
1791
|
+
* combine, so it is dropped here, before the engine is asked for anything: it is never fetched, never
|
|
1792
|
+
* checked for absence, and never counted in the pin-consistency check. `exclude` is read once, so a getter sees
|
|
1793
|
+
* one call.
|
|
1794
|
+
*
|
|
1795
|
+
* With no `exclude` this is one property read and a shared empty list, and with none expired it returns the
|
|
1796
|
+
* caller's own array, so the common case allocates nothing.
|
|
1797
|
+
*/
|
|
1798
|
+
liveExcludes(options) {
|
|
1799
|
+
const exclude = options?.exclude;
|
|
1800
|
+
if (exclude == null || exclude.length === 0) return NO_SEGMENTS;
|
|
1801
|
+
return exclude.some((e) => e.expired()) ? exclude.filter((e) => !e.expired()) : exclude;
|
|
1368
1802
|
}
|
|
1369
1803
|
/**
|
|
1370
1804
|
* Chunk-skipping intersection: stream the ids in **this** segment AND every segment in `others`, ascending.
|
|
@@ -1376,13 +1810,20 @@ var Segment = class {
|
|
|
1376
1810
|
*/
|
|
1377
1811
|
intersect(others, options) {
|
|
1378
1812
|
if (this.expired() || others.some((o) => o.expired())) return EMPTY_IDS;
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1813
|
+
const exclude = this.liveExcludes(options);
|
|
1814
|
+
let engine;
|
|
1815
|
+
try {
|
|
1816
|
+
engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
|
|
1817
|
+
} catch (err) {
|
|
1818
|
+
return failing(err);
|
|
1819
|
+
}
|
|
1820
|
+
return engine.intersect([this.ref, ...others.map((o) => o.ref)], this.refsIn(options, exclude));
|
|
1381
1821
|
}
|
|
1382
1822
|
/**
|
|
1383
1823
|
* Materialize `this ∩ others…` (minus `exclude`) as a **new generation of `dest`** — `dest`'s previous contents
|
|
1384
1824
|
* are superseded, not added to. Streaming + bounded-memory; the result is one immutable object published
|
|
1385
|
-
*
|
|
1825
|
+
* as a load is (the pointer advances to it, fenced on the row), so readers of `dest` see either the old generation
|
|
1826
|
+
* or the new one, never a partial. Needs the
|
|
1386
1827
|
* store built with a backend (throws {@link UnsupportedError} otherwise).
|
|
1387
1828
|
*
|
|
1388
1829
|
* **An empty result does NOT overwrite a non-empty destination.** A combine that comes out empty is far more
|
|
@@ -1400,6 +1841,7 @@ var Segment = class {
|
|
|
1400
1841
|
*/
|
|
1401
1842
|
async intersectInto(dest, others, options) {
|
|
1402
1843
|
this.refuseIfExpired("intersectInto", dest, [...others, ...options?.exclude ?? []]);
|
|
1844
|
+
this.combineEngine([this, ...others, ...options?.exclude ?? []]);
|
|
1403
1845
|
return this.timed(
|
|
1404
1846
|
"intersectInto",
|
|
1405
1847
|
() => this.materialize(dest.ref, this.intersect(others, options), "intersectInto", options)
|
|
@@ -1422,16 +1864,23 @@ var Segment = class {
|
|
|
1422
1864
|
return live[0].union(live.slice(1), options);
|
|
1423
1865
|
}
|
|
1424
1866
|
if (live.length === 0 && others.length > 0) {
|
|
1425
|
-
const
|
|
1426
|
-
return
|
|
1867
|
+
const exclude2 = this.liveExcludes(options);
|
|
1868
|
+
return exclude2.length > 0 ? this.andNot([...exclude2], options) : this.union([], options);
|
|
1427
1869
|
}
|
|
1428
1870
|
if (live.length !== others.length) return this.union(live, options);
|
|
1429
|
-
const
|
|
1430
|
-
|
|
1871
|
+
const exclude = this.liveExcludes(options);
|
|
1872
|
+
let engine;
|
|
1873
|
+
try {
|
|
1874
|
+
engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
return failing(err);
|
|
1877
|
+
}
|
|
1878
|
+
return engine.union([this.ref, ...others.map((o) => o.ref)], this.refsIn(options, exclude));
|
|
1431
1879
|
}
|
|
1432
1880
|
/** Materialize `this ∪ others…` (minus `exclude`) as a **new generation of `dest`** — see {@link intersectInto}. */
|
|
1433
1881
|
async unionInto(dest, others, options) {
|
|
1434
1882
|
this.refuseIfExpired("unionInto", dest, [...others, ...options?.exclude ?? []]);
|
|
1883
|
+
this.combineEngine([this, ...others, ...options?.exclude ?? []]);
|
|
1435
1884
|
return this.timed(
|
|
1436
1885
|
"unionInto",
|
|
1437
1886
|
() => this.materialize(dest.ref, this.union(others, options), "unionInto", options)
|
|
@@ -1445,24 +1894,36 @@ var Segment = class {
|
|
|
1445
1894
|
* suppression list: at most one read per surviving key of `this`, so subtracting a 61,000-chunk global
|
|
1446
1895
|
* opt-out list from a 40-chunk audience costs at most 40 reads, not 61,000.
|
|
1447
1896
|
*
|
|
1897
|
+
* An expired handle in `excludes` excludes nothing, and is skipped without being read; if every one has
|
|
1898
|
+
* expired the result is `this` whole. An expired `this` is empty. `exclude` on `intersect` and `union` follows
|
|
1899
|
+
* the same rule.
|
|
1900
|
+
*
|
|
1448
1901
|
* To filter the *result of an intersection*, do not chain — pass `exclude` to {@link intersect} instead, so
|
|
1449
1902
|
* the suppression folds into the same pass rather than materializing an intermediate segment first.
|
|
1450
1903
|
*/
|
|
1451
1904
|
andNot(excludes, options) {
|
|
1452
1905
|
if (this.expired()) return EMPTY_IDS;
|
|
1453
1906
|
const liveExcludes = excludes.filter((e) => !e.expired());
|
|
1454
|
-
if (liveExcludes.length === 0 && excludes.length > 0)
|
|
1907
|
+
if (liveExcludes.length === 0 && excludes.length > 0) {
|
|
1908
|
+
return this.union([], options == null ? void 0 : readOptions(options));
|
|
1909
|
+
}
|
|
1455
1910
|
if (liveExcludes.length !== excludes.length) return this.andNot(liveExcludes, options);
|
|
1456
|
-
|
|
1911
|
+
let engine;
|
|
1912
|
+
try {
|
|
1913
|
+
engine = this.combineEngine([this, ...excludes]) ?? this.engine;
|
|
1914
|
+
} catch (err) {
|
|
1915
|
+
return failing(err);
|
|
1916
|
+
}
|
|
1457
1917
|
return engine.andNot(
|
|
1458
1918
|
this.ref,
|
|
1459
1919
|
excludes.map((o) => o.ref),
|
|
1460
|
-
options
|
|
1920
|
+
options == null ? void 0 : readOptions(options)
|
|
1461
1921
|
);
|
|
1462
1922
|
}
|
|
1463
1923
|
/** Materialize `this \ (excludes…)` as a **new generation of `dest`** — see {@link intersectInto}. */
|
|
1464
1924
|
async andNotInto(dest, excludes, options) {
|
|
1465
1925
|
this.refuseIfExpired("andNotInto", dest, excludes);
|
|
1926
|
+
this.combineEngine([this, ...excludes]);
|
|
1466
1927
|
return this.timed(
|
|
1467
1928
|
"andNotInto",
|
|
1468
1929
|
() => this.materialize(dest.ref, this.andNot(excludes, options), "andNotInto", options)
|
|
@@ -1471,21 +1932,24 @@ var Segment = class {
|
|
|
1471
1932
|
/**
|
|
1472
1933
|
* Grounded cost report for this segment: storage cost from its **real** `.crbm` size (exact, no payload
|
|
1473
1934
|
* reads); request cost from the supplied `workload` rates. A segment with no Storage generation reports zero
|
|
1474
|
-
* storage.
|
|
1935
|
+
* storage. The pointer refresh is priced at the store's own `cache.genTtlMs`, or at none when the store never
|
|
1936
|
+
* refreshes, unless the workload sets `genTtlMs`. See {@link CostReport} — it always includes a verdict (incl.
|
|
1937
|
+
* the lose-zone).
|
|
1475
1938
|
*/
|
|
1476
1939
|
async costReport(options) {
|
|
1477
1940
|
const canMeasure = this.engine.supportsStorageSize;
|
|
1478
1941
|
const size = canMeasure ? await this.engine.segmentSize(this.ref) : null;
|
|
1942
|
+
const refreshMs = this.engine.pointerRefreshMs;
|
|
1943
|
+
const workload = refreshMs === void 0 || options?.workload?.genTtlMs !== void 0 ? options?.workload : { ...options?.workload, genTtlMs: refreshMs };
|
|
1479
1944
|
return groundedReport({
|
|
1480
1945
|
storageBytes: size?.sizeBytes ?? 0,
|
|
1481
1946
|
grounded: canMeasure,
|
|
1482
|
-
workload
|
|
1947
|
+
workload,
|
|
1483
1948
|
pricing: options?.pricing,
|
|
1484
1949
|
extraNotes: canMeasure ? void 0 : ["storage source has no sizeOf() \u2014 storage not measured, reported as $0."]
|
|
1485
1950
|
});
|
|
1486
1951
|
}
|
|
1487
1952
|
};
|
|
1488
|
-
var VERSION = "0.10.0";
|
|
1489
1953
|
|
|
1490
1954
|
// src/bin/export-segments.ts
|
|
1491
1955
|
function parseConfig(env) {
|
|
@@ -1507,9 +1971,9 @@ function parseConfig(env) {
|
|
|
1507
1971
|
function fsSink(out) {
|
|
1508
1972
|
return {
|
|
1509
1973
|
async open(ref, ext) {
|
|
1510
|
-
const dir = join(out,
|
|
1974
|
+
const dir = join(out, namespacePathPart(ref.namespace));
|
|
1511
1975
|
await mkdir(dir, { recursive: true, mode: 448 });
|
|
1512
|
-
const finalPath = join(dir, `${
|
|
1976
|
+
const finalPath = join(dir, `${encodeNameForPath(ref.segment)}${ext}`);
|
|
1513
1977
|
const tmpPath = `${finalPath}.${randomUUID()}.part`;
|
|
1514
1978
|
const handle = await open(tmpPath, "wx", 384);
|
|
1515
1979
|
return {
|
|
@@ -1545,7 +2009,7 @@ var log = (obj) => {
|
|
|
1545
2009
|
};
|
|
1546
2010
|
async function main(env = process.env, now = () => Date.now()) {
|
|
1547
2011
|
const config = parseConfig(env);
|
|
1548
|
-
const storage = new
|
|
2012
|
+
const storage = new LocalFsStorage(config.root);
|
|
1549
2013
|
const store = new CloudRoaring({ storage });
|
|
1550
2014
|
const manifest = await store.exportSegments(fsSink(config.out), {
|
|
1551
2015
|
format: config.format,
|