What implementing the design in kladde-rs has shown: where kladde-rs falls short of these documents, and what it chose where they leave a question open.
This page is where kladde-rs’s findings are recorded as they come up, and proposed for the other documents from here.
Paths like spec/journal.md are relative to content/, the root of these documents.
Branches of kladde-rs that hold earlier designs, such as address-table, keep their own implementation-notes.md.
Where the implementation falls short
What the code does not do yet, or does less well than kladde-docs or its own goals ask, and what that costs.
- ⚠️ The span table has two levels only: the header, and the leaves it names.
A leaf that names table pages of its own is refused at open.
The header’s room for references to leaves, one or two bytes each, bounds a file to some 2,000 to 4,000 leaves, one to two million spans: a sequence of a few hundred MB in spans of the length the measurements find, and far more than any workload measured, the largest of which takes 21 to 26 leaves.
Beyond the bound, a flush fails with
Error::OutOfBounds, and since opening flushes the replayed journal, a file whose journal holds such a flush no longer opens. No workload measured comes near the bound: the world map’s sequence of 942 KiB takes 21 to 26 leaves. The fix: write interior table pages when the leaves’ references outgrow the header, with the path copy ofimpl/consolidation.md#unlinking-an-emptied-page, and read them at open, whichload.rsrefuses today; test it with the header’s capacity settable from tests. - ⚠️ A single write or splice of 4 GiB or more corrupts the journal:
Records::stashkeeps a record’s length as au32, so the record states a truncated length while the store counts all of it, and one of exactly2^32bytes even encodes the escape.spec/journal.md#framingbounds a transaction’s length by2^32 − 1, so such a write must fail withError::OutOfBounds; only the bound of2^48is checked today. No application writes values of 4 GiB in one piece, but the result is a file that does not open, not an error. - Arithmetic on values read from the file is not checked: the entry decode adds deltas to page numbers, ids and ends, and the journal decode adds a move’s
to_delta, without checking for overflow, so a file whose CRCs match but whose numbers do not panics in a debug build and wraps in a release build, which can accept entries out of order that the delta encoding is meant to make unrepresentable. kladde-cpp checks each of them. FileStoragetakes no lock, wherespec/file-format.md#concurrencyhas a second process refused cleanly, andcreatetruncates whatever file is there; kladde-cpp locks withflockbefore it truncates.- A
CutIdthat escapes a transaction rolled back byStore::atomicallycan match a later record that reuses its index with a cut of the same length, and a paste of it then pastes that record’s bytes without an error. The typed layer never lets a cut out of a failed transaction, which poisons the store; only direct users ofStorecan meet it. - The cut never gives fillers to the header:
impl/flush.md#the-cuthas the last leaf take them, or the header when there is no leaf, and kladde-rs fills the last leaf only. - The budget loop filters offers by their floors before it picks the best ratio, where the pseudocode of
impl/consolidation.md#the-budget-looppicks first and stops if the pick fails its floor; a table offer can then win when the data offer fails the fill floor. - The fold’s output is not checked against the tracked length, which
impl/write-phase-state.mdasks for as a cheap guard against a fold bug. - The decode recurses without a limit on its depth, where
impl/opening.mdasks for a limit or an iterative decode, so a file that nests deeply enough overflows the stack. - A container’s index panics on sizes of 4 GiB or more, where
spec/span-table.md#boundsasks every limit to fail cleanly: the Fenwick tree keeps sizes inu32cells, and converts withexpect. - The load checks a reached
Refagainst its page’s frame, not against the page’scontent_size. It refuses aRefinto page 0 or 1, into a page that is not aDatapage of the header’s world, or reaching into the framing, but not one that reaches past the content into the padding. - Nothing is kept in the private chain, where
impl/consolidator-state.mddesigns what consolidation would carry there: every session starts its budget fromOptions::budget_pages, its rotating walk at the sequence’s start rather than whereimpl/opening.mdseeds it, and each span’s heat from the epoch of the page that holds its entry. - A span costs about 90 bytes in memory, not 32.
The span table takes 48 bytes per span, since each record also holds the span’s chain, its rope node, its heat, its entry’s size and place in its table page, and its place in its data page’s reverse list, which
impl/in-memory-state.mdkeeps beside the span table; and the rope is an implicit treap of 40 bytes per node rather than a counted B-tree of about 15 per span. DurableVec’s index takes 40 bytes even where it is empty or the elements are fixed-size: aVecof cells, its cached total, and the count’s varint length. The total can live in the tree’s allocation, and the count’s length is the vector’s to pass in, which would leave 24 bytes, or 8 with a thin vector.- None of the three refinements of
impl/consolidation.mdis implemented: evacuation writes a victim’s spans as they are, splitting the one that crosses the end of the page it fills, and merges no neighbours; the walk merges runs of cold spans and renumbers nothing; and the churn floor, budget controller and free filling are the address-table design’s, unchanged, over pages of spans. - Compaction mode counts holes by scanning the page table once per flush.
impl/consolidation.md#compaction-modepromisesO(1)amortised; kladde-rs keeps the cached index of the highest live page it describes, but counts the reusable pages below it inO(pages)at the start of every flush, which is small next to what a flush writes. - The budget loop never continues the last page of
pack.impl/consolidation.md#the-budget-looplets that page cut a victim’s span whenever the loop opens another page, so that only the flush’s very last data page can close short; kladde-rs’s loop always opens fresh pages, sopack’s last page is filled by free filling alone, and can close short even when the loop goes on. - What
rust/tutorial/durability.mdsays aboutclose()was measured on the address-table design, and has not been checked since: thatclosetruncates after the last live page only, so that a freshly created file keeps free pages below its data until a flush or two have consolidated them. - The header’s eviction ranks by heat alone, rather than evicting the coldest runs contiguous in id, and merges each evicted span with its neighbours in the sequence, extending each run while a neighbour whose entry also goes to a leaf makes a pair shorter than the bound.
- A value is encoded into a buffer of its own, which the store copies into the record, a copy that a store method taking an encoding closure would save.
- Cuts exist on
DurableVeconly: itscut,insert_cutandpush_cut;impl/values.md#moving-a-value-between-containershas every method that takes a value take a cut too, but a plainVec, the map, fields,Optionand enums neither make nor take them. split_at_mutand the count of changes are not implemented, onlyparts(), for derived structs and enums and for tuples.split_at_mutwas left out only because the evaluation needs it nowhere; the vector’s Fenwick tree lies in cells and answers any element’s offset, so it would serve as the two runs’ index as it is.parts()on a temporaryMutworks in amatchbut not in alet … else, which drops the temporary at the end of the statement. A consuminginto_parts(self)would make theletform work too; it would need a line inrust/derive-macro.mdandrust/tutorial/deriving.md, and is proposed rather than built.MemoryStoragemodels a power cut as any subset of the writes since the last sync, each write whole. It does not model a write torn within a page; the page CRCs are meant to catch those, but no test exercises them yet.- Misusing the transaction and batch calls reports
Error::Corrupt. Ending a transaction that is not open is a caller’s bug rather than a damaged file, and deserves an error variant of its own; the typed layers pair the calls throughTransaction,BatchandRegion::transaction, so only direct users ofStorecan hit it.
Choices kladde-docs leaves open
Where kladde-docs leaves a question open, or allows any answer, and what the implementation chose.
- The journal is replayed by folding the recovered transactions in a flush at open, before the decode, so that the decode reads the state after the last of them and needs no replay of its own;
impl/opening.md#replaying-through-a-flushallows it. - The rope is an implicit treap, one arena type of under 400 lines that also serves the fold’s piece table and the journal’s cuts, since a split at a position and a join of two trees are all three need.
- The span id allocator hands out the id after the predecessor’s, if it is free, and the lowest free id otherwise, which keeps the spans a flush makes one after another consecutive without looking for a run.
- The flush makes a run of 32 zeros or more among new bytes a
Zerospan. - The span policy’s constants, chosen by the measurements of
evaluation/one-sequence.md:Options::span_min, the boundL, 64 bytes;Options::rewrite_below, 64 bytes;Options::inline_threshold, 64 bytes;Options::cold_flushes, 16;Options::walk, 256 spans; andOptions::mu, 0.02. - A
Box<T>’sFIXED_SIZEisNonewhateverT’s, since a type that holds itself through a box would otherwise ask its own size of itself while rustc evaluates it, which rustc refuses as a cycle (E0391); a vector of boxes then keeps an index it could do without. - The
Mutof aFixed<T>replaces the value whole, since aMutof one ofT’s fields would have to write that field in its fixed form, which would take a secondMutfor every type. - The map’s hash table is its own: open addressing over 32-bit positions, probed linearly and at most half full.
FileStoragegrows the file withset_len, not withfallocate. The new region stays unallocated until it is written, so a full disk shows up as a failed write orfsyncduring a flush, which poisons the store, rather than as a failed growth before the flush starts;spec/durability.md#headroomasks only that failure be clean, which poisoning provides.- The controller moves the budget multiplicatively, and measures fill against the whole file: after each commit it divides the live bytes by the capacity of every page but the headers, and multiplies the budget by
exp(4 · (τ − fill)), withinbudget_minandbudget_max. Counting only live pages would let holes go unnoticed. - A page’s record holds what its state has, and nothing else: an enum over the page states, with a state of its own, filling, for pages a flush takes, and journal pages apart from live ones; only a live page has an epoch and a victim bucket.
- The churn floor also bounds each victim, and an offer takes whole victims by score, not by best fit.
- Free filling moves part of at most one victim per page, and the victim stays eligible.
impl/consolidation.md#packing-in-sequence-order-with-look-aheadlists part of a victim too big to take whole as the last filler without saying how many victims to try; one per page is enough, and excluding a victim after a partial move would starve the budget loop of exactly the sparse pages it wants. Kladde::create_inandKladde::open_intake anyStorageandOptions;Kladde::createandKladde::openuse a file and the default options, andKladde::newiscreate_inwith aMemoryStorage, which returns noResult, since a file in memory cannot fail to be created.