Architecture

The central decision: the file is the WAL

Most databases pair an in-place B-tree with a separate write-ahead log: changes are first appended to the log, then later folded back into the tree in place. Arkeion collapses those two structures into one. It is a copy-on-write (CoW) B-tree living in an append-only file. A data page, once written, is never modified. Every write transaction appends the new and modified pages to the end of the file, followed by a small commit page that records the roots of the new version.

That one decision is the whole engine. The four properties people usually bolt on afterwards all fall out of it for free:

Property Why it’s automatic
Time-travel Old pages are immutable, so every past version is still on disk. Reading version N means resolving commit N and reading from its root — O(log n), no log replay.
Branching A branch is just a named ref pointing at a commit, exactly like git. Two branches physically share every page they haven’t changed.
Hash chain Each commit page carries the SHA-256 of its own contents plus the chained hash of the previous commit. The chain exists from the first byte, not added later.
Crash recovery A commit counts only if its commit page is intact. After a crash, a half-written tail is simply ignored. “Replay” is a forward scan, nothing to undo.
Lock-free reads A reader pins one commit and reads immutable pages. It never coordinates with the writer.

The price is that the file grows with its history. That’s paid back by vacuum, which compacts the file under a retention policy (see File format).

Copy-on-write, worked through

Suppose a small tree whose root points at two leaves, A and B, and you update one row that lives in B. Arkeion does not touch B. It writes a new leaf B′ with the change, then a new root that points at the unchanged A and the new B′, and appends both — followed by the commit page for the new version. A is shared by both versions; B stays on disk untouched. The old root is still valid, so the previous version remains fully readable.

root · version 1 root · version 2 root v1 root v2 B old · kept A shared B′ appended
Only the path to the change is rewritten. Everything else is shared; the old version stays intact and readable.

Layers and modules

The engine is a strict stack: dependencies point only downward, and no lower layer ever knows about a higher one. That’s what keeps each piece testable in isolation.

api Database · Connection · Transaction · Rows query engine sql (lexer → parser → AST) · exec (planner + executor) logical branch · catalog · record transactional tx (snapshots · single writer) · commit (hash chain) · btree (CoW) physical pager · crypto · io · format depends on ↓
Strict top-to-bottom dependencies. The B-tree never knows whether pages are encrypted; the query engine never knows how a page reaches disk.
Module Responsibility Key types
format Constants, magic numbers, layout offsets. No logic. PAGE_SIZE, PageId, PageType
io Portable positional read/write (Unix read_at, Windows seek_read). DbFile
crypto trait CryptoProvider { seal(page), open(page) }. Implementations: Aes256GcmProvider, PlainProvider (integrity via truncated SHA-256). CryptoProvider, Key
pager Page append, cached immutable reads (Arc<PageBuf>), A/B meta slots, integrity validation. Pager, PageBuf
btree CoW B-tree: get / insert / delete / scan over byte keys, addressed by PageId. Overflow for large values. Tree, Cursor
commit Builds the commit page (roots, hashes, nonce counter), the fdatasync protocol, recovery scan, chain verification. CommitHeader, ChainVerifier
tx Snapshot (a read, pins a commit) and WriteTx (single, serialised by a Mutex). Snapshot, WriteTx
record Memcomparable key encoding and the compact row format. Value, RowCodec, KeyCodec
catalog Table schema in the data tree (branches with the data); refs and the history index in the meta tree (global). Catalog, TableDef
sql Hand-written lexer and recursive-descent parser. No dependencies. Token, Stmt, Expr
exec Planner (full scan + filter) and a row-by-row iterator executor. Plan, Executor
branch Diff between branches (skipping physically shared subtrees) and a row-level 3-way merge. Diff, MergeReport
api The ergonomic public facade, in the rusqlite style. The only re-exported module. Database, Connection

The two trees

Every commit references two roots, and they behave differently on purpose:

  • Data tree (data_root) — the catalogue and the rows. It follows its branch: a commit on a branch builds on that branch’s previous data root, so a migration or a write on one branch is invisible to the others.
  • Meta tree (meta_root) — branch refs plus the history index (version → commit, timestamp → version). It is global and linear: every commit, from any branch, builds on the meta tree of the previous global commit. That’s why refs and the version index are a single shared source of truth that never diverges between branches.

Keeping the schema in the data tree is the reason branching is honest: because the schema travels with the branch, you can evolve the shape of your data on a branch and merge it deliberately, not have it leak across every branch at once.

meta_root — one global, linear history · never forks v1 v2 v3 v4 c1 c2 c3main c4feature data_root — follows the branch · forks with it
Data roots fork with their branch; the meta chain stays a single global line, so version numbers and refs are never ambiguous.

Concurrency model

  • ReadersConnection::snapshot() pins a commit page and reads only immutable pages through a shared cache. Any number of readers run concurrently, none of them sharing a lock with the writer. Isolation is snapshot isolation.
  • Writer — exactly one, serialised by a Mutex<Writer> at the Database level. Writes are therefore serialisable by construction. Throughput is capped at a single writer thread today; group commit (batching concurrent fsyncs) is a planned optimisation, not yet shipped.
  • Multi-process — an advisory file lock is taken on open(), for a single writer process. Arbitrating writers across processes is out of scope for now.

Write flow

A write turns into pages in memory, appends them, appends one commit page, and reaches durability with a single fdatasync:

execute("UPDATE …")
  → sql::parse → exec::plan
  → WriteTx: btree CoW produces new pages in memory
  → pager: append the new pages, then the commit page
  → commit: one fdatasync                     ← the single durability point
  → meta slot A/B updated (lazy — a boot hint, off the critical path)

There is deliberately no fsync between the data pages and the commit page. Per-page integrity tags make any torn or missing page unreadable, and recovery stops at the first unreadable page — so a half-written commit is simply never adopted. One barrier per commit is all it takes.

Time-travel read flow

Reading the past is not replay; it is a lookup and an ordinary scan from an old root:

query("SELECT … AS OF VERSION 42")
  → meta tree: history[42] → PageId of commit 42
  → Snapshot{commit 42} → btree::scan from data_root(42)