ADR-0016: Pluggable storage backends, SQLite first¶
Status: accepted (2026-09)
A session's trading record — fills, orders, positions, PnL, kill events and session metadata — is written to a pluggable storage backend chosen by [storage] backend, and can be queried without replaying a journal. SQLite ships as the first backend.
Context¶
- The only durable record of a session was the
.fmjjournal: the byte-exact event stream (ADR-0010). Answering "what did I trade yesterday" meant replaying it or parsing it withtools/pnl_report.py, per file, with no index and no cross-session view. - The other durable state was a one-line session epoch file and a kill-state file (
core/session_state.hpp). The/dev/shmstatus segment dies with the process. - The engine already hands the journal its records through an SPSC
MsgRingwritten from the trading thread without allocating or waiting. The same mechanism serves a second consumer. - Storage is a deployment choice. One operator wants a file next to the journal, another an existing ClickHouse or Postgres, a third nothing at all. Baking one into the engine makes the other two fork it.
Decision¶
- The engine writes a record stream, not rows.
core/record_stream.hppdefines four trivially copyable records (fill, order, position, kill) and aRecordWriterthat copies them into aMsgRing. It is allocation-free and wait-free; a full ring drops the record and counts it. The engine knows nothing about storage. - A backend is an interface behind a registry.
store::Backendconsumes records;store::Readeranswers queries.store::StoreRegistrymaps a name to two factories, and[storage] backendnames one.noneis reserved and allocates nothing. Registration is explicit (register_builtin_backends()), like the strategy registry and for the same reason: a static library's self-registering object is dropped by the linker. - A backend parses its own configuration.
[storage]is a free-form section like[sim]and[backtest]; the central schema knows no key in it. An out-of-tree backend adds keys without touching FastMM. - Losing a record is allowed; blocking the engine is not. The store is a reporting record, not the risk path: a full ring or a failing backend is counted and logged, and the session keeps trading. The journal stays the authority and can rebuild it. A backend that cannot be opened stops the session at start-up, so a silent non-record is impossible.
- SQLite, bundled through CPM. WAL,
synchronous=NORMAL, one file, one schema version table with a migration step per version. Bundled rather than taken from the host because FastMM vendors its dependencies (ADR-0005), the WAL and UPSERT behaviour the store relies on is version-dependent, and a trading host should not need a-devpackage.
Consequences¶
- A second queue out of the engine: a fill costs one 256-byte and one 192-byte copy into a ring, an order update one 192-byte copy. With
backend = "none"the writer is disabled and costs a null check. - A third durable artefact per deployment (journal, kill state, store) with its own retention. The store grows with fills, not with market data.
- The store is not a ledger. It records what FastMM saw; it does not reconcile against the venue, and nothing that happened while the process was down is in it.
- Two records can disagree after a crash: the journal loses at most its last unflushed block, the store its last uncommitted batch.
sessions.clean_shutdownandsessions.journal_completesay which.