CloudBitmaps

Architecture · codec-independent

Only the chunks that can possibly contribute.

Every id splits into a 16-bit chunk key and a 16-bit remainder. The key is metadata, the remainder is payload, and the two live apart — which is the whole trick. Intersecting two 2,000,000-id segments compares keys first and fetches only the 100 shared chunks of 2,000. The other 1,900 are never requested and never billed.

Step through a measured run → The API that does it

None of this depends on the codec, so it stays true for every flavor.

Which chunks, decided before a byte moves

A single cursor walks both sorted key lists. A key present in only one segment cannot contribute to an intersection, so it is dropped without being read — skipping is the default, not an optimisation applied after the fact.

Key alignmentBoth segments, one pass
Segment A
Segment B

5 in both — fetched 6 in one segment only — skipped 7 in neither 18 keys, one pass

A key in only one segment cannot contribute to an intersection, so it is dropped after a comparison and nothing more — no request, no transfer, no bill. Five of these eighteen are fetched; on the real segments it is 100 of 2,000.

The whole operation, in four steps

Each step names what it does and what it cost. The numbers are from the same measured run as the panel above.

  1. 01 Resolve the generation once per operand Each segment's current generation is read from its registry row before any fan-out, and every chunk this operation fetches comes from that generation. A read is never a merge of two sources, so it cannot be torn.
  2. 02 keys(A) ∩ keys(B) 100 / 2,000 Metadata only. Cardinality per chunk is stored beside the key, so this phase never opens a payload.
  3. 03 Plan the fetch 3,800 skipped Every non-aligned key in either segment. Not deferred and not cached — the request is never issued.
  4. 04 Stream the AND 3,000 bytes The payload footprint is bounded by concurrency × operands × chunk — the window, not the segment size. That is the whole term: there is no second source to snapshot alongside it, so sizing a function for a many-operand intersection is those three numbers multiplied and nothing else.

There is deliberately no code on this page. What it owes you is the sequence and its costs; the calls that produce it are on usage, once, where they can be copied.

Two tiers, and the pointer that picks the generation

A segment is not moved between tiers — it lives in the object store, and the cache tier is a cache in front of it. Only the pointer decides what a read sees.

Two tiers, one pointerScrolls sideways on narrow screens
Layer Role Cost shape
CACHE · RAM A bounded LRU of decoded chunks in your own process, keyed by generation. Optional and evictable — correctness never depends on it, only latency. your memory
STORAGE · .crbm Immutable, generation-keyed objects in your bucket. Never mutated in place, so a reader always sees a whole generation and a superseded one can be kept as a snapshot. $0.03/mo · 1.2 GiB
GEN · REGISTRY Not a tier: one small row per segment holding currentGen. It stores no bits — it is the compare-and-swap target a publish advances, and the thing a read resolves once so that every chunk it fetches comes from the same generation. one row per segment

Loading a generation

Data enters exactly one way: store.load streams a set of ids into one immutable object, then publishGeneration advances the pointer. There is no per-id write, so there is nothing to fold back in later and no background job on the read path. On S3 the write and the publish cost $11.20 per million loads, pointer included — measured request counts at list prices, for those two steps; store.load's listings and clean-up pass come on top, about doubling it — so batch the refresh.

Exact counts, zero payload reads

Cardinality per chunk is stored beside the chunk key, so count() sums metadata and never opens a payload. It is exact, not estimated — no HyperLogLog, no error bars. $0.14 per million counts in the July run, without the pointer read that the single-bucket store now adds.

An erasure, and a publish

The two operations most systems get wrong. Both are shown rather than asserted, and in both the order of events is the point.

An erasureOne bit, one rewrite
Gen 6
The id
Gen 7

in generation 6 the id being erased cleared in generation 7

eraseSubject streams the current generation, clears one bit, verifies the result and publishes it forward-only — then collects the generation that held the bit. There is no tombstone to honour on the read path, and nothing to compact later: when the call returns, the bit is physically gone from the bucket.

A publishNever in place
Load · stream
Storage objects
Pointer

The new generation is written beside the old one, and the pointer flips last, by compare-and-swap, and only forward. Until it does, every reader still sees generation 6 — so a half-written publish cannot be observed, a crash between the two steps leaves 6 authoritative, and the superseded object stays readable for a grace window before the sweep collects it. Every write in the library is this same protocol.

Three more that hold

Not intentions. Each of these has a named test or a typed guard behind it, and the right-hand column is where to look for it.

Every hard correctness invariant carries named tests — property tests checking a loaded generation against a plain Set oracle, and crash and race tests over the write-then-publish path — and the whole suite runs on every commit. The test count is deliberately not printed here: it moves with almost every commit, and the invariants do not.

Errors, and which ones retry

Every failure is a typed error, never a thrown string, because the interesting question at a call site is not what went wrong but whether trying again could possibly help. That is a property of the error's kind, so the kind decides.

What you will catch 5 of 11 exported types
Error What it means Retry
TransientError The backend was briefly unavailable — throttled, a dropped connection, a 5xx. Automatic
WriteConflictError Another writer already took the generation number you were writing. Write-once storage refusing to be overwritten — working, not failing. New generation
IntegrityError Bytes came back that do not match their checksum. Something is corrupt; the same request will return the same corruption. Never
KeyUnavailableError An encrypted segment's key is gone or unreachable. If it was crypto-shredded, this is the correct and permanent answer. Never
ValidationError A bad argument reached a boundary. Your bug, and it fails immediately. Never

Deterministic failures are never retried. Replaying a request that failed for a reason that has not changed either cannot help or is actively wrong — and a retry loop that hides a corruption is worse than the corruption. Write conflicts are the one middle case, and they split in two: the publish's compare-and-swap re-reads the row and tries again, because advancing a pointer forward is idempotent, while a write-once collision on the object is never replayed — the identical PUT would fail identically, and the fix is a fresh generation number rather than another attempt at the same one.

Watching it, and knowing it holds

The engine emits neutral domain events — no vendor, no telemetry dependency, and no emission at all when no sink is wired, so the default costs nothing on the hot path. A short adapter maps them to OpenTelemetry, Datadog, or a log line.

What this design costs you

Every architecture is a set of trades. These are ours — and where a figure is owed rather than measured, the row says so instead of rounding it into a claim.

See it on a real run.

The demo steps through the intersection above with the recorded counters, and the benchmarks carry the measured costs, the real-cloud calibration, and the rate past which a flat node is cheaper.

Apache-2.0 · v0.10.0 · zero-dependency core · 4 storage drivers

InstallA codec and a storage
npm i @cloudbitmaps/roaring @cloudbitmaps/s3

# the memory and local drivers need nothing else
# for a real backend, add its package:
# @cloudbitmaps/s3 · @cloudbitmaps/gcs · @cloudbitmaps/azure-blob