@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.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 };
@@ -1394,8 +1443,8 @@ var FOOTER = {
1394
1443
  // u32
1395
1444
  flags: 20,
1396
1445
  // u32
1397
- roaringSerializationId: 24,
1398
- // u16
1446
+ payloadCodecId: 24,
1447
+ // u16 — was `roaringSerializationId`; same offset, same width, wider meaning
1399
1448
  elementWidth: 26,
1400
1449
  // u8
1401
1450
  containerCodec: 27,
@@ -1423,7 +1472,10 @@ var FOOTER_CRC_COVERAGE = FOOTER.footerCrc32c;
1423
1472
  var FLAG_ENCRYPTED = 1 << 0;
1424
1473
  var FLAG_LITTLE_ENDIAN = 1 << 2;
1425
1474
  var ELEMENT_WIDTH_32 = 32;
1426
- var ROARING_PORTABLE_ID = 1;
1475
+ var PAYLOAD_CODEC_ROARING_PORTABLE = 1;
1476
+ var KNOWN_PAYLOAD_CODEC_IDS = /* @__PURE__ */ new Set([
1477
+ PAYLOAD_CODEC_ROARING_PORTABLE
1478
+ ]);
1427
1479
  var CONTAINER_CODEC_NONE = 0;
1428
1480
  var DEFAULT_TAIL_BYTES = 256 * 1024;
1429
1481
  var DEFAULT_MAX_INDEX_BYTES = 8 * 1024 * 1024;
@@ -1520,10 +1572,10 @@ var CrbmReader = class _CrbmReader {
1520
1572
  `.crbm element_width ${elementWidth} not supported (v1 reads 32-bit ids; 64-bit is a future major version)`
1521
1573
  );
1522
1574
  }
