279 lines
15 KiB
Markdown
279 lines
15 KiB
Markdown
# ADR-001: Generational MVCC storage and the immutable Db API
|
|
|
|
**Status:** Accepted (ships in 8.0)
|
|
**Date:** 2026-06-10
|
|
|
|
## Context
|
|
|
|
Before 8.0, Brainy carried two overlapping version-control subsystems: a
|
|
copy-on-write branching layer (`fork`/`checkout`/`commit`/`branches`) and a
|
|
separate versioning subsystem (`versions.save/list/compare/restore/prune`),
|
|
plus a read-only historical adapter for commit-based time travel. Together
|
|
they were ~5,100 LOC of mechanism for one product need: *read a consistent
|
|
past state while the store keeps moving, and snapshot/restore cheaply.*
|
|
|
|
Neither subsystem gave a precise isolation guarantee. Reads raced in-place
|
|
JSON overwrites, so a "snapshot" was only as immutable as the bytes it
|
|
happened to share with the live store.
|
|
|
|
8.0 replaces both with **one mechanism**: generational MVCC over immutable,
|
|
generation-stamped records, exposed through a Datomic-style immutable
|
|
database value (`Db`). The same model is implemented natively by versioned
|
|
index providers (LSM snapshots), so semantics are identical on the pure-JS
|
|
path and the native path.
|
|
|
|
## Decision
|
|
|
|
### The model
|
|
|
|
- A **monotonic u64 generation counter** is the store's logical clock. It
|
|
advances once per committed `transact()` batch and once per
|
|
single-operation write (`add`/`update`/`remove`/`relate`/…), so
|
|
`brain.generation()` is always a meaningful watermark. It is persisted in
|
|
`_system/generation.json` and never reissued for anything durable.
|
|
- `brain.now()` **pins** the current generation in O(1) and returns a `Db` —
|
|
an immutable view. Pins are refcounted; `db.release()` (with a
|
|
`FinalizationRegistry` backstop for leaked values) ends the pin.
|
|
- `brain.transact(ops, { meta, ifAtGeneration })` commits a declarative
|
|
batch atomically as **exactly one generation**, with whole-store
|
|
compare-and-swap (`ifAtGeneration` → `GenerationConflictError`) and
|
|
reified transaction metadata appended to `_system/tx-log.jsonl`.
|
|
- `brain.asOf(generation | Date | snapshotPath)` opens past state;
|
|
`db.with(ops)` layers a speculative in-memory overlay (never touching
|
|
disk, the counter, or index providers); `db.persist(path)` cuts an
|
|
instant snapshot; `brain.restore(path, { confirm: true })` replaces state
|
|
from one; `Brainy.load(path)` opens a snapshot read-only with the full
|
|
query surface.
|
|
|
|
### Persisted layout
|
|
|
|
All paths are storage-root-relative:
|
|
|
|
```
|
|
_system/generation.json { generation, updatedAt } atomic tmp+rename
|
|
_system/manifest.json { version, generation, atomic tmp+rename
|
|
committedAt, horizon } (the commit point)
|
|
_system/tx-log.jsonl one line per committed append-only
|
|
transact: { generation,
|
|
timestamp, meta? }
|
|
_generations/<N>/tx.json the generation-N delta: immutable
|
|
touched noun/verb ids + meta
|
|
_generations/<N>/prev/<id>.json before-image of <id> as of immutable
|
|
commit N (raw stored bytes;
|
|
null parts = file was absent)
|
|
```
|
|
|
|
**Why per-generation deltas instead of a global `id → latest generation`
|
|
map in the manifest:** a global map makes every commit O(all ids) — the
|
|
whole map must be rewritten to swap it atomically. The delta layout makes a
|
|
commit O(ids touched) and keeps the manifest a fixed-size watermark, while
|
|
point-in-time resolution stays correct (see "Read resolution" below). The
|
|
trade is that resolution at a pinned generation scans the deltas of later
|
|
commits — bounded by the number of commits since the pin, which is exactly
|
|
the window compaction keeps short.
|
|
|
|
Before-images are deliberately the *only* per-id records. They serve both
|
|
roles the layer needs — the crash-recovery undo log and the point-in-time
|
|
read source. After-images would duplicate state that is already readable
|
|
(the canonical entity files hold the latest bytes; earlier states resolve
|
|
from later before-images) and would double record I/O per commit.
|
|
|
|
### Commit protocol (durability)
|
|
|
|
`transact()` commits under a store-wide mutex:
|
|
|
|
1. **CAS check.** A stale `ifAtGeneration` throws `GenerationConflictError`
|
|
before anything is staged.
|
|
2. **Reserve** generation `N` (counter increment).
|
|
3. **Stage the undo log:** write the before-image of every touched id plus
|
|
`tx.json` under `_generations/N/`, then **fsync** the files and their
|
|
directories. From this point, any crash is recoverable to the exact
|
|
pre-transaction bytes.
|
|
4. **Execute** the planned batch through the TransactionManager (which has
|
|
its own operation-level rollback for in-flight failures).
|
|
5. **Commit point:** persist the counter, then write `_system/manifest.json`
|
|
via atomic tmp+rename and fsync it. The rename *is* the commit: a
|
|
generation directory is committed if and only if `N ≤
|
|
manifest.generation`.
|
|
6. Append the tx-log line (advisory metadata — a crash between 5 and 6
|
|
keeps the transaction).
|
|
|
|
**Crash recovery (on open):** any `_generations/<N>` directory with
|
|
`N > manifest.generation` is an uncommitted transaction. Its before-images
|
|
are restored to the canonical paths (idempotently — recovery itself can
|
|
crash and rerun) and the directory is removed. Because recovery runs before
|
|
any index is built, and a recovery that rolled something back forces a full
|
|
index rebuild, derived indexes never observe rolled-back state. Reader-mode
|
|
instances skip recovery (readers never write; the next writer repairs).
|
|
|
|
A failed (non-crash) transaction takes the same staging directory down the
|
|
abort path: the TransactionManager rolls back applied operations, the
|
|
staging directory is removed, and the generation reservation is returned —
|
|
a failed batch leaves the generation counter unchanged.
|
|
|
|
### Read resolution at a pinned generation
|
|
|
|
The state of id X at pinned generation G is:
|
|
|
|
- the before-image stored by the **first committed generation after G that
|
|
touched X**, or
|
|
- the live canonical bytes, when nothing after G touched X.
|
|
|
|
While nothing has committed past G, *every* read on the `Db` delegates to
|
|
the live fast paths untouched — `now()` adds no read overhead until history
|
|
actually moves.
|
|
|
|
**Two read paths, one result set.** `get()`, metadata-level `find()`, and
|
|
filter-based `related()` resolve directly through the record layer at any
|
|
reachable pinned generation — no extra cost beyond scanning the deltas of
|
|
later commits. Index-accelerated dimensions (semantic/vector search, graph
|
|
traversal, cursors, aggregation) are served by **at-generation index
|
|
materialization**: the first such query on a historical `Db` copies the
|
|
exact at-G record set (live bytes for ids untouched since the pin,
|
|
before-images for the rest; a final reconciliation pass runs under the
|
|
commit mutex so transactions racing the copy cannot skew it) into an
|
|
ephemeral in-memory store and opens a read-only engine over it — the same
|
|
vector/metadata/graph index classes the live brain uses, sharing the host's
|
|
embedder and aggregate definitions. The handle is cached on the `Db` and
|
|
freed by `release()`.
|
|
|
|
**Cost, stated plainly:** materialization is O(n at G) time and memory,
|
|
once per `Db`. That is the open-core price of historical index queries. A
|
|
native `VersionedIndexProvider` (`isGenerationVisible()` + pins over
|
|
retained LSM segments) serves the same reads with no rebuild at all — the
|
|
materializer is the correctness baseline, the provider is the accelerator.
|
|
|
|
**The one remaining boundary.** Speculative `with()` overlays throw
|
|
`SpeculativeOverlayError` for index-accelerated queries and `persist()`:
|
|
overlay entities carry no embeddings (`with()` never invokes the embedder),
|
|
so a "full" index query over an overlay would silently exclude the
|
|
overlay's own entities. Commit with `transact()` to get the full surface.
|
|
|
|
**History granularity.** Generation *records* are written per `transact()`
|
|
batch only. Single-operation writes advance the counter (so watermarks and
|
|
CAS stay sound) but do not stage before-images: they remain visible through
|
|
earlier pins and are not reported by `db.since()`. Code that needs pinned
|
|
isolation across its writes uses `transact()` — that is the documented 8.0
|
|
contract, stated rather than papered over.
|
|
|
|
### Pinning, retention, compaction
|
|
|
|
- Each live `Db` holds one refcounted pin on its generation (plus a
|
|
`pin(generation)` on every registered `VersionedIndexProvider`, whose
|
|
explicit pin lifetime overrides any time-based snapshot retention the
|
|
provider has).
|
|
- `compactHistory({ retainGenerations?, retainMs? })` reclaims record-sets.
|
|
A record-set `N` is reclaimed only when `N` is at or below **every** live
|
|
pin — deleting `N` can only break readers pinned *below* `N`, because
|
|
resolution reads before-images from generations strictly greater than the
|
|
pin. With both retention options supplied, both must allow the reclaim.
|
|
- The manifest records the **horizon** (highest reclaimed generation).
|
|
Generations below the horizon are unreachable; `asOf()` on them throws
|
|
`GenerationCompactedError`. The horizon itself stays reachable, resolved
|
|
from the record-sets above it. To keep a generation readable forever,
|
|
`persist()` it first — snapshots are self-contained.
|
|
|
|
### Snapshots and restore
|
|
|
|
`db.persist(path)` flushes indexes, then cuts the snapshot under the
|
|
store's commit mutex (no commit, compaction, or counter write can
|
|
interleave). On filesystem storage it is a **hard-link farm**: every data
|
|
file is immutable-by-rename, so linking is safe — later rewrites swap
|
|
inodes and the snapshot keeps the old bytes. The two exceptions are handled
|
|
explicitly: the append-in-place tx-log is byte-copied, and process-local
|
|
lock state is excluded. Cross-device targets (and filesystems that refuse
|
|
links) fall back to per-file byte copies. In-memory stores serialize to the
|
|
same directory layout, so persisting a memory brain produces a real,
|
|
durable, loadable store.
|
|
|
|
`persist()` requires the view to still be the store's latest generation
|
|
(a snapshot captures current bytes); a view that history has moved past
|
|
throws rather than persisting the wrong state.
|
|
|
|
`restore(path, { confirm: true })` replaces the store's contents from a
|
|
snapshot via byte copy (never links — the snapshot stays independent),
|
|
reloads all adapter-internal derived state, rebuilds all indexes, and
|
|
floors the generation counter at its pre-restore value so observed
|
|
generation numbers are never reissued. Live pins do not survive a restore;
|
|
a warning is logged when any exist.
|
|
|
|
### Versioned index providers
|
|
|
|
Native index providers may implement the optional 4-method
|
|
`VersionedIndexProvider` capability (`generation()`,
|
|
`isGenerationVisible()`, `pin()`, `release()` — BigInt generations at the
|
|
boundary). The locked consistency model: providers are **post-commit
|
|
appliers**. The storage-record commit is the source of truth; provider
|
|
index state is derived. On open, a provider behind the committed watermark
|
|
replays the gap from storage (or requests a rebuild) — there are no
|
|
provider rollback hooks, because uncommitted transactions are repaired at
|
|
the storage layer before any index opens. Speculative `with()` overlays
|
|
never reach providers.
|
|
|
|
## Guarantees (and their proofs)
|
|
|
|
Each stated guarantee has a test that proves it, not merely exercises it
|
|
(`tests/integration/db-mvcc.test.ts`, plus
|
|
`tests/unit/db/generationStore.test.ts` for the record layer in isolation):
|
|
|
|
| Guarantee | Proof |
|
|
|---|---|
|
|
| Snapshot isolation: a pinned `Db` reads exactly its pinned state, forever | proof 1 (200 mutations, including deletes, against a pinned view) |
|
|
| Atomicity: a failing batch applies nothing; generation unchanged | proofs 2a/2b/2c (plan-time failure, injected execution-phase storage failure, `ifRev` conflict) |
|
|
| Whole-store CAS | proof 3 (`ifAtGeneration` success + conflict with exact expected/actual) |
|
|
| Snapshot integrity under source mutation (hard-link safety) | proofs 4a/4b/4c |
|
|
| Compaction never breaks a pinned read; release enables reclaim | proof 5 |
|
|
| `with()` overlays touch nothing durable | proof 6 |
|
|
| Generation monotonicity across close/reopen | proof 7 |
|
|
| Crash before the manifest rename recovers to exact pre-transaction state through the real recovery path | proof 8 (fault injection that skips abort cleanup, exactly as a dead process would) |
|
|
| Balanced provider pin/release lockstep | proof 9 |
|
|
|
|
One deliberate softness: single-operation generation bumps persist the
|
|
counter coalesced (per write burst), not per write. Durable artifacts —
|
|
records, manifests, snapshots — always persist the counter synchronously at
|
|
their own commit points, so a crash inside the coalescing window can lose
|
|
only counter values that nothing durable ever referenced.
|
|
|
|
## Failure modes
|
|
|
|
| Failure | Outcome |
|
|
|---|---|
|
|
| Crash before staging completes | Partial staging directory > manifest watermark → removed on next open; canonical state untouched. |
|
|
| Crash after staging, before/during batch execution | Before-images restored on next open; indexes rebuilt; byte-identical pre-transaction state. |
|
|
| Crash after execution, before manifest rename | Same as above — the rename is the only commit point. |
|
|
| Crash after manifest rename, before tx-log append | Transaction kept (committed); tx-log misses one advisory line; `asOf(Date)` resolution for that commit falls back to neighboring entries. |
|
|
| Batch fails mid-execution (no crash) | TransactionManager operation rollback + staging-directory removal + reservation return; generation unchanged. |
|
|
| `asOf()` below the compaction horizon | `GenerationCompactedError` — explicit, never partial data. |
|
|
| Index-accelerated query on a `with()` overlay | `SpeculativeOverlayError` — explicit, never silently-incomplete results (overlay entities carry no embeddings). |
|
|
| `persist()` of a view history has moved past | `GenerationConflictError` — a snapshot captures current bytes; persist before further writes. |
|
|
| Torn trailing tx-log line (crashed append) | Tolerated; unparseable lines are skipped by readers. |
|
|
|
|
## Lineage
|
|
|
|
The design is an assembly of well-understood prior art, chosen for being
|
|
boring where it counts:
|
|
|
|
- **Datomic** — the database-as-a-value: an immutable `Db` you query, with
|
|
`with()` for speculation and reified transaction metadata instead of
|
|
commit messages.
|
|
- **LMDB** — reader pins: readers never block writers; a reader's view
|
|
stays valid because nothing overwrites the pages (here: records) it
|
|
references; reclamation waits for the last reader.
|
|
- **LSM trees / Cassandra** — immutable segments make snapshots hard links
|
|
and make compaction a retention policy instead of a locking problem.
|
|
|
|
## Consequences
|
|
|
|
- One mechanism replaces the COW and versioning subsystems (their removal
|
|
is the companion change to this ADR).
|
|
- In-place branch switching (`checkout`) is gone by design; the replacement
|
|
is opening a persisted snapshot as a separate instance — a name→path
|
|
mapping where a product needs named branches.
|
|
- Every commit pays O(ids touched) extra writes (before-images + delta +
|
|
manifest). Single-operation writes pay only an in-memory counter bump
|
|
with coalesced persistence.
|
|
- The full query surface works at every reachable pinned generation.
|
|
Record-path reads (`get`, metadata `find`, filter `related`) are
|
|
effectively free; index-accelerated historical queries pay a one-time
|
|
O(n at G) materialization per `Db` on the open-core path (freed on
|
|
`release()`), and run rebuild-free on a native `VersionedIndexProvider`.
|