The durable-store entry point: open (once per directory) a DiskRecordStore and/or a
KvIndexStore over a DiskKvBackend, take the single-writer lock, and wire what was
opened to the durability daemon.
The two halves open independently. A KB's records and its index are chosen on
separate axes (vaelii.impl.kb), and only one of the combinations that reach here
wants both: :disk is durable records and a durable index, while :disk-memory /
:disk-dense / :disk-columnar keep the derived index in RAM and want the record store
alone — no index log, no index WAL, nothing written to the directory but the records. So each
component is opened on first use rather than as a pair, and the registry records which
ones a directory actually has. A directory opened both ways in one JVM therefore
shares its record store across both KBs and hands the RAM-index one no durable index
at all.
A process-global registry keyed by canonical directory mirrors the memory backend's space registry: two KBs constructed over the same directory share one set of stores — so a KB "restarted" over the same directory in one JVM (the recovery tests) sees the durable records the first wrote, with its own fresh taxonomy/TMS, and the file handles + durability registration + lock are taken once rather than leaking across the suite's hundreds of KB constructions. A true cross-JVM restart opens the directory fresh and rebuilds the RAM state from the durable logs.
The durable-store entry point: open (once per directory) a `DiskRecordStore` and/or a `KvIndexStore` over a `DiskKvBackend`, take the single-writer lock, and wire what was opened to the durability daemon. **The two halves open independently.** A KB's records and its index are chosen on separate axes (`vaelii.impl.kb`), and only one of the combinations that reach here wants both: `:disk` is durable records *and* a durable index, while `:disk-memory` / `:disk-dense` / `:disk-columnar` keep the derived index in RAM and want the record store alone — no index log, no index WAL, nothing written to the directory but the records. So each component is opened on first use rather than as a pair, and the registry records which ones a directory actually has. A directory opened both ways in one JVM therefore shares its record store across both KBs and hands the RAM-index one no durable index at all. A process-global registry keyed by canonical directory mirrors the memory backend's space registry: two KBs constructed over the same directory share one set of stores — so a KB "restarted" over the same directory in one JVM (the recovery tests) sees the durable records the first wrote, with its own fresh taxonomy/TMS, and the file handles + durability registration + lock are taken once rather than leaking across the suite's hundreds of KB constructions. A true cross-JVM restart opens the directory fresh and rebuilds the RAM state from the durable logs.
How a record is shaped on its way into a log frame, and back.
nippy freezes a Clojure record by writing its type tag and every field name into
the frame — so a store of 100M sentexes writes vaelii.impl.sentex.AtomicSentex and
:sentence :context :id :truth :strength 100M times. Measured on the real corpus,
that scaffolding is 56% of the store (87 of 155 B/record) and it says nothing a
frame needs to carry: the field layout is a property of the code, identical in every
frame.
So a frame holds the fields positionally — a plain vector, the shape known here —
which is 1.85× smaller and needs no dictionary, no id allocation, and no new durable
ground truth (lein bench-records). The codec is per kind, because each kind has
one known set of shapes: a sentex frame is tagged atomic/rule, a justification frame
is a bare vector (there is only one shape), and provenance is an open application map
that passes through untouched.
Reading is backward-compatible in both directions. decode dispatches on the
thawed frame: a vector is positional, anything else is returned as it thawed. So a
store written before this codec reads exactly as it did (its frames are records), and
a plain map handed to put-sentex — which the tests do, and which is not an AtomicSentex
— round-trips as the map it is.
Decoding also interns the symbols it rebuilds (sentex/intern-deep), so a record
paged off disk shares the one vocabulary object per name with every other record and
with the in-memory store, rather than minting its own copy per fetch. That matters
most for the records the hot cache retains.
Tokenized bodies are the second, opt-in step (vaelii.disk.tokens): the positional
frame still spells its sentence out in full, and the vocabulary — the same few hundred
thousand predicate and individual names — is written into every one of the frames. A
tokenized frame replaces the s-expression fields with a varint byte string of ids from
the durable dictionary (vaelii.impl.disk.tokens), 2.6× smaller again. It is a
fourth and fifth frame tag, not a format change: a store can hold plain and tokenized
frames side by side, so enabling it costs no rewrite and disabling it leaves what is
already written readable.
How a record is shaped on its way into a log frame, and back. nippy freezes a Clojure record by writing its **type tag and every field name** into the frame — so a store of 100M sentexes writes `vaelii.impl.sentex.AtomicSentex` and `:sentence :context :id :truth :strength` 100M times. Measured on the real corpus, that scaffolding is **56% of the store** (87 of 155 B/record) and it says nothing a frame needs to carry: the field layout is a property of the code, identical in every frame. So a frame holds the fields **positionally** — a plain vector, the shape known here — which is 1.85× smaller and needs no dictionary, no id allocation, and no new durable ground truth (`lein bench-records`). The codec is per *kind*, because each kind has one known set of shapes: a sentex frame is tagged `atomic`/`rule`, a justification frame is a bare vector (there is only one shape), and provenance is an open application map that passes through untouched. **Reading is backward-compatible in both directions.** `decode` dispatches on the thawed frame: a vector is positional, anything else is returned as it thawed. So a store written before this codec reads exactly as it did (its frames are records), and a plain map handed to `put-sentex` — which the tests do, and which is not an `AtomicSentex` — round-trips as the map it is. Decoding also **interns** the symbols it rebuilds (`sentex/intern-deep`), so a record paged off disk shares the one vocabulary object per name with every other record and with the in-memory store, rather than minting its own copy per fetch. That matters most for the records the hot cache retains. **Tokenized bodies** are the second, opt-in step (`vaelii.disk.tokens`): the positional frame still spells its sentence out in full, and the vocabulary — the same few hundred thousand predicate and individual names — is written into every one of the frames. A tokenized frame replaces the s-expression fields with a varint byte string of ids from the durable dictionary (`vaelii.impl.disk.tokens`), 2.6× smaller again. It is a *fourth and fifth frame tag*, not a format change: a store can hold plain and tokenized frames side by side, so enabling it costs no rewrite and disabling it leaves what is already written readable.
Durability management for the disk backend. Each disk store/kv registers itself here on open; one daemon fsyncs every registrant on a tick (default 3 s), and one JVM shutdown hook closes them all on exit. Without it, a crash between manual fsyncs loses everything since the last one.
Registration is capability-based — callers hand in {:fsync :close :label} (plus an
optional {:compact :dead-ratio} for background compaction), so this namespace does
not depend on the stores it drives (which would cycle).
Config (system properties): vaelii.disk.sync-ms (tick interval, 0 disables the
daemon), vaelii.disk.auto-compact (off with false/0/off),
vaelii.disk.compact-dead-ratio (default 0.5),
vaelii.disk.compact-min-interval-ms (default 300000).
Durability management for the disk backend. Each disk store/kv registers itself
here on open; one daemon fsyncs every registrant on a tick (default 3 s), and one
JVM shutdown hook closes them all on exit. Without it, a crash between manual
fsyncs loses everything since the last one.
Registration is capability-based — callers hand in `{:fsync :close :label}` (plus an
optional `{:compact :dead-ratio}` for background compaction), so this namespace does
not depend on the stores it drives (which would cycle).
Config (system properties): `vaelii.disk.sync-ms` (tick interval, 0 disables the
daemon), `vaelii.disk.auto-compact` (off with `false`/`0`/`off`),
`vaelii.disk.compact-dead-ratio` (default 0.5),
`vaelii.disk.compact-min-interval-ms` (default 300000).Low-level file primitives for the on-disk backend.
.log files hold length-prefixed nippy frames.
Frame layout: [len: i32 big-endian][nippy-bytes]. The offset returned from
append-record! points at the len prefix..idx files are fixed-width arrays of 24-byte slots, indexed by id.
Slot layout:
bytes 0..7 offset (i64, -1 = empty, -2 = tombstone)
bytes 8..15 length (i64)
bytes 16..19 flags (u32; bit 0 = the premise bit is meaningful, bit 1 = premise)
bytes 20..23 gen (u32, per-write increment)
Reads rely on the OS page cache; writes overwrite the slot in place.
A slot is read in one positional channel read, not a seek plus four primitive
readLong/readInt calls — a RandomAccessFile is unbuffered, so each of those is
its own syscall and moving 24 bytes cost six of them (measured: 52% of a warm
record fetch, lein bench-records)..nippy files hold whole-blob metadata (counters, the premise set). Callers
rewrite them atomically via write-nippy-atomic!.Shared-pointer invariant. A seek→read/write pair on a RandomAccessFile uses that
object's single shared file pointer, so any access to a store's live RAF must hold the
owning kind lock; the disk adapters take a per-kind lock around every such touch (write,
force!) for exactly this reason. The read primitives here (read-slot,
read-record, read-record-sized) are positional FileChannel reads instead — they
name the file offset in the call and never touch the pointer, so they neither disturb a
concurrent seek nor need one of their own.
Compression: frames freeze under the nippy compressor named by the
vaelii.disk.compress system property (lz4 | zstd | none); default none.
nippy reads the compressor id from each frame header, so mixed frames thaw
correctly. Durability: vaelii.disk.fsync=dsync opens logs rwd (O_DSYNC) so
every append is synchronous — off by default (the durability daemon fsyncs on a
tick instead).
Low-level file primitives for the on-disk backend.
- Append-only `.log` files hold length-prefixed nippy frames.
Frame layout: `[len: i32 big-endian][nippy-bytes]`. The offset returned from
`append-record!` points at the len prefix.
- `.idx` files are fixed-width arrays of 24-byte slots, indexed by id.
Slot layout:
bytes 0..7 offset (i64, -1 = empty, -2 = tombstone)
bytes 8..15 length (i64)
bytes 16..19 flags (u32; bit 0 = the premise bit is meaningful, bit 1 = premise)
bytes 20..23 gen (u32, per-write increment)
Reads rely on the OS page cache; writes overwrite the slot in place.
A slot is read in **one positional channel read**, not a seek plus four primitive
`readLong`/`readInt` calls — a `RandomAccessFile` is unbuffered, so each of those is
its own syscall and moving 24 bytes cost six of them (measured: 52% of a warm
record fetch, `lein bench-records`).
- `.nippy` files hold whole-blob metadata (counters, the premise set). Callers
rewrite them atomically via `write-nippy-atomic!`.
**Shared-pointer invariant.** A seek→read/write pair on a `RandomAccessFile` uses that
object's single shared file pointer, so any access to a store's live RAF must hold the
owning kind lock; the disk adapters take a per-kind lock around every such touch (write,
`force!`) for exactly this reason. The *read* primitives here (`read-slot`,
`read-record`, `read-record-sized`) are positional `FileChannel` reads instead — they
name the file offset in the call and never touch the pointer, so they neither disturb a
concurrent seek nor need one of their own.
Compression: frames freeze under the nippy compressor named by the
`vaelii.disk.compress` system property (`lz4` | `zstd` | `none`); default none.
nippy reads the compressor id from each frame header, so mixed frames thaw
correctly. Durability: `vaelii.disk.fsync=dsync` opens logs `rwd` (O_DSYNC) so
every append is synchronous — off by default (the durability daemon fsyncs on a
tick instead).A mapped snapshot of the columnar index, which pages its cold tail to disk instead of holding all of it in heap.
:disk-columnar keeps durable records and rebuilds the derived index on every open.
That rebuild is O(records) — measured at 5.6 s for 313k, ~30 min at 100M — and the
rebuilt structure is then wholly resident, which is the wall the scale plan names.
This writes the compacted index to disk once and maps it back, so an open reads bytes
and the fact-scaled postings live in the page cache rather than the heap.
The index is derived state: reindex rebuilds every entry from the records. That
is what makes this cheap — no write-ahead log, no op log, no crash-consistent mutation
protocol, no bucket directory. It needs a snapshot that can be thrown away and
rebuilt whenever it is in doubt, and "in doubt" resolves to reindex in every case.
It is also why there is no directory to page. A flat-map index keys every trie node by
a boxed vector of its whole path prefix, so an out-of-core design over it has to page
the keys themselves; the columnar trie has no keys at all — a node's identity is its
int id and its position in the parallel arrays is the directory. columnar/compact!
already produces exactly the arrays this writes.
Under <dir>/index/, four things:
trie.csr — the trie's six CSR sections (fcounts foffsets fedge-tok
fedge-tgt fleaf-off fhandles), each a raw little-endian int run behind a
header naming the counts.roots.csr — the secondary roots / term / rule / exception postings as the same CSR
shape over dense-roots' packed long keys: sorted keys, an offset column, one
shared handle run.roots-fallback.nippy — the term roster and anything else the routed families do not
claim. Vocabulary-scaled and irregularly shaped, so it is a blob rather than a
column.tokens.log — the durable token dictionary the int edges cite, in
vaelii.impl.disk.tokens' format (append-only, id = append position, content-keyed,
first-writer-wins, never reused). That module is reused rather than a second
dictionary format minted: persisting the trie's int edges is precisely the seam
vaelii.impl.tokens names as its durable variant.One file per structure, not one per section. A structure is mapped or discarded as
a unit, so per-section files would multiply the crash window by six for nothing; the
section table in the header already names the offsets map needs.
scale-100m.md's rule is never page the walk — the worst measured index pathology was
the leading-variable trie fan, 18,512 lookups for one query, and a disk seek is worse
than the round trip that pathology was made of. So the load is deliberately asymmetric:
fcounts foffsets fedge-tok fedge-tgt), the
roots' key and offset columns, and the token dictionary. Read into heap on open.fleaf-off / fhandles and the roots' handle run. These are the
fact-scaled mass (the roots alone are 69 MB of a 186 MB compacted 300k-fact index) and
each posting is touched only when its own term is queried. Cold by construction.With mmap the OS page cache is the residency policy, which is the point — but only
because the skeleton stays hot.
The failure to fear is a stale snapshot that passes its check: one can be perfectly
self-consistent and describe a KB that no longer exists. So the stamp covers the
records, not the snapshot's own bytes, and it is checked on every open — never
behind a flag. Three things must agree or the snapshot is discarded and reindex runs:
the format and kv/index-layout-version, the byte-order tag (an image whose endianness
differs is refused rather than read wrong), and record-store/slot-fingerprint. The
decision carries a reason from import's vocabulary — :absent :layout-changed
:records-differ :entries-truncated — because a rebuild nobody can explain is a
rebuild nobody notices.
A commit is one atomic step: the sections are written to temps and fsynced, the meta is deleted, the temps are renamed into place, and the meta is written last. Its presence is the commit point, so a crash anywhere leaves no meta, and no meta means reindex.
1.72–1.84× off the index's resident heap and a 1.5× faster open, on a corpus whose vocabulary is fixed — and resident heap that still grows with the facts, because the token dictionary is fact-scaled and the CSR skeleton is path-scaled. The acceptance property it was built for does not hold, which is why it is off by default.
A **mapped snapshot** of the columnar index, which pages its cold tail to disk instead of holding all of it in heap. `:disk-columnar` keeps durable records and rebuilds the derived index on every open. That rebuild is O(records) — measured at 5.6 s for 313k, ~30 min at 100M — and the rebuilt structure is then wholly resident, which is the wall the scale plan names. This writes the compacted index to disk once and maps it back, so an open reads bytes and the fact-scaled postings live in the page cache rather than the heap. ## The design is a snapshot, not a store The index is **derived state**: `reindex` rebuilds every entry from the records. That is what makes this cheap — no write-ahead log, no op log, no crash-consistent mutation protocol, no bucket directory. It needs a *snapshot* that can be thrown away and rebuilt whenever it is in doubt, and "in doubt" resolves to `reindex` in every case. It is also why there is no directory to page. A flat-map index keys every trie node by a boxed vector of its whole path prefix, so an out-of-core design over it has to page the keys themselves; the columnar trie has no keys at all — a node's identity is its `int` id and its position in the parallel arrays *is* the directory. `columnar/compact!` already produces exactly the arrays this writes. ## What is written Under `<dir>/index/`, four things: * `trie.csr` — the trie's six CSR sections (`fcounts` `foffsets` `fedge-tok` `fedge-tgt` `fleaf-off` `fhandles`), each a raw little-endian `int` run behind a header naming the counts. * `roots.csr` — the secondary roots / term / rule / exception postings as the same CSR shape over `dense-roots`' packed `long` keys: sorted keys, an offset column, one shared handle run. * `roots-fallback.nippy` — the term roster and anything else the routed families do not claim. Vocabulary-scaled and irregularly shaped, so it is a blob rather than a column. * `tokens.log` — the durable token dictionary the `int` edges cite, in `vaelii.impl.disk.tokens`' format (append-only, id = append position, content-keyed, first-writer-wins, never reused). That module is reused rather than a second dictionary format minted: persisting the trie's `int` edges is precisely the seam `vaelii.impl.tokens` names as its durable variant. **One file per structure**, not one per section. A structure is mapped or discarded as a unit, so per-section files would multiply the crash window by six for nothing; the section table in the header already names the offsets `map` needs. ## The residency split `scale-100m.md`'s rule is *never page the walk* — the worst measured index pathology was the leading-variable trie fan, 18,512 lookups for one query, and a disk seek is worse than the round trip that pathology was made of. So the load is deliberately asymmetric: * **resident** — the CSR skeleton (`fcounts` `foffsets` `fedge-tok` `fedge-tgt`), the roots' key and offset columns, and the token dictionary. Read into heap on open. * **mapped** — `fleaf-off` / `fhandles` and the roots' handle run. These are the fact-scaled mass (the roots alone are 69 MB of a 186 MB compacted 300k-fact index) and each posting is touched only when its own term is queried. Cold by construction. With `mmap` the OS page cache is the residency policy, which is the point — but only because the skeleton stays hot. ## Validity is the whole design The failure to fear is a stale snapshot that passes its check: one can be perfectly self-consistent and describe a KB that no longer exists. So the stamp covers the **records**, not the snapshot's own bytes, and it is checked on **every** open — never behind a flag. Three things must agree or the snapshot is discarded and `reindex` runs: the format and `kv/index-layout-version`, the byte-order tag (an image whose endianness differs is refused rather than read wrong), and `record-store/slot-fingerprint`. The decision carries a reason from `import`'s vocabulary — `:absent` `:layout-changed` `:records-differ` `:entries-truncated` — because a rebuild nobody can explain is a rebuild nobody notices. A commit is one atomic step: the sections are written to temps and fsynced, the meta is **deleted**, the temps are renamed into place, and the meta is written last. Its presence is the commit point, so a crash anywhere leaves no meta, and no meta means reindex. ## What it measured 1.72–1.84× off the index's resident heap and a 1.5× faster open, on a corpus whose vocabulary is fixed — and resident heap that still grows with the facts, because the token dictionary is fact-scaled and the CSR skeleton is path-scaled. The acceptance property it was built for does **not** hold, which is why it is off by default.
The index store on disk — a KvBackend (vaelii.impl.kv) over a durable
write-ahead log.
The index is derived state (small next to the records, and reindex can rebuild it
from the records alone), so the disk KV keeps the whole key→value map in RAM — a Long at
each counter key, a set at each set key, exactly the shape MemoryKvBackend holds —
and durably logs every mutation to a kv.log of length-prefixed nippy frames. Reads
are the in-RAM map, so kv-members / kv-intersect are the same reference /
set-intersection operations the memory backend does — the disk only buys durability.
Logical (op) logging. A frame is the write op itself — [:add-to-set k m], [:remove-from-set k m], [:put k v], [:delete k], [:increment k], [:decrement k] — not the resulting value. A
set-add logs the one added member, O(1), so a bulk load of N members into one root
writes O(N) WAL bytes; new-value logging re-serialized the size-i set on the i-th add
and cost O(N²). On open the log replays by folding each frame through apply-op, the
same function that applies a live op. compact! rewrites the log as one [:put k v]
op per live key, so every frame — ordinary or post-compaction — is a uniform op and
the reader needs no snapshot-vs-delta discrimination; it also bounds replay length and
reclaims the delta frames (compaction is this store's snapshot cadence).
Crash-safety: scan-log truncates a torn tail on open (a partial op frame is dropped
whole, never half-applied), and compaction rewrites the log crash-safely
(files/recover-log-compaction!). All log writes hold the backend lock (the RAF file
pointer is shared).
The index store on disk — a `KvBackend` (`vaelii.impl.kv`) over a durable write-ahead log. The index is derived state (small next to the records, and `reindex` can rebuild it from the records alone), so the disk KV keeps the whole key→value map in RAM — a `Long` at each counter key, a set at each set key, exactly the shape `MemoryKvBackend` holds — and durably logs every mutation to a `kv.log` of length-prefixed nippy frames. Reads are the in-RAM map, so `kv-members` / `kv-intersect` are the same reference / `set-intersection` operations the memory backend does — the disk only buys durability. **Logical (op) logging.** A frame is the write op itself — `[:add-to-set k m]`, `[:remove-from-set k m]`, `[:put k v]`, `[:delete k]`, `[:increment k]`, `[:decrement k]` — not the resulting value. A set-add logs the one added member, O(1), so a bulk load of N members into one root writes O(N) WAL bytes; new-value logging re-serialized the size-i set on the i-th add and cost O(N²). On open the log replays by folding each frame through `apply-op`, the same function that applies a live op. `compact!` rewrites the log as one `[:put k v]` op per live key, so every frame — ordinary or post-compaction — is a uniform op and the reader needs no snapshot-vs-delta discrimination; it also bounds replay length and reclaims the delta frames (compaction is this store's snapshot cadence). Crash-safety: `scan-log` truncates a torn tail on open (a partial op frame is dropped whole, never half-applied), and compaction rewrites the log crash-safely (`files/recover-log-compaction!`). All log writes hold the backend lock (the RAF file pointer is shared).
Single-writer guard for the on-disk KB.
The disk record store and the disk KV index have no cross-process file locking:
two JVMs appending to the same logs tear them. This namespace takes an OS advisory
FileLock on <dir>/.vaelii.lock when a disk backend opens, and fails fast if
another live JVM already holds it. This matches pure's single-writer contract
(docs/storage.md) — one process holds the KB.
The lock is exclusive and ref-counted per canonical directory (the record store and
the index share one dir, so both acquire and the last release drops it). The OS
releases it when the JVM exits, so a crash leaves no stale lock to reap. Set
vaelii.disk.lock=false (system property) to disable in a trusted single-host
scenario.
Single-writer guard for the on-disk KB. The disk record store and the disk KV index have no cross-process file locking: two JVMs appending to the same logs tear them. This namespace takes an OS advisory `FileLock` on `<dir>/.vaelii.lock` when a disk backend opens, and fails fast if another live JVM already holds it. This matches pure's single-writer contract (docs/storage.md) — one process holds the KB. The lock is exclusive and ref-counted per canonical directory (the record store and the index share one dir, so both acquire and the last release drops it). The OS releases it when the JVM exits, so a crash leaves no stale lock to reap. Set `vaelii.disk.lock=false` (system property) to disable in a trusted single-host scenario.
The record store on disk — an implementation of RecordStore over per-kind
log/idx pairs (vaelii.impl.disk.files).
Three int-keyed kinds — sentexes, justifications, provenance — each a .log of
length-prefixed nippy frames plus a .idx of fixed 24-byte slots mapping handle →
log offset. A frame holds its record's fields positionally
(vaelii.impl.disk.codec), so the type tag and field names are not rewritten into
every one of them; a frame written before that codec still reads, as its own shape. A record is paged from disk on get: read the slot, read the frame
it points at, thaw it — two positional reads, no seek, and the records do not sit in
RAM. What does sit in RAM per kind is the small set of live handles (so enumeration
is O(1)), rebuilt from the idx on open, and a bounded LRU of hot records in front
of the read (vaelii.disk.cache, 0 to disable).
next-id is a monotonic counter recovered as max(the counters blob, 1 + the highest slot id across the record kinds) — the highest slot id is stable across
deletes (a tombstone keeps its slot) and across compaction (slot ids are preserved),
so a handle is never reused even if the counters blob is stale after a crash. Within
a session every write holds the same bound (clear-counter!), because a record can
arrive carrying its own :id and nothing re-reads the slots until the next open.
A premise is exactly a sentex whose :strength is non-nil (the strength lives on
the record, as on every backend), so the premise set is derived from the durable
records — rebuilt on open, maintained in lockstep by mark/unmark — rather than
stored separately. Rebuilding it does not mean reading them: every write records
the answer in its idx slot's flags, so the open reads the set off the slot walk it
already makes. A slot that does not carry the bit sends that one handle to its
record, and the record is authoritative wherever both speak (rebuild-premises!).
Recovery on open: finish any interrupted compaction, truncate a torn log tail, then
tombstone any slot whose frame now extends past the log (validate-idx-tail!).
Crash-safety rests on the write ordering (append the frame, then point the slot at it)
and on files' crash-safe compaction. The tail is located from the frame lengths
(files/log-tail-offset) and nothing is decoded to find it — and a clean close!
records each log's length, so an open whose log is still that long skips even the walk.
The marker is consumed here, so it never describes a store in use; every disagreement
falls back to the walk.
Every RAF touch holds the owning kind's lock. A write or force! must, because the
file pointer is shared (see files' shared-pointer invariant); a read no longer has
to — the read primitives are positional — but still does, because that is what
serializes it against a concurrent append and its slot write.
The record store on disk — an implementation of `RecordStore` over per-kind log/idx pairs (`vaelii.impl.disk.files`). Three int-keyed kinds — sentexes, justifications, provenance — each a `.log` of length-prefixed nippy frames plus a `.idx` of fixed 24-byte slots mapping handle → log offset. A frame holds its record's fields **positionally** (`vaelii.impl.disk.codec`), so the type tag and field names are not rewritten into every one of them; a frame written before that codec still reads, as its own shape. A record is **paged** from disk on `get`: read the slot, read the frame it points at, thaw it — two positional reads, no seek, and the records do not sit in RAM. What does sit in RAM per kind is the small set of live handles (so enumeration is O(1)), rebuilt from the idx on open, and a **bounded LRU of hot records** in front of the read (`vaelii.disk.cache`, 0 to disable). `next-id` is a monotonic counter recovered as `max(the counters blob, 1 + the highest slot id across the record kinds)` — the highest slot id is stable across deletes (a tombstone keeps its slot) and across compaction (slot ids are preserved), so a handle is never reused even if the counters blob is stale after a crash. Within a session every write holds the same bound (`clear-counter!`), because a record can arrive carrying its own `:id` and nothing re-reads the slots until the next open. A premise is exactly a sentex whose `:strength` is non-nil (the strength lives on the record, as on every backend), so the premise set is derived from the durable records — rebuilt on open, maintained in lockstep by mark/unmark — rather than stored separately. Rebuilding it does not mean *reading* them: every write records the answer in its idx slot's flags, so the open reads the set off the slot walk it already makes. A slot that does not carry the bit sends that one handle to its record, and the record is authoritative wherever both speak (`rebuild-premises!`). Recovery on open: finish any interrupted compaction, truncate a torn log tail, then tombstone any slot whose frame now extends past the log (`validate-idx-tail!`). Crash-safety rests on the write ordering (append the frame, then point the slot at it) and on `files`' crash-safe compaction. The tail is located from the frame *lengths* (`files/log-tail-offset`) and nothing is decoded to find it — and a clean `close!` records each log's length, so an open whose log is still that long skips even the walk. The marker is consumed here, so it never describes a store in use; every disagreement falls back to the walk. Every RAF touch holds the owning kind's lock. A write or `force!` must, because the file pointer is shared (see `files`' shared-pointer invariant); a read no longer has to — the read primitives are positional — but still does, because that is what serializes it against a concurrent append and its slot write.
A durable token dictionary for a disk store: symbol/keyword ↔ int, append-only,
ids assigned in append order and never reused. It is what lets a record frame spell
its sentence as ids rather than names (vaelii.impl.disk.codec), which is 2.6× smaller
than the positional frame it replaces — the vocabulary is written once here instead of
once per frame in all 100M of them.
The ordering that makes it safe. A frame referencing an id the dictionary cannot decode is unreadable data, so the dictionary must never lag the records that cite it. Two rules give that, and neither costs a write:
emit! interns as it
encodes, which happens before the frame is written), so the token log leads the
record log in write order at all times;fsync fsyncs this log first, holding the sentexes kind lock, so nothing is
appended between the two fsyncs. Every record durable after a tick therefore has
its tokens durable too.fsyncing per new token would also give the ordering, and is what this did first —
but it makes a cold load fsync-bound (measured: ~217 records/s, since a new token is
not rare during one). Between ticks the two logs can still skew on a machine crash,
exactly as the record log and its idx can; open-record-store repairs it the same way
it repairs those, by tombstoning a record whose ids the dictionary does not hold.
Only symbols and keywords are interned. Those are bounded by the ontology.
Numbers and strings are not — a KB of measurements would mint a dictionary entry per
distinct value — so codec carries them beside the id stream as literals instead.
Ids are content-keyed and first-writer-wins, so they are stable for the life of the
store; the id value depends on first-encounter order, which nothing above this reads.
Tokens are never deleted (an id must keep decoding), so the log has no dead frames and
needs no compaction; only a whole-store clear-records! empties it.
Writes take the log's lock (there is one writer, and the append must not interleave); the reverse map is an atom holding a vector, so a decode — which runs on every fetch, under a different lock — reads it without one and still sees a safely published entry.
A **durable** token dictionary for a disk store: `symbol/keyword ↔ int`, append-only, ids assigned in append order and never reused. It is what lets a record frame spell its sentence as ids rather than names (`vaelii.impl.disk.codec`), which is 2.6× smaller than the positional frame it replaces — the vocabulary is written once here instead of once per frame in all 100M of them. **The ordering that makes it safe.** A frame referencing an id the dictionary cannot decode is unreadable data, so the dictionary must never lag the records that cite it. Two rules give that, and neither costs a write: - a token is appended **before** the record frame citing it (`emit!` interns as it encodes, which happens before the frame is written), so the token log leads the record log in *write* order at all times; - `fsync` fsyncs this log **first, holding the sentexes kind lock**, so nothing is appended between the two fsyncs. Every record durable after a tick therefore has its tokens durable too. fsyncing *per new token* would also give the ordering, and is what this did first — but it makes a cold load fsync-bound (measured: ~217 records/s, since a new token is not rare during one). Between ticks the two logs can still skew on a machine crash, exactly as the record log and its idx can; `open-record-store` repairs it the same way it repairs those, by tombstoning a record whose ids the dictionary does not hold. **Only symbols and keywords are interned.** Those are bounded by the ontology. Numbers and strings are not — a KB of measurements would mint a dictionary entry per distinct value — so `codec` carries them beside the id stream as literals instead. Ids are **content-keyed and first-writer-wins**, so they are stable for the life of the store; the id *value* depends on first-encounter order, which nothing above this reads. Tokens are never deleted (an id must keep decoding), so the log has no dead frames and needs no compaction; only a whole-store `clear-records!` empties it. Writes take the log's lock (there is one writer, and the append must not interleave); the *reverse* map is an atom holding a vector, so a decode — which runs on every fetch, under a different lock — reads it without one and still sees a safely published entry.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |