everything hydrant stores goes through src/db. it opens one fjall database, gives each keyspace a typed handle, and owns the writes that have to keep several keyspaces in step, so the rest of the code can't half do them. the record keyspaces it guards are described in record storage.
keyspaces and their schemas
each keyspace is a type implementing Schema in src/db/schema.rs:
pub trait Schema {
const NAME: &'static str;
const TRAIN: Option<TrainCfg> = None;
fn options(cx: &OpenCx) -> KeyspaceCreateOptions;
fn render_value(value: &[u8]) -> Value { ... }
fn parse_debug_key(key: &str) -> Option<Vec<u8>> { ... }
fn render_debug_key(key: &[u8]) -> String { ... }
}NAME is the name on disk and must never change. options is the keyspace's tuning (memtable size, block sizes, compression per level, bloom filters), with the reasons in comments next to each setting. TRAIN says whether POST /db/train builds a zstd dictionary for it. the render and parse functions are what /debug/* uses to show keys and values.
Ks<S> is the handle a Db holds for each keyspace: the fjall Keyspace plus the database's poison flag. its get, contains_key, insert and remove have the same signatures as Keyspace's and check for poison. anything else (range, prefix, iter, batch writes) goes through a Deref to the raw Keyspace. raw() and keyspace() hand out the raw handle on purpose, for code that takes a &Keyspace like the redaction and erase helpers in db::indexer.
Ks<S> doesn't know its key or value types yet, so writing RepoState bytes into cursors compiles fine and only fails when something reads it. key formats are free functions in src/db/keys/ returning a Vec<u8>, and values are msgpack through helpers like ser_repo_state, though plenty of code calls rmp_serde itself.
the registry
src/db/registry.rs lists every keyspace once, as a Db field path and its schema, with cfg on the row when it belongs to a feature:
registry! {
repos => schema::Repos,
...
#[cfg(feature = "indexer")]
indexer.heads => schema::Heads,
#[cfg(feature = "indexer")]
indexer.history => schema::History,
...
}the macro builds everything that has to name all the keyspaces: Db::keyspace_by_name, Db::all_keyspaces (which /stats and POST /db/compact walk), the /debug helpers debug_value, debug_parse_key and debug_render_key, trainable() for dictionary training and dictionary loading, and names().
the registry doesn't create the keyspaces. each mode group opens its own with Ks::open(cx), which writes the name down, and at the end of Db::open the opened names have to equal registry::names() or open fails with "keyspace registry drift" (assemble_keyspaces_and_verify in src/db/open.rs). a keyspace with no row, or a row with no keyspace, fails at startup instead of going missing from /stats or /debug.
to add a keyspace: write its schema type, add the field and its Ks::open line to its group (or to Db and assemble_keyspaces_and_verify if every mode has it), and add its registry row.
per-mode groups
src/db/keyspaces.rs declares the keyspaces that only some builds have, one struct per feature: IndexerDb (indexer), StreamDb (indexer_stream), JetstreamDb (jetstream) and RelayDb (relay). Db holds each one behind a single cfg, next to the keyspaces every mode has (repos, repo_metadata, cursors, counts, filter, crawler) and backlinks. a group's open opens its keyspaces, and the ones with a stream also hold its broadcast channel and its position counters, which their init resumes after the last stored event. OpenCx carries what a keyspace needs to open (the database, the config, the compression choice and the opened names), and its fields are private to db so nothing else can open a keyspace.
who can write what
module privacy is how the db layer keeps writes that have to happen together in one place.
IndexerDb.heads,history,redactions,pendingandpending_didsarepub(super), so only code indbcan touch them. everything else reads throughhead,head_prefix,head_range,history_range,is_pending,pending_iterandpending_did_range, writes records throughRecordTxn, and queues backfills throughstage_pending_insertandstage_pending_remove, which always write both queue keyspaces in one batch.StreamDb.eventsispub(super). events reach it only through a transaction's outbox, see committing.stage_headwrites a head directly and only exists for test fixtures and the debug and bench seeding endpoints (it's compiled undertestorindexer_stream).
the rest isn't enforced yet. Db.repos, Db.counts and Db.inner are pub, and the event keyspaces of jetstream and relay, StreamDb.event_bodies and Db.backlinks are pub(crate). so repos rows are only written through Txn::put_repo and Txn::remove_repo by convention, and code all over the crate still opens its own db.inner.batch().
transactions
Txn (src/db/txn.rs) is one atomic write: a fjall batch plus everything that has to change when that batch commits.
pub(crate) struct Txn<'db> {
pub(crate) batch: fjall::OwnedWriteBatch,
pub(crate) db: &'db Db,
pub(crate) counts: CountDeltas,
pub(crate) outbox: Outbox,
...
}countsholds deltas for the global counters. they're written into the batch as entries in thecountsdelta log and only applied to the in-memory counts after the batch commits, so the memory never runs ahead of the disk. a restart replays the log past the last checkpoint.- per-collection record counts (
r|{did}|{col}) and the lifecycle gauges go throughadd_record_count,replace_record_counts,transition_lifecycleand friends, which stage them on the same txn. outboxholds the stream events the write produces, without positions yet.
repo rows
Txn::put_repo and Txn::remove_repo are the only writers of repos rows. each k|p|{host} counter counts the active repos on a pds host, and these move it by the row the write replaces. only a repo's owner shard writes its row, so that row can't change before the commit. read_repo reads the row through the txn and remembers its host, so a later put_repo in the same txn doesn't read it again. a raw batch.insert(&db.repos, ..) leaves a host's count wrong for good.
committing
commit, commit_with and commit_assigned all end in commit_inner, which does the steps in one order every time:
- stage the record counts, the lifecycle gauges and the count deltas into the batch.
- run the caller's
stageclosure, if any, for writes that have to land in the same batch. - commit through the outbox. with events to send,
Sequencer::commit(src/db/sequencer.rs) takes its lock, gives the events their positions, writes their rows into the batch and commits it. - apply the counts in memory.
- broadcast the events, still under the sequencer lock.
since positions are only handed out inside that lock, position order is commit order, a broadcast is only sent once its row can be read, and broadcasts reach subscribers in position order across every writer. counts go before the broadcast, so a subscriber reading counts when an event arrives sees it counted. positions are reserved in blocks written to cursors in the same batch, so they never repeat across restarts, even when their rows get pruned.
dropping a Txn without committing it throws away the batch and every delta with it.
record writes
RecordTxn is the one write path for records. Txn::records(state, rev, did) makes one for a live commit and reads the repo's exclude entry, and Txn::backfill_step_records makes one for a backfill step, which skips that read because the shard did it at the first step. it borrows the Txn, so its writes land in the same batch and commit with everything else.
put_record(collection, rkey, block, action)anddelete_record(collection, rkey)keep everything about a record in step: the head inheads, the old body moving tohistory, the total and per-collection counts, backlinks (with thebacklinksfeature), and theStoredEventand jetstream event, whichRecordEmitterputs intxn.outbox.put_recordtakes aCarBlock, hashing it unless the car parse already did, so sparse backfill needs no second parse.- counts follow what happened to the head, not the op's claimed action, so replaying a commit is a no-op for them.
- the count deltas go straight onto the
Txn(txn.countsand its record counts), so there's nothing to flush, and severalRecordTxns in oneTxnadd up instead of overwriting each other. the resync drain uses that to apply a few buffered commits in one txn. read_repoandupdate_repo_statego throughTxn::put_repo.
one catch: RecordTxn reads the current head from the database and not from the batch, so a second commit on the same record in one txn doesn't see what the first one staged.
callers are ops::apply_commit for live commits, and for backfills the repo's owner shard (src/ingest/indexer/bulk.rs), which applies the steps that backfill/worker/apply.rs builds for full and sparse backfills alike. the chunked writers in db::indexer (a deleted account's wipe, an erase, a redaction) write heads and history directly, since they're inside db.
blocking
fjall calls are blocking. async code runs them through Db::run (src/db/mod.rs), which runs the closure on spawn_blocking with a clone of the Db and fails with Cancelled if the runtime shut down before it started. threads that are already blocking, like the indexer shards, call the db directly. a fjall call made straight from an async task blocks a runtime worker until it's done.
poison
once a disk write fails, fjall refuses every later write with Error::Poisoned. the first error like that has to reach db.poison.check(..), or check_report(..) for a miette::Report, which ends Hydrant::run with DatabasePoisoned instead of exiting the process, since hydrant can be embedded. Ks's point reads and writes do the check themselves. iterators reached through Deref and batch commits don't, so the code using them has to.