Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-0008 — Persistent store: fjall as the content-addressed backend

Status: proposed Serves: R1 (revocability), INV-01 (append-only), INV-18 (elench owns store)

Context

The current Store in elench-store is in-memory (HashMap). Claims, blobs, and trees do not persist across sessions. ADR-0001 requires elench to own its own content-addressed store; ADR-0005 requires pure Rust with no C/C++ dependency.

Decision

Use fjall as the persistent backend for elench-store, behind an optional fjall-backend feature flag.

Keyspaces

One fjall database, three keyspaces (matching the current Store API):

KeyspaceKeyValueDurability
blobsSHA-256 OID (64 hex)blob dataBuffer (content-addressed, can be recomputed)
treesSHA-256 OID (64 hex)serialized tree entriesBuffer
claimscl_ + SHA-256 OID (67 chars)serialized claim JSONSyncAll (evidence must survive crashes)

StoreBackend trait

Extract a StoreBackend trait from the current Store:

#![allow(unused)]
fn main() {
pub trait StoreBackend {
    fn store_blob(&mut self, data: &[u8]) -> Result<Oid, StoreError>;
    fn store_tree(&mut self, entries: Vec<TreeEntry>) -> Result<Oid, StoreError>;
    fn store_claim(&mut self, claim: &Claim) -> Result<String, StoreError>;
    fn read_blob(&self, oid: &Oid) -> Result<Vec<u8>, StoreError>;
    fn read_tree(&self, oid: &Oid) -> Result<Tree, StoreError>;
    fn read_all_claims(&self) -> Result<Vec<Claim>, StoreError>;
    fn read_claims_for_tree(&self, tree: &Oid) -> Result<Vec<Claim>, StoreError>;
    fn has_blob(&self, oid: &Oid) -> bool;
    fn has_tree(&self, oid: &Oid) -> bool;
    fn has_claim(&self, claim_oid: &str) -> bool;
    fn blob_count(&self) -> usize;
    fn tree_count(&self) -> usize;
    fn claim_count(&self) -> usize;
}
}
  • MemoryStore: current HashMap implementation (default, no deps)
  • FjallStore: fjall-backed implementation (optional feature)

elench-projection::synthesize takes &impl StoreBackend instead of &Store. The CLI accepts --store memory (default) or --store fjall <path>.

Why fjall

Criterionfjallredbsledrocksdbcustom
Pure RustYesYesYesNo (C++)Yes
Build depsNoneNoneNonecmake, clangNone
Binary size~100KB~50KB~200KB~5MB0
LSM-treeYesNo (B-tree)YesYesManual
Append-onlyNaturalNoYesYesManual
Crash recoveryAutomaticAutomaticYesAutomaticManual
CompactionYesNoneYesYesNone
CompressionLZ4NoYesYesNo
Cross-keyspace atomicYesNoNoNoManual
Key-value separationYesNoNoNoManual
Stable disk formatYesYesNoYesN/A
MaintainedYes (2.3k stars)YesNo (since 2022)YesN/A
Proven in productionkiseki 3.1kiseki (rev-1)NoYesNo

kiseki migrated from redb to fjall across 4 revisions (ADR-022). Benchmark: 36k PUT/s vs redb’s 18k (2x), 194k GET/s vs 150k (1.3x).

Rejected alternatives

  • redb. B-tree, no compaction, no key-value separation. Was kiseki’s first choice; 2x slower on PUT. No cross-keyspace atomicity (blobs + trees + claims need separate databases).
  • sled. Unmaintained since 2022. Unstable disk format. NOT recommended.
  • RocksDB. C++ dependency (cmake, clang, librocksdb). Violates ADR-0005 (no C surface). ~5MB binary size.
  • Custom files. Zero deps but no crash recovery, no compaction, no compression. Manual fsync. Not worth the engineering cost.

Consequences

Adds fjall as an optional dependency (~100KB binary size when enabled). Gains crash recovery, compaction, compression, and cross-keyspace atomicity. The StoreBackend trait keeps the API stable — existing 30 store tests and 14 projection tests continue to pass against MemoryStore. New tests exercise FjallStore with #[ignore = "slow: requires fjall"] for Tier 2.

Feature flag: fjall-backend (off by default). Users who want persistence add --features fjall-backend to their build.