Spaces:
Running on Zero
A newer version of the Gradio SDK is available: 6.19.0
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_URLat Neon (postgresql+psycopg://USER:PASSWORD@HOST/DB?sslmode=require) and the durable log lives in managed Postgres; everything else is unchanged. - A
DATABASE_URLis required to run;make_ledger()raises without one. Tests pass an ephemeralsqlite://URL (a realSqlAlchemyLedger, no server) as the mock store, so the suite stays green with no database server and no network. snapshot_tois 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 thatfrom_filereopens.scripts/resume_run.pyandmodal_app.pyuseDATABASE_URLwhen 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_URLacross scenarios (oneeventstable). For isolated durable runs, useresume_run.py, which keeps one database per scenario. Multi-run/multi-scenario partitioning within a single store (e.g. filtering byrun_id) is a follow-up. pgvector-based episodic retrieval and PostgresLISTEN/NOTIFYtailing (noted indocs/architecture/persistence.md) become possible now that the durable backend is Postgres; neither is built here.