@cloudbitmaps/roaring 0.11.2 → 0.12.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/PRIVACY.md CHANGED
@@ -43,7 +43,7 @@ from constructing a cross-region topology. The points where personal data moves
43
43
  | Location | What's there | Residency note |
44
44
  |---|---|---|
45
45
  | **Storage** (object store) | immutable `.crbm` generations — every generation a segment has had, until a superseded one is collected. Azure Blob objects, and GCS objects above the simple-upload threshold, also carry a random write id in their metadata (`cbwid`): 128 random bits, no data from the bitmap or the source | the region of the bucket you wire |
46
- | **Registry** (S3 / GCS / Azure Blob / local) | one row per segment: the current-generation pointer, wrapped keys, retention metadata — no IDs. An Azure Blob row also carries the random write id in its metadata | the region of the bucket you wire |
46
+ | **Registry** (S3 / GCS / Azure Blob / local) | one row per segment: the current-generation pointer, wrapped keys, retention metadata, and the current generation's id count and your metadata (sealed when the segment is encrypted) — no IDs, unless you put one in the metadata, which you must not. An Azure Blob row also carries the random write id in its metadata | the region of the bucket you wire |
47
47
  | **cache** (process RAM) | decoded chunks, bounded LRU | **wherever your process/Lambda runs** — an EU segment queried from a US function is processed in the US |
48
48
  | **Loads and rewrites** (`store.load()`, the `*Into` verbs, `eraseSubject`) | read your source (or existing generations), write a new generation | run wherever you run them — a loader in one region writing to a bucket in another is a transfer |
49
49
  | **Intersection** | pulls chunks from N segments into one process | co-locates those segments in one region |
@@ -60,8 +60,8 @@ CloudBitmaps gives you three levers with different guarantees. Use them delibera
60
60
 
61
61
  | Lever | API | Guarantee | Use for |
62
62
  |---|---|---|---|
