@cloudbitmaps/core 0.7.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 +202 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +193 -2
- package/dist/index.d.ts +193 -2
- package/dist/index.js +202 -24
- 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 };
|
|
@@ -2638,8 +2687,10 @@ async function gcOrphanGenerations(ref, deps, options = {}) {
|
|
|
2638
2687
|
const current = record.currentGen;
|
|
2639
2688
|
const gens = [];
|
|
2640
2689
|
for await (const key of deps.cold.list(ref)) gens.push(key.generation);
|
|
2641
|
-
const
|
|
2642
|
-
|
|
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
|
+
);
|
|
2643
2694
|
for (const generation of toDelete) {
|
|
2644
2695
|
await deps.cold.delete({ namespace: ref.namespace, segment: ref.segment, generation });
|
|
2645
2696
|
}
|
|
@@ -3959,6 +4010,8 @@ var InProcessKeystore = class {
|
|
|
3959
4010
|
// src/core/erasure.ts
|
|
3960
4011
|
var MAX_CAS_ATTEMPTS = 8;
|
|
3961
4012
|
var MAX_WARM_PASSES = 4;
|
|
4013
|
+
var ERASE_CONCURRENCY = 8;
|
|
4014
|
+
var MAX_COLD_SWEEPS = 3;
|
|
3962
4015
|
async function destroySegment(ref, deps, options) {
|
|
3963
4016
|
if (options.confirmSegment !== ref.segment) {
|
|
3964
4017
|
throw new ValidationError(
|
|
@@ -4017,28 +4070,147 @@ async function eraseNamespace(namespace, deps, options) {
|
|
|
4017
4070
|
audit.onEvent({ kind: "namespace.erase", namespace, segmentsShredded });
|
|
4018
4071
|
return { destroyed };
|
|
4019
4072
|
}
|
|
4020
|
-
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
|
+
}
|
|
4021
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") {
|
|
4192
|
+
const base = { segment: ref.segment, namespace: ref.namespace };
|
|
4193
|
+
let warmRowsDeleted = 0;
|
|
4022
4194
|
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
|
|
4023
4195
|
const record = await deps.registry.get(ref);
|
|
4024
4196
|
if (record === null) {
|
|
4025
|
-
|
|
4197
|
+
warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
|
|
4026
4198
|
return {
|
|
4027
4199
|
...base,
|
|
4028
4200
|
destroyed: false,
|
|
4029
4201
|
cryptoShredded: false,
|
|
4030
4202
|
reason: "absent",
|
|
4031
|
-
warmRowsDeleted
|
|
4203
|
+
warmRowsDeleted
|
|
4032
4204
|
};
|
|
4033
4205
|
}
|
|
4034
4206
|
if (record.status === "destroyed") {
|
|
4035
|
-
|
|
4207
|
+
warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
|
|
4036
4208
|
return {
|
|
4037
4209
|
...base,
|
|
4038
4210
|
destroyed: true,
|
|
4039
4211
|
cryptoShredded: false,
|
|
4040
4212
|
reason: "already",
|
|
4041
|
-
warmRowsDeleted
|
|
4213
|
+
warmRowsDeleted
|
|
4042
4214
|
};
|
|
4043
4215
|
}
|
|
4044
4216
|
const encrypted = record.wrappedDeks !== void 0 && record.wrappedDeks.length > 0;
|
|
@@ -4048,10 +4220,10 @@ async function shredSegment(ref, deps, allowCleartext) {
|
|
|
4048
4220
|
destroyed: false,
|
|
4049
4221
|
cryptoShredded: false,
|
|
4050
4222
|
reason: "cleartext",
|
|
4051
|
-
warmRowsDeleted
|
|
4223
|
+
warmRowsDeleted
|
|
4052
4224
|
};
|
|
4053
4225
|
}
|
|
4054
|
-
|
|
4226
|
+
warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
|
|
4055
4227
|
try {
|
|
4056
4228
|
await deps.registry.compareAndSwap(ref, record.token, {
|
|
4057
4229
|
status: "destroyed",
|
|
@@ -4066,9 +4238,9 @@ async function shredSegment(ref, deps, allowCleartext) {
|
|
|
4066
4238
|
if (!isWriteConflictError(err)) throw err;
|
|
4067
4239
|
}
|
|
4068
4240
|
}
|
|
4069
|
-
throw new WriteConflictError(
|
|
4241
|
+
throw new WriteConflictError(`${op}: contention shredding "${ref.segment}" \u2014 retry`);
|
|
4070
4242
|
}
|
|
4071
|
-
async function eraseWarm(warm, ref) {
|
|
4243
|
+
async function eraseWarm(warm, ref, op = "destroySegment") {
|
|
4072
4244
|
let deleted = 0;
|
|
4073
4245
|
let conflicts = 0;
|
|
4074
4246
|
for (let pass = 0; pass < MAX_WARM_PASSES; pass++) {
|
|
@@ -4077,21 +4249,27 @@ async function eraseWarm(warm, ref) {
|
|
|
4077
4249
|
for await (const row of warm.listChunks(ref))
|
|
4078
4250
|
rows.push({ chunkKey: row.chunkKey, token: row.token });
|
|
4079
4251
|
if (rows.length === 0) break;
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
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
|
+
}
|
|
4088
4264
|
}
|
|
4089
|
-
|
|
4265
|
+
);
|
|
4266
|
+
for (const ok of erased) if (ok) deleted += 1;
|
|
4267
|
+
conflicts = erased.length - erased.filter(Boolean).length;
|
|
4090
4268
|
if (conflicts === 0) break;
|
|
4091
4269
|
}
|
|
4092
4270
|
if (conflicts > 0) {
|
|
4093
4271
|
throw new WriteConflictError(
|
|
4094
|
-
|
|
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.`
|
|
4095
4273
|
);
|
|
4096
4274
|
}
|
|
4097
4275
|
return deleted;
|
|
@@ -4100,7 +4278,7 @@ async function eraseWarm(warm, ref) {
|
|
|
4100
4278
|
// src/core/consistency.ts
|
|
4101
4279
|
var DEFAULT_MAX_SCAN_SEGMENTS = 25e4;
|
|
4102
4280
|
var DEFAULT_CHECK_CONCURRENCY = 8;
|
|
4103
|
-
async function
|
|
4281
|
+
async function listGenerations2(cold, ref) {
|
|
4104
4282
|
const present = /* @__PURE__ */ new Set();
|
|
4105
4283
|
for await (const key of cold.list(ref)) present.add(key.generation);
|
|
4106
4284
|
return present;
|
|
@@ -4131,7 +4309,7 @@ async function runConsistencyCheck(deps, options = {}) {
|
|
|
4131
4309
|
try {
|
|
4132
4310
|
const live = await deps.registry.get(ref);
|
|
4133
4311
|
if (!live || live.status === "destroyed") return { kind: "ok" };
|
|
4134
|
-
const present = await
|
|
4312
|
+
const present = await listGenerations2(deps.cold, ref);
|
|
4135
4313
|
if (present.has(live.currentGen)) return { kind: "ok" };
|
|
4136
4314
|
return {
|
|
4137
4315
|
kind: "issue",
|
|
@@ -4397,6 +4575,7 @@ exports.chunkRefKey = chunkRefKey;
|
|
|
4397
4575
|
exports.collectWithinBudget = collectWithinBudget;
|
|
4398
4576
|
exports.compactSegment = compactSegment;
|
|
4399
4577
|
exports.destroySegment = destroySegment;
|
|
4578
|
+
exports.dropSegment = dropSegment;
|
|
4400
4579
|
exports.eraseNamespace = eraseNamespace;
|
|
4401
4580
|
exports.estimateCost = estimateCost;
|
|
4402
4581
|
exports.findCompactable = findCompactable;
|