blockwatcher-storage
blockwatcher-storage implements the Storage port twice over: versioned,
optimistic-concurrency resources, per-network checkpoints, an
append-only dead-letter queue, persisted operator pause, a bounded
delivery journal, and the gate hit journal (gate_hits / gate_meta),
backed by an in-memory map or a durable sqlite file
(crates/blockwatcher-storage/src/lib.rs). It is a
module crate: the heavy driver
(rusqlite) is quarantined here, and nothing above the Storage
port ever has to know which backend
a running instance chose.
Delivery guarantees § Dead
letters already states the
operator-facing consequence of the choice between backends: sqlite is what
makes a dead-letter queue survive a restart at all, memory does not. This
page covers the mechanics underneath that: the Storage contract in full,
each backend’s concrete data structures, sqlite’s schema and migration
approach, and exactly how optimistic concurrency and dead-letter payload
absence are implemented: verified against the schema and the SQL itself,
not restated at the operator level.
Key takeaways
blockwatcher-storageimplements theStorageport twice over: an in-memory backend with no persistence, and a durable sqlite-backed one.- The heavy driver (
rusqlite) is quarantined in this crate; nothing above theStorageport ever has to know which backend a running instance chose. - sqlite is what makes a dead-letter queue survive a restart at all; memory does not.
Responsibilities
- Implement the full
Storageport over an in-memoryHashMap-backed structure with no persistence at all:MemoryStorage(memory.rs), including the delivery journal as a per-networkVec. - Implement the full
Storageport over one sqlite file and one guarded connection, durable across a restart:SqliteStorage(sqlite.rs). - Enforce optimistic concurrency on every resource write and delete: a
mismatched expected version is a typed
VersionConflict, never a silent overwrite, in both backends (memory.rs,sqlite.rs). - Apply
put_batch’s create-only, all-or-nothing contract atomically in both backends, overriding the port’s own non-atomic default loop (memory.rs,sqlite.rs). - Register both backends under the module names
"memory"and"sqlite"so a composition root’s catalog can select either by config (registry.rs). - (sqlite only) Create its schema idempotently on first open, migrate schema
version 1 → 2 (delivery journal table), 2 → 3 (persisted pause), and 3 → 4
(
gate_hits/gate_meta) in place, refuse to open a file written by a newer schema version, and serialize every connection access through one mutex insidespawn_blockingso the async runtime never blocks on file I/O:open_blocking(sqlite.rs).
Not this crate’s job: declaring the Storage trait itself, or any of
the types it moves (Checkpoint, DeadLetter, VersionedRecord,
ResourceKind): those are blockwatcher-ports and blockwatcher-types; deciding
when a checkpoint is safe to persist, or retrying a failed persist:
blockwatcher-core’s CheckpointWriter owns that policy and calls this crate’s
backends only to execute the write it already decided to make; deciding
that a dead letter should be discarded outright, singly or in bulk, rather
than replayed: that is ControlHandle::discard_dead_letter/
discard_dead_letters in blockwatcher-core, which this crate’s
delete_dead_letter/delete_dead_letters only execute; pruning the
delivery journal: backends self-prune behind journal_depth on
each record_delivery, and there is no operator prune API; picking which backend a deployment runs: that is blockwatcher.toml, read by
blockwatcher-core’s module catalog.
Key types and traits
| Name | Kind | Role |
|---|---|---|
MemoryStorage | struct | In-process Storage implementation; nothing survives the process (memory.rs) |
memory::Registry | struct | ModuleRegistry impl registering the "memory" storage module, taking no configuration (memory.rs) |
SqliteStorage | struct | Single-file, single-connection durable Storage implementation (sqlite.rs) |
sqlite::Registry | struct | ModuleRegistry impl registering the "sqlite" storage module: { path, busy_timeout_ms } (sqlite.rs) |
storages::get_all | fn | Family enumeration folding both backends into a factory lookup table (registry.rs) |
Neither backend defines its own error type: both return
blockwatcher_ports::StorageError (VersionConflict, AlreadyExists,
NotFound, Backend { message, class }, InvalidConfig), and neither
backend’s Config struct is pub (reachable only through its
ModuleRegistry::factory), documented via its own
registry_examples/*.json file rather than a public type.
The Storage port contract
Every method below is declared once, in crates/blockwatcher-ports/src/storage.rs,
and both backends implement every one of them:
| Method | Category | Purpose |
|---|---|---|
get, list | resources | Fetch one resource by (kind, id), or every resource of a kind, unpaginated (storage.rs) |
put, delete | resources | Create or optimistic-concurrency update, and version-guarded delete (storage.rs) |
put_batch | resources | Create-only batch write; the trait’s own default is a plain, non-atomic loop over put that a backend may override (storage.rs) |
get_checkpoint, put_checkpoint | checkpoints | Read and write one network’s resume point (storage.rs) |
list_checkpoints, delete_checkpoint | checkpoints | Every persisted checkpoint, unpaginated; remove one explicitly (storage.rs) |
record_dead_letter | dead letters | Append a match that exhausted delivery; prune the oldest letters behind retention in the same write, returning how many it dropped (storage.rs) |
list_dead_letters | dead letters | Page a network’s dead letters in arrival order (storage.rs) |
count_dead_letters | dead letters | How many dead letters a network currently has, without paging through them; the trait’s own default is list_dead_letters(..).len() (storage.rs) |
delete_dead_letter, delete_dead_letters | dead letters | Discard one letter by match id, or every letter matching optional sink/monitor filters, without attempting delivery (storage.rs) |
update_dead_letter | dead letters | Replace a letter in place at the same queue position, after a failed replay (storage.rs) |
set_paused, list_paused | pause | Persist or clear one monitor’s or network’s pause by id, and list every id currently paused for a target (storage.rs) |
record_delivery | delivery journal | Record that match_id was delivered or dead-lettered at cursor; prune rows behind journal_depth in the same write (storage.rs) |
list_deliveries_after | delivery journal | Deliveries whose cursor is strictly after from, in cursor order; unknown pipeline is empty (storage.rs) |
forget_delivery | delivery journal | Drop a journaled id after a successful retract; absent ids succeed (storage.rs) |
get_gate_state | gate journal | Fetch the full gate state (hits and meta) for one (pipeline, monitor) in one read (storage.rs) |
replace_gate_hits | gate journal | Replace the entire hit journal for one (pipeline, monitor) (storage.rs) |
put_gate_meta | gate journal | Upsert the metadata sidecar for one (pipeline, monitor) (storage.rs) |
prune_gate_hits_after | gate journal | Delete hold rows with cursor > from for a pipeline; rows with cursor ≤ from stay (storage.rs) |
delete_gate_state | gate journal | Drop that monitor’s gate_hits and gate_meta (gate-envelope change / monitor delete, always with the pipeline stopped) (storage.rs) |
Every method on the trait belongs to one of these categories: resources
(keyed by ResourceKind × id, with a u64 version for optimistic
concurrency), checkpoints (keyed by NetworkId), dead letters (keyed by
NetworkId + MatchId), pause (keyed by PauseTarget + id), the
delivery journal (keyed by NetworkId + MatchId, ordered by cursor),
and the gate journal (gate_hits / gate_meta, keyed by pipeline +
monitor). Memory, sqlite, and FlakyStorage all implement it;
exercise_storage_contract covers the gate methods too.
Both backends persist the identical categories; only where they land differs:
flowchart LR
subgraph categories["categories"]
res["resources<br/>kind + id keyed, versioned"]
chk["checkpoints<br/>one cursor per network"]
dl["dead letters<br/>append-only queue per network"]
pause["pause<br/>target + id keyed"]
journal["delivery journal<br/>match ids in a cursor window"]
end
categories --> mem["MemoryStorage<br/>HashMap, gone on restart"]
categories --> sql["SqliteStorage<br/>one file, survives restart"]
The memory backend
MemoryStorage holds one Mutex<State> (memory.rs), where State
is one plain Rust collection per category and nothing else (memory.rs):
#![allow(unused)]
fn main() {
#[derive(Default)]
struct State {
records: HashMap<(ResourceKind, String), (u64, serde_json::Value)>,
checkpoints: HashMap<NetworkId, Checkpoint>,
/// Appended to, never reordered: operators triage dead letters in the
/// order delivery gave up on them.
dead_letters: HashMap<NetworkId, Vec<DeadLetter>>,
/// Per-pipeline delivered match ids, newest write prunes by primary.
journal: HashMap<NetworkId, Vec<(Cursor, MatchId)>>,
/// Present means paused; a resume removes the entry.
paused: BTreeSet<(PauseTarget, String)>,
}
}
Every method is a lock, a map operation, and an unlock, with no I/O
anywhere in the file (module doc, memory.rs). Optimistic concurrency
is a plain match over (expected_version, current) in put
(memory.rs); put_batch pre-validates every entry against both existing
state and the rest of the batch before writing any of it, under one lock
acquisition, which makes it genuinely atomic here despite the trait’s own
default being a non-atomic loop (memory.rs). Dead letters are a real,
ordered Vec<DeadLetter> per network (append-only except for
delete_dead_letter/delete_dead_letters and update_dead_letter’s
targeted mutations, memory.rs), not a stub: paging (skip/take),
removal, and in-place replacement all behave exactly as the port contract
specifies. Nothing here is written to disk; a process restart loses every
record, checkpoint, dead letter, and pause, matching the crate’s own
trade-off note (memory.rs).
The sqlite backend
Schema
One CREATE TABLE/CREATE INDEX batch per schema version (SCHEMA_V1,
SCHEMA_V2, SCHEMA_V3, SCHEMA_V4 in sqlite.rs), with SCHEMA_VERSION 4:
CREATE TABLE resources ( … );
CREATE TABLE checkpoints ( … );
CREATE TABLE dead_letters ( … );
CREATE INDEX dead_letters_pipeline ON dead_letters (pipeline, seq);
CREATE TABLE delivery_journal (
pipeline TEXT NOT NULL,
cursor_primary INTEGER NOT NULL,
cursor_secondary INTEGER NOT NULL,
match_id TEXT NOT NULL,
PRIMARY KEY (pipeline, match_id)
);
CREATE INDEX delivery_journal_pipeline_primary
ON delivery_journal (pipeline, cursor_primary);
CREATE TABLE pauses (
target TEXT NOT NULL,
id TEXT NOT NULL,
PRIMARY KEY (target, id)
);
CREATE TABLE gate_hits (
pipeline TEXT NOT NULL,
monitor TEXT NOT NULL,
cursor_primary INTEGER NOT NULL,
cursor_secondary INTEGER NOT NULL,
event_index INTEGER NOT NULL,
event_ts INTEGER NOT NULL,
event_json TEXT NOT NULL,
PRIMARY KEY (pipeline, monitor, cursor_primary, cursor_secondary, event_index)
);
CREATE INDEX gate_hits_pipeline_primary ON gate_hits (pipeline, cursor_primary);
CREATE TABLE gate_meta (
pipeline TEXT NOT NULL,
monitor TEXT NOT NULL,
last_emit_ts INTEGER,
aux BLOB,
PRIMARY KEY (pipeline, monitor)
);
Each table stores its whole record as one JSON TEXT blob (value,
checkpoint, entry) alongside only the columns a query needs to filter
or order by, pauses excepted: a pause carries no payload beyond its own
existence, so target and id are the entire row and presence alone
means paused. resources keys on (kind, id); checkpoints keys on
pipeline; pauses keys on (target, id); dead_letters gets an
autoincrementing seq that is both its primary key and, via the
dead_letters_pipeline index, the ordering list_dead_letters pages by:
arrival order, for free, from the column sqlite already maintains.
Migration approach
A single idempotent function run once at open (open_blocking,
sqlite.rs): MIGRATIONS is an ordered array holding each version’s
schema batch ([SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4]), and SCHEMA_VERSION
is simply its length, so adding a version and bumping the version number
can never drift apart from each other. Opening reads PRAGMA user_version
and slices MIGRATIONS from that index onward: a fresh file (0) runs
every step, a 1 file runs only SCHEMA_V2 onward, a 2 file
runs only SCHEMA_V3 onward, a 3 file runs only SCHEMA_V4, and a file already at SCHEMA_VERSION runs
nothing. Every pending step executes inside one transaction, PRAGMA user_version is set to SCHEMA_VERSION inside that same transaction, and
the whole thing commits together, so a crash mid-migration can never leave
the file at its old version with some of the new tables already present,
a state this backend would otherwise treat as fresh and fail to re-create.
A version outside 0..=SCHEMA_VERSION (including a negative value, which
this backend never writes) refuses to open at all, before any write,
naming the found version (sqlite.rs), a refusal pinned by a test that
diffs the file’s bytes before and after it to prove nothing was touched.
A future bump means appending one more schema batch to MIGRATIONS, not
introducing a new subsystem or a new arm to a hand-written match.
Optimistic concurrency, precisely
put’s update path (sqlite.rs:256-276) is a single conditional
UPDATE:
UPDATE resources SET version = version + 1, value = ?4
WHERE kind = ?1 AND id = ?2 AND version = ?3
If rows_affected comes back 0, a follow-up SELECT version tells the
two possible causes apart: no row at all (NotFound) versus a row that
exists at a different version (VersionConflict { expected, actual }).
delete (sqlite.rs) is the identical pattern with a conditional
DELETE. A create (expected_version: None) is a plain INSERT, whose
rusqlite::Error for a primary-key collision is mapped to AlreadyExists
(sqlite.rs). put_batch serializes every entry to JSON before
opening a transaction (so a serde failure never leaves one open), then
runs one INSERT per entry inside it, dropping the transaction uncommitted
on any failure (sqlite.rs), which is what makes it atomic where
the port’s own default loop is not.
Dead letters and checkpoint provenance are inside the blob, not a column
record_dead_letter/update_dead_letter serialize the whole
DeadLetter struct (match_id, monitor, sink, cursor, attempts,
reason, and the optional payload) into the single entry TEXT column
(sqlite.rs). A query that needs to find one letter by
match_id reaches into that blob with sqlite’s own json_extract, rather
than a dedicated column:
DELETE FROM dead_letters
WHERE pipeline = ?1 AND json_extract(entry, '$.match_id') = ?2
(sqlite.rs:454-480, comment noting this scan is accepted at
triage-scale, with a dedicated index left for whichever deployment first
proves it necessary). Because DeadLetter.payload: Option<SinkEvent> is
#[serde(default, skip_serializing_if = "Option::is_none")]
(crates/blockwatcher-types/src/event.rs), a dead letter recorded before
replay support existed has no SQL NULL to check at all: its entry
blob simply has no "payload" key, and serde_json::from_str fills that
gap back in as None on read via #[serde(default)]. A payload that is
a tagged SinkEvent or a legacy bare Match object both load; see
Delivery guarantees § What a sink
receives. The 409 checkpoint_provenance check the HTTP API documents (which module wrote a
checkpoint) works the same way: Checkpoint.module: Option<String> lives
inside the checkpoints.checkpoint JSON blob (sqlite.rs), and
this crate stores and returns it opaquely: the module comparison itself
happens one layer up, in the engine that reads the decoded Checkpoint
back out.
Cursor representation
Cursor { primary: u64, secondary: u64 } never gets its own columns in
either backend: in MemoryStorage it is a plain, unserialized struct field
inside the in-memory Checkpoint; in SqliteStorage it exists only as the
nested {"primary": N, "secondary": N} object inside the whole
Checkpoint’s serialized JSON blob, the same shape the HTTP API’s
checkpoint field shows on the wire.
Connection handling
SqliteStorage holds Arc<Mutex<rusqlite::Connection>>: one connection,
not a pool (sqlite.rs). Every port method funnels through a shared
with_conn helper that clones the Arc, locks the mutex, and runs the
blocking sqlite call inside tokio::task::spawn_blocking, so the async
runtime is never blocked on file I/O even though the connection itself
serializes every access (sqlite.rs). with_conn also takes a read guard
on a tokio::sync::RwLock<()> fence before spawning, and moves the guard
into the blocking closure rather than merely holding it across the
.await: if the caller awaiting with_conn is aborted, the closure keeps
running on the blocking pool regardless, and the guard travels with it,
so it still marks the call as in flight until the closure actually
returns. SqliteStorage’s Storage::quiesce override takes the fence’s
write side and immediately drops it, which a fair, write-preferring
RwLock resolves only once every read guard taken before it has been
dropped: this is what lets a caller that just escalated a drain to
abort() wait out whatever storage call that abort left running, rather
than risk a successor racing it. PRAGMA journal_mode=WAL
is set at open for a file-backed database (skipped for :memory:, which
cannot run WAL), and busy_timeout is set from the configured
busy_timeout_ms (default 5_000) to soften contention from an external
reader, not to support two blockwatcher processes writing the same file, which
this backend does not support at all (sqlite.rs).
Neighbours
blockwatcher-storage depends on, in production:
blockwatcher-types: vocabulary crate (Checkpoint,DeadLetter,MatchId,NetworkId,PauseTarget,ResourceKind,VersionedRecord)blockwatcher-ports: theStoragetrait this crate implements twiceasync-trait: required to implement theasync fn-bearingStoragetraitserde: (de)serialization derive for each backend’sConfigserde_json: the JSON representation every stored value, checkpoint, and dead letter serializes totokio(rtfeature):spawn_blockingfor the sqlite backend’s connection accessrusqlite: the sqlite driver itself, this crate’s onecheck-dep-graph.shfamily exemption
and, in [dev-dependencies] only:
blockwatcher-ports(fakesfeature):blockwatcher_ports::fakes::MemoryStorage, run through the same shared contract test as this crate’s own two backends, for parityblockwatcher-testkit:dead_letter(a fixture builder) andexercise_storage_contract, the shared behavioral-contract suite both backends are proven againsttokio(macros/rt/time/test-utilfeatures): async teststempfile: a real filesystem path for sqlite’s file-backed tests
The following crates depend on it directly (per the dependency table):
blockwatcher-embed: folds both backends into the catalogbuild_catalogreturnsblockwatcher(binary): reaches storage both directly and through embed
Reading the source
- Start at
lib.rs: threepub moddeclarations and nothing else; the module doc names the categories this crate persists. registry.rs:storages::get_all, and the self-verifying test that constructs every registered backend from its own documented example config.memory.rs:MemoryStorage,State, and everyStoragemethod in file order; read this first, since every method here is the simplest possible correct implementation of the same contractsqlite.rsimplements durably.sqlite.rs:SCHEMA_V1/SCHEMA_VERSION,open_blocking(the migration logic), then everyStoragemethod; read the module doc comment first for the single-writer trade-off this backend accepts.tests/contract.rs(workspace-shared, viablockwatcher-testkit): the one suite both backends (and the portsfakes::MemoryStorage) run through, proving they satisfy an identical contract by shared test rather than shared code.