63
- | **Subject erasure** | `store.eraseSubject(id, { namespace })` | *Physical on return* — the segment's current generation is rewritten without the id (every chunk streamed through, one bit cleared), published **fenced on the generation it streamed**, and **the generation that held the bit is deleted before the call returns**. So is every other generation still holding the id: a retained *superseded* one (an ex-member dropped by a re-seed), and each one above the pointer after a `rollback` — generations a rollback can move the pointer onto again — while, when the current generation does not hold the id, the ones there that never held it stay as rollback targets (a rewrite is numbered above everything, so its `keep: 0` collection takes every older generation, above the pointer or below). Before it says `erased: true` the call lists the bucket and reads what is left, so **`erased: true` means no generation of the segment holds the id**, above the pointer or below it. `eraseSubject` reports a per-segment fault as an `error: …` ledger entry rather than throwing it, and a call that a racing writer overtook (a load, another erasure, or a rollback) as `erased: false, note: 'superseded'`, so "on return" is a claim about every segment whose entry says **`erased: true`** (a fault is an `error: …` entry, never `erased: true`). The store that performs the call stops serving the id immediately; **other processes converge on their own read TTL** — see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). Does **not** reach backups, replicas or noncurrent object versions — those hold the old generation until their own lifecycle removes it. | "forget this person" — GDPR Art. 17 |
64
- | **Dispose** | `store.dropSegment(ref, { confirmSegment })` | *Immediate* — the segment is tombstoned and its Storage generations deleted, reclaiming the space. **Check `generationsRemaining`:** if it is non-empty the space was *not* fully reclaimed and the drop should be re-run (a load that was already writing when the tombstone landed still finishes its object). Works on cleartext; on an encrypted segment it *also* crypto-shreds, on the terms in the next row. Does **not** reach noncurrent versions / replicas / PITR snapshots — deleting an object is weaker than removing a key. | retiring a dated bucket; rolling-window retention |
63
+ | **Subject erasure** | `store.eraseSubject(id, { namespace })` | *Physical on return* — the segment's current generation is rewritten without the id (every chunk streamed through, one bit cleared), published **fenced on the generation it streamed**, and **the generation that held the bit is deleted before the call returns**. So is every other generation still holding the id: a retained *superseded* one (an ex-member dropped by a re-seed), and each one above the pointer after a `rollback` — generations a rollback can move the pointer onto again — while, when the current generation does not hold the id, the ones there that never held it stay as rollback targets (a rewrite is numbered above everything, so its `keep: 0` collection takes every older generation, above the pointer or below). Before it says `erased: true` the call lists the bucket and reads what is left, so **`erased: true` means no generation of the segment holds the id**, above the pointer or below it. `eraseSubject` reports a per-segment fault as an `error: …` ledger entry rather than throwing it, and a call that a racing writer overtook (a load, another erasure, or a rollback) as `erased: false, note: 'superseded'`, so "on return" is a claim about every segment whose entry says **`erased: true`** (a fault is an `error: …` entry, never `erased: true`). The store that performs the call stops serving the id immediately; **other processes converge on their own read TTL** — see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). Does **not** reach backups, replicas or noncurrent object versions — those hold the old generation until their own lifecycle removes it. It carries a generation's metadata over unchanged and does **not** scan it, so **never put a subject's id in metadata**. | "forget this person" — GDPR Art. 17 |
64
+ | **Dispose** | `store.dropSegment(ref, { confirmSegment })` | *Immediate* — the segment is tombstoned and its Storage generations deleted, reclaiming the space. **Check `generationsRemaining`:** if it is non-empty the space was *not* fully reclaimed and the drop should be re-run (a load that had read the segment before the tombstone landed, even one still consuming its ids, can write its object after the drop's sweep; it deletes that object itself once its publish is refused, and only one whose process stops in between, or whose publish fails without a definite answer (a lost response, a timeout), leaves it, for a re-run of the drop). Works on cleartext; on an encrypted segment it *also* crypto-shreds, on the terms in the next row. Does **not** reach noncurrent versions / replicas / PITR snapshots — deleting an object is weaker than removing a key. | retiring a dated bucket; rolling-window retention |
65
65
  | **Crypto-shred** | `destroySegment` / `eraseNamespace` | *At rest, complete once no copy of the wrapped key survives* — removes the segment's wrapped DEK(s) from its registry row in one compare-and-swap, without touching the bytes or any KEK. **Every** copy of the objects (current, prior generations, backups, WORM-locked objects) is then unreadable once no retained copy of that row still holds the wrapped key — noncurrent object versions, backups and PITR copies of the row included — or once every KEK that wrapped it is destroyed. Until then, a retained copy of the row and a KEK that wrapped it decrypt the segment, and a registry restore to a point before the shred makes it readable again; see *Erasure vs. backups / WORM* below, and [an optional registry expiry rule](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/disaster-recovery.md#optional-make-a-shred-durable-with-a-registry-expiry-rule) that makes a shred durable after a set number of days. Requires the segment to be encrypted. A process that had already opened the segment holds the **unwrapped** key in memory and keeps reading until it is told — call `store.invalidate(ref)` there; see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). | whole-segment / tenant offboarding; erasure under immutable backups (see below) |
66
66
 
67
67
  **Subject-wide erasure** (GDPR Art. 17 — "forget this person everywhere") is
@@ -244,6 +244,16 @@ to run, and the deletion is ours to perform correctly.** Practical patterns:
244
244
  assumed: `generationsRemaining` non-empty means bytes survived and the call should be repeated. It tombstones
245
245
  the segment and deletes **every Storage generation** — so the space is actually reclaimed. It works on a
246
246
  cleartext segment, and on an encrypted one it *also* crypto-shreds, making it a strict superset there.
247
+ - **A deleted row keeps its record only where the registry cannot remove it.** Where the registry reports
248
+ `conditionalDelete` (the default for S3 when its client sends to an AWS S3 host, for
249
+ Azure Blob, the local filesystem and memory), `registry.delete` and the retention sweep's purge of a tombstone remove
250
+ a row created by 0.12 from the bucket, and its token's random incarnation id keeps a re-create apart from it. Where it
251
+ does not (an S3 client that sends to an S3-compatible store or an emulator, or a GCS client, by default, or
252
+ `conditionalDelete: false`), and for a row written by a release before 0.12 on any backend, a delete writes a deleted marker that holds
253
+ the record, so the row's token counter survives a re-create. A tombstone left by `dropSegment` or `destroySegment`
254
+ holds no wrapped key and no summary, since both clear them, so a purge that leaves a tombstone keeps the name, the
255
+ pointer, the retention policy and the timestamps. A **live** row deleted directly with `registry.delete` keeps its
256
+ summary, the current generation's id count and your metadata, in a marker where one is written.
247
257
  - **`destroySegment` crypto-shreds** — it removes the wrapped key from the registry row, so the Storage bytes
248
258
  become unreadable *everywhere including backups, replicas and WORM-locked copies* once no retained copy of that
249
259
  row still holds the key, or every KEK that wrapped it is destroyed (see the trap above), which no object
@@ -266,16 +276,41 @@ So the two are complements, not alternatives: **`dropSegment` for "stop paying f
266
276
  > failed `dropSegment`, and a fine way to expire *noncurrent* object versions; give it a window comfortably
267
277
  > longer than your retention and never let it touch a current generation.
268
278
 
269
- > ⚠️ **Never let a lifecycle rule expire a registry row's current version or its tombstone** — a separate trap,
270
- > and a worse one. Deleting a registry row does not remove it: it writes a **tombstone** carrying a counter that only
271
- > ever advances, which is what makes the row's token unique for all time. Expire those tombstones and a segment
272
- > re-created under the same name starts the counter again and re-issues a token that was already used. That
273
- > token is the segment's **identity**: it is what a cached reader, a fenced publish and a generation-collection
274
- > pass each compare to decide whether two observations describe the same segment. Re-issue one and they can all
275
- > answer "yes" about a segment that no longer exists — serving a deleted incarnation's data, or collecting a
276
- > live one's objects. Storage for a tombstone is a few bytes per retired segment; treat it as permanent. A rule
277
- > on *noncurrent* versions of the registry prefix touches neither the current row nor its tombstone; what it
278
- > shortens is the history a registry restore picks from, and the time a crypto-shred takes to complete (above).
279
+ > ⚠️ **Never let a lifecycle rule expire a registry row's current version, a tombstone, or a due-index pointer** —
280
+ > a separate trap, and a worse one. A rule cannot tell a live row from a tombstone, and an expired live row is a
281
+ > segment whose pointer is gone: its generations look unreferenced, and nothing reads or collects them. The library
282
+ > removes a row only by a delete the store applies to the exact version it judged, and only a row whose token
283
+ > carries a random incarnation id, so a name re-created later is told apart from it with overwhelming probability.
284
+ > A tombstone of a row written by a release before 0.12 carries the row's token counter on instead, and that counter
285
+ > is what keeps the name's tokens unique against a process still on that release. That token is the segment's
286
+ > **identity**: it is what a cached reader, a fenced publish and a generation-collection pass each compare to decide
287
+ > whether two observations describe the same segment. Re-issue one and they can all answer "yes" about a segment that
288
+ > no longer exists — serving a deleted incarnation's data, or collecting a live one's objects. A rule on
289
+ > *noncurrent* versions of the registry prefix touches neither the current row nor its tombstone; what it shortens is
290
+ > the history a registry restore picks from, and the time a crypto-shred takes to complete (above).
291
+
292
+ **What the registry keeps of a retired segment, and for how long.** A retirement by `retireExpired` leaves a
293
+ `destroyed` row: the segment's namespace and name, its `retention` and `residency` metadata (any keys of yours in
294
+ them included), its timestamps and its token. It holds no id, and on an encrypted segment no wrapped key, since the
295
+ drop shreds it. The due index holds a pointer to it whose name spells out the same namespace and name. Both stay
296
+ for `tombstoneGraceMs` (24 h by default) after the retirement, and for as long as the segment's storage cannot be
297
+ proven gone. Then the sweep's purge removes the row, and the pointers it read to it, from the bucket, where the registry
298
+ reports `conditionalDelete`: the default for S3 when its client sends to an AWS S3 host,
299
+ for Azure Blob, the local filesystem and memory. **A pointer can outlive its row**, and with it the name in its key: a
300
+ purge that ran with another `tombstoneGraceMs` than the sweep that filed the pointer, a delete that landed and lost its
301
+ response, a removal the registry refused (`purgeFaults` counts it), or a pointer older than an index scan's
302
+ `lookbackBuckets`. The next sweep that reads it removes it once its row is confirmed absent: an index scan from the days
303
+ it reads, and an unscoped fleet scan, one given no `namespace`, from every day. A deployment that scopes every sweep to a
304
+ namespace, or runs only index scans, keeps such a pointer until an unscoped fleet scan runs. Where the registry does not
305
+ report `conditionalDelete` (an S3 client that sends to an S3-compatible store or an emulator, or a GCS client, by default, or
306
+ `conditionalDelete: false`), and for a row written by a release before 0.12 on any backend, the purge leaves a tombstone
307
+ instead: every one of those fields, in the bucket, indefinitely, read by every full listing. A registry that does not
308
+ report it files no pointer to begin with. A removed
309
+ row stays recoverable wherever the storage keeps a copy of it: with object versioning on, its earlier versions stay until
310
+ a noncurrent-version rule expires them, as an overwritten row's do; with soft delete on, it stays for the retention
311
+ window (GCS: on by default for a new bucket, 7 days; Azure Blob: where enabled, for the days set), whatever the registry
312
+ did. A tombstone a hand-run `dropSegment` or a
313
+ crypto-shred left is never purged by the library.
279
314
 
