@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.js CHANGED
@@ -414,6 +414,55 @@ var SegmentEngine = class {
414
414
  removeMany(seg, ids) {
415
415
  return this.mutateMany(seg, ids, applyRemoveAll);
416
416
  }
417
+ /**
418
+ * **Atomically claim ids: add them, and return only the ones that were not already there.**
419
+ *
420
+ * The durable analogue of Redis `SETBIT` returning the prior bit — the primitive an exactly-once "have I
421
+ * already processed / already sent to this id?" check needs. `has()` then `add()` cannot do this: two workers
422
+ * can both read absent and both proceed.
423
+ *
424
+ * **Bulk on purpose.** The per-id shape is the one this library is worst at, because a Warm write rewrites a
425
+ * whole 64K-id chunk bitmap: claiming 5,000 ids one at a time costs ~5,000 writes and ~23 MB, and claiming
426
+ * them in one call costs 1 write and ~8 KB. So this takes a batch and does one OCC read-modify-write per
427
+ * distinct chunk — Redis's semantics at this library's cost shape, rather than Redis's cost shape.
428
+ *
429
+ * **Atomicity is per id, which is the guarantee that matters here.** Each id lives in exactly one chunk, and a
430
+ * chunk is one OCC row, so for any id exactly one concurrent claimer sees it as new. It is NOT atomic across
431
+ * chunks (neither is `addMany`) — a mid-flight failure can leave some chunks claimed. Re-running is safe:
432
+ * already-claimed ids simply come back as not-new.
433
+ *
434
+ * **The presence test consults Cold, not just the Warm delta.** Checking `adds` alone would report an id as
435
+ * newly claimed when a compaction had already folded it into a Cold generation — silently breaking
436
+ * exactly-once on any segment that has ever been compacted. The check is the full effective set,
437
+ * `(cold ∪ adds) \ removes`.
438
+ *
439
+ * Returns the claimed ids grouped nowhere in particular — order is unspecified.
440
+ */
441
+ async claimMany(seg, ids) {
442
+ const byChunk = /* @__PURE__ */ new Map();
443
+ for (const id of ids) {
444
+ const { chunkKey, remainder } = splitId(id);
445
+ const bucket = byChunk.get(chunkKey);
446
+ if (bucket) bucket.push(remainder);
447
+ else byChunk.set(chunkKey, [remainder]);
448
+ }
449
+ const gen = await this.currentGen(seg);
450
+ const perChunk = await mapWithConcurrency(
451
+ [...byChunk],
452
+ this.writeConcurrency,
453
+ async ([chunkKey, remainders]) => {
454
+ const cold = await this.coldChunk({ ...seg, chunkKey }, gen) ?? this.codec.empty();
455
+ let claimed = [];
456
+ await this.readModifyWrite(seg, chunkKey, (delta) => {
457
+ const present = effective(cold, delta);
458
+ claimed = remainders.filter((r) => !present.has(r));
459
+ for (const r of claimed) applyAdd(delta, r);
460
+ });
461
+ return claimed.map((r) => joinId(chunkKey, r));
462
+ }
463
+ );
464
+ return perChunk.flat();
465
+ }
417
466
  async has(seg, id, opts) {
418
467
  const { chunkKey, remainder } = splitId(id);
419
468
  const ref = { ...seg, chunkKey };
@@ -2528,8 +2577,10 @@ async function gcOrphanGenerations(ref, deps, options = {}) {
2528
2577
  const current = record.currentGen;
2529
2578
  const gens = [];
2530
2579
  for await (const key of deps.cold.list(ref)) gens.push(key.generation);
2531
- const below = gens.filter((g) => g < current).sort((a, b) => b - a);
2532
- const toDelete = below.slice(keep);
2580
+ const toDelete = record.status === "destroyed" ? gens.sort((a, b) => a - b) : (
2581
+ // Delete generations below current, except the newest `keep` of them (the grace window).
2582
+ gens.filter((g) => g < current).sort((a, b) => b - a).slice(keep)
2583
+ );
2533
2584
  for (const generation of toDelete) {
2534
2585
  await deps.cold.delete({ namespace: ref.namespace, segment: ref.segment, generation });
2535
2586
  }
@@ -3643,6 +3694,8 @@ var InProcessKeystore = class {
3643
3694
  // src/core/erasure.ts
3644
3695
  var MAX_CAS_ATTEMPTS = 8;
3645
3696
  var MAX_WARM_PASSES = 4;
3697
+ var ERASE_CONCURRENCY = 8;
3698
+ var MAX_COLD_SWEEPS = 3;
3646
3699
  async function destroySegment(ref, deps, options) {
3647
3700
  if (options.confirmSegment !== ref.segment) {
3648
3701
  throw new ValidationError(
@@ -3701,28 +3754,147 @@ async function eraseNamespace(namespace, deps, options) {
3701
3754
  audit.onEvent({ kind: "namespace.erase", namespace, segmentsShredded });
3702
3755
  return { destroyed };
3703
3756
  }
3704
- async function shredSegment(ref, deps, allowCleartext) {
3757
+ async function dropSegment(ref, deps, options) {
3758
+ if (options.confirmSegment !== ref.segment) {
3759
+ throw new ValidationError(
3760
+ `dropSegment: confirmSegment must equal the segment name "${ref.segment}" (guard against accidental deletion)`
3761
+ );
3762
+ }
3705
3763
  const base = { segment: ref.segment, namespace: ref.namespace };
3764
+ if (options.dryRun === true) {
3765
+ const record = await deps.registry.get(ref);
3766
+ const wouldDelete = await listGenerations(deps.cold, ref);
3767
+ return {
3768
+ ...base,
3769
+ dropped: false,
3770
+ warmRowsDeleted: 0,
3771
+ generationsDeleted: [],
3772
+ generationsRemaining: [],
3773
+ wouldDelete,
3774
+ wouldDeleteWarmRows: await countWarmRows(deps.warm, ref),
3775
+ wouldCryptoShred: record?.wrappedDeks !== void 0 && record.wrappedDeks.length > 0,
3776
+ cryptoShredded: false,
3777
+ reason: record === null ? wouldDelete.length === 0 ? "absent" : void 0 : record.status === "destroyed" ? "already" : void 0
3778
+ };
3779
+ }
3780
+ let shred = await shredSegment(ref, deps, true, "dropSegment");
3781
+ if (shred.reason === "absent") {
3782
+ const orphans = await listGenerations(deps.cold, ref);
3783
+ if (orphans.length === 0) {
3784
+ return {
3785
+ ...base,
3786
+ dropped: false,
3787
+ warmRowsDeleted: shred.warmRowsDeleted,
3788
+ generationsDeleted: [],
3789
+ generationsRemaining: [],
3790
+ cryptoShredded: false,
3791
+ reason: "absent"
3792
+ };
3793
+ }
3794
+ try {
3795
+ await deps.registry.create(ref, {
3796
+ // The max listed generation, so the row is consistent with what is on disk if anything reads it before
3797
+ // the sweep finishes. It is a tombstone, so no reader resolves through it either way.
3798
+ currentGen: orphans[orphans.length - 1],
3799
+ status: "destroyed"
3800
+ });
3801
+ shred = { ...shred, destroyed: true, reason: void 0 };
3802
+ } catch (err) {
3803
+ if (!isWriteConflictError(err)) throw err;
3804
+ shred = await shredSegment(ref, deps, true, "dropSegment");
3805
+ }
3806
+ }
3807
+ const lateWarmRows = shred.destroyed ? await eraseWarmQuietly(deps.warm, ref) : 0;
3808
+ if (shred.cryptoShredded) {
3809
+ safeAudit(options.audit ?? NOOP_AUDIT).onEvent({
3810
+ kind: "segment.erase",
3811
+ namespace: ref.namespace,
3812
+ segment: ref.segment
3813
+ });
3814
+ }
3815
+ const generationsDeleted = [];
3816
+ for (let pass = 0; pass < MAX_COLD_SWEEPS; pass++) {
3817
+ const present = await listGenerations(deps.cold, ref);
3818
+ if (present.length === 0) break;
3819
+ const outcomes = await mapWithConcurrency(present, ERASE_CONCURRENCY, async (generation) => {
3820
+ try {
3821
+ await deps.cold.delete({ ...ref, generation });
3822
+ return generation;
3823
+ } catch {
3824
+ return null;
3825
+ }
3826
+ });
3827
+ let deletedThisPass = 0;
3828
+ for (const g of outcomes) {
3829
+ if (g !== null) {
3830
+ generationsDeleted.push(g);
3831
+ deletedThisPass += 1;
3832
+ }
3833
+ }
3834
+ if (deletedThisPass === 0) break;
3835
+ }
3836
+ const generationsRemaining = await listGenerations(deps.cold, ref);
3837
+ if (shred.destroyed) {
3838
+ safeAudit(options.audit ?? NOOP_AUDIT).onEvent({
3839
+ kind: "segment.dispose",
3840
+ namespace: ref.namespace,
3841
+ segment: ref.segment,
3842
+ generationsDeleted: generationsDeleted.length
3843
+ });
3844
+ }
3845
+ return {
3846
+ ...base,
3847
+ dropped: shred.destroyed,
3848
+ warmRowsDeleted: shred.warmRowsDeleted + lateWarmRows,
3849
+ generationsDeleted,
3850
+ generationsRemaining,
3851
+ cryptoShredded: shred.cryptoShredded,
3852
+ reason: shred.reason
3853
+ };
3854
+ }
3855
+ async function listGenerations(cold, ref) {
3856
+ const generations = [];
3857
+ for await (const key of cold.list(ref)) generations.push(key.generation);
3858
+ return generations.sort((a, b) => a - b);
3859
+ }
3860
+ async function countWarmRows(warm, ref) {
3861
+ let n = 0;
3862
+ for await (const _row of warm.listChunks(ref)) {
3863
+ n += 1;
3864
+ }
3865
+ return n;
3866
+ }
3867
+ async function eraseWarmQuietly(warm, ref) {
3868
+ try {
3869
+ return await eraseWarm(warm, ref, "dropSegment");
3870
+ } catch (err) {
3871
+ if (!isWriteConflictError(err)) throw err;
3872
+ return 0;
3873
+ }
3874
+ }
3875
+ async function shredSegment(ref, deps, allowCleartext, op = "destroySegment") {
3876
+ const base = { segment: ref.segment, namespace: ref.namespace };
3877
+ let warmRowsDeleted = 0;
3706
3878
  for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
3707
3879
  const record = await deps.registry.get(ref);
3708
3880
  if (record === null) {
3709
- const warmRowsDeleted2 = await eraseWarm(deps.warm, ref);
3881
+ warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
3710
3882
  return {
3711
3883
  ...base,
3712
3884
  destroyed: false,
3713
3885
  cryptoShredded: false,
3714
3886
  reason: "absent",
3715
- warmRowsDeleted: warmRowsDeleted2
3887
+ warmRowsDeleted
3716
3888
  };
3717
3889
  }
3718
3890
  if (record.status === "destroyed") {
3719
- const warmRowsDeleted2 = await eraseWarm(deps.warm, ref);
3891
+ warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
3720
3892
  return {
3721
3893
  ...base,
3722
3894
  destroyed: true,
3723
3895
  cryptoShredded: false,
3724
3896
  reason: "already",
3725
- warmRowsDeleted: warmRowsDeleted2
3897
+ warmRowsDeleted
3726
3898
  };
3727
3899
  }
3728
3900
  const encrypted = record.wrappedDeks !== void 0 && record.wrappedDeks.length > 0;
@@ -3732,10 +3904,10 @@ async function shredSegment(ref, deps, allowCleartext) {
3732
3904
  destroyed: false,
3733
3905
  cryptoShredded: false,
3734
3906
  reason: "cleartext",
3735
- warmRowsDeleted: 0
3907
+ warmRowsDeleted
3736
3908
  };
3737
3909
  }
3738
- const warmRowsDeleted = await eraseWarm(deps.warm, ref);
3910
+ warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
3739
3911
  try {
3740
3912
  await deps.registry.compareAndSwap(ref, record.token, {
3741
3913
  status: "destroyed",
@@ -3750,9 +3922,9 @@ async function shredSegment(ref, deps, allowCleartext) {
3750
3922
  if (!isWriteConflictError(err)) throw err;
3751
3923
  }
3752
3924
  }
3753
- throw new WriteConflictError(`destroySegment: contention shredding "${ref.segment}" \u2014 retry`);
3925
+ throw new WriteConflictError(`${op}: contention shredding "${ref.segment}" \u2014 retry`);
3754
3926
  }
3755
- async function eraseWarm(warm, ref) {
3927
+ async function eraseWarm(warm, ref, op = "destroySegment") {
3756
3928
  let deleted = 0;
3757
3929
  let conflicts = 0;
3758
3930
  for (let pass = 0; pass < MAX_WARM_PASSES; pass++) {
@@ -3761,21 +3933,27 @@ async function eraseWarm(warm, ref) {
3761
3933
  for await (const row of warm.listChunks(ref))
3762
3934
  rows.push({ chunkKey: row.chunkKey, token: row.token });
3763
3935
  if (rows.length === 0) break;
3764
- for (const { chunkKey, token } of rows) {
3765
- const chunkRef = { namespace: ref.namespace, segment: ref.segment, chunkKey };
3766
- try {
3767
- await warm.deleteConditional(chunkRef, token);
3768
- deleted += 1;
3769
- } catch (err) {
3770
- if (!isWriteConflictError(err)) throw err;
3771
- conflicts += 1;
3936
+ const erased = await mapWithConcurrency(
3937
+ rows,
3938
+ ERASE_CONCURRENCY,
3939
+ async ({ chunkKey, token }) => {
3940
+ const chunkRef = { namespace: ref.namespace, segment: ref.segment, chunkKey };
3941
+ try {
3942
+ await warm.deleteConditional(chunkRef, token);
3943
+ return true;
3944
+ } catch (err) {
3945
+ if (!isWriteConflictError(err)) throw err;
3946
+ return false;
3947
+ }
3772
3948
  }
3773
- }
3949
+ );
3950
+ for (const ok of erased) if (ok) deleted += 1;
3951
+ conflicts = erased.length - erased.filter(Boolean).length;
3774
3952
  if (conflicts === 0) break;
3775
3953
  }
3776
3954
  if (conflicts > 0) {
3777
3955
  throw new WriteConflictError(
3778
- `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.`
3956
+ `${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.`
3779
3957
  );
3780
3958
  }
3781
3959
  return deleted;
@@ -3784,7 +3962,7 @@ async function eraseWarm(warm, ref) {
3784
3962
  // src/core/consistency.ts
3785
3963
  var DEFAULT_MAX_SCAN_SEGMENTS = 25e4;
3786
3964
  var DEFAULT_CHECK_CONCURRENCY = 8;
3787
- async function listGenerations(cold, ref) {
3965
+ async function listGenerations2(cold, ref) {
3788
3966
  const present = /* @__PURE__ */ new Set();
3789
3967
  for await (const key of cold.list(ref)) present.add(key.generation);
3790
3968
  return present;
@@ -3815,7 +3993,7 @@ async function runConsistencyCheck(deps, options = {}) {
3815
3993
  try {
3816
3994
  const live = await deps.registry.get(ref);
3817
3995
  if (!live || live.status === "destroyed") return { kind: "ok" };
3818
- const present = await listGenerations(deps.cold, ref);
3996
+ const present = await listGenerations2(deps.cold, ref);
3819
3997
  if (present.has(live.currentGen)) return { kind: "ok" };
3820
3998
  return {
3821
3999
  kind: "issue",
@@ -4030,6 +4208,6 @@ var RetryingRegistryDriver = class {
4030
4208
  }
4031
4209
  };
4032
4210
 
4033
- export { AWS_US_EAST_1_ONDEMAND, BoundedLru, BufferReader, BufferSink, CountingMetricsSink, CrbmColdChunkSource, CrbmReader, CrbmWriter, DEFAULT_BUDGET, DEFAULT_MAX_SCAN_SEGMENTS, DEFAULT_MAX_WARM_SCAN_BYTES, DEFAULT_OCC_BACKOFF, DEFAULT_PRICING, DEFAULT_RETRY_POLICY, DEFAULT_WRITE_CONCURRENCY, InProcessKeystore, LocalFsColdDriver, LocalFsRegistryDriver, LocalFsWarmDriver, MemoryColdChunkSource, MemoryColdDriver, MemoryRegistryDriver, MemoryWarmDriver, NOOP_AUDIT, NOOP_METRICS, NodeAead, RecordingAuditSink, RetryingColdChunkSource, RetryingColdDriver, RetryingRegistryDriver, RetryingWarmDriver, SegmentEngine, aadFor, bulkLoadCrbmGeneration, checkBudget, chunkRefKey, collectWithinBudget, compactSegment, destroySegment, eraseNamespace, estimateCost, findCompactable, gcOrphanGenerations, groundedReport, isTransient, joinId, publishGeneration, resolveBudget, resolvePerOpBudget, runCompactionCycle, runConsistencyCheck, runExport, safeMetrics, segmentKey, splitId, validateCompactionOptions, withRetry, writeCrbmGeneration };
4211
+ export { AWS_US_EAST_1_ONDEMAND, BoundedLru, BufferReader, BufferSink, CountingMetricsSink, CrbmColdChunkSource, CrbmReader, CrbmWriter, DEFAULT_BUDGET, DEFAULT_MAX_SCAN_SEGMENTS, DEFAULT_MAX_WARM_SCAN_BYTES, DEFAULT_OCC_BACKOFF, DEFAULT_PRICING, DEFAULT_RETRY_POLICY, DEFAULT_WRITE_CONCURRENCY, InProcessKeystore, LocalFsColdDriver, LocalFsRegistryDriver, LocalFsWarmDriver, MemoryColdChunkSource, MemoryColdDriver, MemoryRegistryDriver, MemoryWarmDriver, NOOP_AUDIT, NOOP_METRICS, NodeAead, RecordingAuditSink, RetryingColdChunkSource, RetryingColdDriver, RetryingRegistryDriver, RetryingWarmDriver, SegmentEngine, aadFor, bulkLoadCrbmGeneration, checkBudget, chunkRefKey, collectWithinBudget, compactSegment, destroySegment, dropSegment, eraseNamespace, estimateCost, findCompactable, gcOrphanGenerations, groundedReport, isTransient, joinId, publishGeneration, resolveBudget, resolvePerOpBudget, runCompactionCycle, runConsistencyCheck, runExport, safeMetrics, segmentKey, splitId, validateCompactionOptions, withRetry, writeCrbmGeneration };
4034
4212
  //# sourceMappingURL=index.js.map
4035
4213
  //# sourceMappingURL=index.js.map