@cloudbitmaps/core 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +213 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +207 -6
- package/dist/index.d.ts +207 -6
- package/dist/index.js +213 -32
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -501,6 +501,55 @@ var SegmentEngine = class {
|
|
|
501
501
|
removeMany(seg, ids) {
|
|
502
502
|
return this.mutateMany(seg, ids, applyRemoveAll);
|
|
503
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* **Atomically claim ids: add them, and return only the ones that were not already there.**
|
|
506
|
+
*
|
|
507
|
+
* The durable analogue of Redis `SETBIT` returning the prior bit — the primitive an exactly-once "have I
|
|
508
|
+
* already processed / already sent to this id?" check needs. `has()` then `add()` cannot do this: two workers
|
|
509
|
+
* can both read absent and both proceed.
|
|
510
|
+
*
|
|
511
|
+
* **Bulk on purpose.** The per-id shape is the one this library is worst at, because a Warm write rewrites a
|
|
512
|
+
* whole 64K-id chunk bitmap: claiming 5,000 ids one at a time costs ~5,000 writes and ~23 MB, and claiming
|
|
513
|
+
* them in one call costs 1 write and ~8 KB. So this takes a batch and does one OCC read-modify-write per
|
|
514
|
+
* distinct chunk — Redis's semantics at this library's cost shape, rather than Redis's cost shape.
|
|
515
|
+
*
|
|
516
|
+
* **Atomicity is per id, which is the guarantee that matters here.** Each id lives in exactly one chunk, and a
|
|
517
|
+
* chunk is one OCC row, so for any id exactly one concurrent claimer sees it as new. It is NOT atomic across
|
|
518
|
+
* chunks (neither is `addMany`) — a mid-flight failure can leave some chunks claimed. Re-running is safe:
|
|
519
|
+
* already-claimed ids simply come back as not-new.
|
|
520
|
+
*
|
|
521
|
+
* **The presence test consults Cold, not just the Warm delta.** Checking `adds` alone would report an id as
|
|
522
|
+
* newly claimed when a compaction had already folded it into a Cold generation — silently breaking
|
|
523
|
+
* exactly-once on any segment that has ever been compacted. The check is the full effective set,
|
|
524
|
+
* `(cold ∪ adds) \ removes`.
|
|
525
|
+
*
|
|
526
|
+
* Returns the claimed ids grouped nowhere in particular — order is unspecified.
|
|
527
|
+
*/
|
|
528
|
+
async claimMany(seg, ids) {
|
|
529
|
+
const byChunk = /* @__PURE__ */ new Map();
|
|
530
|
+
for (const id of ids) {
|
|
531
|
+
const { chunkKey, remainder } = splitId(id);
|
|
532
|
+
const bucket = byChunk.get(chunkKey);
|
|
533
|
+
if (bucket) bucket.push(remainder);
|
|
534
|
+
else byChunk.set(chunkKey, [remainder]);
|
|
535
|
+
}
|
|
536
|
+
const gen = await this.currentGen(seg);
|
|
537
|
+
const perChunk = await mapWithConcurrency(
|
|
538
|
+
[...byChunk],
|
|
539
|
+
this.writeConcurrency,
|
|
540
|
+
async ([chunkKey, remainders]) => {
|
|
541
|
+
const cold = await this.coldChunk({ ...seg, chunkKey }, gen) ?? this.codec.empty();
|
|
542
|
+
let claimed = [];
|
|
543
|
+
await this.readModifyWrite(seg, chunkKey, (delta) => {
|
|
544
|
+
const present = effective(cold, delta);
|
|
545
|
+
claimed = remainders.filter((r) => !present.has(r));
|
|
546
|
+
for (const r of claimed) applyAdd(delta, r);
|
|
547
|
+
});
|
|
548
|
+
return claimed.map((r) => joinId(chunkKey, r));
|
|
549
|
+
}
|
|
550
|
+
);
|
|
551
|
+
return perChunk.flat();
|
|
552
|
+
}
|
|
504
553
|
async has(seg, id, opts) {
|
|
505
554
|
const { chunkKey, remainder } = splitId(id);
|
|
506
555
|
const ref = { ...seg, chunkKey };
|
|
@@ -1504,8 +1553,8 @@ var FOOTER = {
|
|
|
1504
1553
|
// u32
|
|
1505
1554
|
flags: 20,
|
|
1506
1555
|
// u32
|
|
1507
|
-
|
|
1508
|
-
// u16
|
|
1556
|
+
payloadCodecId: 24,
|
|
1557
|
+
// u16 — was `roaringSerializationId`; same offset, same width, wider meaning
|
|
1509
1558
|
elementWidth: 26,
|
|
1510
1559
|
// u8
|
|
1511
1560
|
containerCodec: 27,
|
|
@@ -1533,7 +1582,10 @@ var FOOTER_CRC_COVERAGE = FOOTER.footerCrc32c;
|
|
|
1533
1582
|
var FLAG_ENCRYPTED = 1 << 0;
|
|
1534
1583
|
var FLAG_LITTLE_ENDIAN = 1 << 2;
|
|
1535
1584
|
var ELEMENT_WIDTH_32 = 32;
|
|
1536
|
-
var
|
|
1585
|
+
var PAYLOAD_CODEC_ROARING_PORTABLE = 1;
|
|
1586
|
+
var KNOWN_PAYLOAD_CODEC_IDS = /* @__PURE__ */ new Set([
|
|
1587
|
+
PAYLOAD_CODEC_ROARING_PORTABLE
|
|
1588
|
+
]);
|
|
1537
1589
|
var CONTAINER_CODEC_NONE = 0;
|
|
1538
1590
|
var DEFAULT_TAIL_BYTES = 256 * 1024;
|
|
1539
1591
|
var DEFAULT_MAX_INDEX_BYTES = 8 * 1024 * 1024;
|
|
@@ -1630,10 +1682,10 @@ var CrbmReader = class _CrbmReader {
|
|
|
1630
1682
|
`.crbm element_width ${elementWidth} not supported (v1 reads 32-bit ids; 64-bit is a future major version)`
|
|
1631
1683
|
);
|
|
1632
1684
|
}
|
|
1633
|
-
const
|
|
1634
|
-
if (
|
|
1685
|
+
const payloadCodecId = fview.getUint16(FOOTER.payloadCodecId, true);
|
|
1686
|
+
if (!KNOWN_PAYLOAD_CODEC_IDS.has(payloadCodecId)) {
|
|
1635
1687
|
throw new UnsupportedError(
|
|
1636
|
-
`.crbm
|
|
1688
|
+
`.crbm payload_codec_id ${payloadCodecId} not supported by this build (known: ${[...KNOWN_PAYLOAD_CODEC_IDS].join(", ")}; ${PAYLOAD_CODEC_ROARING_PORTABLE}=roaring portable). A generation written by a different codec is rejected rather than decoded \u2014 a store uses one codec throughout.`
|
|
1637
1689
|
);
|
|
1638
1690
|
}
|
|
1639
1691
|
const containerCodec = footer[FOOTER.containerCodec];
|
|
@@ -1928,8 +1980,8 @@ var CrbmWriter = class {
|
|
|
1928
1980
|
view.setUint32(FOOTER.indexCrc32c, crc32c(indexRegion), true);
|
|
1929
1981
|
view.setUint32(FOOTER.flags, FLAG_LITTLE_ENDIAN | (encrypted ? FLAG_ENCRYPTED : 0), true);
|
|
1930
1982
|
view.setUint16(
|
|
1931
|
-
FOOTER.
|
|
1932
|
-
this.options.
|
|
1983
|
+
FOOTER.payloadCodecId,
|
|
1984
|
+
this.options.payloadCodecId ?? PAYLOAD_CODEC_ROARING_PORTABLE,
|
|
1933
1985
|
true
|
|
1934
1986
|
);
|
|
1935
1987
|
footer[FOOTER.elementWidth] = this.options.elementWidth ?? ELEMENT_WIDTH_32;
|
|
@@ -2635,8 +2687,10 @@ async function gcOrphanGenerations(ref, deps, options = {}) {
|
|
|
2635
2687
|
const current = record.currentGen;
|
|
2636
2688
|
const gens = [];
|
|
2637
2689
|
for await (const key of deps.cold.list(ref)) gens.push(key.generation);
|
|
2638
|
-
const
|
|
2639
|
-
|
|
2690
|
+
const toDelete = record.status === "destroyed" ? gens.sort((a, b) => a - b) : (
|
|
2691
|
+
// Delete generations below current, except the newest `keep` of them (the grace window).
|
|
2692
|
+
gens.filter((g) => g < current).sort((a, b) => b - a).slice(keep)
|
|
2693
|
+
);
|
|
2640
2694
|
for (const generation of toDelete) {
|
|
2641
2695
|
await deps.cold.delete({ namespace: ref.namespace, segment: ref.segment, generation });
|
|
2642
2696
|
}
|
|
@@ -3956,6 +4010,8 @@ var InProcessKeystore = class {
|
|
|
3956
4010
|
// src/core/erasure.ts
|
|
3957
4011
|
var MAX_CAS_ATTEMPTS = 8;
|
|
3958
4012
|
var MAX_WARM_PASSES = 4;
|
|
4013
|
+
var ERASE_CONCURRENCY = 8;
|
|
4014
|
+
var MAX_COLD_SWEEPS = 3;
|
|
3959
4015
|
async function destroySegment(ref, deps, options) {
|
|
3960
4016
|
if (options.confirmSegment !== ref.segment) {
|
|
3961
4017
|
throw new ValidationError(
|
|
@@ -4014,28 +4070,147 @@ async function eraseNamespace(namespace, deps, options) {
|
|
|
4014
4070
|
audit.onEvent({ kind: "namespace.erase", namespace, segmentsShredded });
|
|
4015
4071
|
return { destroyed };
|
|
4016
4072
|
}
|
|
4017
|
-
async function
|
|
4073
|
+
async function dropSegment(ref, deps, options) {
|
|
4074
|
+
if (options.confirmSegment !== ref.segment) {
|
|
4075
|
+
throw new ValidationError(
|
|
4076
|
+
`dropSegment: confirmSegment must equal the segment name "${ref.segment}" (guard against accidental deletion)`
|
|
4077
|
+
);
|
|
4078
|
+
}
|
|
4079
|
+
const base = { segment: ref.segment, namespace: ref.namespace };
|
|
4080
|
+
if (options.dryRun === true) {
|
|
4081
|
+
const record = await deps.registry.get(ref);
|
|
4082
|
+
const wouldDelete = await listGenerations(deps.cold, ref);
|
|
4083
|
+
return {
|
|
4084
|
+
...base,
|
|
4085
|
+
dropped: false,
|
|
4086
|
+
warmRowsDeleted: 0,
|
|
4087
|
+
generationsDeleted: [],
|
|
4088
|
+
generationsRemaining: [],
|
|
4089
|
+
wouldDelete,
|
|
4090
|
+
wouldDeleteWarmRows: await countWarmRows(deps.warm, ref),
|
|
4091
|
+
wouldCryptoShred: record?.wrappedDeks !== void 0 && record.wrappedDeks.length > 0,
|
|
4092
|
+
cryptoShredded: false,
|
|
4093
|
+
reason: record === null ? wouldDelete.length === 0 ? "absent" : void 0 : record.status === "destroyed" ? "already" : void 0
|
|
4094
|
+
};
|
|
4095
|
+
}
|
|
4096
|
+
let shred = await shredSegment(ref, deps, true, "dropSegment");
|
|
4097
|
+
if (shred.reason === "absent") {
|
|
4098
|
+
const orphans = await listGenerations(deps.cold, ref);
|
|
4099
|
+
if (orphans.length === 0) {
|
|
4100
|
+
return {
|
|
4101
|
+
...base,
|
|
4102
|
+
dropped: false,
|
|
4103
|
+
warmRowsDeleted: shred.warmRowsDeleted,
|
|
4104
|
+
generationsDeleted: [],
|
|
4105
|
+
generationsRemaining: [],
|
|
4106
|
+
cryptoShredded: false,
|
|
4107
|
+
reason: "absent"
|
|
4108
|
+
};
|
|
4109
|
+
}
|
|
4110
|
+
try {
|
|
4111
|
+
await deps.registry.create(ref, {
|
|
4112
|
+
// The max listed generation, so the row is consistent with what is on disk if anything reads it before
|
|
4113
|
+
// the sweep finishes. It is a tombstone, so no reader resolves through it either way.
|
|
4114
|
+
currentGen: orphans[orphans.length - 1],
|
|
4115
|
+
status: "destroyed"
|
|
4116
|
+
});
|
|
4117
|
+
shred = { ...shred, destroyed: true, reason: void 0 };
|
|
4118
|
+
} catch (err) {
|
|
4119
|
+
if (!isWriteConflictError(err)) throw err;
|
|
4120
|
+
shred = await shredSegment(ref, deps, true, "dropSegment");
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
const lateWarmRows = shred.destroyed ? await eraseWarmQuietly(deps.warm, ref) : 0;
|
|
4124
|
+
if (shred.cryptoShredded) {
|
|
4125
|
+
safeAudit(options.audit ?? NOOP_AUDIT).onEvent({
|
|
4126
|
+
kind: "segment.erase",
|
|
4127
|
+
namespace: ref.namespace,
|
|
4128
|
+
segment: ref.segment
|
|
4129
|
+
});
|
|
4130
|
+
}
|
|
4131
|
+
const generationsDeleted = [];
|
|
4132
|
+
for (let pass = 0; pass < MAX_COLD_SWEEPS; pass++) {
|
|
4133
|
+
const present = await listGenerations(deps.cold, ref);
|
|
4134
|
+
if (present.length === 0) break;
|
|
4135
|
+
const outcomes = await mapWithConcurrency(present, ERASE_CONCURRENCY, async (generation) => {
|
|
4136
|
+
try {
|
|
4137
|
+
await deps.cold.delete({ ...ref, generation });
|
|
4138
|
+
return generation;
|
|
4139
|
+
} catch {
|
|
4140
|
+
return null;
|
|
4141
|
+
}
|
|
4142
|
+
});
|
|
4143
|
+
let deletedThisPass = 0;
|
|
4144
|
+
for (const g of outcomes) {
|
|
4145
|
+
if (g !== null) {
|
|
4146
|
+
generationsDeleted.push(g);
|
|
4147
|
+
deletedThisPass += 1;
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
if (deletedThisPass === 0) break;
|
|
4151
|
+
}
|
|
4152
|
+
const generationsRemaining = await listGenerations(deps.cold, ref);
|
|
4153
|
+
if (shred.destroyed) {
|
|
4154
|
+
safeAudit(options.audit ?? NOOP_AUDIT).onEvent({
|
|
4155
|
+
kind: "segment.dispose",
|
|
4156
|
+
namespace: ref.namespace,
|
|
4157
|
+
segment: ref.segment,
|
|
4158
|
+
generationsDeleted: generationsDeleted.length
|
|
4159
|
+
});
|
|
4160
|
+
}
|
|
4161
|
+
return {
|
|
4162
|
+
...base,
|
|
4163
|
+
dropped: shred.destroyed,
|
|
4164
|
+
warmRowsDeleted: shred.warmRowsDeleted + lateWarmRows,
|
|
4165
|
+
generationsDeleted,
|
|
4166
|
+
generationsRemaining,
|
|
4167
|
+
cryptoShredded: shred.cryptoShredded,
|
|
4168
|
+
reason: shred.reason
|
|
4169
|
+
};
|
|
4170
|
+
}
|
|
4171
|
+
async function listGenerations(cold, ref) {
|
|
4172
|
+
const generations = [];
|
|
4173
|
+
for await (const key of cold.list(ref)) generations.push(key.generation);
|
|
4174
|
+
return generations.sort((a, b) => a - b);
|
|
4175
|
+
}
|
|
4176
|
+
async function countWarmRows(warm, ref) {
|
|
4177
|
+
let n = 0;
|
|
4178
|
+
for await (const _row of warm.listChunks(ref)) {
|
|
4179
|
+
n += 1;
|
|
4180
|
+
}
|
|
4181
|
+
return n;
|
|
4182
|
+
}
|
|
4183
|
+
async function eraseWarmQuietly(warm, ref) {
|
|
4184
|
+
try {
|
|
4185
|
+
return await eraseWarm(warm, ref, "dropSegment");
|
|
4186
|
+
} catch (err) {
|
|
4187
|
+
if (!isWriteConflictError(err)) throw err;
|
|
4188
|
+
return 0;
|
|
4189
|
+
}
|
|
4190
|
+
}
|
|
4191
|
+
async function shredSegment(ref, deps, allowCleartext, op = "destroySegment") {
|
|
4018
4192
|
const base = { segment: ref.segment, namespace: ref.namespace };
|
|
4193
|
+
let warmRowsDeleted = 0;
|
|
4019
4194
|
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
|
|
4020
4195
|
const record = await deps.registry.get(ref);
|
|
4021
4196
|
if (record === null) {
|
|
4022
|
-
|
|
4197
|
+
warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
|
|
4023
4198
|
return {
|
|
4024
4199
|
...base,
|
|
4025
4200
|
destroyed: false,
|
|
4026
4201
|
cryptoShredded: false,
|
|
4027
4202
|
reason: "absent",
|
|
4028
|
-
warmRowsDeleted
|
|
4203
|
+
warmRowsDeleted
|
|
4029
4204
|
};
|
|
4030
4205
|
}
|
|
4031
4206
|
if (record.status === "destroyed") {
|
|
4032
|
-
|
|
4207
|
+
warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
|
|
4033
4208
|
return {
|
|
4034
4209
|
...base,
|
|
4035
4210
|
destroyed: true,
|
|
4036
4211
|
cryptoShredded: false,
|
|
4037
4212
|
reason: "already",
|
|
4038
|
-
warmRowsDeleted
|
|
4213
|
+
warmRowsDeleted
|
|
4039
4214
|
};
|
|
4040
4215
|
}
|
|
4041
4216
|
const encrypted = record.wrappedDeks !== void 0 && record.wrappedDeks.length > 0;
|
|
@@ -4045,10 +4220,10 @@ async function shredSegment(ref, deps, allowCleartext) {
|
|
|
4045
4220
|
destroyed: false,
|
|
4046
4221
|
cryptoShredded: false,
|
|
4047
4222
|
reason: "cleartext",
|
|
4048
|
-
warmRowsDeleted
|
|
4223
|
+
warmRowsDeleted
|
|
4049
4224
|
};
|
|
4050
4225
|
}
|
|
4051
|
-
|
|
4226
|
+
warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
|
|
4052
4227
|
try {
|
|
4053
4228
|
await deps.registry.compareAndSwap(ref, record.token, {
|
|
4054
4229
|
status: "destroyed",
|
|
@@ -4063,9 +4238,9 @@ async function shredSegment(ref, deps, allowCleartext) {
|
|
|
4063
4238
|
if (!isWriteConflictError(err)) throw err;
|
|
4064
4239
|
}
|
|
4065
4240
|
}
|
|
4066
|
-
throw new WriteConflictError(
|
|
4241
|
+
throw new WriteConflictError(`${op}: contention shredding "${ref.segment}" \u2014 retry`);
|
|
4067
4242
|
}
|
|
4068
|
-
async function eraseWarm(warm, ref) {
|
|
4243
|
+
async function eraseWarm(warm, ref, op = "destroySegment") {
|
|
4069
4244
|
let deleted = 0;
|
|
4070
4245
|
let conflicts = 0;
|
|
4071
4246
|
for (let pass = 0; pass < MAX_WARM_PASSES; pass++) {
|
|
@@ -4074,21 +4249,27 @@ async function eraseWarm(warm, ref) {
|
|
|
4074
4249
|
for await (const row of warm.listChunks(ref))
|
|
4075
4250
|
rows.push({ chunkKey: row.chunkKey, token: row.token });
|
|
4076
4251
|
if (rows.length === 0) break;
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4252
|
+
const erased = await mapWithConcurrency(
|
|
4253
|
+
rows,
|
|
4254
|
+
ERASE_CONCURRENCY,
|
|
4255
|
+
async ({ chunkKey, token }) => {
|
|
4256
|
+
const chunkRef = { namespace: ref.namespace, segment: ref.segment, chunkKey };
|
|
4257
|
+
try {
|
|
4258
|
+
await warm.deleteConditional(chunkRef, token);
|
|
4259
|
+
return true;
|
|
4260
|
+
} catch (err) {
|
|
4261
|
+
if (!isWriteConflictError(err)) throw err;
|
|
4262
|
+
return false;
|
|
4263
|
+
}
|
|
4085
4264
|
}
|
|
4086
|
-
|
|
4265
|
+
);
|
|
4266
|
+
for (const ok of erased) if (ok) deleted += 1;
|
|
4267
|
+
conflicts = erased.length - erased.filter(Boolean).length;
|
|
4087
4268
|
if (conflicts === 0) break;
|
|
4088
4269
|
}
|
|
4089
4270
|
if (conflicts > 0) {
|
|
4090
4271
|
throw new WriteConflictError(
|
|
4091
|
-
|
|
4272
|
+
`${op}: ${conflicts} warm row(s) of "${ref.segment}" were rewritten during every one of ${MAX_WARM_PASSES} erase passes \u2014 the segment was NOT destroyed and its cleartext warm deltas are still readable. Stop writes to this segment and retry.`
|
|
4092
4273
|
);
|
|
4093
4274
|
}
|
|
4094
4275
|
return deleted;
|
|
@@ -4097,7 +4278,7 @@ async function eraseWarm(warm, ref) {
|
|
|
4097
4278
|
// src/core/consistency.ts
|
|
4098
4279
|
var DEFAULT_MAX_SCAN_SEGMENTS = 25e4;
|
|
4099
4280
|
var DEFAULT_CHECK_CONCURRENCY = 8;
|
|
4100
|
-
async function
|
|
4281
|
+
async function listGenerations2(cold, ref) {
|
|
4101
4282
|
const present = /* @__PURE__ */ new Set();
|
|
4102
4283
|
for await (const key of cold.list(ref)) present.add(key.generation);
|
|
4103
4284
|
return present;
|
|
@@ -4128,7 +4309,7 @@ async function runConsistencyCheck(deps, options = {}) {
|
|
|
4128
4309
|
try {
|
|
4129
4310
|
const live = await deps.registry.get(ref);
|
|
4130
4311
|
if (!live || live.status === "destroyed") return { kind: "ok" };
|
|
4131
|
-
const present = await
|
|
4312
|
+
const present = await listGenerations2(deps.cold, ref);
|
|
4132
4313
|
if (present.has(live.currentGen)) return { kind: "ok" };
|
|
4133
4314
|
return {
|
|
4134
4315
|
kind: "issue",
|
|
@@ -4394,6 +4575,7 @@ exports.chunkRefKey = chunkRefKey;
|
|
|
4394
4575
|
exports.collectWithinBudget = collectWithinBudget;
|
|
4395
4576
|
exports.compactSegment = compactSegment;
|
|
4396
4577
|
exports.destroySegment = destroySegment;
|
|
4578
|
+
exports.dropSegment = dropSegment;
|
|
4397
4579
|
exports.eraseNamespace = eraseNamespace;
|
|
4398
4580
|
exports.estimateCost = estimateCost;
|
|
4399
4581
|
exports.findCompactable = findCompactable;
|