280
315
  Full detail, including the dated-bucket pattern and the pitfalls, is in the retention section of the
281
316
  [retention guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/retention.md).
package/README.md CHANGED
@@ -48,7 +48,9 @@ To persist, pass a different **backend** as `storage`. Nothing else changes:
48
48
  | `AzureBlobStorage({ connectionString, container })` | `@cloudbitmaps/azure-blob` | Azure Blob Storage; outside the [validated envelope](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/ROADMAP.md#the-validated-envelope--whats-proven-and-what-isnt) |
49
49
 
50
50
  There is no `add` or `remove`: a segment changes only by loading a new generation. A load is a batch job, not a
51
- request handler. `store.exists(ref)` says whether a segment has been loaded.
51
+ request handler. `store.exists(ref)` says whether a segment has been loaded. A load takes ids, or a bitmap you already
52
+ hold: `store.load(ref, { bitmap })` for a `RoaringBitmap32`, or `{ serialized }` for portable Roaring bytes, checked
53
+ before anything is written and loaded with no per-id work, into the same bytes its ids would write.
52
54
 
53
55
  ## Options
54
56
 
@@ -70,7 +72,7 @@ name.
70
72
  Measured against real S3 in `us-east-1`, with the pointer in the same bucket as the data: the median cold `intersect`
71
73
  of two 500,000-id segments sharing 100 of their 1,999 chunks made 206 GETs, **$82.40 per million**, requesting only
72
74
  the shared chunks. Inside the region it is expected at 204 GETs, $81.60 per million. Writing and publishing a segment
73
- is **$11.20 per million**, pointer included, from its measured requests at list prices. `count()` reads no payload.
75
+ is **$11.20 per million**, pointer included, from its measured requests at list prices. A cold `count()` is one pointer read: it reads no payload and no object.
74
76
 
75
77
  The trade is stated plainly. A membership check that misses the cache costs a ranged GET against object storage,
76
78
  where an in-process store costs a memory read. If you need sub-millisecond answers on a working set that fits a
@@ -80,8 +82,8 @@ give the method and what the numbers do not establish.
80
82
  ## Before production
81
83
 
82
84
  - Grant the permissions the library issues and add a lifecycle rule to abort incomplete uploads.
83
- - Use a bucket that honors conditional writes, and `@aws-sdk/client-s3` 3.645.0 or later if you pass your own S3 client.
84
- - Set a request timeout on your storage client. Reads retry by themselves; a write that fails transiently is yours to re-run.
85
+ - Use a bucket that honors conditional writes, and `@aws-sdk/client-s3` 3.700.0 or later if you pass your own S3 client.
86
+ - Set a request timeout on your storage client. Reads retry by themselves, and a read can be timed on every backend (`readTimeoutMs`); a write that fails transiently is yours to re-run.
85
87
  - Back up the registry with the data, and the keystore too if you encrypt. If you lose the key, the data is gone.
86
88
  - Schedule your loads, and `retireExpired` if you use retention. Run loads off the request path.
87
89