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

A newer version of the Gradio SDK is available: 6.19.0

Upgrade

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.