1523
- const serializationId = fview.getUint16(FOOTER.roaringSerializationId, true);
1524
- if (serializationId !== ROARING_PORTABLE_ID) {
1575
+ const payloadCodecId = fview.getUint16(FOOTER.payloadCodecId, true);
1576
+ if (!KNOWN_PAYLOAD_CODEC_IDS.has(payloadCodecId)) {
1525
1577
  throw new UnsupportedError(
1526
- `.crbm roaring_serialization_id ${serializationId} not supported (v1 is portable=${ROARING_PORTABLE_ID})`
1578
+ `.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.`
1527
1579
  );
1528
1580
  }
1529
1581
  const containerCodec = footer[FOOTER.containerCodec];
@@ -1818,8 +1870,8 @@ var CrbmWriter = class {
1818
1870
  view.setUint32(FOOTER.indexCrc32c, crc32c(indexRegion), true);
1819
1871
  view.setUint32(FOOTER.flags, FLAG_LITTLE_ENDIAN | (encrypted ? FLAG_ENCRYPTED : 0), true);
1820
1872
  view.setUint16(
1821
- FOOTER.roaringSerializationId,
1822
- this.options.roaringSerializationId ?? ROARING_PORTABLE_ID,
1873
+ FOOTER.payloadCodecId,
1874
+ this.options.payloadCodecId ?? PAYLOAD_CODEC_ROARING_PORTABLE,
1823
1875
  true
1824
1876
  );
1825
1877
  footer[FOOTER.elementWidth] = this.options.elementWidth ?? ELEMENT_WIDTH_32;
@@ -2525,8 +2577,10 @@ async function gcOrphanGenerations(ref, deps, options = {}) {
2525
2577
  const current = record.currentGen;
2526
2578
  const gens = [];
2527
2579
  for await (const key of deps.cold.list(ref)) gens.push(key.generation);
2528
- const below = gens.filter((g) => g < current).sort((a, b) => b - a);
2529
- 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
+ );
2530
2584
  for (const generation of toDelete) {
2531
2585
  await deps.cold.delete({ namespace: ref.namespace, segment: ref.segment, generation });
2532
2586
  }
@@ -3640,6 +3694,8 @@ var InProcessKeystore = class {
3640
3694
  // src/core/erasure.ts
3641
3695
  var MAX_CAS_ATTEMPTS = 8;
3642
3696
  var MAX_WARM_PASSES = 4;
3697
+ var ERASE_CONCURRENCY = 8;
3698
+ var MAX_COLD_SWEEPS = 3;
3643
3699
  async function destroySegment(ref, deps, options) {
3644
3700
  if (options.confirmSegment !== ref.segment) {
3645
3701
  throw new ValidationError(
@@ -3698,28 +3754,147 @@ async function eraseNamespace(namespace, deps, options) {
3698
3754
  audit.onEvent({ kind: "namespace.erase", namespace, segmentsShredded });
3699
3755
  return { destroyed };
3700
3756
  }
3701
- 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
+ }
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") {
3702
3876
  const base = { segment: ref.segment, namespace: ref.namespace };
3877
+ let warmRowsDeleted = 0;
3703
3878
  for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
3704
3879
  const record = await deps.registry.get(ref);
3705
3880
  if (record === null) {
3706
- const warmRowsDeleted2 = await eraseWarm(deps.warm, ref);
3881
+ warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
3707
3882
  return {
3708
3883
  ...base,
3709
3884
  destroyed: false,
3710
3885
  cryptoShredded: false,
3711
3886
  reason: "absent",
3712
- warmRowsDeleted: warmRowsDeleted2
3887
+ warmRowsDeleted
3713
3888
  };
3714
3889
  }
3715
3890
  if (record.status === "destroyed") {
3716
- const warmRowsDeleted2 = await eraseWarm(deps.warm, ref);
3891
+ warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
3717
3892
  return {
3718
3893
  ...base,
3719
3894
  destroyed: true,
3720
3895
  cryptoShredded: false,
3721
3896
  reason: "already",
3722
- warmRowsDeleted: warmRowsDeleted2
3897
+ warmRowsDeleted
3723
3898
  };
3724
3899
  }
3725
3900
  const encrypted = record.wrappedDeks !== void 0 && record.wrappedDeks.length > 0;
@@ -3729,10 +3904,10 @@ async function shredSegment(ref, deps, allowCleartext) {
3729
3904
  destroyed: false,
3730
3905
  cryptoShredded: false,
3731
3906
  reason: "cleartext",
3732
- warmRowsDeleted: 0
3907
+ warmRowsDeleted
3733
3908
  };
3734
3909
  }
3735
- const warmRowsDeleted = await eraseWarm(deps.warm, ref);
3910
+ warmRowsDeleted += await eraseWarm(deps.warm, ref, op);
3736
3911
  try {
3737
3912
  await deps.registry.compareAndSwap(ref, record.token, {
3738
3913
  status: "destroyed",
@@ -3747,9 +3922,9 @@ async function shredSegment(ref, deps, allowCleartext) {
3747
3922
  if (!isWriteConflictError(err)) throw err;
3748
3923
  }
3749
3924
  }
3750
- throw new WriteConflictError(`destroySegment: contention shredding "${ref.segment}" \u2014 retry`);
3925
+ throw new WriteConflictError(`${op}: contention shredding "${ref.segment}" \u2014 retry`);
3751
3926
  }
3752
- async function eraseWarm(warm, ref) {
3927
+ async function eraseWarm(warm, ref, op = "destroySegment") {
3753
3928
  let deleted = 0;
3754
3929
  let conflicts = 0;
3755
3930
  for (let pass = 0; pass < MAX_WARM_PASSES; pass++) {
@@ -3758,21 +3933,27 @@ async function eraseWarm(warm, ref) {
3758
3933
  for await (const row of warm.listChunks(ref))
3759
3934
  rows.push({ chunkKey: row.chunkKey, token: row.token });
3760
3935
  if (rows.length === 0) break;
3761
- for (const { chunkKey, token } of rows) {
3762
- const chunkRef = { namespace: ref.namespace, segment: ref.segment, chunkKey };
3763
- try {
3764
- await warm.deleteConditional(chunkRef, token);
3765
- deleted += 1;
3766
- } catch (err) {
3767
- if (!isWriteConflictError(err)) throw err;
3768
- 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
+ }
3769
3948
  }
3770
- }
3949
+ );
3950
+ for (const ok of erased) if (ok) deleted += 1;
3951
+ conflicts = erased.length - erased.filter(Boolean).length;
3771
3952
  if (conflicts === 0) break;
3772
3953
  }
3773
3954
  if (conflicts > 0) {
3774
3955
  throw new WriteConflictError(
3775
- `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.`
3776
3957
  );
3777
3958
  }
3778
3959
  return deleted;
@@ -3781,7 +3962,7 @@ async function eraseWarm(warm, ref) {
3781
3962
  // src/core/consistency.ts
3782
3963
  var DEFAULT_MAX_SCAN_SEGMENTS = 25e4;
3783
3964
  var DEFAULT_CHECK_CONCURRENCY = 8;
3784
- async function listGenerations(cold, ref) {
3965
+ async function listGenerations2(cold, ref) {
3785
3966
  const present = /* @__PURE__ */ new Set();
3786
3967
  for await (const key of cold.list(ref)) present.add(key.generation);
3787
3968
  return present;
@@ -3812,7 +3993,7 @@ async function runConsistencyCheck(deps, options = {}) {
3812
3993
  try {
3813
3994
  const live = await deps.registry.get(ref);
3814
3995
  if (!live || live.status === "destroyed") return { kind: "ok" };
3815
- const present = await listGenerations(deps.cold, ref);
3996
+ const present = await listGenerations2(deps.cold, ref);
3816
3997
  if (present.has(live.currentGen)) return { kind: "ok" };
3817
3998
  return {
3818
3999
  kind: "issue",
@@ -4027,6 +4208,6 @@ var RetryingRegistryDriver = class {
4027
4208
  }
4028
4209
  };
4029
4210
 
4030
- 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 };
4031
4212
  //# sourceMappingURL=index.js.map
4032
4213
  //# sourceMappingURL=index.js.map