@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 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 below = gens.filter((g) => g < current).sort((a, b) => b - a);
2642
- const toDelete = below.slice(keep);
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 shredSegment(ref, deps, allowCleartext) {
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
- const warmRowsDeleted2 = await eraseWarm(deps.warm, ref);
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: warmRowsDeleted2
4203
+ warmRowsDeleted
4032
4204
  };
4033
4205
  }
4034
4206
  if (record.status === "destroyed") {
4035
- const warmRowsDeleted2 = await eraseWarm(deps.warm, ref);
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: warmRowsDeleted2
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: 0
4223
+ warmRowsDeleted
4052
4224
  };
4053
4225
  }
4054
- const warmRowsDeleted = await eraseWarm(deps.warm, ref);
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(`destroySegment: contention shredding "${ref.segment}" \u2014 retry`);
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
- for (const { chunkKey, token } of rows) {
4081
- const chunkRef = { namespace: ref.namespace, segment: ref.segment, chunkKey };
4082
- try {
4083
- await warm.deleteConditional(chunkRef, token);
4084
- deleted += 1;
4085
- } catch (err) {
4086
- if (!isWriteConflictError(err)) throw err;
4087
- conflicts += 1;
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
- `destroySegment: ${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.`
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 listGenerations(cold, ref) {
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 listGenerations(deps.cold, ref);
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;