multi-agent-lab / docs /adr /0014-postgres-event-store.md
agharsallah
feat: require live infrastructure for event store and inference, removing offline mode
eed2172
|
Raw
History Blame Contribute Delete
5.16 kB
# ADR-0014: Durable Postgres Event Store Behind the Ledger Interface
## Status
Accepted
## Context
The append-only event ledger is the single source of truth: all projections,
memory, and crash recovery derive from it (ADR-0001). `SQLiteLedger` (ADR-0013,
`docs/architecture/persistence.md`) made that log durable on a local file, which
is enough for one process on one disk. A hosted, multi-instance deployment wants
a managed database: durable across restarts, reachable by several workers, and
operated by someone else. Managed Postgres β€” e.g. Neon β€” is the natural target,
but the engine must not be coupled to a single vendor or to a database being
present at all: the suite has to stay green with no connection.
Two product requests shaped the implementation: use **SQLAlchemy** for the store,
and use the **`eventsourcing`** library. We evaluated `eventsourcing`'s
persistence primitives against our model and they do not compose cleanly (see
below).
## Decision
Add a durable backend *for* the ledger; do not replace the ledger. The `Event`
envelope and the `Ledger` interface are unchanged.
**Layered.** `SqlAlchemyLedger(Ledger)` (`src/core/sqlalchemy_ledger.py`) is a
drop-in backend that mirrors `SQLiteLedger`'s surface β€” `append`, `events`,
`reset`, `extend`, plus `snapshot_to` / `from_file` / `tail` / `latest_offset` /
`close`. Idempotency is a `UNIQUE` constraint on the event `id`; insertion order
is a serial `offset` column (not `created_at`, which is subject to clock skew, nor
`turn`, which repeats on retry) β€” the same guarantees as the SQLite backend. The
*same* SQLAlchemy code drives both Postgres and SQLite, so the backend is
exercised in CI against SQLite without a server, and the Neon path is
code-identical.
**The durable store is required.** A small factory (`src/core/ledger_factory.py`,
`make_ledger()`) constructs the backend from `DATABASE_URL` (`SqlAlchemyLedger`).
There is **no in-memory fallback**: with no URL resolved, `make_ledger()` raises β€”
the app persists to a real event store and refuses to run without one (this is
part of dropping the offline product mode; see ADR-0010). The store deps
(`sqlalchemy>=2.0`, `psycopg[binary]>=3` β€” the Neon driver) are therefore **core
dependencies** in `pyproject.toml`, not an optional extra. SQLAlchemy is still
imported lazily inside the backend (so `src.core.*` stays importable in minimal
contexts), but it always ships. Tests pass an explicit ephemeral `sqlite://` URL
as the mock store β€” a real `SqlAlchemyLedger` with no server.
**SQLAlchemy-direct, not the `eventsourcing` library.** `eventsourcing` is built
around DDD aggregates: its `StoredEvent` is keyed on `originator_id` +
`originator_version` (a per-aggregate sequence), reads are
`select_events(originator_id, gt=version)`, and event state is opaque serialised
`bytes`. Our ledger is a *flat envelope* with a single global insertion-ordered
log and idempotency by UUID `id` β€” not aggregate streams. Mapping onto its
recorder would force either one synthetic aggregate per run (conflating
idempotency-by-`id` with version-by-sequence and losing a clean global order) or
opaque blobs (losing the queryable, indexed `run_id` / `kind` / `actor` columns
the ledger relies on). That is the awkward aggregate model to avoid, so the lib is
**not** a dependency. SQLAlchemy is the right level for a flat event table.
*How `eventsourcing` could layer in later:* if a scenario ever needs true DDD
aggregates (per-entity invariants, optimistic-concurrency version checks), it can
adopt `eventsourcing` *above* this store β€” an `ApplicationRecorder` backed by the
same Postgres β€” while the flat ledger remains the cross-cutting source of truth.
The two are complementary, not competing.
## Consequences
- A hosted deployment points `DATABASE_URL` at Neon
(`postgresql+psycopg://USER:PASSWORD@HOST/DB?sslmode=require`) and the durable
log lives in managed Postgres; everything else is unchanged.
- A `DATABASE_URL` is required to run; `make_ledger()` raises without one. Tests
pass an ephemeral `sqlite://` URL (a real `SqlAlchemyLedger`, no server) as the
mock store, so the suite stays green with no database server and no network.
- `snapshot_to` is backend-agnostic (it replays the log into a destination
ledger, default a SQLite file) since Postgres has no portable in-process backup
API like SQLite's `.backup()`; a Postgres run can be checkpointed to a portable
file that `from_file` reopens.
- `scripts/resume_run.py` and `modal_app.py` use `DATABASE_URL` when set and fall
back to their local SQLite file otherwise, so their offline behaviour is intact.
- Caveat: the live multi-scenario UI shares one `DATABASE_URL` across scenarios
(one `events` table). For isolated durable runs, use `resume_run.py`, which
keeps one database per scenario. Multi-run/multi-scenario partitioning within a
single store (e.g. filtering by `run_id`) is a follow-up.
- `pgvector`-based episodic retrieval and Postgres `LISTEN/NOTIFY` tailing (noted
in `docs/architecture/persistence.md`) become possible now that the durable
backend is Postgres; neither is built here.