The whole picture
One bitmap, three temperatures
A Roaring Bitmap is already a set of 16-bit chunks: an ID in [0, 2³²)
splits into a 16-bit chunk key (which chunk) and a 16-bit
remainder (which bit inside it). CloudBitmaps takes that natural seam and
maps each chunk onto whichever storage tier its access pattern deserves.
id ∈ [0, 2³²) ──split──▶ chunkKey (16-bit) ‖ remainder (16-bit) │ ▼ routes to one chunk ┌──────────────────────────────────────────────────────────────────────┐ │ HOT — bounded LRU of decoded chunks, in process RAM │ │ hard ceiling (cacheMaxChunks, default 1024) · optional TTL │ ├──────────────────────────────────────────────────────────────────────┤ │ WARM — mutable per-chunk delta rows { adds, removes } │ │ every add/remove lands here · OCC tokens · cross-process │ ├──────────────────────────────────────────────────────────────────────┤ │ COLD — immutable, generation-keyed .crbm archives │ │ segment.<gen>.crbm · write-once · CRC + (optional) AES-256-GCM │ └──────────────────────────────────────────────────────────────────────┘ │ a read merges the tiers: ▼ effective(chunk) = ( COLD ∪ WARM.adds ) \ WARM.removes
Two design rules hold the whole thing together, and every phase below respects them:
-
Tombstones are first-class. A delete isn't "rebuild the bitmap without
this ID" — it's a
removesentry in the warm delta. The effective set subtracts it at read time. That's what makesremove()O(one chunk write) over an immutable cold base. -
The core is pure.
core/touches no clock, no randomness, and no cloud SDK — time, entropy, and all I/O are injected. A lint rule fails the build if that's ever violated. This is what lets a deterministic simulator replay any concurrency or crash bug from a single seed.
Everything else is drivers behind interfaces. IWarmDriver,
IColdDriver, and IRegistryDriver each have in-memory,
local-filesystem, and cloud implementations that all pass the same conformance
suite — so you swap storage backends without the engine noticing.
const store = new CloudRoaring({ warm: new DynamoDbWarmDriver({ client: dynamo, tableName: 'cloudbitmaps' }), cold: s3Cold, // raw driver — the store builds the .crbm cold source registry, keystore, }); // retries, backoff and the HOT cache are on by default — nothing else to wire. const seg = store.segment('high-value-shoppers');
Milestone M1 · local
The in-memory engine routing · tombstones · LRU · OCC
Phase 1 is the brain with no storage yet — just the in-memory drivers. It establishes the data model every later tier has to honor: route an ID to its chunk, keep deletes as tombstones, cache decoded chunks under a hard ceiling, and guard concurrent writers with optimistic concurrency.
SafeBitmap wraps the native roaring addon but only ever uses
its safe deserializer behind a size cap — every byte from any tier is treated
as untrusted input. The warm row for a chunk is a delta { adds, removes };
writes carry an opaque, never-reused OCC token, so two writers to the same chunk can't
silently lose an update (one gets a WriteConflictError and re-reads).
vips.remove(1_234_567_890) vips.has(1_234_567_890) │ │ ▼ ▼ WARM delta: removes += {id} read merges tiers: (one chunk write — no scan, COLD has it? yes no rebuild of the bitmap) WARM.removes has it? yes ⇒ effective = false ✓ immediately
import { CloudRoaring, MemoryWarmDriver, MemoryColdChunkSource } from '@cloudbitmaps/roaring'; const store = new CloudRoaring({ warm: new MemoryWarmDriver(), cold: new MemoryColdChunkSource(), }); const vips = store.segment('high-value-shoppers'); await vips.add(1_234_567_890); await vips.addMany([5, 99_999, 2_000_000_000]); // grouped by chunk → one write per chunk await vips.has(1_234_567_890); // → true await vips.remove(1_234_567_890); // single-chunk tombstone await vips.has(1_234_567_890); // → false, immediately await vips.count(); // exact cardinality
Invariant secured
OR-merge can't delete — so deletes are tombstones, not rebuilds.
addMany of 10,000 IDs spanning 12 chunks becomes 12 writes, not 10,000.
Concurrent writers never lose an acknowledged update (OCC). Named property + concurrency
tests pin both.
Milestone M1 · local
Disk, the .crbm format & the simulator durability · CRC · determinism
Phase 2 makes state survive a restart and defines the on-disk cold format. A
.crbm archive is a sequence of per-chunk roaring blobs followed by a footer
index — every chunk, the index, and the footer each carry a CRC32C, so
corruption is caught before a byte reaches the native decoder. Cold objects are
generation-keyed and immutable: segment.<gen>.crbm,
never overwritten in place.
This phase also builds the correctness moat: a
FoundationDB-style deterministic simulator that runs the real
core/ against fake in-memory drivers under a seeded scheduler, injecting
latency, errors, reordering and crashes — and checks every result against an
oracle (a trivial Set). A mismatch prints the seed; the
seed replays the failure byte-for-byte.
segment.0.crbm ┌───────────────┬───────────────┬─────┬──────────────┬───────────────┐ │ chunk blob │ chunk blob │ ... │ FOOTER INDEX │ FOOTER (104B) │ │ roaring+crc │ roaring+crc │ │ offsets +crc │ magic +crc │ └───────────────┴───────────────┴─────┴──────────────┴───────────────┘ every layer checksum-verified before the native decoder sees it ↑ reserved space for crypto fields (filled in Phase 4e)
import { CloudRoaring, LocalFsWarmDriver, LocalFsColdDriver } from '@cloudbitmaps/roaring'; const store = new CloudRoaring({ warm: new LocalFsWarmDriver('./.cloudbitmaps/warm'), cold: new LocalFsColdDriver('./.cloudbitmaps/cold'), // raw driver → reads .crbm generations }); await store.segment('active-this-week').add(42); // a fresh process pointed at the same dirs sees 42 again — writes are durable.
Invariant secured
Byte-exact round-trip, and no corrupt byte reaches the decoder. Property tests fuzz the codec against the oracle; golden files pin the v1 bytes; the simulator turns "zero data loss" from a hope into a seed-reproducible test.
Milestone M2 · Topology-A
S3 cold & the crown jewel: chunk-skipping intersection serverless ∩
Phase 3 puts the cold tier on S3 (or any S3-compatible store like MinIO) and delivers
the feature the whole project exists for. The S3ColdDriver lives at the
@cloudbitmaps/roaring/s3 subpath, so the AWS SDK is an
optional peer dependency — the core package never pulls it in.
Generations are write-once via a conditional If-None-Match:* PUT.
The crown jewel is intersect(). To AND two segments, you only need the
chunks whose 16-bit key appears in both — a key missing from either side
contributes nothing and is never downloaded. So two billion-ID
segments that barely overlap transfer almost nothing, and the whole thing streams under
a bounded in-flight window: it runs in a small / serverless process regardless of
segment size.
segment A keys: [ 3 7 12 40 91 ] segment B keys: [ 7 19 40 ] │ │ intersection of keys ──▶ { 7, 40 } ← only these chunks fetched keys 3,12,19,91 are SKIPPED — zero bytes transferred ┌─ bounded window ─────────────────────────────────────────────┐ │ fetch chunk 7 (A∩B) ─▶ emit ascending ids ─▶ fetch chunk 40 │ │ ≤ `concurrency` chunks in RAM at once — Lambda-friendly │ └──────────────────────────────────────────────────────────────┘
import { S3Client } from '@aws-sdk/client-s3'; import { S3ColdDriver } from '@cloudbitmaps/roaring/s3'; const cold = new S3ColdDriver({ client: new S3Client({ region: 'us-east-1' }), bucket: 'my-bitmaps' }); const store = new CloudRoaring({ warm, cold }); const shoppers = store.segment('high-value-shoppers'); const active = store.segment('active-this-week'); // stream the ids in BOTH segments — only overlapping chunks leave S3 for await (const id of shoppers.intersect([active])) { /* … */ } // or materialize the result straight into a new segment await shoppers.intersectInto(store.segment('campaign-targets'), [active]);
Invariant secured
Bounded memory and bounded transfer, always. Only chunks present in
every operand are fetched; only a small window is resident. intersect is
commutative — a.intersect([b]) ≡ b.intersect([a]) — and verified so by
test.
Milestone M3 · Topology-B · the production cluster
Phase 4 — making it a real distributed system
Topology-A proved the idea. Phase 4 is six sub-phases that turn it into something you'd run in production: a real multi-writer warm tier, resilience to flaky networks, a registry so reads don't scan, a crash-safe background compactor, encryption with crypto-shred, and finally constant-memory streaming so segment size never bounds RAM.
DynamoDB warm tier — real cross-process OCC
The warm tier moves to DynamoDB (subpath @cloudbitmaps/roaring/dynamodb, optional
peer dep). A single table — PK = ns#…|seg#…, SK = chunk#… —
holds the delta rows, and every write is a conditional write. Two
processes mutating the same chunk now get genuine optimistic concurrency: one wins, the
other gets a WriteConflictError and re-reads. The OCC token of Phase 1 is
now backed by a real distributed compare-and-set.
import { DynamoDbWarmDriver } from '@cloudbitmaps/roaring/dynamodb'; const warm = new DynamoDbWarmDriver({ client: dynamo, tableName: 'cloudbitmaps' }); const store = new CloudRoaring({ warm, cold }); await store.segment('active-this-week').add(42); // safe under N concurrent writers
Invariant secured
No lost updates under N writers (driver invariant D4), proven in the concurrency simulator. The same conformance suite that LocalFs passes runs against DynamoDB-Local in CI.
Resilience — retries, backoff & jitter, on by default
Cloud storage throttles, returns 5xx, and drops connections. Every warm/cold call now
automatically retries transient faults with bounded exponential
backoff and full jitter — you don't wire anything. Crucially,
deterministic errors are never retried: a ValidationError,
IntegrityError, or WriteConflictError can't be helped by
trying again, so they surface immediately.
Safety across a retry is the subtle part: a write that times out after it committed is caught on retry (the OCC token has advanced → a clean conflict, never a double-apply), and cold generations are write-once so there's no half-written object a reader could pick up. A transient outage costs you latency, not correctness.
const store = new CloudRoaring({ warm, cold }); // retries already enabled const tuned = new CloudRoaring({ warm, cold, retry: { maxAttempts: 4, baseDelayMs: 50, maxDelayMs: 2_000, jitter: 'full' }, onRetry: ({ attempt, delayMs }) => log.warn({ attempt, delayMs }), // …or `retry: false` to defer entirely to your client's own retries. });
Invariant secured
Transient ≠ deterministic. Only infrastructure faults retry; correctness errors fail fast. No double-apply, no torn read across an outage.
The segment registry — resolve the current generation in one read
Cold is a series of immutable, numbered generations; a reader needs to know which one
is current. Without a registry, the cold source lists every generation and
takes the max — a storage scan per segment. The registry replaces that with a single
authoritative record (currentGen) you read once. It's optional (omit it,
fall back to the scan), with memory / LocalFs / DynamoDB implementations. The record
also reserves the slots later phases fill: status,
dirtyChunkCount, and the wrapped-DEK list.
const registry = new LocalFsRegistryDriver('./.cloudbitmaps/registry'); // seed a generation AND publish it to the registry await bulkLoadCrbmGeneration(cold, { segment: 'active', generation: 0 }, ids, { registry }); const store = new CloudRoaring({ warm, cold, registry, // no scan });
Invariant secured
One authoritative pointer, forward-only. publishGeneration never regresses currentGen; the registry round-trip is conformance-tested across all three backends.
Crash-safe compaction — a 2-phase commit you can kill at any step
Every add/remove grows the warm tier; left alone, reads slow down as more deltas merge. Compaction folds the warm deltas into a fresh immutable cold generation and clears the warm rows — in a separate background process that never touches your app path. The hard part is doing it without ever losing a write that lands mid-compaction. The answer is a 2-phase commit, fenced on the per-chunk version it archived, that recovers cleanly from a crash at any step.
LEASE take a per-segment lease (stealable if a worker died) │ STAGE merge COLD ∪ WARM → write segment.<gen+1>.crbm (write-once) │ VERIFY re-read the new generation, check it against the tally │ SWAP ◀── the commit point: conditional currentGen → gen+1 │ (a crash before here changes nothing visible) PURGE delete warm rows conditionally on the archived version │ ← any write that arrived after STAGE survives untouched GC drop superseded generations, keeping a grace window for in-flight readers (pinned-gen safe re-resolve)
import { runCompactionCycle } from '@cloudbitmaps/roaring'; const deps = { cold, warm, registry, clock: { now: () => Date.now() } }; export const handler = async (_e, ctx) => { const { results } = await runCompactionCycle(deps, { owner: `lambda:${ctx.awsRequestId}`, keep: 1 }); return results; }; // or the bundled CLI: CR_COMPACT_MODE=loop npx compact-segments
Invariant secured
Merge-preservation + fenced purge + no torn reads. A write landing mid-compaction is never lost; the swap is the single atomic commit point; a reader pinned to a GC'd generation re-resolves rather than failing. Verified by crash-at-every-step injection.
Encryption at rest & crypto-shred
Encrypt the cold objects so a leaked bucket reveals
neither IDs nor cardinality (payloads and the chunk index are
encrypted; the footer's count fields are zeroed and re-derived after decryption). It's
envelope encryption: you hold one root KEK (32 bytes,
bring-your-own — no required cloud dependency); each segment gets a random
DEK, wrapped under your KEK and stored in the registry. Each
ciphertext's AAD binds it to (namespace, segment, generation, chunkKey) so
a blob can't be lifted into another context.
That same design gives crypto-shred — GDPR "right to erasure" that
works even on immutable or backed-up storage. Delete the wrapped DEK and the encrypted
bytes are permanently unreadable everywhere, no overwrite needed.
Encryption is fully opt-in: pass a keystore and it's on; set
requireEncryption: true to forbid any cleartext write or read.
your KEK (32B, in your secrets manager) │ wraps ▼ per-segment DEK ──▶ AES-256-GCM ──▶ encrypted .crbm (chunks + index) stored wrapped AAD = (ns, segment, gen, chunkKey) in the registry │ crypto-shred: delete the wrapped DEK ⇒ cold bytes unreadable forever, including in backups
import { InProcessKeystore } from '@cloudbitmaps/roaring'; const keystore = new InProcessKeystore({ keys: { '2026-06': loadKekFromSecrets() }, // 32-byte Uint8Array activeKeyId: '2026-06', // keyId-aware → rotate w/o re-encrypting // recoveryKeyId: 'offline-escrow', // optional offline recovery KEK }); await bulkLoadCrbmGeneration(cold, { segment: 'pii', generation: 0 }, ids, { registry, keystore }); const store = new CloudRoaring({ warm, cold, registry, keystore, }); // erase — irreversible; you must name the segment as confirmation: await destroySegment({ segment: 'pii' }, { registry, warm }, { confirmSegment: 'pii' });
⚠ The one rule when you turn this on
Back up the KEK like a database password. Lose every KEK for a segment and its at-rest bytes are gone — by design (that's also what makes crypto-shred real). But CloudBitmaps segments are almost always derived data, so the recovery is usually "re-seed from source," not permanent loss. Rotate, keep old keys, and hold a recovery KEK offline.
Invariant secured
A leaked bucket leaks nothing; erasure is provable. No IDs, no cardinality, no cross-context blob reuse. Crypto-shred works on immutable and backed-up storage — the property no overwrite-based deletion can offer.
Streaming compaction — constant memory at any segment size
The final piece closes the project's founding promise: compaction must not buffer a whole segment in RAM. The merge became an async generator feeding a streaming cold sink; on S3 that's multipart upload with at most one part buffered (default 8 MiB). Peak write memory is ~one part whether the segment is 2 GiB or 50 GiB.
Write-once survived the rewrite: a small object is a single conditional
PutObject; a large one finishes with a conditional
CompleteMultipartUpload (If-None-Match:*). And the object
ceiling is honest — S3 caps multipart at 10,000 parts, so the cap is
partBytes × 10,000 (≈80 GiB at the default, growing via
partBytes up to S3's 5 TiB), enforced with a typed error rather than a
silent overflow.
COLD ∪ WARM ──async generator──▶ merge, chunk by chunk │ (never the whole segment in RAM) ▼ ┌─ multipart sink — buffers ≤ one part (8 MiB default) ─┐ │ fill part ─▶ UploadPart ─▶ free ─▶ fill part ─▶ … │ └──────────────────────────────────┬───────────────────────┘ ▼ small → single conditional PutObject (write-once) large → conditional CompleteMultipartUpload (If-None-Match:*) peak RAM ≈ one part · regardless of 2 GiB or 50 GiB segment
There's no new API to call — it's the same runCompactionCycle and the same
S3ColdDriver from §4d/§3. Large objects simply upload via multipart
automatically.
Invariant secured
Bounded memory regardless of segment size — and write-once on both paths. The advertised object ceiling is actually achievable (part-count enforced); an overflow is a typed ValidationError, never opaque truncation.
Status
Where this leaves us
Phases 1–2 ✓
The engine, tombstone data model, durable disk tier, .crbm format, and the deterministic simulator.
Phase 3 ✓
S3 cold tier and serverless chunk-skipping intersection — the crown jewel.
Phase 4a–4f ✓
DynamoDB warm, resilience, registry, crash-safe + constant-memory compaction, encryption & crypto-shred.
Phase 5 — to scope
Observability, the cost estimator, benchmark-as-test thresholds, and an audit sink.
The through-line: every phase shipped with named tests for its hard invariant, a full local gate (lint · arch-lint · format · typecheck · test · build), and a parallel adversarial review before merge. The result is a distributed bitmap engine whose correctness is reproducible from a seed — not hoped for. ← Back to the overview