@cloudbitmaps/roaring 0.9.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/NOTICE +3 -3
- package/PRIVACY.md +268 -76
- package/README.md +85 -95
- package/dist/bin/export-segments.d.ts +21 -0
- package/dist/bin/export-segments.js +1972 -30
- package/dist/bin/export-segments.js.map +7 -1
- package/dist/index.d.ts +969 -458
- package/dist/index.js +1461 -354
- package/dist/index.js.map +7 -1
- package/dist/open-charging-storage.d.ts +34 -0
- package/dist/option-keys.d.ts +19 -0
- package/dist/portable/decode.d.ts +53 -0
- package/dist/portable/layout.d.ts +32 -0
- package/dist/reserved-namespace.d.ts +3 -0
- package/dist/roaring-codec.d.ts +68 -0
- package/dist/system-clock.d.ts +39 -0
- package/dist/testing/conformance.d.ts +54 -0
- package/dist/testing/fuzz-codec.d.ts +23 -0
- package/package.json +11 -117
- package/dist/azure/index.cjs +0 -14
- package/dist/azure/index.cjs.map +0 -1
- package/dist/azure/index.d.cts +0 -9
- package/dist/azure/index.d.ts +0 -9
- package/dist/azure/index.js +0 -3
- package/dist/azure/index.js.map +0 -1
- package/dist/bin/compact-segments.js +0 -201
- package/dist/bin/compact-segments.js.map +0 -1
- package/dist/cassandra/index.cjs +0 -14
- package/dist/cassandra/index.cjs.map +0 -1
- package/dist/cassandra/index.d.cts +0 -1
- package/dist/cassandra/index.d.ts +0 -1
- package/dist/cassandra/index.js +0 -3
- package/dist/cassandra/index.js.map +0 -1
- package/dist/chunk-D6LIJSGF.js +0 -906
- package/dist/chunk-D6LIJSGF.js.map +0 -1
- package/dist/dynamodb/index.cjs +0 -14
- package/dist/dynamodb/index.cjs.map +0 -1
- package/dist/dynamodb/index.d.cts +0 -1
- package/dist/dynamodb/index.d.ts +0 -1
- package/dist/dynamodb/index.js +0 -3
- package/dist/dynamodb/index.js.map +0 -1
- package/dist/gcs/index.cjs +0 -14
- package/dist/gcs/index.cjs.map +0 -1
- package/dist/gcs/index.d.cts +0 -1
- package/dist/gcs/index.d.ts +0 -1
- package/dist/gcs/index.js +0 -3
- package/dist/gcs/index.js.map +0 -1
- package/dist/index.cjs +0 -894
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -734
- package/dist/mongodb/index.cjs +0 -14
- package/dist/mongodb/index.cjs.map +0 -1
- package/dist/mongodb/index.d.cts +0 -1
- package/dist/mongodb/index.d.ts +0 -1
- package/dist/mongodb/index.js +0 -3
- package/dist/mongodb/index.js.map +0 -1
- package/dist/mysql/index.cjs +0 -14
- package/dist/mysql/index.cjs.map +0 -1
- package/dist/mysql/index.d.cts +0 -1
- package/dist/mysql/index.d.ts +0 -1
- package/dist/mysql/index.js +0 -3
- package/dist/mysql/index.js.map +0 -1
- package/dist/postgres/index.cjs +0 -14
- package/dist/postgres/index.cjs.map +0 -1
- package/dist/postgres/index.d.cts +0 -1
- package/dist/postgres/index.d.ts +0 -1
- package/dist/postgres/index.js +0 -3
- package/dist/postgres/index.js.map +0 -1
- package/dist/redis/index.cjs +0 -14
- package/dist/redis/index.cjs.map +0 -1
- package/dist/redis/index.d.cts +0 -1
- package/dist/redis/index.d.ts +0 -1
- package/dist/redis/index.js +0 -3
- package/dist/redis/index.js.map +0 -1
- package/dist/s3/index.cjs +0 -14
- package/dist/s3/index.cjs.map +0 -1
- package/dist/s3/index.d.cts +0 -1
- package/dist/s3/index.d.ts +0 -1
- package/dist/s3/index.js +0 -3
- package/dist/s3/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,256 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
BoundedLru,
|
|
4
|
+
PinnedStorageChunkSource,
|
|
5
|
+
segmentKey,
|
|
6
|
+
CrbmStorageChunkSource,
|
|
7
|
+
DEFAULT_BUDGET,
|
|
8
|
+
DEFAULT_RETRY_POLICY,
|
|
9
|
+
withRetry,
|
|
10
|
+
isStorageBackend,
|
|
11
|
+
NOOP_METRICS,
|
|
12
|
+
RetryingStorageChunkSource,
|
|
13
|
+
SegmentEngine,
|
|
14
|
+
UnsupportedError,
|
|
15
|
+
ValidationError as ValidationError2,
|
|
16
|
+
WriteConflictError,
|
|
17
|
+
MIN_EXPIRES_AT_MS,
|
|
18
|
+
checkBudget,
|
|
19
|
+
collectWithinBudget,
|
|
20
|
+
excludingReservedRows,
|
|
21
|
+
dropSegment,
|
|
22
|
+
eraseIdFromSegment,
|
|
23
|
+
estimateCost,
|
|
24
|
+
groundedReport,
|
|
25
|
+
loadSegment,
|
|
26
|
+
mapWithConcurrency,
|
|
27
|
+
resolveBudget,
|
|
28
|
+
resolvePerOpBudget,
|
|
29
|
+
retireExpired,
|
|
30
|
+
runConsistencyCheck,
|
|
31
|
+
runExport,
|
|
32
|
+
setSegmentRetention,
|
|
33
|
+
clearSegmentRetention,
|
|
34
|
+
getSegmentRetention,
|
|
35
|
+
safeMetrics,
|
|
36
|
+
splitId
|
|
37
|
+
} from "@cloudbitmaps/core";
|
|
38
|
+
import { validateSegmentRef } from "@cloudbitmaps/core/driver-kit";
|
|
39
|
+
|
|
40
|
+
// src/open-charging-storage.ts
|
|
41
|
+
var OpenChargingStorage = class {
|
|
42
|
+
/**
|
|
43
|
+
* @param inner the driver the erasure would otherwise use
|
|
44
|
+
* @param current the generation the segment's row named when it was listed, or `null` when it named none
|
|
45
|
+
* @param charge called once per charged open; it throws to refuse
|
|
46
|
+
*/
|
|
47
|
+
constructor(inner, current, charge) {
|
|
48
|
+
this.inner = inner;
|
|
49
|
+
this.charge = charge;
|
|
50
|
+
if (current !== null) this.free.add(current);
|
|
51
|
+
}
|
|
52
|
+
inner;
|
|
53
|
+
charge;
|
|
54
|
+
free = /* @__PURE__ */ new Set();
|
|
55
|
+
capabilities() {
|
|
56
|
+
return this.inner.capabilities();
|
|
57
|
+
}
|
|
58
|
+
async putImmutable(key, write) {
|
|
59
|
+
const written = await this.inner.putImmutable(key, write);
|
|
60
|
+
this.free.add(key.generation);
|
|
61
|
+
return written;
|
|
62
|
+
}
|
|
63
|
+
getRange(key, offset, length) {
|
|
64
|
+
return this.inner.getRange(key, offset, length);
|
|
65
|
+
}
|
|
66
|
+
/** One `getTail` is one open: a reader takes the footer and the index from it, and reads chunks with `getRange`. */
|
|
67
|
+
async getTail(key, maxBytes) {
|
|
68
|
+
if (!this.free.has(key.generation)) this.charge();
|
|
69
|
+
return this.inner.getTail(key, maxBytes);
|
|
70
|
+
}
|
|
71
|
+
delete(key) {
|
|
72
|
+
return this.inner.delete(key);
|
|
73
|
+
}
|
|
74
|
+
list(ref) {
|
|
75
|
+
return this.inner.list(ref);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
4
78
|
|
|
5
79
|
// src/index.ts
|
|
80
|
+
import { listGenerations, rollbackSegment } from "@cloudbitmaps/core";
|
|
81
|
+
import { listSegments, segmentExists } from "@cloudbitmaps/core";
|
|
82
|
+
|
|
83
|
+
// src/reserved-namespace.ts
|
|
84
|
+
import { ValidationError } from "@cloudbitmaps/core";
|
|
85
|
+
var RESERVED_NAMESPACE_PREFIX = "cbm.due.";
|
|
86
|
+
function refuseReservedNamespace(namespace) {
|
|
87
|
+
if (namespace !== void 0 && namespace.startsWith(RESERVED_NAMESPACE_PREFIX)) {
|
|
88
|
+
throw new ValidationError(
|
|
89
|
+
`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.`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/roaring-codec.ts
|
|
95
|
+
import roaring from "roaring";
|
|
96
|
+
import { IntegrityError as IntegrityError2 } from "@cloudbitmaps/core";
|
|
97
|
+
|
|
98
|
+
// src/portable/layout.ts
|
|
99
|
+
import { IntegrityError } from "@cloudbitmaps/core";
|
|
100
|
+
var SERIAL_COOKIE_NO_RUNCONTAINER = 12346;
|
|
101
|
+
var SERIAL_COOKIE = 12347;
|
|
102
|
+
var NO_OFFSET_THRESHOLD = 4;
|
|
103
|
+
var ARRAY_MAX_CARDINALITY = 4096;
|
|
104
|
+
var BITMAP_CONTAINER_BYTES = 8192;
|
|
105
|
+
var MAX_LOW = 65535;
|
|
106
|
+
var ARRAY = 0;
|
|
107
|
+
var BITMAP = 1;
|
|
108
|
+
var RUN = 2;
|
|
109
|
+
function overrun(bytes, offset, length, what) {
|
|
110
|
+
return new IntegrityError(
|
|
111
|
+
`portable roaring: ${what} needs bytes [${offset}, ${offset + length}) of a ${bytes.byteLength}-byte buffer`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
function checkPortableLayout(bytes) {
|
|
115
|
+
walk(bytes, null);
|
|
116
|
+
}
|
|
117
|
+
function walk(bytes, out) {
|
|
118
|
+
const length = bytes.byteLength;
|
|
119
|
+
if (length === 0) return;
|
|
120
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, length);
|
|
121
|
+
if (length < 4) throw overrun(bytes, 0, 4, "the cookie");
|
|
122
|
+
const cookie = view.getUint32(0, true);
|
|
123
|
+
let count;
|
|
124
|
+
let runFlagsAt = -1;
|
|
125
|
+
let pos;
|
|
126
|
+
if (cookie === SERIAL_COOKIE_NO_RUNCONTAINER) {
|
|
127
|
+
if (length < 8) throw overrun(bytes, 4, 4, "the container count");
|
|
128
|
+
count = view.getUint32(4, true);
|
|
129
|
+
pos = 8;
|
|
130
|
+
} else if ((cookie & 65535) === SERIAL_COOKIE) {
|
|
131
|
+
count = (cookie >>> 16) + 1;
|
|
132
|
+
runFlagsAt = 4;
|
|
133
|
+
const flagBytes = count + 7 >>> 3;
|
|
134
|
+
if (runFlagsAt + flagBytes > length) {
|
|
135
|
+
throw overrun(bytes, runFlagsAt, flagBytes, "the run-container bitmap");
|
|
136
|
+
}
|
|
137
|
+
pos = runFlagsAt + flagBytes;
|
|
138
|
+
} else {
|
|
139
|
+
throw new IntegrityError(
|
|
140
|
+
`portable roaring: unrecognized cookie 0x${cookie.toString(16)}, not a portable-format bitmap`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const descriptiveAt = pos;
|
|
144
|
+
if (pos + count * 4 > length) throw overrun(bytes, pos, count * 4, "the descriptive header");
|
|
145
|
+
pos += count * 4;
|
|
146
|
+
let offsetsAt = -1;
|
|
147
|
+
if (runFlagsAt < 0 || count >= NO_OFFSET_THRESHOLD) {
|
|
148
|
+
if (pos + count * 4 > length) throw overrun(bytes, pos, count * 4, "the offset header");
|
|
149
|
+
offsetsAt = pos;
|
|
150
|
+
pos += count * 4;
|
|
151
|
+
}
|
|
152
|
+
let previousKey = -1;
|
|
153
|
+
for (let i = 0; i < count; i++) {
|
|
154
|
+
const key = view.getUint16(descriptiveAt + i * 4, true);
|
|
155
|
+
if (key <= previousKey) {
|
|
156
|
+
throw new IntegrityError(
|
|
157
|
+
`portable roaring: container keys are not strictly ascending (${previousKey} then ${key} at index ${i})`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
previousKey = key;
|
|
161
|
+
const cardinality = view.getUint16(descriptiveAt + i * 4 + 2, true) + 1;
|
|
162
|
+
const isRun = runFlagsAt >= 0 && (bytes[runFlagsAt + (i >>> 3)] & 1 << (i & 7)) !== 0;
|
|
163
|
+
const kind = isRun ? RUN : cardinality > ARRAY_MAX_CARDINALITY ? BITMAP : ARRAY;
|
|
164
|
+
if (offsetsAt >= 0) {
|
|
165
|
+
const stated = view.getUint32(offsetsAt + i * 4, true);
|
|
166
|
+
if (stated !== pos) {
|
|
167
|
+
throw new IntegrityError(
|
|
168
|
+
`portable roaring: the offset header puts container ${i} at byte ${stated}, but it starts at ${pos}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let size;
|
|
173
|
+
if (kind === ARRAY) {
|
|
174
|
+
size = cardinality * 2;
|
|
175
|
+
if (pos + size > length) throw overrun(bytes, pos, size, `container ${i} (array)`);
|
|
176
|
+
checkArray(view, pos, cardinality, i);
|
|
177
|
+
} else if (kind === BITMAP) {
|
|
178
|
+
size = BITMAP_CONTAINER_BYTES;
|
|
179
|
+
if (pos + size > length) throw overrun(bytes, pos, size, `container ${i} (bitmap)`);
|
|
180
|
+
const bits = popcount(view, pos);
|
|
181
|
+
if (bits !== cardinality) {
|
|
182
|
+
throw new IntegrityError(
|
|
183
|
+
`portable roaring: container ${i} (bitmap) holds ${bits} values, but its header says ${cardinality}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
if (pos + 2 > length) throw overrun(bytes, pos, 2, `container ${i} (run count)`);
|
|
188
|
+
const runs = view.getUint16(pos, true);
|
|
189
|
+
size = 2 + runs * 4;
|
|
190
|
+
if (pos + size > length) throw overrun(bytes, pos, size, `container ${i} (run)`);
|
|
191
|
+
const covered = checkRuns(view, pos + 2, runs, i);
|
|
192
|
+
if (covered !== cardinality) {
|
|
193
|
+
throw new IntegrityError(
|
|
194
|
+
`portable roaring: container ${i} (run) covers ${covered} values, but its header says ${cardinality}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
out?.push({ key, cardinality, kind, offset: pos });
|
|
199
|
+
pos += size;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function checkArray(view, at, cardinality, index) {
|
|
203
|
+
let previous = -1;
|
|
204
|
+
let p = at;
|
|
205
|
+
const end = at + cardinality * 2;
|
|
206
|
+
for (; p + 4 <= end; p += 4) {
|
|
207
|
+
const pair = view.getUint32(p, true);
|
|
208
|
+
const first = pair & 65535;
|
|
209
|
+
const second = pair >>> 16;
|
|
210
|
+
if (first <= previous || second <= first) throw unordered(index, (p - at) / 2);
|
|
211
|
+
previous = second;
|
|
212
|
+
}
|
|
213
|
+
if (p < end && view.getUint16(p, true) <= previous) throw unordered(index, (p - at) / 2);
|
|
214
|
+
}
|
|
215
|
+
function unordered(index, element) {
|
|
216
|
+
return new IntegrityError(
|
|
217
|
+
`portable roaring: container ${index} (array) is not strictly ascending at or after element ${element}`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
function checkRuns(view, at, runs, index) {
|
|
221
|
+
let covered = 0;
|
|
222
|
+
let lowest = 0;
|
|
223
|
+
for (let r = 0; r < runs; r++) {
|
|
224
|
+
const run = view.getUint32(at + r * 4, true);
|
|
225
|
+
const start = run & 65535;
|
|
226
|
+
const last = start + (run >>> 16);
|
|
227
|
+
if (start < lowest) {
|
|
228
|
+
throw new IntegrityError(
|
|
229
|
+
`portable roaring: container ${index} (run) has run ${r} out of order, overlapping or touching the one before`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (last > MAX_LOW) {
|
|
233
|
+
throw new IntegrityError(
|
|
234
|
+
`portable roaring: container ${index} (run) has run ${r} running past the end of the container`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
covered += last - start + 1;
|
|
238
|
+
lowest = last + 2;
|
|
239
|
+
}
|
|
240
|
+
return covered;
|
|
241
|
+
}
|
|
242
|
+
function popcount(view, at) {
|
|
243
|
+
let bits = 0;
|
|
244
|
+
for (let p = at, end = at + BITMAP_CONTAINER_BYTES; p < end; p += 4) {
|
|
245
|
+
let w = view.getUint32(p, true);
|
|
246
|
+
w -= w >>> 1 & 1431655765;
|
|
247
|
+
w = (w & 858993459) + (w >>> 2 & 858993459);
|
|
248
|
+
bits += Math.imul(w + (w >>> 4) & 252645135, 16843009) >>> 24;
|
|
249
|
+
}
|
|
250
|
+
return bits;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/roaring-codec.ts
|
|
6
254
|
var { RoaringBitmap32, SerializationFormat, DeserializationFormat } = roaring;
|
|
7
255
|
var SafeBitmap = class _SafeBitmap {
|
|
8
256
|
bitmap;
|
|
@@ -16,18 +264,24 @@ var SafeBitmap = class _SafeBitmap {
|
|
|
16
264
|
return new _SafeBitmap(new RoaringBitmap32(values));
|
|
17
265
|
}
|
|
18
266
|
/**
|
|
19
|
-
*
|
|
20
|
-
* Throws `IntegrityError` if the input exceeds `maxBytes
|
|
21
|
-
* native addon is never handed unbounded or unsafe-format input.
|
|
267
|
+
* Cap the size, check the structure, then deserialize with the **portable** format.
|
|
268
|
+
* Throws `IntegrityError` if the input exceeds `maxBytes`, is not a well-formed portable bitmap, or fails to
|
|
269
|
+
* decode — the native addon is never handed unbounded, malformed or unsafe-format input.
|
|
270
|
+
*
|
|
271
|
+
* The structural check is what makes the decoded bitmap's answers mean anything. The native deserializer
|
|
272
|
+
* bounds its reads and checks nothing else, so bytes of the right length and the wrong shape (containers or
|
|
273
|
+
* values out of order, overlapping runs, a cardinality that disagrees with the bits) decode into a bitmap
|
|
274
|
+
* whose `maximum()`, `has()` and `size` are wrong, and some of those shapes crash the process when used.
|
|
22
275
|
*/
|
|
23
276
|
static safeDeserialize(bytes, maxBytes) {
|
|
24
277
|
if (bytes.length > maxBytes) {
|
|
25
|
-
throw new
|
|
278
|
+
throw new IntegrityError2(`serialized bitmap is ${bytes.length}B, exceeds cap ${maxBytes}B`);
|
|
26
279
|
}
|
|
280
|
+
checkPortableLayout(bytes);
|
|
27
281
|
try {
|
|
28
282
|
return new _SafeBitmap(RoaringBitmap32.deserialize(bytes, DeserializationFormat.portable));
|
|
29
283
|
} catch (err) {
|
|
30
|
-
throw new
|
|
284
|
+
throw new IntegrityError2(`failed to deserialize bitmap: ${err.message}`);
|
|
31
285
|
}
|
|
32
286
|
}
|
|
33
287
|
serialize() {
|
|
@@ -48,15 +302,17 @@ var SafeBitmap = class _SafeBitmap {
|
|
|
48
302
|
* a different baseline that happens to land near 63× on the run shape too. `pnpm bench:encoding` prints both
|
|
49
303
|
* columns; don't carry a figure between them without checking which denominator it had.
|
|
50
304
|
*
|
|
51
|
-
* Called
|
|
52
|
-
*
|
|
305
|
+
* Called only when writing an immutable objects generation — see {@link CodecBitmap.optimize} for why the read
|
|
306
|
+
* path deliberately does not.
|
|
53
307
|
*/
|
|
54
308
|
optimize() {
|
|
55
309
|
this.bitmap.runOptimize();
|
|
56
310
|
}
|
|
57
311
|
/**
|
|
58
|
-
* Largest value, or `undefined` when empty. O(1) — roaring reads it off the container
|
|
59
|
-
* per-chunk range assertion costs one call per chunk rather than a walk per id.
|
|
312
|
+
* Largest value, or `undefined` when empty. O(1) — roaring reads it off the last container, so the engine's
|
|
313
|
+
* per-chunk range assertion costs one call per chunk rather than a walk per id. The last container holds the
|
|
314
|
+
* largest value only because {@link SafeBitmap.safeDeserialize} refuses bytes whose containers or values are
|
|
315
|
+
* out of order.
|
|
60
316
|
*/
|
|
61
317
|
maximum() {
|
|
62
318
|
return this.bitmap.isEmpty ? void 0 : this.bitmap.maximum();
|
|
@@ -131,7 +387,7 @@ var SystemClock = class {
|
|
|
131
387
|
* A long CPU-bound loop in `core/` (bulk-load is the one that matters) periodically hands the loop back so a
|
|
132
388
|
* co-resident HTTP server keeps answering. Measured on the **synthetic yield-primitive benchmark** — a
|
|
133
389
|
* 61,035-iteration loop yielding every 1,024, isolating the primitive rather than timing a real load (the
|
|
134
|
-
* end-to-end figures are
|
|
390
|
+
* end-to-end figures are 519 ms → 24 ms; see `cooperative.ts`, which lists all three experiments):
|
|
135
391
|
*
|
|
136
392
|
* ```text
|
|
137
393
|
* no yield 568 ms wall 568.0 ms worst event-loop gap
|
|
@@ -154,17 +410,57 @@ var SystemClock = class {
|
|
|
154
410
|
});
|
|
155
411
|
}
|
|
156
412
|
};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
413
|
+
|
|
414
|
+
// src/option-keys.ts
|
|
415
|
+
var OPTION_KEYS = {
|
|
416
|
+
top: ["storage", "cache", "encryption", "retry", "metrics", "budget", "seams"],
|
|
417
|
+
cache: ["maxChunks", "ttlMs", "genTtlMs", "readerMax", "readerMaxBytes"],
|
|
418
|
+
encryption: ["keystore", "required"],
|
|
419
|
+
retry: ["maxAttempts", "baseDelayMs", "maxDelayMs", "backoffFactor", "jitter", "onRetry"],
|
|
420
|
+
budget: ["maxRequests"],
|
|
421
|
+
seams: ["clock", "rng"]
|
|
422
|
+
};
|
|
165
423
|
|
|
166
424
|
// src/index.ts
|
|
425
|
+
import {
|
|
426
|
+
MemoryStorage,
|
|
427
|
+
LocalFsStorage,
|
|
428
|
+
CrbmStorageChunkSource as CrbmStorageChunkSource2,
|
|
429
|
+
destroySegment,
|
|
430
|
+
eraseNamespace,
|
|
431
|
+
excludingReservedRows as excludingReservedRows2,
|
|
432
|
+
readRetentionPolicy,
|
|
433
|
+
MIN_EXPIRES_AT_MS as MIN_EXPIRES_AT_MS2,
|
|
434
|
+
InProcessKeystore,
|
|
435
|
+
NodeAead,
|
|
436
|
+
aadFor,
|
|
437
|
+
CloudRoaringError,
|
|
438
|
+
ValidationError as ValidationError3,
|
|
439
|
+
WriteConflictError as WriteConflictError2,
|
|
440
|
+
IntegrityError as IntegrityError3,
|
|
441
|
+
NotFoundError,
|
|
442
|
+
UnsupportedError as UnsupportedError2,
|
|
443
|
+
CapabilityError,
|
|
444
|
+
TransientError,
|
|
445
|
+
KeyUnavailableError,
|
|
446
|
+
BudgetExceededError,
|
|
447
|
+
isCloudRoaringError,
|
|
448
|
+
isWriteConflictError,
|
|
449
|
+
isTransientError,
|
|
450
|
+
isNotFoundError,
|
|
451
|
+
isIntegrityError,
|
|
452
|
+
isValidationError,
|
|
453
|
+
CrbmReader,
|
|
454
|
+
BufferReader,
|
|
455
|
+
CountingMetricsSink,
|
|
456
|
+
RecordingAuditSink,
|
|
457
|
+
DEFAULT_RETRY_POLICY as DEFAULT_RETRY_POLICY2,
|
|
458
|
+
AWS_US_EAST_1_ONDEMAND,
|
|
459
|
+
ELASTICACHE_REDIS_US_EAST_1_ONDEMAND,
|
|
460
|
+
ONE_REDIS_HA_CLUSTER
|
|
461
|
+
} from "@cloudbitmaps/core";
|
|
167
462
|
var SystemRng = class {
|
|
463
|
+
/** A float in `[0, 1)`. Jitter only — never key material, never anything a caller can observe. */
|
|
168
464
|
next() {
|
|
169
465
|
return Math.random();
|
|
170
466
|
}
|
|
@@ -173,84 +469,195 @@ var DEFAULT_CACHE_MAX_CHUNKS = 1024;
|
|
|
173
469
|
var DEFAULT_ADMIN_CONCURRENCY = 8;
|
|
174
470
|
function validateConcurrency(concurrency) {
|
|
175
471
|
if (concurrency !== void 0 && (!Number.isInteger(concurrency) || concurrency < 1)) {
|
|
176
|
-
throw new
|
|
472
|
+
throw new ValidationError2(`concurrency must be a positive integer; got ${concurrency}`);
|
|
177
473
|
}
|
|
178
474
|
}
|
|
179
475
|
function requireScope(options, op) {
|
|
476
|
+
refuseReservedNamespace(options.namespace);
|
|
180
477
|
if (options.namespace === void 0 && options.allNamespaces !== true) {
|
|
181
|
-
throw new
|
|
478
|
+
throw new ValidationError2(
|
|
182
479
|
`${op} scans the global id space across all namespaces \u2014 pass an explicit \`namespace\`, or \`{ allNamespaces: true }\` to intentionally sweep the whole fleet`
|
|
183
480
|
);
|
|
184
481
|
}
|
|
185
482
|
}
|
|
186
|
-
function
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
189
|
-
throw new
|
|
483
|
+
function resolveStorageSource(options, clock) {
|
|
484
|
+
const storage = options.storage;
|
|
485
|
+
if (storage === null || typeof storage !== "object") {
|
|
486
|
+
throw new ValidationError2(
|
|
487
|
+
"`storage` must be a StorageBackend, an IStorageDriver, or a StorageChunkSource"
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
const asBackend = storage;
|
|
491
|
+
const isObject = (v) => v !== null && typeof v === "object";
|
|
492
|
+
const hasGetChunk = typeof storage.getChunk === "function";
|
|
493
|
+
const hasPutImmutable = typeof storage.putImmutable === "function";
|
|
494
|
+
const isBackend = isStorageBackend(storage);
|
|
495
|
+
if (isBackend && (hasGetChunk || hasPutImmutable)) {
|
|
496
|
+
throw new ValidationError2(
|
|
497
|
+
`\`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.`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const hasStorageHalf = isObject(asBackend.storage);
|
|
501
|
+
const hasRegistryHalf = isObject(asBackend.registry);
|
|
502
|
+
if (!isBackend && hasPutImmutable && hasRegistryHalf) {
|
|
503
|
+
throw new ValidationError2(
|
|
504
|
+
"`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`."
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (!isBackend && !hasGetChunk && !hasPutImmutable && hasStorageHalf && hasRegistryHalf) {
|
|
508
|
+
const storageOk = typeof asBackend.storage.putImmutable === "function";
|
|
509
|
+
const registryOk = typeof asBackend.registry.compareAndSwap === "function";
|
|
510
|
+
if (!storageOk || !registryOk) {
|
|
511
|
+
const bad = !storageOk ? "`storage` half is not an IStorageDriver (no `putImmutable`)" : "`registry` half is not an IRegistryDriver (no `compareAndSwap`)";
|
|
512
|
+
throw new ValidationError2(
|
|
513
|
+
"`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`."
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
throw new ValidationError2(
|
|
517
|
+
"`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."
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
if (isBackend) {
|
|
521
|
+
const backend = storage;
|
|
522
|
+
return {
|
|
523
|
+
source: new CrbmStorageChunkSource(backend.storage, {
|
|
524
|
+
registry: backend.registry,
|
|
525
|
+
keystore: options.encryption?.keystore,
|
|
526
|
+
requireEncryption: options.encryption?.required,
|
|
527
|
+
clock,
|
|
528
|
+
currentGenTtlMs: options.cache?.genTtlMs,
|
|
529
|
+
maxOpenSegments: options.cache?.readerMax,
|
|
530
|
+
maxOpenIndexBytes: options.cache?.readerMaxBytes
|
|
531
|
+
}),
|
|
532
|
+
driver: backend.storage,
|
|
533
|
+
registry: backend.registry
|
|
534
|
+
};
|
|
190
535
|
}
|
|
191
|
-
const hasGetChunk = typeof cold.getChunk === "function";
|
|
192
|
-
const hasPutImmutable = typeof cold.putImmutable === "function";
|
|
193
536
|
if (hasGetChunk && hasPutImmutable) {
|
|
194
|
-
throw new
|
|
195
|
-
"`
|
|
537
|
+
throw new ValidationError2(
|
|
538
|
+
"`storage` exposes both `getChunk` and `putImmutable` \u2014 ambiguous; pass a StorageBackend, an IStorageDriver, or a StorageChunkSource, not a hybrid"
|
|
196
539
|
);
|
|
197
540
|
}
|
|
198
541
|
if (!hasGetChunk && !hasPutImmutable) {
|
|
199
|
-
throw new
|
|
542
|
+
throw new ValidationError2(
|
|
543
|
+
"`storage` must be a StorageBackend, an IStorageDriver, or a StorageChunkSource"
|
|
544
|
+
);
|
|
200
545
|
}
|
|
201
546
|
if (hasGetChunk) {
|
|
202
|
-
if (options.
|
|
203
|
-
throw new
|
|
204
|
-
"
|
|
547
|
+
if (options.encryption?.keystore !== void 0 || options.encryption?.required === true) {
|
|
548
|
+
throw new ValidationError2(
|
|
549
|
+
"keystore/requireEncryption apply only when the store builds its own read path; configure them on the StorageChunkSource you passed instead"
|
|
205
550
|
);
|
|
206
551
|
}
|
|
207
|
-
return { source:
|
|
552
|
+
return { source: storage, driver: void 0, registry: void 0 };
|
|
208
553
|
}
|
|
209
|
-
const driver =
|
|
554
|
+
const driver = storage;
|
|
210
555
|
return {
|
|
211
|
-
source: new
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
requireEncryption: options.requireEncryption,
|
|
556
|
+
source: new CrbmStorageChunkSource(driver, {
|
|
557
|
+
keystore: options.encryption?.keystore,
|
|
558
|
+
requireEncryption: options.encryption?.required,
|
|
215
559
|
clock,
|
|
216
|
-
currentGenTtlMs: options.
|
|
217
|
-
maxOpenSegments: options.
|
|
218
|
-
maxOpenIndexBytes: options.
|
|
560
|
+
currentGenTtlMs: options.cache?.genTtlMs,
|
|
561
|
+
maxOpenSegments: options.cache?.readerMax,
|
|
562
|
+
maxOpenIndexBytes: options.cache?.readerMaxBytes
|
|
219
563
|
}),
|
|
220
|
-
driver
|
|
564
|
+
driver,
|
|
565
|
+
registry: void 0
|
|
221
566
|
};
|
|
222
567
|
}
|
|
223
|
-
var
|
|
568
|
+
var NOT_A_GROUP = {
|
|
569
|
+
Null: "null",
|
|
570
|
+
Array: "an array",
|
|
571
|
+
Map: "a Map",
|
|
572
|
+
Set: "a Set",
|
|
573
|
+
Boolean: "a boxed boolean",
|
|
574
|
+
Number: "a boxed number",
|
|
575
|
+
String: "a boxed string"
|
|
576
|
+
};
|
|
577
|
+
var CloudRoaring = class _CloudRoaring {
|
|
224
578
|
engine;
|
|
579
|
+
cache;
|
|
580
|
+
/** The store's retry options, for a pinned engine to read through as the store's own does; undefined when off. */
|
|
581
|
+
retryOptions;
|
|
582
|
+
crbmSource;
|
|
225
583
|
clock;
|
|
226
584
|
metrics;
|
|
227
|
-
// The store's own drivers, kept so the
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
warmDriver;
|
|
585
|
+
// The store's own drivers, kept so the lifecycle helpers and the `*Into` verbs reuse them instead of making
|
|
586
|
+
// you re-pass deps. `storageDriver` is set only when `storage` was a raw IStorageDriver (a pre-built StorageChunkSource has
|
|
587
|
+
// no underlying driver to write through).
|
|
588
|
+
storageDriver;
|
|
232
589
|
registry;
|
|
233
590
|
keystore;
|
|
234
591
|
requireEncryption;
|
|
235
592
|
/** Resolved store-level per-op budget (null = disabled); the admin scans use it, with a per-op override. */
|
|
236
593
|
budget;
|
|
594
|
+
/**
|
|
595
|
+
* Refuse any option key the store does not take, at the top level or inside a group, naming each one.
|
|
596
|
+
*
|
|
597
|
+
* An option the store ignored would do nothing and look as if it had — a typo'd `cache.maxChunk`, a key from
|
|
598
|
+
* another library's config spread into this one — so every key is checked against {@link OPTION_KEYS}.
|
|
599
|
+
*/
|
|
600
|
+
static rejectUnknownOptions(options) {
|
|
601
|
+
if (options === null || options === void 0 || typeof options !== "object") {
|
|
602
|
+
throw new ValidationError2(
|
|
603
|
+
"CloudRoaring needs an options object with a `storage` key \u2014 got " + (options === null ? "null" : typeof options)
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
const bag = options;
|
|
607
|
+
const unknown = [];
|
|
608
|
+
for (const key of Object.keys(bag)) {
|
|
609
|
+
if (!OPTION_KEYS.top.includes(key)) unknown.push({ group: null, key });
|
|
610
|
+
}
|
|
611
|
+
const groups = Object.keys(OPTION_KEYS).filter((g) => g !== "top");
|
|
612
|
+
for (const group of groups) {
|
|
613
|
+
const value = bag[group];
|
|
614
|
+
if (value === void 0) continue;
|
|
615
|
+
const offOk = group === "retry" || group === "budget";
|
|
616
|
+
if (value === false && offOk) continue;
|
|
617
|
+
const tag = Object.prototype.toString.call(value).slice(8, -1);
|
|
618
|
+
if (typeof value !== "object" || value === null || NOT_A_GROUP[tag] !== void 0) {
|
|
619
|
+
const got = typeof value !== "object" ? typeof value : NOT_A_GROUP[tag];
|
|
620
|
+
throw new ValidationError2(
|
|
621
|
+
`CloudRoaring's \`${group}\` must be an object${offOk ? " or `false`" : ""} \u2014 got ${got}`
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
const known = OPTION_KEYS[group];
|
|
625
|
+
for (const key of Object.keys(value)) if (!known.includes(key)) unknown.push({ group, key });
|
|
626
|
+
}
|
|
627
|
+
if (unknown.length === 0) return;
|
|
628
|
+
const takes = (keys) => keys.map((k) => `\`${k}\``).join(", ");
|
|
629
|
+
const named = [...new Set(unknown.map((u) => u.group))];
|
|
630
|
+
throw new ValidationError2(
|
|
631
|
+
`CloudRoaring does not take ${takes(unknown.map((u) => u.group === null ? u.key : `${u.group}.${u.key}`))}. ` + named.map(
|
|
632
|
+
(g) => g === null ? `The store takes ${takes(OPTION_KEYS.top)}.` : `\`${g}\` takes ${takes(OPTION_KEYS[g])}.`
|
|
633
|
+
).join(" ") + " https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/api-reference.md#build-a-store--new-cloudroaringoptions"
|
|
634
|
+
);
|
|
635
|
+
}
|
|
237
636
|
constructor(options) {
|
|
238
|
-
|
|
239
|
-
const
|
|
637
|
+
_CloudRoaring.rejectUnknownOptions(options);
|
|
638
|
+
const clock = options.seams?.clock ?? new SystemClock();
|
|
639
|
+
const rng = options.seams?.rng ?? new SystemRng();
|
|
240
640
|
const metrics = safeMetrics(options.metrics ?? NOOP_METRICS);
|
|
241
641
|
const cache = new BoundedLru({
|
|
242
|
-
maxEntries: options.
|
|
243
|
-
ttlMs: options.
|
|
642
|
+
maxEntries: options.cache?.maxChunks ?? DEFAULT_CACHE_MAX_CHUNKS,
|
|
643
|
+
ttlMs: options.cache?.ttlMs,
|
|
244
644
|
clock
|
|
245
645
|
});
|
|
246
|
-
const resolved =
|
|
247
|
-
let
|
|
248
|
-
let warm = options.warm;
|
|
646
|
+
const resolved = resolveStorageSource(options, clock);
|
|
647
|
+
let storage = resolved.source;
|
|
249
648
|
if (options.retry !== false) {
|
|
649
|
+
const { onRetry: userOnRetry, ...ov } = options.retry ?? {};
|
|
650
|
+
const policy = {
|
|
651
|
+
maxAttempts: ov.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
|
|
652
|
+
baseDelayMs: ov.baseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs,
|
|
653
|
+
maxDelayMs: ov.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
|
|
654
|
+
backoffFactor: ov.backoffFactor ?? DEFAULT_RETRY_POLICY.backoffFactor,
|
|
655
|
+
jitter: ov.jitter ?? DEFAULT_RETRY_POLICY.jitter
|
|
656
|
+
};
|
|
250
657
|
const retryOpts = {
|
|
251
658
|
clock,
|
|
252
659
|
rng,
|
|
253
|
-
policy
|
|
660
|
+
policy,
|
|
254
661
|
// Bridge transient-fault retries into the metrics stream, then call the user's own hook.
|
|
255
662
|
onRetry: (info) => {
|
|
256
663
|
metrics.onEvent({
|
|
@@ -259,88 +666,175 @@ var CloudRoaring = class {
|
|
|
259
666
|
attempt: info.attempt,
|
|
260
667
|
delayMs: info.delayMs
|
|
261
668
|
});
|
|
262
|
-
|
|
669
|
+
userOnRetry?.(info);
|
|
263
670
|
}
|
|
264
671
|
};
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
if (options.writeConcurrency !== void 0 && (!Number.isInteger(options.writeConcurrency) || options.writeConcurrency < 1)) {
|
|
269
|
-
throw new ValidationError(
|
|
270
|
-
`writeConcurrency must be a positive integer; got ${options.writeConcurrency}`
|
|
271
|
-
);
|
|
672
|
+
storage = new RetryingStorageChunkSource(storage, retryOpts);
|
|
673
|
+
this.retryOptions = retryOpts;
|
|
272
674
|
}
|
|
273
675
|
this.budget = resolveBudget(options.budget, DEFAULT_BUDGET);
|
|
274
676
|
const deps = {
|
|
275
|
-
|
|
276
|
-
cold,
|
|
677
|
+
storage,
|
|
277
678
|
cache,
|
|
278
679
|
codec: roaringCodec,
|
|
279
680
|
// the facade injects the flagship codec; core stays codec-agnostic
|
|
280
681
|
clock,
|
|
281
|
-
rng,
|
|
282
|
-
occBackoff: options.occBackoff,
|
|
283
682
|
metrics,
|
|
284
|
-
warmReadConsistency: options.warmReadConsistency,
|
|
285
|
-
writeConcurrency: options.writeConcurrency,
|
|
286
|
-
maxWarmScanBytes: options.maxWarmScanBytes,
|
|
287
683
|
budget: this.budget
|
|
288
684
|
};
|
|
289
685
|
this.engine = new SegmentEngine(deps);
|
|
686
|
+
this.cache = cache;
|
|
687
|
+
this.crbmSource = resolved.source instanceof CrbmStorageChunkSource ? resolved.source : void 0;
|
|
290
688
|
this.clock = clock;
|
|
291
689
|
this.metrics = metrics;
|
|
292
|
-
this.
|
|
293
|
-
this.
|
|
294
|
-
this.
|
|
295
|
-
this.
|
|
296
|
-
this.requireEncryption = options.requireEncryption ?? false;
|
|
690
|
+
this.storageDriver = resolved.driver;
|
|
691
|
+
this.registry = resolved.registry;
|
|
692
|
+
this.keystore = options.encryption?.keystore;
|
|
693
|
+
this.requireEncryption = options.encryption?.required ?? false;
|
|
297
694
|
}
|
|
298
695
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* free function with explicit deps.
|
|
696
|
+
* The write-side deps, from the store's own drivers, for the lifecycle helpers and the `*Into` verbs. Requires
|
|
697
|
+
* the store to have been constructed with a **backend**, which supplies both halves: an `IStorageDriver` to
|
|
698
|
+
* write generations through (a pre-built `StorageChunkSource` has none) and the registry holding the pointer
|
|
699
|
+
* every write publishes.
|
|
304
700
|
*/
|
|
305
|
-
|
|
306
|
-
if (this.
|
|
701
|
+
lifecycleDeps(op) {
|
|
702
|
+
if (this.storageDriver === void 0) {
|
|
307
703
|
throw new UnsupportedError(
|
|
308
|
-
|
|
704
|
+
`${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.`
|
|
309
705
|
);
|
|
310
706
|
}
|
|
311
707
|
if (this.registry === void 0) {
|
|
312
708
|
throw new UnsupportedError(
|
|
313
|
-
|
|
709
|
+
`${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.`
|
|
314
710
|
);
|
|
315
711
|
}
|
|
316
712
|
return {
|
|
317
|
-
|
|
318
|
-
warm: this.warmDriver,
|
|
713
|
+
storage: this.storageDriver,
|
|
319
714
|
registry: this.registry,
|
|
320
715
|
clock: this.clock,
|
|
321
716
|
codec: roaringCodec,
|
|
322
717
|
// facade injects the flagship codec
|
|
323
718
|
keystore: this.keystore,
|
|
324
|
-
requireEncryption: this.requireEncryption
|
|
325
|
-
metrics: this.metrics
|
|
326
|
-
// safe-wrapped sink → store.compact/eraseSubject emit `compaction` events (gap #2)
|
|
719
|
+
requireEncryption: this.requireEncryption
|
|
327
720
|
};
|
|
328
721
|
}
|
|
329
|
-
/** Get a handle to a segment. Validates the name
|
|
722
|
+
/** Get a handle to a segment. Validates the name (non-empty, well-formed, within the encoded-length cap). */
|
|
330
723
|
segment(name, options) {
|
|
331
724
|
const ref = { segment: name, namespace: options?.namespace };
|
|
332
725
|
validateSegmentRef(ref);
|
|
333
|
-
|
|
726
|
+
refuseReservedNamespace(ref.namespace);
|
|
727
|
+
const expiresAt = options?.expiresAt;
|
|
728
|
+
if (expiresAt !== void 0) {
|
|
729
|
+
if (!Number.isFinite(expiresAt) || !Number.isInteger(expiresAt) || expiresAt < MIN_EXPIRES_AT_MS) {
|
|
730
|
+
throw new ValidationError2(
|
|
731
|
+
`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)`
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return makeSegment({
|
|
736
|
+
engine: this.engine,
|
|
737
|
+
ref,
|
|
738
|
+
clock: this.clock,
|
|
739
|
+
metrics: this.metrics,
|
|
740
|
+
materialize: (dest, ids, op, options2) => this.materialize(dest, ids, op, options2),
|
|
741
|
+
pinned: (r, e) => this.pinSegment(r, e),
|
|
742
|
+
combineEngine: (handles) => this.engineForCombine(handles),
|
|
743
|
+
expiresAt
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Write `ids` as a **new generation of `dest`** and publish it — the shared body of the `*Into` verbs. An `*Into`
|
|
748
|
+
* materialisation is a load, and publishes the same way (hard invariant 1): every load that finds a row fences its
|
|
749
|
+
* publish on the row's token; a guarded load (the default, since the empty refusal reads the current generation)
|
|
750
|
+
* also fences on the pointer it judged (`expectFrom`), and one that found no row fences on that absence instead.
|
|
751
|
+
* Only an unguarded load (`allowEmpty: true` and no `guard.minRetained`) onto a segment with no row publishes bare
|
|
752
|
+
* forward-only. The destination's previous generation stays readable until the publish lands (readers re-resolve
|
|
753
|
+
* within `cache.genTtlMs`).
|
|
754
|
+
*
|
|
755
|
+
* This routes through `loadSegment` rather than writing the generation itself, and that is the whole point
|
|
756
|
+
* of it. A materialisation is a load whose ids happen to come from a combine instead of from upstream, so
|
|
757
|
+
* everything `load()` learned the hard way applies unchanged: the generation is written UNPUBLISHED, the
|
|
758
|
+
* guard runs while the old generation is still authoritative, the publish is fenced as above, and a refused
|
|
759
|
+
* object is reclaimed only when a re-read finds the row unchanged (the same token) or gone (hard invariant 1:
|
|
760
|
+
* deleting it after a purge-and-recreate would put a live row over a missing generation).
|
|
761
|
+
*
|
|
762
|
+
* That last check narrows the window rather than closing it: the row read and the delete are two round
|
|
763
|
+
* trips, and `IStorageDriver` has no conditional delete to make them one. the collection a load runs carries
|
|
764
|
+
* the same residual. The failure it leaves is an orphan object, which costs storage until
|
|
765
|
+
* something collects it — deliberately the cheaper side of the trade.
|
|
766
|
+
*
|
|
767
|
+
* Written and published in one step, with no guard, an empty combine — a typo'd operand, an `exclude` that
|
|
768
|
+
* swallowed everything, an operand that had not loaded yet — would silently replace `dest` with an empty
|
|
769
|
+
* generation: the same failure `load()`'s guard exists to prevent, on the same data, reachable without passing
|
|
770
|
+
* any option at all.
|
|
771
|
+
*
|
|
772
|
+
* **A lost race throws.** `loadSegment` reports one as `reason: 'superseded'`; the `*Into` verbs throw
|
|
773
|
+
* {@link WriteConflictError} for it, so a caller can `catch (WriteConflictError)`. That one refusal is
|
|
774
|
+
* translated into the throw, and `MaterializeResult.reason` never carries it.
|
|
775
|
+
*
|
|
776
|
+
* **A `WriteConflictError` does not by itself mean nothing was published**, and that is worth knowing
|
|
777
|
+
* before you write the retry. `'superseded'` covers five different causes — the write-once PUT collided,
|
|
778
|
+
* the pointer moved, a row appeared where the load found none, the row's token changed, the row was purged —
|
|
779
|
+
* and only the first three are the "somebody beat us" the name suggests. A token can also change on a write
|
|
780
|
+
* 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
|
|
781
|
+
* successful publish can raise the same error. So: treat it as "re-read the destination and decide",
|
|
782
|
+
* never as "the write did not happen".
|
|
783
|
+
*
|
|
784
|
+
* **And by default it collects nothing**, unlike `load()`. See the `keep` default below.
|
|
785
|
+
*/
|
|
786
|
+
async materialize(dest, ids, op, options) {
|
|
787
|
+
const deps = this.lifecycleDeps(op);
|
|
788
|
+
let result;
|
|
789
|
+
try {
|
|
790
|
+
result = await loadSegment(dest, ids, deps, {
|
|
791
|
+
...options?.allowEmpty === void 0 ? {} : { allowEmpty: options.allowEmpty },
|
|
792
|
+
...options?.guard === void 0 ? {} : { guard: options.guard },
|
|
793
|
+
// COLLECT NOTHING by default, which `loadSegment` does not — it keeps a grace window of 1 and deletes
|
|
794
|
+
// the rest. By default a materialisation collects nothing: the guide states "**It deletes nothing**,
|
|
795
|
+
// unlike `load()`. The destination's previous generations stay in the bucket until you collect them", and
|
|
796
|
+
// the ownership table
|
|
797
|
+
// puts that call on the operator. Inheriting `load()`'s collection would silently delete the
|
|
798
|
+
// generations an operator's recovery story depends on — `rollbackSegment` refuses a collected target —
|
|
799
|
+
// as a side effect of adding a guard whose entire purpose is preventing data loss. Opt in with `keep`.
|
|
800
|
+
keep: options?.keep ?? KEEP_EVERY_GENERATION,
|
|
801
|
+
...options?.audit === void 0 ? {} : { audit: options.audit }
|
|
802
|
+
});
|
|
803
|
+
} finally {
|
|
804
|
+
this.engine.invalidate(dest);
|
|
805
|
+
}
|
|
806
|
+
const reason = result.reason;
|
|
807
|
+
if (reason === "superseded") {
|
|
808
|
+
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`;
|
|
809
|
+
throw new WriteConflictError(
|
|
810
|
+
`${op}: the destination "${dest.segment}" changed while this materialisation was in flight, so it never became current: ${wrote}. The pointer may have moved, the row may have been rewritten (a retention policy does this) or purged. Re-read the destination and re-run.`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
generation: result.generation,
|
|
815
|
+
published: result.published,
|
|
816
|
+
...reason === void 0 ? {} : { reason },
|
|
817
|
+
cardinality: result.cardinality,
|
|
818
|
+
cardinalityBefore: result.cardinalityBefore,
|
|
819
|
+
chunkCount: result.chunkCount,
|
|
820
|
+
size: result.size,
|
|
821
|
+
collected: result.collected
|
|
822
|
+
};
|
|
334
823
|
}
|
|
335
824
|
/**
|
|
336
|
-
* **Subject access (GDPR Art. 15 / CCPA right-to-know): which segments is this id a member of?**
|
|
825
|
+
* **Subject access (GDPR Art. 15 / CCPA right-to-know): which segments is this id a member of?**
|
|
826
|
+
*
|
|
827
|
+
* Enumerates the **registered** segments (via the store's own `registry`) and does a `has(id)` on each — no
|
|
828
|
+
* drivers to re-pass. Complete only over registered segments (every loaded segment has a row, so build the
|
|
829
|
+
* store on the backend the loads used). There is deliberately **no `id → segments` reverse index** — that would
|
|
830
|
+
* tax every load for a rare request; this admin scan is `O(registered segments)` and touches no hot path. Needs
|
|
831
|
+
* a storage backend (throws {@link UnsupportedError} otherwise).
|
|
337
832
|
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
* (throws {@link UnsupportedError} otherwise).
|
|
833
|
+
* **Current as of the registry row, not as of the reader's cache.** Each segment's resolved generation is
|
|
834
|
+
* compared with the row the scan listed, and a segment whose cached generation differs is re-resolved before the
|
|
835
|
+
* read, so a load or an erasure from another process shows at once — whatever `cache.genTtlMs` is, `0` included.
|
|
836
|
+
* The comparison is on the generation **and** the row's token, so a segment retired, purged and loaded again from
|
|
837
|
+
* generation 0 while this store held its old generation 0 is told apart too.
|
|
344
838
|
*/
|
|
345
839
|
async subjectReport(id, options = {}) {
|
|
346
840
|
const registry = this.requireRegistry("subjectReport");
|
|
@@ -349,7 +843,9 @@ var CloudRoaring = class {
|
|
|
349
843
|
const budget = resolvePerOpBudget(options.budget, this.budget);
|
|
350
844
|
splitId(id);
|
|
351
845
|
const recs = await collectWithinBudget(
|
|
352
|
-
|
|
846
|
+
// A subject cannot be in a due-index row, and charging this request's budget for them would refuse a
|
|
847
|
+
// GDPR Art. 15 report for a reason unrelated to the subject.
|
|
848
|
+
excludingReservedRows(registry.list(options.namespace)),
|
|
353
849
|
budget,
|
|
354
850
|
"subjectReport"
|
|
355
851
|
);
|
|
@@ -359,90 +855,131 @@ var CloudRoaring = class {
|
|
|
359
855
|
async (rec) => {
|
|
360
856
|
if (rec.status === "destroyed") return null;
|
|
361
857
|
const ref = { segment: rec.segment, namespace: rec.namespace };
|
|
362
|
-
|
|
858
|
+
if (this.crbmSource !== void 0) {
|
|
859
|
+
const held = await this.crbmSource.currentVersion(ref);
|
|
860
|
+
const listed = rec.currentGen === null ? null : `${rec.currentGen}:${String(rec.token)}`;
|
|
861
|
+
if (held !== listed) this.engine.invalidate(ref);
|
|
862
|
+
}
|
|
863
|
+
return await this.engine.has(ref, id) ? { segment: rec.segment, namespace: rec.namespace } : null;
|
|
363
864
|
}
|
|
364
865
|
);
|
|
365
866
|
const segments = membership.filter((m) => m !== null);
|
|
366
867
|
return { id, segments, scannedSegments: recs.length };
|
|
367
868
|
}
|
|
368
869
|
/**
|
|
369
|
-
* **Subject erasure (GDPR Art. 17): remove an id from every segment it's in, with
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* For each **registered** segment the id is a member of
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
870
|
+
* **Subject erasure (GDPR Art. 17): remove an id from every segment it's in, with the bit physically gone
|
|
871
|
+
* from the bucket on return.**
|
|
872
|
+
*
|
|
873
|
+
* For each **registered** segment the id is a member of, `eraseIdFromSegment` rewrites the current generation
|
|
874
|
+
* without the id — every chunk streamed through, one bit cleared — publishes the rewrite **fenced on the
|
|
875
|
+
* generation it was derived from**, and collects the generation that held the bit. A segment whose current
|
|
876
|
+
* generation does not hold the id is searched anyway, every generation in its bucket: a retained superseded one
|
|
877
|
+
* can still hold it, and so can one above the pointer after a {@link CloudRoaring.rollback}, which the rollback
|
|
878
|
+
* could make current again. Each holder is deleted — below the pointer by a `keep: 0` collection, above it one
|
|
879
|
+
* by one, re-proved against the row first — and the generations above the pointer that never held the id stay
|
|
880
|
+
* as rollback targets. An entry says `erased: true` only once the call has listed the bucket and read what is
|
|
881
|
+
* left: **no generation of the segment holds the id**. The returned per-segment record is your **erasure ledger** —
|
|
882
|
+
* persist it / route it to your audit sink as the proof of deletion (a `segment.rewrite` audit event is also
|
|
883
|
+
* emitted per rewrite when you pass `audit`).
|
|
884
|
+
*
|
|
885
|
+
* Uses the backend's **own** two halves, so the membership check and the rewrite provably run
|
|
886
|
+
* over the same generation. Requires the store built with a **backend** (throws
|
|
887
|
+
* {@link UnsupportedError} otherwise; a pre-built `StorageChunkSource` store has no `IStorageDriver` to write
|
|
888
|
+
* through; build the store on a backend instead).
|
|
889
|
+
*
|
|
890
|
+
* **One contract remains** (an integrator obligation the library cannot check): **do not load the segment
|
|
891
|
+
* while erasing from it.** A load that lands after the rewrite carries whatever its source held, and the
|
|
892
|
+
* library cannot know that source was meant to exclude the id. Quiesce loads of the affected segments for the
|
|
893
|
+
* duration, or fix the source first and load after. A writer that lands *during* the rewrite is caught: the
|
|
894
|
+
* rewrite's publish is refused **by the fence** — the publish lands its compare-and-swap only
|
|
895
|
+
* while the pointer is still on the generation the rewrite streamed — and the entry says `note: 'superseded'`,
|
|
896
|
+
* so re-run. Forward-only alone would NOT refuse it: the rewrite's number goes above everything in the bucket,
|
|
897
|
+
* so the rewrite would out-rank the newer generation and then collect it.
|
|
898
|
+
*
|
|
899
|
+
* A racing **erasure** is caught before that, and reported the same way. It collects with `keep: 0`, taking
|
|
900
|
+
* every generation below its new pointer — the one this rewrite is streaming, and the object this rewrite
|
|
901
|
+
* just wrote — so the loser can find its own inputs deleted mid-flight. That surfaces as an outcome rather than
|
|
902
|
+
* an error, read off the row: a moved pointer is a `'superseded'` entry, which a re-run settles, and a segment
|
|
903
|
+
* that a concurrent `dropSegment` tombstoned or a retention sweep purged is left out of the ledger, as a fresh
|
|
904
|
+
* call would leave it out.
|
|
905
|
+
*
|
|
906
|
+
* **Read `note` on any `erased: false` entry — the two reasons mean different things.** `'superseded'` means
|
|
907
|
+
* another writer (a load, another erasure, or a rollback) moved the pointer mid-call, so **this call** did not
|
|
908
|
+
* erase the id. Re-run: it erases the id if it is still there, and lists nothing for the segment if a racing
|
|
909
|
+
* erasure of the same id already removed it. Do not read `'superseded'` as "the id is still present" —
|
|
910
|
+
* read it as "not done by this call, and the re-run settles it".
|
|
911
|
+
* `` `error: …` `` is a per-segment fault (caught so one segment can't discard the whole ledger) and it can land
|
|
912
|
+
* on either side of the publish: if the rewrite had not published, the id is still there and a re-run erases
|
|
913
|
+
* it; if the publish succeeded and only the **collection** of the old generation failed, the id is already
|
|
914
|
+
* absent from every read and what remains is an object in the bucket that still contains the bit. The same
|
|
915
|
+
* note, after a publish, can mean a rollback moved the pointer back onto a generation that still holds the id
|
|
916
|
+
* while the collection ran — then the id is served again, and the entry says so rather than `erased: true`. A
|
|
917
|
+
* re-run searches every generation in the bucket, so it rewrites or collects what is left and usually reports
|
|
918
|
+
* `erased: true`; {@link SubjectErasureEntry.note} says what else a re-run can report. Re-running is otherwise safe
|
|
919
|
+
* and idempotent: a segment the id is no longer in is not listed. Admin-only path;
|
|
920
|
+
* `O(registered segments + superseded generations)`, no hot-path cost: a segment whose current generation lacks
|
|
921
|
+
* the id is searched generation by generation, one open for each other generation in its bucket, and with the
|
|
922
|
+
* keep-everything default of the `*Into` verbs that is one per generation the segment ever had. The per-op
|
|
923
|
+
* `budget` is charged one unit for each segment and one for each generation opened beyond the one its row names,
|
|
924
|
+
* so a fleet with long histories can exhaust it where the segment count alone would not. A segment that does is
|
|
925
|
+
* reported `erased: false` with an `error:` note, before it deletes anything (the one refusal that can come after a
|
|
926
|
+
* rewrite is the last check for a generation a concurrent writer left behind), and the
|
|
927
|
+
* rest of the scan continues: the call itself does not throw for it. Per-subject crypto-shred is infeasible (a subject's bit is
|
|
400
928
|
* co-mingled in a shared container), so this is the single-subject erasure route; whole-segment/tenant erasure
|
|
401
|
-
* is the `destroySegment`/`eraseNamespace` free functions.
|
|
929
|
+
* is `dropSegment` / the `destroySegment`/`eraseNamespace` free functions.
|
|
402
930
|
*/
|
|
403
|
-
async eraseSubject(id, options) {
|
|
404
|
-
const
|
|
405
|
-
validateCompactionOptions({ owner: options.owner });
|
|
931
|
+
async eraseSubject(id, options = {}) {
|
|
932
|
+
const deps = this.lifecycleDeps("eraseSubject");
|
|
406
933
|
requireScope(options, "eraseSubject");
|
|
407
934
|
validateConcurrency(options.concurrency);
|
|
408
935
|
const budget = resolvePerOpBudget(options.budget, this.budget);
|
|
409
936
|
splitId(id);
|
|
410
937
|
const recs = await collectWithinBudget(
|
|
411
|
-
|
|
938
|
+
excludingReservedRows(deps.registry.list(options.namespace)),
|
|
412
939
|
budget,
|
|
413
940
|
"eraseSubject"
|
|
414
941
|
);
|
|
942
|
+
let opens = 0;
|
|
943
|
+
const charge = () => {
|
|
944
|
+
opens += 1;
|
|
945
|
+
checkBudget(budget, recs.length + opens, "eraseSubject");
|
|
946
|
+
};
|
|
415
947
|
const entries = await mapWithConcurrency(
|
|
416
948
|
recs,
|
|
417
949
|
options.concurrency ?? DEFAULT_ADMIN_CONCURRENCY,
|
|
418
950
|
async (rec) => {
|
|
419
951
|
if (rec.status === "destroyed") return null;
|
|
420
952
|
const ref = { segment: rec.segment, namespace: rec.namespace };
|
|
421
|
-
let removed = false;
|
|
422
953
|
try {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
954
|
+
const result = await eraseIdFromSegment(
|
|
955
|
+
ref,
|
|
956
|
+
id,
|
|
957
|
+
budget === null ? deps : { ...deps, storage: new OpenChargingStorage(deps.storage, rec.currentGen, charge) },
|
|
958
|
+
{ audit: options.audit }
|
|
959
|
+
);
|
|
960
|
+
if (result.reason === "not-member" || result.reason === "absent") return null;
|
|
961
|
+
if (result.reason === "no-generation") return null;
|
|
962
|
+
if (result.reason === "destroyed") return null;
|
|
430
963
|
return {
|
|
431
964
|
segment: rec.segment,
|
|
432
965
|
namespace: rec.namespace,
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
note: result.
|
|
966
|
+
erased: result.erased,
|
|
967
|
+
fromGeneration: result.fromGeneration,
|
|
968
|
+
generation: result.generation,
|
|
969
|
+
note: result.erased ? void 0 : result.reason
|
|
437
970
|
};
|
|
438
971
|
} catch (err) {
|
|
439
972
|
return {
|
|
440
973
|
segment: rec.segment,
|
|
441
974
|
namespace: rec.namespace,
|
|
442
|
-
|
|
443
|
-
physicallyPurged: false,
|
|
975
|
+
erased: false,
|
|
444
976
|
note: `error: ${err instanceof Error ? err.message : String(err)}`
|
|
445
977
|
};
|
|
978
|
+
} finally {
|
|
979
|
+
try {
|
|
980
|
+
this.engine.invalidate(ref);
|
|
981
|
+
} catch {
|
|
982
|
+
}
|
|
446
983
|
}
|
|
447
984
|
}
|
|
448
985
|
);
|
|
@@ -450,30 +987,179 @@ var CloudRoaring = class {
|
|
|
450
987
|
return { id, erasedFrom, scannedSegments: recs.length };
|
|
451
988
|
}
|
|
452
989
|
/**
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
* constant-memory, but full-segment I/O). Use the `compact-segments` daemon (`runCompactionCycle`) for routine
|
|
461
|
-
* background compaction; reach for `store.compact()` for occasional/manual one-shots — a maintenance endpoint,
|
|
462
|
-
* a small daemon-less deployment, or right after a targeted `remove`.
|
|
990
|
+
* Every generation still in the bucket for this segment, ascending, with the current one marked.
|
|
991
|
+
*
|
|
992
|
+
* What the bucket holds, not what the segment has ever been — collection deletes superseded objects, so this is
|
|
993
|
+
* the grace window plus whatever has not been collected yet. It is the set {@link CloudRoaring.rollback} can
|
|
994
|
+
* choose from, which is the reason to look at it. One registry read and one listing; it does not open the objects.
|
|
995
|
+
*
|
|
996
|
+
* Needs a backend.
|
|
463
997
|
*/
|
|
464
|
-
async
|
|
998
|
+
async generations(ref) {
|
|
465
999
|
validateSegmentRef(ref);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1000
|
+
return listGenerations(ref, this.lifecycleDeps("generations"));
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Whether a read of this segment would find anything — the "do I already have this?" question, as one
|
|
1004
|
+
* registry point read.
|
|
1005
|
+
*
|
|
1006
|
+
* There is no `create` in this library: {@link CloudRoaring.segment} is a validated address and does no I/O,
|
|
1007
|
+
* so naming a segment can never collide with an existing one. A segment starts existing when something is
|
|
1008
|
+
* first loaded into it, and this is how you ask whether that has happened.
|
|
1009
|
+
*
|
|
1010
|
+
* **Not the same as `count() > 0`.** A segment loaded with no ids exists and counts zero. Distinguishing
|
|
1011
|
+
* "never loaded" from "loaded, and genuinely empty" is the thing `count()` cannot do and the reason this
|
|
1012
|
+
* exists. It is `false` for a segment whose row was minted ahead of its first load (by `setRetention`) and
|
|
1013
|
+
* for a `destroyed` tombstone, because a read answers empty in both cases.
|
|
1014
|
+
*
|
|
1015
|
+
* Two states answer `true` where a read still gives you nothing: a torn restore (a live pointer whose object
|
|
1016
|
+
* was deleted) makes reads *throw* rather than answer empty — `checkConsistency` is the call for that — and
|
|
1017
|
+
* a handle carrying an expired `expiresAt` reads empty by a rule that lives on the handle, not the row.
|
|
1018
|
+
*
|
|
1019
|
+
* Not a lock: the answer can change the moment it returns. If it has to hold, use the fence built for that —
|
|
1020
|
+
* `load`'s `guard`, or `expectFrom`/`expectToken` on a publish. Needs a storage backend.
|
|
1021
|
+
*
|
|
1022
|
+
* ```ts
|
|
1023
|
+
* if (!(await store.exists({ segment: 'users' }))) {
|
|
1024
|
+
* await store.load({ segment: 'users' }, idsFromUpstream);
|
|
1025
|
+
* }
|
|
1026
|
+
* ```
|
|
1027
|
+
*/
|
|
1028
|
+
async exists(ref) {
|
|
1029
|
+
validateSegmentRef(ref);
|
|
1030
|
+
return segmentExists(ref, this.requireRegistry("exists"));
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Every segment the registry knows about, streamed — optionally scoped to one namespace.
|
|
1034
|
+
*
|
|
1035
|
+
* The registry is already the list of your segments, which is why you should not keep a second one beside it:
|
|
1036
|
+
* a hand-maintained list is a source of truth that drifts from this one the first time a load fails halfway.
|
|
1037
|
+
*
|
|
1038
|
+
* **An admin/discovery call, not a request-path one.** This is the registry's own enumeration — a paged
|
|
1039
|
+
* LIST over the `registry/` prefix — so its cost grows with the size of the fleet rather than with what you
|
|
1040
|
+
* are looking for.
|
|
1041
|
+
*
|
|
1042
|
+
* Scoping to a namespace narrows the LIST prefix, so it really is the difference between reading one tenant
|
|
1043
|
+
* and reading all of them.
|
|
1044
|
+
*
|
|
1045
|
+
* It streams, and stopping the iteration stops the scan. It reads the registry directly and is not retried: a
|
|
1046
|
+
* transient fault part-way through ends the loop with that error, and calling `segments()` again scans from the
|
|
1047
|
+
* start.
|
|
1048
|
+
*
|
|
1049
|
+
* Yields `destroyed` tombstones and rows with `currentGen: null`, because a filtered enumeration that looks
|
|
1050
|
+
* complete is worse than an honest one — filter on `status`/`currentGen` yourself, or ask
|
|
1051
|
+
* {@link CloudRoaring.exists} the narrower question. Needs a storage backend.
|
|
1052
|
+
*
|
|
1053
|
+
* ```ts
|
|
1054
|
+
* for await (const s of store.segments({ namespace: 'active-daily' })) {
|
|
1055
|
+
* console.log(s.segment, s.currentGen, s.status);
|
|
1056
|
+
* }
|
|
1057
|
+
* ```
|
|
1058
|
+
*/
|
|
1059
|
+
segments(options = {}) {
|
|
1060
|
+
if (options.namespace !== void 0) {
|
|
1061
|
+
validateSegmentRef({ segment: "x", namespace: options.namespace });
|
|
1062
|
+
refuseReservedNamespace(options.namespace);
|
|
472
1063
|
}
|
|
473
|
-
return
|
|
1064
|
+
return listSegments(this.requireRegistry("segments"), options);
|
|
474
1065
|
}
|
|
475
1066
|
/**
|
|
476
|
-
* **
|
|
1067
|
+
* Move this segment's pointer **back** to a generation still in the bucket — the one write in the library that
|
|
1068
|
+
* is not forward-only.
|
|
1069
|
+
*
|
|
1070
|
+
* Forward-only is right for a writer: a load whose ids came from upstream loses nothing by being out-raced, and
|
|
1071
|
+
* letting it regress would let a slow loader silently undo a fast one. It is wrong for an operator who has
|
|
1072
|
+
* looked at the segment, decided the current generation is wrong, and knows which one they want. So this is
|
|
1073
|
+
* reachable only by asking for it by name — no sweep, retry or reconciliation calls it — and it is audited
|
|
1074
|
+
* (`segment.rollback`), because every other pointer move can be reconstructed from "a load happened" and this
|
|
1075
|
+
* one cannot.
|
|
1076
|
+
*
|
|
1077
|
+
* It refuses rather than guesses: a generation not in the bucket (collected, or never written) throws
|
|
1078
|
+
* `NotFoundError` naming what *is* available, and a crypto-shredded segment throws
|
|
1079
|
+
* {@link ValidationError} because every generation of it is unreadable. Rolling to the generation already
|
|
1080
|
+
* current is a no-op that reports itself.
|
|
1081
|
+
*
|
|
1082
|
+
* It deletes nothing. The generations above the new pointer stay put — which is what makes this reversible —
|
|
1083
|
+
* and are then *above* `currentGen`, where collection never looks. They remain until a load numbers above them
|
|
1084
|
+
* (collection then keeps the newest `keep` of what is below its pointer), {@link CloudRoaring.dropSegment}
|
|
1085
|
+
* deletes them, or {@link CloudRoaring.eraseSubject} does: all of those present when it rewrites, and only those holding
|
|
1086
|
+
* the id when the current generation does not. An operator who has just undone a bad load should not have the
|
|
1087
|
+
* evidence collected out from under them, while a rollback target that still holds erased data would make the
|
|
1088
|
+
* erasure undoable.
|
|
1089
|
+
*
|
|
1090
|
+
* A target **above** the pointer needs `{ allowForward: true }`, and is refused with {@link ValidationError}
|
|
1091
|
+
* without it: that is also where objects live that were never published, such as a load that wrote its object
|
|
1092
|
+
* and died before the publish. Undoing an earlier rollback is what the opt-in is for.
|
|
1093
|
+
*
|
|
1094
|
+
* Needs a backend.
|
|
1095
|
+
*/
|
|
1096
|
+
async rollback(ref, toGeneration, options = {}) {
|
|
1097
|
+
validateSegmentRef(ref);
|
|
1098
|
+
const deps = this.lifecycleDeps("rollback");
|
|
1099
|
+
try {
|
|
1100
|
+
return await rollbackSegment(ref, toGeneration, deps, options);
|
|
1101
|
+
} finally {
|
|
1102
|
+
this.engine.invalidate(ref);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* **Replace this segment's contents** with `ids`, as one new immutable generation, and make it current.
|
|
1107
|
+
*
|
|
1108
|
+
* The whole write path in one call: take the next generation number, write the object, check the result is
|
|
1109
|
+
* plausible, move the pointer, collect what the move superseded. Composed by hand those are four functions and
|
|
1110
|
+
* the one that gets left out is the last, so segments quietly accumulate superseded generations nobody pays
|
|
1111
|
+
* attention to and everybody pays for.
|
|
1112
|
+
*
|
|
1113
|
+
* ```ts
|
|
1114
|
+
* const r = await store.load({ segment: 'audience:active' }, idsFromWarehouse, {
|
|
1115
|
+
* guard: { minRetained: 0.5 }, // refuse a load that would drop more than half the segment
|
|
1116
|
+
* });
|
|
1117
|
+
* if (!r.published) console.warn(`load refused: ${r.reason}`);
|
|
1118
|
+
* ```
|
|
1119
|
+
*
|
|
1120
|
+
* **A load REPLACES.** Whatever the stream contains is what the segment contains afterwards, so an upstream
|
|
1121
|
+
* query that returns fewer rows than usual is a shrink nobody asked for and an empty one is a wipe — both
|
|
1122
|
+
* reported as a successful write, because at the storage layer they are one. That is what `guard` and the
|
|
1123
|
+
* default empty refusal are for, and why they run **between** the write and the publish: the only moment where
|
|
1124
|
+
* the new content is known and the old one is still authoritative.
|
|
1125
|
+
*
|
|
1126
|
+
* **Branch on `published`.** A refusal is a normal outcome, not an exception — `published: false` with a
|
|
1127
|
+
* `reason`, in the same shape as a success — so a discarded result is a load that silently did nothing, and
|
|
1128
|
+
* with the empty guard on by default this verb refuses more readily than any other. Note the sibling `*Into`
|
|
1129
|
+
* verbs *throw* on the same superseded condition rather than reporting it; this one follows
|
|
1130
|
+
* `eraseIdFromSegment` instead, because three of its four refusals are expected guard outcomes rather than
|
|
1131
|
+
* faults.
|
|
1132
|
+
*
|
|
1133
|
+
* `'superseded'` means another writer got there first: another load took the same generation number, so this
|
|
1134
|
+
* one wrote nothing (`size: 0`), or the segment's registry row changed while the load was writing — another load
|
|
1135
|
+
* published, a retention change, a rollback or an erasure wrote the row, or the row was deleted.
|
|
1136
|
+
*
|
|
1137
|
+
* A refused load deletes the object it wrote before returning — it sits above `currentGen`, where generation
|
|
1138
|
+
* collection deliberately never looks — but only while the segment's registry row is unchanged or gone. Once
|
|
1139
|
+
* another write has changed the row, the generation number it holds may name another incarnation's live object,
|
|
1140
|
+
* so it leaves the orphan rather than risk deleting live data. The orphan is an ordinary generation once a later
|
|
1141
|
+
* one is current above it, and collection counts it within `keep`.
|
|
1142
|
+
*
|
|
1143
|
+
* What it **throws** for is a fault rather than an outcome: invalid options or ids, and a crypto-shredded
|
|
1144
|
+
* segment (`ValidationError`); a key the keystore cannot provide (`KeyUnavailableError`); a current generation
|
|
1145
|
+
* that will not open when a guard reads its size (`IntegrityError`); a driver failure; and a collection pass
|
|
1146
|
+
* that could not prove the segment was still the same one (`WriteConflictError`). The last can be raised
|
|
1147
|
+
* **after** the publish already landed, so a throw does not by itself mean the load did not take effect —
|
|
1148
|
+
* re-read the pointer rather than assuming.
|
|
1149
|
+
*
|
|
1150
|
+
* Needs a backend (throws {@link UnsupportedError} otherwise).
|
|
1151
|
+
*/
|
|
1152
|
+
async load(ref, ids, options = {}) {
|
|
1153
|
+
validateSegmentRef(ref);
|
|
1154
|
+
const deps = this.lifecycleDeps("load");
|
|
1155
|
+
try {
|
|
1156
|
+
return await loadSegment(ref, ids, deps, options);
|
|
1157
|
+
} finally {
|
|
1158
|
+
this.engine.invalidate(ref);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* **Dispose of a segment — tombstone it, then delete its storage objects.** Irreversible.
|
|
477
1163
|
*
|
|
478
1164
|
* The operation a rolling window needs: `destroySegment` crypto-shreds (bytes unreadable everywhere including
|
|
479
1165
|
* backups, but still sitting in your bucket and still billed, and it requires encryption), while this one
|
|
@@ -485,7 +1171,8 @@ var CloudRoaring = class {
|
|
|
485
1171
|
*
|
|
486
1172
|
* ```ts
|
|
487
1173
|
* for (const day of expiredDays) {
|
|
488
|
-
* //
|
|
1174
|
+
* // The family in the namespace, the date in the segment — `registry.list(namespace)` then enumerates
|
|
1175
|
+
* // exactly this family's buckets, which a flat `active:2026-08-01` name cannot do without string-matching.
|
|
489
1176
|
* const ref = { namespace: 'active-daily', segment: day };
|
|
490
1177
|
* await store.dropSegment(ref, { confirmSegment: ref.segment });
|
|
491
1178
|
* }
|
|
@@ -495,29 +1182,32 @@ var CloudRoaring = class {
|
|
|
495
1182
|
* `{ dropped: false, reason: 'absent' }`, not a throw, so a retention loop with that mistake deletes nothing
|
|
496
1183
|
* forever and quietly. Branch on `dropped`, and treat `reason: 'absent'` as the alert.
|
|
497
1184
|
*
|
|
498
|
-
* **Works on an accumulator too.** A segment you created by writing to it — never bulk-loaded, never compacted,
|
|
499
|
-
* so it has no registry row and no Cold objects — is retired by deleting its Warm rows alone. That reports
|
|
500
|
-
* `{ dropped: true, reason: 'warm-only' }`: there is no tombstone to write, and deliberately none is written,
|
|
501
|
-
* because a `destroyed` row per retired daily bucket would be registry litter. The data really is gone.
|
|
502
|
-
*
|
|
503
1185
|
* **Inspect `generationsRemaining`.** Empty is the normal outcome; non-empty means the storage was NOT fully
|
|
504
|
-
* reclaimed and the drop should be re-run. A
|
|
505
|
-
*
|
|
506
|
-
*
|
|
1186
|
+
* reclaimed and the drop should be re-run. A load that was already writing when the tombstone landed still
|
|
1187
|
+
* finishes its object, so a single sweep can miss it — this call re-sweeps and then reports whatever it still
|
|
1188
|
+
* could not remove rather than returning a result that looks like a clean drop.
|
|
507
1189
|
*
|
|
508
|
-
* Reads become empty within `
|
|
509
|
-
* segment may answer from its cached generation +
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
1190
|
+
* Reads become empty within `cache.genTtlMs` (default 2 s), not instantly: a store that had already read this
|
|
1191
|
+
* segment may answer from its cached generation + cached chunks until that window lapses, or, while the registry
|
|
1192
|
+
* cannot be read because of a transient fault, until a retry 500 ms apart reaches it again. A reader that never
|
|
1193
|
+
* touched it sees empty at once. **That bound needs a registry and `cache.genTtlMs > 0`.** A store with no registry
|
|
1194
|
+
* (a bare `IStorageDriver`), with `cache.genTtlMs: 0`, or on a storage source built with no clock, has no timed
|
|
1195
|
+
* refresh. It notices the drop only when a read has to fetch
|
|
1196
|
+
* from a deleted generation or its reader cache evicts the segment, and until then a chunk it has cached
|
|
1197
|
+
* answers without reaching storage, so it can keep answering `true` for a dropped segment indefinitely. Tell
|
|
1198
|
+
* it with {@link invalidate}, or restart it.
|
|
513
1199
|
*
|
|
514
|
-
* Needs the store built with a **
|
|
515
|
-
* because it has to enumerate and delete generations — a pre-built `
|
|
1200
|
+
* Needs the store built with a **backend** (throws {@link UnsupportedError} otherwise),
|
|
1201
|
+
* because it has to enumerate and delete generations — a pre-built `StorageChunkSource` only reads.
|
|
516
1202
|
*/
|
|
517
1203
|
async dropSegment(ref, options) {
|
|
518
1204
|
validateSegmentRef(ref);
|
|
519
|
-
const deps = this.
|
|
520
|
-
|
|
1205
|
+
const deps = this.lifecycleDeps("dropSegment");
|
|
1206
|
+
try {
|
|
1207
|
+
return await dropSegment(ref, { registry: deps.registry, storage: deps.storage }, options);
|
|
1208
|
+
} finally {
|
|
1209
|
+
if (options.dryRun !== true) this.engine.invalidate(ref);
|
|
1210
|
+
}
|
|
521
1211
|
}
|
|
522
1212
|
/**
|
|
523
1213
|
* **Record when this segment becomes eligible for retirement.** One registry write; nothing is deleted here,
|
|
@@ -526,9 +1216,9 @@ var CloudRoaring = class {
|
|
|
526
1216
|
* no background thread (it has to work identically in a Lambda, an edge isolate and a long-lived server).
|
|
527
1217
|
*
|
|
528
1218
|
* `expiresAt` is an **absolute epoch-ms you compute**, not a duration the library derives. A relative TTL would
|
|
529
|
-
* have to be anchored to something the library knows — `updatedAt`, or the current generation — and
|
|
1219
|
+
* have to be anchored to something the library knows — `updatedAt`, or the current generation — and every load
|
|
530
1220
|
* rewrites both, so "expire 30 days after the last write" would keep a busy daily bucket alive forever
|
|
531
|
-
* precisely because
|
|
1221
|
+
* precisely because it is being reloaded.
|
|
532
1222
|
*
|
|
533
1223
|
* ```ts
|
|
534
1224
|
* const DAY = 86_400_000;
|
|
@@ -536,15 +1226,14 @@ var CloudRoaring = class {
|
|
|
536
1226
|
* await store.setRetention(ref, { expiresAt: Date.now() + 30 * DAY });
|
|
537
1227
|
* ```
|
|
538
1228
|
*
|
|
539
|
-
* **Works
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
* becomes enumerable while every read still resolves exactly as before.
|
|
1229
|
+
* **Works before the first load**: a segment with no registry row yet gets one (`createdRow: true` in the
|
|
1230
|
+
* result) with **no Storage generation**, so the policy is recorded ahead of the data and the segment is already
|
|
1231
|
+
* enumerable by the sweep; the first load then publishes onto that row.
|
|
543
1232
|
*
|
|
544
1233
|
* A value in the past is legal and means "eligible on the next sweep" — backfilling a policy onto existing
|
|
545
1234
|
* buckets is normal. A value below `MIN_EXPIRES_AT_MS` (2001-09-09) is rejected: it is almost certainly epoch
|
|
546
|
-
* **seconds**, which would read as long-expired and retire the segment on the next pass. Needs a
|
|
547
|
-
*
|
|
1235
|
+
* **seconds**, which would read as long-expired and retire the segment on the next pass. Needs a storage
|
|
1236
|
+
* backend (throws {@link UnsupportedError} otherwise), and refuses a crypto-shredded segment.
|
|
548
1237
|
*/
|
|
549
1238
|
async setRetention(ref, policy) {
|
|
550
1239
|
validateSegmentRef(ref);
|
|
@@ -573,17 +1262,17 @@ var CloudRoaring = class {
|
|
|
573
1262
|
* **Run the retention sweep: retire every segment whose `expiresAt` has passed.** This is the call that acts on
|
|
574
1263
|
* the policies `setRetention` records — and it is a **call, not a daemon**. Nothing schedules it; you run it from
|
|
575
1264
|
* whatever heartbeat your deployment already has (an EventBridge rule, a Kubernetes CronJob, a queue consumer,
|
|
576
|
-
*
|
|
1265
|
+
* the job that runs your loads). A library that started a timer would behave differently in a Lambda, an edge
|
|
577
1266
|
* isolate and a long-lived server, which is worse than not having one.
|
|
578
1267
|
*
|
|
579
|
-
* Each retirement goes through `dropSegment`, so the
|
|
580
|
-
*
|
|
581
|
-
*
|
|
1268
|
+
* Each retirement goes through `dropSegment`, so the registry → Storage ordering, the re-sweep for an object a
|
|
1269
|
+
* load was still writing, and the `generationsRemaining` report all come from one implementation rather than
|
|
1270
|
+
* two.
|
|
582
1271
|
*
|
|
583
1272
|
* ```ts
|
|
584
1273
|
* // In your scheduled handler. Start with a preview in a new deployment.
|
|
585
1274
|
* const preview = await store.retireExpired({ namespace: 'active-daily', dryRun: true });
|
|
586
|
-
* console.log(`would retire ${preview.
|
|
1275
|
+
* console.log(`would retire ${preview.wouldRetire} of ${preview.scanned} (limited: ${preview.limited})`);
|
|
587
1276
|
*
|
|
588
1277
|
* const swept = await store.retireExpired({ namespace: 'active-daily' });
|
|
589
1278
|
* for (const e of swept.entries) {
|
|
@@ -599,90 +1288,424 @@ var CloudRoaring = class {
|
|
|
599
1288
|
* sweep would leave the caller unable to say which segments were retired, having already retired some. (A bad
|
|
600
1289
|
* argument does throw, and so does a fleet larger than `maxScanSegments`.) `limited: true` means the per-cycle
|
|
601
1290
|
* `limit` (default 100) cut the pass short and more are eligible. That cap is charged on **attempts**, not
|
|
602
|
-
* successes, which is what makes it a real bound: `dropSegment`
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
1291
|
+
* successes, which is what makes it a real bound: `dropSegment` writes the tombstone before sweeping Storage, so a
|
|
1292
|
+
* fault in the Storage phase is a segment that is already retired. Retirements are sequential, so `limit` is a
|
|
1293
|
+
* wall-clock knob too, and `retired` counts deletions only — a dry run reports `wouldRetire` instead, so a
|
|
1294
|
+
* dashboard summing `retired` can never show a phantom deletion.
|
|
606
1295
|
*
|
|
607
1296
|
* It also **deletes the tombstone rows its own past retirements left**, after `tombstoneGraceMs` (default 24 h)
|
|
608
|
-
* and only once that segment's
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
1297
|
+
* and only once that segment's Storage generations are provably gone — collecting a straggler generation itself
|
|
1298
|
+
* first, since nothing else ever would for a tombstoned segment. Attribution is a **positive marker the sweep
|
|
1299
|
+
* stamps on its own retirements**, not an inference from "destroyed + an expired policy": a crypto-shred leaves
|
|
1300
|
+
* `retention` untouched, so setting a policy and then honouring a right-to-erasure request mid-window produces
|
|
1301
|
+
* exactly that row, and deleting it would destroy the Art. 17 attestation and un-fence the name. By default a
|
|
1302
|
+
* segment that held nothing has its row deleted in the pass that retires it, since that row would only fence the name.
|
|
1303
|
+
* Pass `purgeTombstones: false` to keep every tombstone, that row included.
|
|
614
1304
|
*
|
|
615
|
-
* Needs the store built with a **
|
|
616
|
-
* because retiring a segment deletes its
|
|
1305
|
+
* Needs the store built with a **backend** (throws {@link UnsupportedError} otherwise),
|
|
1306
|
+
* because retiring a segment deletes its storage objects. `now` defaults to the store's clock.
|
|
617
1307
|
*/
|
|
618
1308
|
async retireExpired(options = {}) {
|
|
619
|
-
const deps = this.
|
|
620
|
-
|
|
621
|
-
{ registry: deps.registry,
|
|
1309
|
+
const deps = this.lifecycleDeps("retireExpired");
|
|
1310
|
+
const result = await retireExpired(
|
|
1311
|
+
{ registry: deps.registry, storage: deps.storage },
|
|
622
1312
|
{ ...options, now: options.now ?? this.clock.now() }
|
|
623
1313
|
);
|
|
1314
|
+
if (options.dryRun !== true) {
|
|
1315
|
+
for (const entry of result.entries) {
|
|
1316
|
+
this.engine.invalidate({ segment: entry.segment, namespace: entry.namespace });
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return result;
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* **Forget everything this store has cached about a segment.** Use it after destroying or retiring a segment
|
|
1323
|
+
* through a path this store did not perform itself.
|
|
1324
|
+
*
|
|
1325
|
+
* A store keeps two layers of derived state — a resolved snapshot per segment (an open `.crbm` reader, plus
|
|
1326
|
+
* the DEK it unwrapped) and decoded chunks keyed by generation. Both are built for one event: **a publish
|
|
1327
|
+
* that advances `currentGen`**, which the snapshot TTL notices and the generation-keyed cache misses on.
|
|
1328
|
+
* Neither notices an event that *destroys* what they were derived from.
|
|
1329
|
+
*
|
|
1330
|
+
* The store's own writes handle themselves: `load`, `rollback`, `eraseSubject`, `dropSegment` and `retireExpired`
|
|
1331
|
+
* here, and a segment's `*Into` verbs, invalidate what they touch. This method is for the cases they cannot see:
|
|
1332
|
+
*
|
|
1333
|
+
* - **`destroySegment` / `eraseNamespace`**, which are free functions over raw drivers rather than methods
|
|
1334
|
+
* here, so a crypto-shred performed beside this store invalidates nothing in it. It keeps its open reader and
|
|
1335
|
+
* the unwrapped DEK, and decrypts with them, chunks it had never fetched before the shred included, until its
|
|
1336
|
+
* snapshot re-resolves: within `cache.genTtlMs` when the store has a registry and a `genTtlMs` above 0, and
|
|
1337
|
+
* otherwise only when its caches let the segment go or a read has to fetch from a generation a sweep collected.
|
|
1338
|
+
* A pin answers with no timed bound, until it is told or both caches let it go.
|
|
1339
|
+
* - **Another process.** Erasing on one box invalidates nothing on the others; each store bounds its own
|
|
1340
|
+
* staleness by `cache.genTtlMs`, and a store with no registry (a bare `IStorageDriver`), with
|
|
1341
|
+
* `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
|
|
1342
|
+
* reader converging, you need to signal them — this is the call to make when your own fan-out delivers.
|
|
1343
|
+
*
|
|
1344
|
+
* It also forgets any replacement this store found of a pin's object, so once it is called after a restore puts that
|
|
1345
|
+
* object back, the pin reads it again.
|
|
1346
|
+
*
|
|
1347
|
+
* Synchronous, best-effort, and safe to call for a segment this store has never read.
|
|
1348
|
+
*
|
|
1349
|
+
* ```ts
|
|
1350
|
+
* await destroySegment(ref, { registry: backend.registry }, { confirmSegment: ref.segment });
|
|
1351
|
+
* store.invalidate(ref); // this process
|
|
1352
|
+
* await bus.publish('cloudbitmaps.invalidate', ref); // and every other one
|
|
1353
|
+
* ```
|
|
1354
|
+
*/
|
|
1355
|
+
invalidate(ref) {
|
|
1356
|
+
validateSegmentRef(ref);
|
|
1357
|
+
refuseReservedNamespace(ref.namespace);
|
|
1358
|
+
this.engine.invalidate(ref);
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Build a handle held at the generation `ref` resolves to right now. See {@link Segment.pin}.
|
|
1362
|
+
*
|
|
1363
|
+
* The pinned handle gets its own engine but **shares the store's chunk cache**, under keys of its own: the
|
|
1364
|
+
* pinned view reports the version captured at pin time, marked as a pin's, so its decoded chunks are never
|
|
1365
|
+
* those of a live read that fetched across a publish (see {@link PinnedStorageChunkSource.currentVersion}).
|
|
1366
|
+
*/
|
|
1367
|
+
async pinSegment(ref, expiresAt) {
|
|
1368
|
+
const crbm = this.crbmSource;
|
|
1369
|
+
if (crbm === void 0) {
|
|
1370
|
+
throw new UnsupportedError(
|
|
1371
|
+
"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)"
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
const at = await this.withRetries(() => crbm.pinGeneration(ref));
|
|
1375
|
+
const pinnedAt = {
|
|
1376
|
+
generation: at?.generation ?? null,
|
|
1377
|
+
version: at?.version ?? null,
|
|
1378
|
+
fingerprint: at?.fingerprint ?? null
|
|
1379
|
+
};
|
|
1380
|
+
const pins = /* @__PURE__ */ new Map([[segmentKey(ref), pinnedAt]]);
|
|
1381
|
+
return makeSegment({
|
|
1382
|
+
engine: this.engineWithPins(pins),
|
|
1383
|
+
ref,
|
|
1384
|
+
clock: this.clock,
|
|
1385
|
+
metrics: this.metrics,
|
|
1386
|
+
materialize: (dest, ids, op, options) => this.materialize(dest, ids, op, options),
|
|
1387
|
+
pinned: (r, e) => this.pinSegment(r, e),
|
|
1388
|
+
combineEngine: (handles) => this.engineForCombine(handles),
|
|
1389
|
+
expiresAt,
|
|
1390
|
+
pinnedAt
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* An engine reading every segment in `pins` at its pinned generation and everything else live, with the store's
|
|
1395
|
+
* retries, so a transient fault on a pinned read is retried as it would be on a live one.
|
|
1396
|
+
*
|
|
1397
|
+
* It **shares the store's chunk cache and its memory bound**, under keys of its own for each pinned segment.
|
|
1398
|
+
*/
|
|
1399
|
+
engineWithPins(pins) {
|
|
1400
|
+
const crbm = this.crbmSource;
|
|
1401
|
+
if (crbm === void 0) {
|
|
1402
|
+
throw new UnsupportedError(
|
|
1403
|
+
"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)"
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
const pinned = new PinnedStorageChunkSource(crbm, pins);
|
|
1407
|
+
return new SegmentEngine({
|
|
1408
|
+
storage: this.retryOptions === void 0 ? pinned : new RetryingStorageChunkSource(pinned, this.retryOptions),
|
|
1409
|
+
cache: this.cache,
|
|
1410
|
+
codec: roaringCodec,
|
|
1411
|
+
clock: this.clock,
|
|
1412
|
+
metrics: this.metrics,
|
|
1413
|
+
budget: this.budget
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* The engine a combine should run on, given the handles involved.
|
|
1418
|
+
*
|
|
1419
|
+
* A pinned handle passed as an **operand** must still be read at its pin. Routing by "which handle the call
|
|
1420
|
+
* was made on" reads it live instead — `snap.intersect([other])` honours the pin while
|
|
1421
|
+
* `other.intersect([snap])` silently does not, and the two are the same question. So the combine collects
|
|
1422
|
+
* every pin in play and runs on an engine that honours all of them. With no pins anywhere this is the
|
|
1423
|
+
* store's own engine and costs nothing.
|
|
1424
|
+
*
|
|
1425
|
+
* One engine reads a segment at one generation, so a combine that holds the same segment at two — two pins of
|
|
1426
|
+
* it, or a pin and a live handle — is refused. Keeping one of them would answer the question for the wrong
|
|
1427
|
+
* instant, and say nothing: diffing two snapshots of a segment returned no ids at all.
|
|
1428
|
+
*/
|
|
1429
|
+
engineForCombine(handles) {
|
|
1430
|
+
if (!handles.some((h) => h.pinnedAt !== void 0)) return void 0;
|
|
1431
|
+
const pins = /* @__PURE__ */ new Map();
|
|
1432
|
+
const held = /* @__PURE__ */ new Map();
|
|
1433
|
+
for (const h of handles) {
|
|
1434
|
+
const key = h.key();
|
|
1435
|
+
const at = h.pinnedAt;
|
|
1436
|
+
if (held.has(key)) {
|
|
1437
|
+
const before = held.get(key);
|
|
1438
|
+
if (!samePin(before, at)) {
|
|
1439
|
+
throw new ValidationError2(
|
|
1440
|
+
`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, [])\``
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
} else {
|
|
1444
|
+
held.set(key, at);
|
|
1445
|
+
}
|
|
1446
|
+
if (at !== void 0) pins.set(key, at);
|
|
1447
|
+
}
|
|
1448
|
+
return this.engineWithPins(pins);
|
|
1449
|
+
}
|
|
1450
|
+
/** `op` under the store's retries, as the store's own reads run; bare when the store has retries off. */
|
|
1451
|
+
withRetries(op) {
|
|
1452
|
+
const r = this.retryOptions;
|
|
1453
|
+
return r === void 0 ? op() : withRetry(op, r.policy ?? DEFAULT_RETRY_POLICY, r);
|
|
624
1454
|
}
|
|
625
1455
|
/** The store's registry, or a typed error naming the operation that needs one. */
|
|
626
1456
|
requireRegistry(op) {
|
|
627
1457
|
if (this.registry === void 0) {
|
|
628
|
-
throw new UnsupportedError(
|
|
1458
|
+
throw new UnsupportedError(
|
|
1459
|
+
`${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.`
|
|
1460
|
+
);
|
|
629
1461
|
}
|
|
630
1462
|
return this.registry;
|
|
631
1463
|
}
|
|
632
1464
|
/**
|
|
633
|
-
* **Cross-tier DR consistency check
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
1465
|
+
* **Cross-tier DR consistency check.** After a restore/failover, verify every registered segment's `currentGen`
|
|
1466
|
+
* actually has its `.crbm` present in Storage — catching a **torn restore** where the registry (`currentGen`) came
|
|
1467
|
+
* back ahead of the object store, so a pointer references a generation that isn't there (reads would then
|
|
1468
|
+
* throw). Read-only, bounded fan-out; run it at startup after a restore. Returns `{ checked, inconsistent,
|
|
1469
|
+
* errored }` — `inconsistent` empty ⇒ coherent; otherwise it names the segments to recover (restore the object
|
|
1470
|
+
* store, or roll the registry back to a coherent point). Needs the store built with a **backend**
|
|
1471
|
+
* (throws {@link UnsupportedError} otherwise). `destroyed` (crypto-shredded) segments are skipped. Pair it with
|
|
1472
|
+
* the DR runbook (docs/guide/disaster-recovery.md).
|
|
1473
|
+
*
|
|
1474
|
+
* It holds the registry rows it enumerates resident, at most 250,000 of them, and throws
|
|
1475
|
+
* `BudgetExceededError` past that rather than report a partial scan as a whole one. This method takes no
|
|
1476
|
+
* ceiling of its own: narrow the scan with `namespace`, or call `runConsistencyCheck` over the backend's
|
|
1477
|
+
* `storage` and `registry` with a higher `maxScanSegments`.
|
|
641
1478
|
*/
|
|
642
1479
|
async checkConsistency(options = {}) {
|
|
643
|
-
const deps = this.
|
|
644
|
-
return runConsistencyCheck({
|
|
1480
|
+
const deps = this.lifecycleDeps("checkConsistency");
|
|
1481
|
+
return runConsistencyCheck({ storage: deps.storage, registry: deps.registry }, options);
|
|
645
1482
|
}
|
|
646
1483
|
/**
|
|
647
|
-
* **Export ("eject") every registered segment's current
|
|
648
|
-
*
|
|
649
|
-
*
|
|
1484
|
+
* **Export ("eject") every registered segment's current generation** through the injected `sink`, using only
|
|
1485
|
+
* public read APIs — so your data is readable **without CloudRoaring** (the exit path; see the README's "Your
|
|
1486
|
+
* data stays yours"). `format: 'roaring'` (default) writes one **portable RoaringBitmap32** per segment
|
|
650
1487
|
* (loadable by any roaring library); `'ndjson'` writes newline-delimited ids (zero-dependency, streaming).
|
|
651
1488
|
* Enumerates via the store's **own** registry (needs one — throws {@link UnsupportedError} otherwise) so the
|
|
652
|
-
* enumeration and the read path provably share one registry
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
1489
|
+
* enumeration and the read path provably share one registry. Encrypted segments are decrypted transparently
|
|
1490
|
+
* **iff** this store was wired with their keystore — the export is therefore **cleartext**; protect it.
|
|
1491
|
+
* Crypto-shredded segments are skipped. A segment that can't be read is isolated into the manifest's `failed[]`
|
|
1492
|
+
* (the run continues), so "a manifest exists" means the run finished — check `failed`. The `export-segments`
|
|
1493
|
+
* CLI wraps this with a filesystem sink.
|
|
657
1494
|
*/
|
|
658
1495
|
async exportSegments(sink, options = {}) {
|
|
659
1496
|
const registry = this.requireRegistry("exportSegments");
|
|
660
|
-
return runExport
|
|
1497
|
+
return runExport(this, registry, sink, {
|
|
661
1498
|
...options,
|
|
662
1499
|
codec: options.codec ?? roaringCodec
|
|
663
1500
|
});
|
|
664
1501
|
}
|
|
665
1502
|
/**
|
|
666
|
-
* Planning cost estimate
|
|
667
|
-
*
|
|
1503
|
+
* Planning cost estimate — pure, no instance/data needed: sizing, sales, what-if. For a real, grounded report
|
|
1504
|
+
* from live segment sizes, use `store.segment(name).costReport()`. See {@link CostReport}.
|
|
668
1505
|
*/
|
|
669
1506
|
static estimateCost(input) {
|
|
670
1507
|
return estimateCost(input);
|
|
671
1508
|
}
|
|
672
1509
|
};
|
|
673
|
-
var
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1510
|
+
var failing = (err) => ({
|
|
1511
|
+
[Symbol.asyncIterator]: () => ({ next: () => Promise.reject(err) })
|
|
1512
|
+
});
|
|
1513
|
+
var samePin = (a, b) => a === void 0 || b === void 0 ? a === b : a.generation === b.generation && a.fingerprint === b.fingerprint;
|
|
1514
|
+
var twoPins = (a, b) => {
|
|
1515
|
+
const one = (p) => p === void 0 ? "live" : `pinned at generation ${p.generation ?? "none"}`;
|
|
1516
|
+
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)}`;
|
|
1517
|
+
};
|
|
1518
|
+
var rangeOf = (options) => ({
|
|
1519
|
+
after: options.after,
|
|
1520
|
+
through: options.through
|
|
1521
|
+
});
|
|
1522
|
+
var readOptions = (options) => ({
|
|
1523
|
+
...rangeOf(options),
|
|
1524
|
+
concurrency: options.concurrency,
|
|
1525
|
+
budget: options.budget,
|
|
1526
|
+
allowAbsentOperands: options.allowAbsentOperands
|
|
1527
|
+
});
|
|
1528
|
+
var NO_SEGMENTS = [];
|
|
1529
|
+
var EMPTY_IDS = {
|
|
1530
|
+
async *[Symbol.asyncIterator]() {
|
|
680
1531
|
}
|
|
1532
|
+
};
|
|
1533
|
+
var KEEP_EVERY_GENERATION = Number.MAX_SAFE_INTEGER;
|
|
1534
|
+
var makeSegment;
|
|
1535
|
+
var minting = false;
|
|
1536
|
+
var Segment = class _Segment {
|
|
1537
|
+
metricsOn;
|
|
681
1538
|
engine;
|
|
682
1539
|
ref;
|
|
683
1540
|
clock;
|
|
684
1541
|
metrics;
|
|
685
|
-
|
|
1542
|
+
materialize;
|
|
1543
|
+
pinned;
|
|
1544
|
+
combineEngine;
|
|
1545
|
+
/** Absolute epoch-ms deadline from {@link SegmentOptions.expiresAt}; `undefined` ⇒ this handle never expires. */
|
|
1546
|
+
expiresAt;
|
|
1547
|
+
/**
|
|
1548
|
+
* The generation this handle is held at, when it came from {@link Segment.pin}. Read by the store so a
|
|
1549
|
+
* pinned handle passed as an **operand** is still read at its pin rather than live.
|
|
1550
|
+
*/
|
|
1551
|
+
pinnedAt;
|
|
1552
|
+
static {
|
|
1553
|
+
makeSegment = (parts) => {
|
|
1554
|
+
minting = true;
|
|
1555
|
+
try {
|
|
1556
|
+
return new _Segment(parts);
|
|
1557
|
+
} finally {
|
|
1558
|
+
minting = false;
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Not constructible: a handle comes from {@link CloudRoaring.segment}, which wires it to the store's engine,
|
|
1564
|
+
* caches and write path. The constructor takes those internals, so it is not part of the surface.
|
|
1565
|
+
*/
|
|
1566
|
+
constructor(parts) {
|
|
1567
|
+
if (!minting) {
|
|
1568
|
+
throw new ValidationError2(
|
|
1569
|
+
"a Segment is not constructed directly; call `store.segment(name)`"
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
this.engine = parts.engine;
|
|
1573
|
+
this.ref = parts.ref;
|
|
1574
|
+
this.clock = parts.clock;
|
|
1575
|
+
this.metrics = parts.metrics;
|
|
1576
|
+
this.materialize = parts.materialize;
|
|
1577
|
+
this.pinned = parts.pinned;
|
|
1578
|
+
this.combineEngine = parts.combineEngine;
|
|
1579
|
+
this.expiresAt = parts.expiresAt;
|
|
1580
|
+
this.pinnedAt = parts.pinnedAt;
|
|
1581
|
+
this.metricsOn = parts.metrics !== NOOP_METRICS;
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* **Hold this segment at the generation that is current right now**, for as long as you keep the handle.
|
|
1585
|
+
*
|
|
1586
|
+
* An ordinary handle re-resolves on `cache.genTtlMs`, so a publish part-way through a long job means its second
|
|
1587
|
+
* half describes a different instant than its first — every chunk whole and verified, but the answer covering
|
|
1588
|
+
* two moments, with nothing in the result saying so. That is fine for a dashboard and wrong for an export, a
|
|
1589
|
+
* reconciliation, or a send that has to match the count you reported. A pin is how you get one instant.
|
|
1590
|
+
*
|
|
1591
|
+
* ```ts
|
|
1592
|
+
* const snap = await store.segment('active-30d').pin();
|
|
1593
|
+
* const total = await snap.count(); // the number you report
|
|
1594
|
+
* for await (const id of snap.iterate()) { … } // …and the ids it counted, however long this takes
|
|
1595
|
+
* ```
|
|
1596
|
+
*
|
|
1597
|
+
* **Only this segment is pinned.** `snap.intersect([other])` reads `snap` at its pinned generation and
|
|
1598
|
+
* `other` at whatever is current — pin each segment if you want the whole query held. And a pinned handle
|
|
1599
|
+
* used as an *operand* is still read at its pin, never live.
|
|
1600
|
+
*
|
|
1601
|
+
* **One call reads a segment at one generation**, so a combine that holds this segment at two —
|
|
1602
|
+
* `snap0.andNot([snap1])`, or `live.intersect([snap])` — throws {@link ValidationError} when it is read, and an
|
|
1603
|
+
* `*Into` of one throws before it reads anything. Materialise one side first, with `intersectInto(dest, [])`.
|
|
1604
|
+
*
|
|
1605
|
+
* **A pin knows its object, not only its number.** A name purged and loaded again starts again at generation
|
|
1606
|
+
* 0, so a pin of the old segment never reads the new one: what it has already read still answers, as the
|
|
1607
|
+
* instant it pinned, and anything it would have to fetch fails with `NotFoundError`, as a swept pin's does.
|
|
1608
|
+
*
|
|
1609
|
+
* **It is a hold, not a lease.** Nothing here stops a collection deleting the generation underneath
|
|
1610
|
+
* you: a pinned read deliberately does **not** heal forward, because silently serving a different generation
|
|
1611
|
+
* is the one thing a pin exists to prevent, so it fails instead. Size `keep` to cover your longest pinned
|
|
1612
|
+
* 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
|
|
1613
|
+
* you are not collecting.
|
|
1614
|
+
*
|
|
1615
|
+
* A segment with no current generation pins nothing and reads empty, exactly as it would unpinned. A pinned
|
|
1616
|
+
* segment whose row is later dropped or destroyed fails once it must open its object again, rather than go empty
|
|
1617
|
+
* part-way through a call. **A pin
|
|
1618
|
+
* keeps the key its reader unwrapped for as long as that reader stays open, and answers from the chunks it
|
|
1619
|
+
* decoded for as long as they stay cached.** This store invalidates a pin:
|
|
1620
|
+
*
|
|
1621
|
+
* - a `load`, a `rollback` or an `*Into` invalidates a pin of the segment it writes;
|
|
1622
|
+
* - `dropSegment` invalidates a pin of the segment it drops, and `retireExpired` a pin of each segment its ledger
|
|
1623
|
+
* lists, retired or not, neither on a dry run;
|
|
1624
|
+
* - `eraseSubject` invalidates a pin of each segment it scans that is not already destroyed.
|
|
1625
|
+
*
|
|
1626
|
+
* An invalidated pin opens its object again, and fails if that object is gone or replaced, or its row is gone or
|
|
1627
|
+
* destroyed.
|
|
1628
|
+
* Anything else leaves the pin as it is: after a `destroySegment` beside this store, or an erasure, a drop or a
|
|
1629
|
+
* retirement through another store, in this process or another, the pin answers from what it holds until this
|
|
1630
|
+
* store's reader cache evicts the pin's reader and its chunk cache evicts the chunks the pin decoded, or
|
|
1631
|
+
* {@link CloudRoaring.invalidate} is called on this store. Where the
|
|
1632
|
+
* object the pin reads has been deleted, by an erasure, a drop or a sweep, a chunk the pin has not cached fails at
|
|
1633
|
+
* once. No timed refresh bounds any of that, as none bounds anything else a pin holds.
|
|
1634
|
+
*
|
|
1635
|
+
* A replacement a pin has found is remembered, so its later chunk reads fail with no request, until the store
|
|
1636
|
+
* forgets it: an invalidation of the segment does, as does a later `pin()` of the same version that opens the object
|
|
1637
|
+
* again, and the store remembers at most `cache.readerMax` of them. So once a restore puts the pinned object back,
|
|
1638
|
+
* call {@link CloudRoaring.invalidate} on this store: the pin then reads the object again.
|
|
1639
|
+
*
|
|
1640
|
+
* `pin()` reads the registry row and opens the generation at once, so the pin knows its object before its first
|
|
1641
|
+
* read. With a registry, pins of one generation taken while its row is unchanged share one reader while the store
|
|
1642
|
+
* keeps it open, pins taken at the same moment included: the first costs a tail read, and a key unwrap for an
|
|
1643
|
+
* encrypted segment, whether or not it is read, and the rest cost the row read alone. Without a registry every
|
|
1644
|
+
* `pin()` lists the segment's objects and makes the tail read, since only the object can tell two incarnations of
|
|
1645
|
+
* a name apart there. It retries a
|
|
1646
|
+
* transient fault as the store's reads do, and heals a generation swept between the two, but it fails where the
|
|
1647
|
+
* generation cannot be opened: `NotFoundError` for a pointer at a missing object, `IntegrityError` for a damaged
|
|
1648
|
+
* or misfiled one.
|
|
1649
|
+
*
|
|
1650
|
+
* Needs a store built on the `.crbm` storage source (the default when you pass a backend or a raw driver). Throws
|
|
1651
|
+
* {@link UnsupportedError} on a store wired with a pre-built source that cannot pin.
|
|
1652
|
+
*/
|
|
1653
|
+
async pin() {
|
|
1654
|
+
return this.pinned(this.ref, this.expiresAt);
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* An opaque string that names this handle's segment, namespace included: two handles of one segment have the
|
|
1658
|
+
* same key, a live handle and its pins among them. Use it as a `Map` key or a log field when you track handles
|
|
1659
|
+
* and have not kept the name you made them with. The format is not specified and is not a storage key, so
|
|
1660
|
+
* compare keys, never parse one.
|
|
1661
|
+
*/
|
|
1662
|
+
key() {
|
|
1663
|
+
return segmentKey(this.ref);
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Has this handle's deadline passed? **The lazy half of expiry** — the whole check is one comparison against
|
|
1667
|
+
* the injected clock, so it costs nothing on a handle with no deadline and no I/O on one that has expired.
|
|
1668
|
+
*
|
|
1669
|
+
* Deliberately evaluated per call rather than cached: a long-lived handle created before its deadline must
|
|
1670
|
+
* start reading empty the moment the deadline passes, without the caller re-creating it.
|
|
1671
|
+
*/
|
|
1672
|
+
expired() {
|
|
1673
|
+
return this.expiresAt !== void 0 && this.clock.now() >= this.expiresAt;
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* Refuse a materialisation that involves an **expired** handle. Called by the three `*Into` verbs only.
|
|
1677
|
+
*
|
|
1678
|
+
* On the read verbs an expired handle answers empty, which is the point of lazy expiry. On a *write* the same
|
|
1679
|
+
* rule would be destructive in a way nobody asks for: `a.intersectInto(dest, [b])` where `b`'s deadline has
|
|
1680
|
+
* quietly passed would, unrefused, replace `dest` with an **empty generation** — a wipe, reported as a successful
|
|
1681
|
+
* write, with the cause (a deadline on a handle somewhere) nowhere in the result. Reads degrade to empty; writes must not.
|
|
1682
|
+
*
|
|
1683
|
+
* So every handle in the call is checked, `dest` included: an expired `dest` does not change the bytes written,
|
|
1684
|
+
* but a caller who put a deadline on the thing they are writing into has said something contradictory and is
|
|
1685
|
+
* better told than guessed at. Open a handle without `expiresAt` to write, or drop the deadline.
|
|
1686
|
+
*
|
|
1687
|
+
* (The broader guard — refusing to publish an empty or implausible generation over a non-empty one, with an
|
|
1688
|
+
* `allowEmpty` override — covers these verbs too: they route through the same guarded write path as
|
|
1689
|
+
* {@link CloudRoaring.load}. The two stay separate because they differ in kind. That one is a REPORTED
|
|
1690
|
+
* refusal a caller may legitimately override; an expired handle is a wiring mistake, so it THROWS, before
|
|
1691
|
+
* any object is written — and `allowEmpty: true` does not reach it.)
|
|
1692
|
+
*
|
|
1693
|
+
* The verbs are `async` so this surfaces as a **rejected promise**, like every other validation in the facade —
|
|
1694
|
+
* a synchronous throw out of a promise-returning method escapes a caller who attached `.catch()` instead of
|
|
1695
|
+
* awaiting.
|
|
1696
|
+
*/
|
|
1697
|
+
refuseIfExpired(op, dest, operands) {
|
|
1698
|
+
const stale = [];
|
|
1699
|
+
for (const seg of [this, dest, ...operands]) {
|
|
1700
|
+
if (seg.expired())
|
|
1701
|
+
stale.push(seg.ref.namespace ? `${seg.ref.namespace}/${seg.ref.segment}` : seg.ref.segment);
|
|
1702
|
+
}
|
|
1703
|
+
if (stale.length > 0) {
|
|
1704
|
+
throw new ValidationError2(
|
|
1705
|
+
`${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.`
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
686
1709
|
/**
|
|
687
1710
|
* Time an op with the injected clock and emit an `op` metric on completion (success or throw). Skipped
|
|
688
1711
|
* entirely when no sink is wired, so the default path pays nothing.
|
|
@@ -696,104 +1719,122 @@ var Segment = class {
|
|
|
696
1719
|
this.metrics.onEvent({ kind: "op", name, ms: Math.max(0, this.clock.now() - startedAt) });
|
|
697
1720
|
}
|
|
698
1721
|
}
|
|
699
|
-
/**
|
|
700
|
-
|
|
701
|
-
|
|
1722
|
+
/** Membership: one chunk — the cache, else one ranged GET. Throws {@link ValidationError} on a bad id. */
|
|
1723
|
+
has(id) {
|
|
1724
|
+
if (this.expired()) return Promise.resolve(false);
|
|
1725
|
+
return this.timed("has", () => this.engine.has(this.ref, id));
|
|
702
1726
|
}
|
|
703
1727
|
/**
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
* An async source means a database cursor streams straight in — `addMany(athenaCursor())` rather than
|
|
707
|
-
* hand-batching `page → addMany(page)`. Ids are grouped by chunk and each chunk is written **exactly once**,
|
|
708
|
-
* however long the stream: pending ids are held compressed, not buffered-and-flushed, so a long stream costs
|
|
709
|
-
* no more backend writes than a short one. Peak memory is the roaring representation of the ids you pass.
|
|
1728
|
+
* Cardinality — summed from the `.crbm` index with **zero payload reads** on a loaded segment.
|
|
710
1729
|
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
* the
|
|
1730
|
+
* What this trusts: the answer is the sum of the per-chunk cardinalities the index records, and no payload is
|
|
1731
|
+
* decoded to confirm it. The index is checked for internal consistency when the object is opened (each key in
|
|
1732
|
+
* range and ascending, each cardinality in `1..65536`, each payload inside the payload region, and, on an
|
|
1733
|
+
* unencrypted object, the footer's chunk count and total agreeing with the index), and a corrupt index that is
|
|
1734
|
+
* still internally consistent yields a wrong count. `iterate()` and the combines decode the payloads, whose
|
|
1735
|
+
* structure is checked.
|
|
715
1736
|
*/
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
/** Remove an id (integer in `[0, 2^32)`). Throws {@link ValidationError} on a bad id. */
|
|
720
|
-
remove(id) {
|
|
721
|
-
return this.timed("remove", () => this.engine.remove(this.ref, id));
|
|
722
|
-
}
|
|
723
|
-
/** Remove many ids, from a **sync or async** iterable. Not atomic across chunks — see {@link addMany}. */
|
|
724
|
-
removeMany(ids) {
|
|
725
|
-
return this.timed("removeMany", () => this.engine.removeMany(this.ref, ids));
|
|
1737
|
+
count() {
|
|
1738
|
+
if (this.expired()) return Promise.resolve(0);
|
|
1739
|
+
return this.timed("count", () => this.engine.count(this.ref));
|
|
726
1740
|
}
|
|
727
1741
|
/**
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
* workers can both read absent and both proceed.
|
|
1742
|
+
* Every id, ascending, streamed one chunk at a time. Pass a range to read part of the segment: the ids in
|
|
1743
|
+
* `(after, through]`, fetching only the chunks the range overlaps. The per-op budget is charged for every chunk in
|
|
1744
|
+
* the range, so give a page `through` as well as `after`. On a pinned handle it reads the pinned generation, as a
|
|
1745
|
+
* full read does.
|
|
733
1746
|
*
|
|
734
1747
|
* ```ts
|
|
735
|
-
*
|
|
736
|
-
* const
|
|
737
|
-
* for (const id of toSend) enqueue(id); // ...so nobody else will send them
|
|
1748
|
+
* // Keyset paging: each page resumes where the one before ended, and stops at its window's end.
|
|
1749
|
+
* for await (const id of seg.iterate({ after: previousEnd, through: windowEnd })) await send(id);
|
|
738
1750
|
* ```
|
|
739
1751
|
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
* distinct chunk, giving you Redis's *semantics* without Redis's per-id *cost shape*.
|
|
744
|
-
*
|
|
745
|
-
* **Exactly-once holds per id**, which is the guarantee that matters: each id lives in one chunk, a chunk is
|
|
746
|
-
* one OCC row, so exactly one concurrent claimer sees any given id as new. Like `addMany` it is **not** atomic
|
|
747
|
-
* across chunks — a mid-flight failure can leave some chunks claimed — but re-running is safe, because
|
|
748
|
-
* already-claimed ids simply come back as not-new.
|
|
749
|
-
*
|
|
750
|
-
* Order of the returned ids is unspecified.
|
|
1752
|
+
* Each bound is optional and an integer in `0..4294967295`; a bad one throws {@link ValidationError} when the
|
|
1753
|
+
* stream is first read. `after >= through` is an empty range, which reads nothing. An expired handle reads empty
|
|
1754
|
+
* without checking its options, as every read of one does.
|
|
751
1755
|
*/
|
|
752
|
-
|
|
753
|
-
|
|
1756
|
+
iterate(options) {
|
|
1757
|
+
if (this.expired()) return EMPTY_IDS;
|
|
1758
|
+
const range = options == null ? void 0 : rangeOf(options);
|
|
1759
|
+
const none = range === void 0 || range.after === void 0 && range.through === void 0;
|
|
1760
|
+
return this.engine.iterate(this.ref, none ? void 0 : range);
|
|
754
1761
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1762
|
+
/**
|
|
1763
|
+
* The options a combine hands the engine, read once, when it is called ({@link readOptions}), with the `exclude`
|
|
1764
|
+
* it was given, already reduced to its live handles ({@link liveExcludes}) and mapped down to the plain refs
|
|
1765
|
+
* `core` takes. A method rather than a module function because `ref` is class-private — the encapsulation is
|
|
1766
|
+
* worth more than the free function.
|
|
1767
|
+
*
|
|
1768
|
+
* Each field is named rather than copied with a rest spread: a rest copy takes only own enumerable properties, so
|
|
1769
|
+
* a bound held in a getter or inherited from a prototype was dropped, and the read silently widened to the whole
|
|
1770
|
+
* segment.
|
|
1771
|
+
*/
|
|
1772
|
+
refsIn(options, exclude) {
|
|
1773
|
+
if (options == null) return void 0;
|
|
1774
|
+
return {
|
|
1775
|
+
...readOptions(options),
|
|
1776
|
+
exclude: exclude.length > 0 ? exclude.map((o) => o.ref) : void 0
|
|
1777
|
+
};
|
|
760
1778
|
}
|
|
761
|
-
|
|
762
|
-
|
|
1779
|
+
/**
|
|
1780
|
+
* The handles in `options.exclude` that have not expired. **An expired exclusion excludes nothing**, in every
|
|
1781
|
+
* combine, so it is dropped here, before the engine is asked for anything: it is never fetched, never
|
|
1782
|
+
* checked for absence, and never counted in the pin-consistency check. `exclude` is read once, so a getter sees
|
|
1783
|
+
* one call.
|
|
1784
|
+
*
|
|
1785
|
+
* With no `exclude` this is one property read and a shared empty list, and with none expired it returns the
|
|
1786
|
+
* caller's own array, so the common case allocates nothing.
|
|
1787
|
+
*/
|
|
1788
|
+
liveExcludes(options) {
|
|
1789
|
+
const exclude = options?.exclude;
|
|
1790
|
+
if (exclude == null || exclude.length === 0) return NO_SEGMENTS;
|
|
1791
|
+
return exclude.some((e) => e.expired()) ? exclude.filter((e) => !e.expired()) : exclude;
|
|
763
1792
|
}
|
|
764
1793
|
/**
|
|
765
1794
|
* Chunk-skipping intersection: stream the ids in **this** segment AND every segment in `others`, ascending.
|
|
766
|
-
* Fetches only the
|
|
767
|
-
* and is never downloaded), streaming under a bounded in-flight window — so the
|
|
1795
|
+
* Fetches only the Storage chunks present in *all* operands (a key absent from any operand contributes nothing
|
|
1796
|
+
* and is never downloaded), streaming under a bounded in-flight window — so the Storage footprint stays small
|
|
768
1797
|
* (Lambda-friendly) regardless of segment size. Pass `concurrency` to tune that window (a positive integer).
|
|
769
|
-
* AND is commutative, so `a.intersect([b])` and `b.intersect([a])` yield the same ids.
|
|
770
|
-
*
|
|
771
|
-
* `budget` to override the store's per-op denial-of-wallet budget for this call (or `false` to lift it).
|
|
772
|
-
*/
|
|
773
|
-
/**
|
|
774
|
-
* Map the facade's `Segment` handles in `exclude` down to the plain refs `core` takes. A method rather than
|
|
775
|
-
* a module function because `ref` is class-private — the encapsulation is worth more than the free function.
|
|
1798
|
+
* AND is commutative, so `a.intersect([b])` and `b.intersect([a])` yield the same ids. Pass `budget` to
|
|
1799
|
+
* override the store's per-op denial-of-wallet budget for this call (or `false` to lift it).
|
|
776
1800
|
*/
|
|
777
|
-
refsIn(options) {
|
|
778
|
-
if (options === void 0) return void 0;
|
|
779
|
-
const { exclude, ...rest } = options;
|
|
780
|
-
return exclude === void 0 ? rest : { ...rest, exclude: exclude.map((o) => o.ref) };
|
|
781
|
-
}
|
|
782
1801
|
intersect(others, options) {
|
|
783
|
-
|
|
1802
|
+
if (this.expired() || others.some((o) => o.expired())) return EMPTY_IDS;
|
|
1803
|
+
const exclude = this.liveExcludes(options);
|
|
1804
|
+
let engine;
|
|
1805
|
+
try {
|
|
1806
|
+
engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
|
|
1807
|
+
} catch (err) {
|
|
1808
|
+
return failing(err);
|
|
1809
|
+
}
|
|
1810
|
+
return engine.intersect([this.ref, ...others.map((o) => o.ref)], this.refsIn(options, exclude));
|
|
784
1811
|
}
|
|
785
1812
|
/**
|
|
786
|
-
* Materialize `this ∩ others…` (minus `exclude`) **
|
|
787
|
-
* bounded-memory
|
|
1813
|
+
* Materialize `this ∩ others…` (minus `exclude`) as a **new generation of `dest`** — `dest`'s previous contents
|
|
1814
|
+
* are superseded, not added to. Streaming + bounded-memory; the result is one immutable object published
|
|
1815
|
+
* as a load is (the pointer advances to it, fenced on the row), so readers of `dest` see either the old generation
|
|
1816
|
+
* or the new one, never a partial. Needs the
|
|
1817
|
+
* store built with a backend (throws {@link UnsupportedError} otherwise).
|
|
1818
|
+
*
|
|
1819
|
+
* **An empty result does NOT overwrite a non-empty destination.** A combine that comes out empty is far more
|
|
1820
|
+
* often a mistake upstream — a typo'd operand, an `exclude` that swallowed everything, an operand that has
|
|
1821
|
+
* not loaded yet — than an intent, and once it publishes it is indistinguishable from a correct run. So the
|
|
1822
|
+
* write is **refused and reported**: `published: false`, `reason: 'empty'`, and `dest` keeps what it had.
|
|
1823
|
+
* Pass `allowEmpty: true` when emptying the destination is the point.
|
|
1824
|
+
*
|
|
1825
|
+
* `guard` adds the same plausibility bounds `load()` takes — `minCardinality` and `minRetained` — judged
|
|
1826
|
+
* against what `dest` held before. A refusal is **reported, not thrown**, exactly as on `load()`; branch on
|
|
1827
|
+
* `published`. A lost race is the one outcome that still throws ({@link WriteConflictError}), because a
|
|
1828
|
+
* materialisation that silently did not take effect is the one thing a caller cannot detect on its own.
|
|
1829
|
+
*
|
|
1830
|
+
* A call involving an **expired handle** is refused before any of this. See {@link refuseIfExpired}.
|
|
788
1831
|
*/
|
|
789
|
-
intersectInto(dest, others, options) {
|
|
1832
|
+
async intersectInto(dest, others, options) {
|
|
1833
|
+
this.refuseIfExpired("intersectInto", dest, [...others, ...options?.exclude ?? []]);
|
|
1834
|
+
this.combineEngine([this, ...others, ...options?.exclude ?? []]);
|
|
790
1835
|
return this.timed(
|
|
791
1836
|
"intersectInto",
|
|
792
|
-
() => this.
|
|
793
|
-
dest.ref,
|
|
794
|
-
[this.ref, ...others.map((o) => o.ref)],
|
|
795
|
-
this.refsIn(options)
|
|
796
|
-
)
|
|
1837
|
+
() => this.materialize(dest.ref, this.intersect(others, options), "intersectInto", options)
|
|
797
1838
|
);
|
|
798
1839
|
}
|
|
799
1840
|
/**
|
|
@@ -804,20 +1845,35 @@ var Segment = class {
|
|
|
804
1845
|
* `intersect` prunes any key missing from any operand; union has nothing to prune. It is charged against the
|
|
805
1846
|
* same per-op budget, so a wide union is refused rather than quietly billed — pass `budget` to raise it
|
|
806
1847
|
* deliberately. If you find yourself unioning the same segments on every read, materializing the combined
|
|
807
|
-
* segment once (`unionInto`, or a
|
|
1848
|
+
* segment once (`unionInto`, or a load) is the cheaper shape.
|
|
808
1849
|
*/
|
|
809
1850
|
union(others, options) {
|
|
810
|
-
|
|
1851
|
+
const live = others.filter((o) => !o.expired());
|
|
1852
|
+
if (this.expired()) {
|
|
1853
|
+
if (live.length === 0) return EMPTY_IDS;
|
|
1854
|
+
return live[0].union(live.slice(1), options);
|
|
1855
|
+
}
|
|
1856
|
+
if (live.length === 0 && others.length > 0) {
|
|
1857
|
+
const exclude2 = this.liveExcludes(options);
|
|
1858
|
+
return exclude2.length > 0 ? this.andNot([...exclude2], options) : this.union([], options);
|
|
1859
|
+
}
|
|
1860
|
+
if (live.length !== others.length) return this.union(live, options);
|
|
1861
|
+
const exclude = this.liveExcludes(options);
|
|
1862
|
+
let engine;
|
|
1863
|
+
try {
|
|
1864
|
+
engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
|
|
1865
|
+
} catch (err) {
|
|
1866
|
+
return failing(err);
|
|
1867
|
+
}
|
|
1868
|
+
return engine.union([this.ref, ...others.map((o) => o.ref)], this.refsIn(options, exclude));
|
|
811
1869
|
}
|
|
812
|
-
/** Materialize `this ∪ others…` (minus `exclude`) **
|
|
813
|
-
unionInto(dest, others, options) {
|
|
1870
|
+
/** Materialize `this ∪ others…` (minus `exclude`) as a **new generation of `dest`** — see {@link intersectInto}. */
|
|
1871
|
+
async unionInto(dest, others, options) {
|
|
1872
|
+
this.refuseIfExpired("unionInto", dest, [...others, ...options?.exclude ?? []]);
|
|
1873
|
+
this.combineEngine([this, ...others, ...options?.exclude ?? []]);
|
|
814
1874
|
return this.timed(
|
|
815
1875
|
"unionInto",
|
|
816
|
-
() => this.
|
|
817
|
-
dest.ref,
|
|
818
|
-
[this.ref, ...others.map((o) => o.ref)],
|
|
819
|
-
this.refsIn(options)
|
|
820
|
-
)
|
|
1876
|
+
() => this.materialize(dest.ref, this.union(others, options), "unionInto", options)
|
|
821
1877
|
);
|
|
822
1878
|
}
|
|
823
1879
|
/**
|
|
@@ -828,48 +1884,99 @@ var Segment = class {
|
|
|
828
1884
|
* suppression list: at most one read per surviving key of `this`, so subtracting a 61,000-chunk global
|
|
829
1885
|
* opt-out list from a 40-chunk audience costs at most 40 reads, not 61,000.
|
|
830
1886
|
*
|
|
1887
|
+
* An expired handle in `excludes` excludes nothing, and is skipped without being read; if every one has
|
|
1888
|
+
* expired the result is `this` whole. An expired `this` is empty. `exclude` on `intersect` and `union` follows
|
|
1889
|
+
* the same rule.
|
|
1890
|
+
*
|
|
831
1891
|
* To filter the *result of an intersection*, do not chain — pass `exclude` to {@link intersect} instead, so
|
|
832
1892
|
* the suppression folds into the same pass rather than materializing an intermediate segment first.
|
|
833
1893
|
*/
|
|
834
1894
|
andNot(excludes, options) {
|
|
835
|
-
|
|
1895
|
+
if (this.expired()) return EMPTY_IDS;
|
|
1896
|
+
const liveExcludes = excludes.filter((e) => !e.expired());
|
|
1897
|
+
if (liveExcludes.length === 0 && excludes.length > 0) {
|
|
1898
|
+
return this.union([], options == null ? void 0 : readOptions(options));
|
|
1899
|
+
}
|
|
1900
|
+
if (liveExcludes.length !== excludes.length) return this.andNot(liveExcludes, options);
|
|
1901
|
+
let engine;
|
|
1902
|
+
try {
|
|
1903
|
+
engine = this.combineEngine([this, ...excludes]) ?? this.engine;
|
|
1904
|
+
} catch (err) {
|
|
1905
|
+
return failing(err);
|
|
1906
|
+
}
|
|
1907
|
+
return engine.andNot(
|
|
836
1908
|
this.ref,
|
|
837
1909
|
excludes.map((o) => o.ref),
|
|
838
|
-
options
|
|
1910
|
+
options == null ? void 0 : readOptions(options)
|
|
839
1911
|
);
|
|
840
1912
|
}
|
|
841
|
-
/** Materialize `this \ (excludes…)` **
|
|
842
|
-
andNotInto(dest, excludes, options) {
|
|
1913
|
+
/** Materialize `this \ (excludes…)` as a **new generation of `dest`** — see {@link intersectInto}. */
|
|
1914
|
+
async andNotInto(dest, excludes, options) {
|
|
1915
|
+
this.refuseIfExpired("andNotInto", dest, excludes);
|
|
1916
|
+
this.combineEngine([this, ...excludes]);
|
|
843
1917
|
return this.timed(
|
|
844
1918
|
"andNotInto",
|
|
845
|
-
() => this.
|
|
846
|
-
dest.ref,
|
|
847
|
-
this.ref,
|
|
848
|
-
excludes.map((o) => o.ref),
|
|
849
|
-
options
|
|
850
|
-
)
|
|
1919
|
+
() => this.materialize(dest.ref, this.andNot(excludes, options), "andNotInto", options)
|
|
851
1920
|
);
|
|
852
1921
|
}
|
|
853
1922
|
/**
|
|
854
|
-
* Grounded cost report for this segment
|
|
855
|
-
*
|
|
856
|
-
*
|
|
1923
|
+
* Grounded cost report for this segment: storage cost from its **real** `.crbm` size (exact, no payload
|
|
1924
|
+
* reads); request cost from the supplied `workload` rates. A segment with no Storage generation reports zero
|
|
1925
|
+
* storage. The pointer refresh is priced at the store's own `cache.genTtlMs`, or at none when the store never
|
|
1926
|
+
* refreshes, unless the workload sets `genTtlMs`. See {@link CostReport} — it always includes a verdict (incl.
|
|
1927
|
+
* the lose-zone).
|
|
857
1928
|
*/
|
|
858
1929
|
async costReport(options) {
|
|
859
|
-
const canMeasure = this.engine.
|
|
1930
|
+
const canMeasure = this.engine.supportsStorageSize;
|
|
860
1931
|
const size = canMeasure ? await this.engine.segmentSize(this.ref) : null;
|
|
1932
|
+
const refreshMs = this.engine.pointerRefreshMs;
|
|
1933
|
+
const workload = refreshMs === void 0 || options?.workload?.genTtlMs !== void 0 ? options?.workload : { ...options?.workload, genTtlMs: refreshMs };
|
|
861
1934
|
return groundedReport({
|
|
862
|
-
|
|
1935
|
+
storageBytes: size?.sizeBytes ?? 0,
|
|
863
1936
|
grounded: canMeasure,
|
|
864
|
-
workload
|
|
865
|
-
topology: options?.topology,
|
|
1937
|
+
workload,
|
|
866
1938
|
pricing: options?.pricing,
|
|
867
|
-
extraNotes: canMeasure ? void 0 : ["
|
|
1939
|
+
extraNotes: canMeasure ? void 0 : ["storage source has no sizeOf() \u2014 storage not measured, reported as $0."]
|
|
868
1940
|
});
|
|
869
1941
|
}
|
|
870
1942
|
};
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1943
|
+
export {
|
|
1944
|
+
AWS_US_EAST_1_ONDEMAND,
|
|
1945
|
+
BudgetExceededError,
|
|
1946
|
+
BufferReader,
|
|
1947
|
+
CapabilityError,
|
|
1948
|
+
CloudRoaring,
|
|
1949
|
+
CloudRoaringError,
|
|
1950
|
+
CountingMetricsSink,
|
|
1951
|
+
CrbmReader,
|
|
1952
|
+
CrbmStorageChunkSource2 as CrbmStorageChunkSource,
|
|
1953
|
+
DEFAULT_RETRY_POLICY2 as DEFAULT_RETRY_POLICY,
|
|
1954
|
+
ELASTICACHE_REDIS_US_EAST_1_ONDEMAND,
|
|
1955
|
+
InProcessKeystore,
|
|
1956
|
+
IntegrityError3 as IntegrityError,
|
|
1957
|
+
KeyUnavailableError,
|
|
1958
|
+
LocalFsStorage,
|
|
1959
|
+
MIN_EXPIRES_AT_MS2 as MIN_EXPIRES_AT_MS,
|
|
1960
|
+
MemoryStorage,
|
|
1961
|
+
NodeAead,
|
|
1962
|
+
NotFoundError,
|
|
1963
|
+
ONE_REDIS_HA_CLUSTER,
|
|
1964
|
+
RecordingAuditSink,
|
|
1965
|
+
Segment,
|
|
1966
|
+
TransientError,
|
|
1967
|
+
UnsupportedError2 as UnsupportedError,
|
|
1968
|
+
ValidationError3 as ValidationError,
|
|
1969
|
+
WriteConflictError2 as WriteConflictError,
|
|
1970
|
+
aadFor,
|
|
1971
|
+
destroySegment,
|
|
1972
|
+
eraseNamespace,
|
|
1973
|
+
excludingReservedRows2 as excludingReservedRows,
|
|
1974
|
+
isCloudRoaringError,
|
|
1975
|
+
isIntegrityError,
|
|
1976
|
+
isNotFoundError,
|
|
1977
|
+
isTransientError,
|
|
1978
|
+
isValidationError,
|
|
1979
|
+
isWriteConflictError,
|
|
1980
|
+
readRetentionPolicy
|
|
1981
|
+
};
|
|
874
1982
|
//# sourceMappingURL=index.js.map
|
|
875
|
-
//# sourceMappingURL=index.js.map
|