Spec: db layer restructuring #
Status: draft
Tracking: beads epic (see bd show, filed alongside this spec)
Motivation #
The db layer works, but its correctness rests on conventions that every callsite must independently remember. Concretely:
Db(src/db/mod.rs:48) exposes every keyspace as a publicKeyspacefield pluspub inner: Arc<Database>. Any module can write raw bytes to any keyspace with hand-built keys;db.inner.batch()appears in 27+ files.- Four access styles coexist: half-dead static async helpers (
Db::get, withinsert/remove/contains_keymarked#[allow(dead_code)]), direct sync calls insidespawn_blockingclosures, direct sync calls on dedicated blocking threads, and free functions taking(batch, db, ...). - Key formats live in free functions returning
Vec<u8>(src/db/keys/) with prefix constants scattered per module —db/filter.rsmaintains its ownSEP/prefix constants separate fromkeys::SEP. The authoritative documentation of key formats is comments (duplicated again in AGENTS.md). - Serialization helpers exist (
ser_repo_stateetc.) but ~31 files callrmp_serdedirectly, several deserializingRepoStateraw and bypassing the helpers (backfill/worker/process.rs:58,backfill/manager.rs:122,backfill/worker/task.rs:187).
None of these are hypothetical risks:
- The record-import invariant is duplicated three times. A record write
must keep in sync:
recordsks,blocksks, per-collection counts, backlinks (cfg), aStoredEvent, and a jetstream event (cfg). This is implemented once properly inops::apply_commit(viaRecordEmitter), then re-implemented by hand inbackfill/sparse.rs(persist_sparse_backfillbuildsStoredEvent+ event key +jetstream::stage_eventmanually, and carries its own cfg'd backlinks block) and twice more inbackfill/worker/process.rs. Divergence between these is silent index drift — missing events, missing backlinks, wrong counts. - Three manual registries per keyspace.
keyspace_by_name(db/keyspaces.rs), the/debugname→type deserializer match (api/debug.rs:99), and the/statsendpoint must each be updated when a keyspace is added.open.rs:209admits this with a "when adding new keyspaces, make sure to..." comment. That comment is the bug report. - The counts protocol is convention-only. Every write site must
remember: stage deltas into the batch →
batch.commit()→apply_count_deltas(plus lifecycle reservation apply). The pairing is unenforced; a missed apply drifts in-memory counts from persisted state until restart replay. Three separate subsystems (count deltas + watermarks,LifecycleCountBatch, per-collection record count free fns) each carry their own call-order rules. - Nothing pairs a keyspace to its key format or value type. Inserting
RepoStatebytes intocursors, or building a record key against thereposkeyspace, compiles fine and fails at read time — or worse, doesn't. Db::openis a 260-line monolith;check_poisonedis called at some iteration sites and not others; every key construction heap-allocates.
What is already good and stays untouched: the migration framework (frozen
versioned wire types, ordered forward migrations), the per-mode keyspace
groups (IndexerDb/StreamDb/JetstreamDb/RelayDb + OpenCx), the
RecordEmitter ZST facade pattern, the count-delta crash-safe replay design,
and the per-keyspace tuning rationale.
Design #
One principle: a keyspace is one type, declared in one table, and its invariant-bearing writes have exactly one owner. Everything else derives.
1. Keyspace schemas: Ks<S> #
Each keyspace gets a schema type pairing name, key codec, value codec, tuning, and debug rendering:
pub trait Schema {
const NAME: &'static str;
type Key: KeyCodec;
type Value: ValueCodec;
/// per-keyspace tuning, applied at open (moves out of open.rs).
fn options(cx: &OpenCx) -> KsOptions;
/// debug rendering; default impl transcodes msgpack -> json.
fn render(value: &[u8]) -> Result<serde_json::Value> { ... }
}
/// zero-cost typed handle; Clone is cheap (Keyspace is an Arc'd handle).
pub struct Ks<S: Schema> {
inner: Keyspace,
_s: PhantomData<S>,
}
impl<S: Schema> Ks<S> {
pub fn get(&self, key: &S::Key) -> Result<Option<S::Value>>;
/// zero-copy escape for CAS blocks and other raw-value reads.
pub fn get_raw(&self, key: &S::Key) -> Result<Option<Slice>>;
pub fn contains(&self, key: &S::Key) -> Result<bool>;
pub fn insert(&self, txn: &mut Txn, key: &S::Key, value: &S::Value);
pub fn remove(&self, txn: &mut Txn, key: &S::Key);
pub fn prefix(&self, p: impl Prefix<S>) -> impl Iterator<Item = Result<(S::Key, S::Value)>>;
/// greppable escape hatch (migrations, debug endpoints).
pub fn raw(&self) -> &Keyspace;
}
Poison checks (check_poisoned) move into the read/iterator adapters so
every access is covered instead of the current ad-hoc sites.
KeyCodec replaces the keys/ free functions with structs that are the
documented format:
pub trait KeyCodec: Sized {
/// KeyBuf = SmallVec<[u8; 64]>: typical keys never touch the heap.
fn encode_into(&self, buf: &mut KeyBuf);
fn decode(bytes: &[u8]) -> Result<Self, KeyError>;
}
/// {DID}|{COL}|{RKey} — the definition is the documentation.
pub struct RecordKey<'a> {
pub did: &'a str,
pub collection: &'a str,
pub rkey: &'a str,
}
This deletes the duplicate SEP/prefix constants in db/filter.rs and makes
"wrong key against wrong keyspace" a type error.
ValueCodec has three impls: msgpack via rmp_serde (blanket over
Serialize + DeserializeOwned wire types), RawBytes (CAS blocks), and
BeU64/BeI64 (cursors, counts). The ~31 scattered rmp_serde callsites
and the ser_/deser_ helper pair in mod.rs collapse into this.
2. One declarative registry #
A single table declares every keyspace, grouped exactly like the existing mode groups:
keyspaces! {
core {
repos: Repos,
repo_metadata: RepoMetadata,
cursors: Cursors,
counts: Counts,
filter: Filter,
crawler: Crawler,
}
#[cfg(feature = "indexer")]
indexer {
records: Records,
blocks: Blocks,
pending: Pending,
resync: Resync,
resync_buffer: ResyncBuffer,
}
#[cfg(feature = "indexer_stream")]
stream { events: Events, ... }
#[cfg(feature = "jetstream")]
jetstream { ... }
#[cfg(feature = "relay")]
relay { relay_events: RelayEvents, ... }
#[cfg(feature = "backlinks")]
backlinks { backlinks: Backlinks }
}
The macro generates:
- the
Dbfield groups (matching today'sIndexerDbetc. — this extends the existing seam, it does not replace it), - open-time creation with
S::options(cx)tuning (guts the open.rs monolith), keyspace_by_name(&str),fn all() -> impl Iterator<...>for/stats,/debugrendering viaS::render.
Adding a keyspace becomes: write the schema type, add one row. Forgetting the
row means the keyspace doesn't exist at all — no silent partial registration.
The open.rs:209 comment dies.
3. Visibility is the enforcement mechanism #
Registry-generated fields are private to db::. Read access is exposed
per keyspace where legitimately needed; the invariant-bearing keyspaces
(records, blocks, counts, events, jetstream_events, backlinks)
get no public write access at all. The only way to mutate a record is the
op type below. raw() escape hatches exist for db::migration and the
/debug write endpoint — both inside or routed through db::, both
greppable.
This is the "use the type system to encode correctness constraints" rule applied with module privacy, which is the only enforcement Rust actually gives us.
4. One write path for records: RecordTxn #
Layered write handles:
/// typed batch over fjall::Batch; plain writes (cursors, filter, pds_meta...).
pub struct Txn<'db> { batch: fjall::Batch, db: &'db Db }
impl Txn<'_> {
pub fn commit(self, mode: PersistMode) -> Result<()>;
}
/// the ONLY path that may mutate records/blocks/counts/events/backlinks.
pub struct RecordTxn<'db> {
txn: Txn<'db>,
counts: CountDeltas,
lifecycle: LifecycleCountBatch,
emitter: RecordEmitter, // existing ZST facade, reused as-is
}
impl RecordTxn<'_> {
pub fn put_record(&mut self, did, coll, rkey, cid, block, action) -> Result<()>;
pub fn delete_record(&mut self, did, coll, rkey, prev_cid) -> Result<()>;
pub fn update_repo_state(&mut self, did, f: impl FnOnce(&mut RepoState)) -> Result<()>;
/// stage deltas -> batch.commit -> apply_count_deltas -> lifecycle apply.
/// the ordering exists exactly once, here.
pub fn commit(self, mode: PersistMode) -> Result<()>;
}
put_record/delete_recordown the full invariant: records ks, blocks ks, per-collection counts, backlinks (one cfg site, inside),StoredEventemission and jetstream staging viaRecordEmitter.ops::apply_commit,backfill/sparse.rs::persist_sparse_backfill, and bothbackfill/worker/process.rssites route through it. The three hand-rolledStoredEventconstructions and sparse.rs's private backlinks cfg block are deleted, not deprecated.- Drop-without-commit is the only failure mode, and it is exactly the case the existing count-delta restart replay already handles. No new crash-safety machinery — the existing design, minus the per-callsite ceremony.
This is not a transaction abstraction over fjall; batches are already atomic. It is the existing protocol given a shape that cannot be half-followed.
5. Blocking discipline #
One rule: Ks methods are sync. Async contexts use one wrapper:
impl Db {
/// spawn_blocking with the Db handle; the one async<->sync seam.
pub async fn run<T>(&self, f: impl FnOnce(&Db) -> T + Send) -> T;
}
Dedicated blocking threads (ingest/indexer/shard.rs, backfill retry worker)
call sync methods directly, unchanged. The static async helpers on Db
(get/insert/remove/contains_key, mostly dead code today) are removed.
Non-goals #
- No storage-engine abstraction, no fjall replacement, no mock-store trait for tests (fjall in a tempdir is fast; tests keep using the real thing).
- No runtime mode selection. Modes stay compile-time; the registry table keeps cfg at the composition root per the feature-gating conventions.
- The migration framework is untouched. Migrations deliberately read old
formats and keep raw keyspace access via
raw(). - No change to on-disk formats, key layouts, or wire types. This is a code-shape refactor; a fresh build must open an existing database and produce byte-identical writes.
Phasing #
Each phase lands independently, warning-free through
nu tests/feature_matrix.nu (all 13 combos) and nu tests/run_all.nu.
Within a phase, old and new APIs may coexist; at each phase boundary the old
path is deleted — no lingering dual conventions.
- Schema + registry.
Schema,Ks<S>,ValueCodec, thekeyspaces!table; port every keyspace; derivekeyspace_by_name,/stats,/debugfrom it; move tuning out ofopen.rs. Pure mechanical, no behavior change. (Fields staypub(crate)during this phase.) - Key codecs. Key structs replace
keys/free functions; delete the duplicate separator/prefix constants indb/filter.rs; poison checks fold into accessors/iterators. RecordTxn. Routeapply_commit, sparse backfill, and backfill worker processing throughput_record/delete_record; delete the duplicatedStoredEvent/backlinks/counts blocks; flip invariant keyspaces to private.- Counts + cleanup. Fold count staging/apply and lifecycle reservations
into
Txn/RecordTxncommit; remove looseapply_count_deltascallsites;Db::runreplaces the static async helpers; decompose what remains ofopen.rs.
Ordering: 1–2 are low-risk mechanical enablers; 3 is the highest-value correctness fix and depends on them; 4 rides on 3's types.
Verification #
- Per phase:
nu tests/feature_matrix.nu,nu tests/run_all.nu --skip-creds. - Phase 3 specifically (invariant-touching):
repo_sync_integrity.nu,backlinks.nu,stream_live_backfill.nu,stream_cursor_replay.nu, andrepo_count_resync.nuwith credentials — these are exactly the tests that observe the record-import invariant from outside. - Phase 4:
repo_count_resync.nuagain (count drift is its purpose), plusephemeral_ttl_*.nufor event pruning paths. - Open an existing pre-refactor database directory and verify
/statsand/debug/iterparity against a pre-refactor build (no format changes means byte-identical output).
Risks #
- Callsite breadth. Dozens of files touch keyspaces directly. Mitigation: phase boundaries are keyspace-by-keyspace mechanical ports; the compiler drives (private fields = exhaustive callsite list as errors).
- Iterator decode overhead. Typed iterators decode per item; hot scans
that only need raw slices use
prefix_rawvariants. CAS block reads keep zero-copySliceviaget_raw. - Monomorphization.
Ks<S>per keyspace is ~20 instantiations of small methods; negligible. RecordTxnshape vs. sparse backfill. Sparse import builds records from proof CARs, not commit diffs;put_recordmust accept a pre-parsed block without forcing a re-parse. The API above takes(cid, block)raw for this reason — verify during phase 3 that sparse's zero-copyBytes::slicepath survives.