How eterDB works

1. Transactions, and what gets undone

Every change is a transaction. You rarely type the word. Run a bare UPDATE and Postgres wraps it in one for you:

What you write
UPDATE accounts
  SET balance = balance - 100
WHERE id = 7;
What Postgres runs
BEGIN;     ← added
  UPDATE accounts
    SET balance = balance - 100
  WHERE id = 7;
COMMIT;    ← added

It matters when one action is really several writes that must stand or fall together. A transfer is a debit and a credit, never one without the other. Your ORM's transaction block lowers to exactly that shape: a BEGIN, the writes, a COMMIT.

All of it is written or none of it is. Fail partway and Postgres discards the unit as if it never ran. Commit and the change survives a crash. That all-or-nothing unit is the natural thing to reverse, which is why eterDB undoes a transaction and not a stray row.

A transfer staged statement by statement, then committed or rolled back. Both rows are written together, or neither is.

So the question is what a committed transaction leaves behind that's enough to run it backwards. Two things, and they're captured by two different halves of the system: the rows it wrote, and the rows it read.

2. The shape of the system

Exactly one job has to run inside the Postgres process: recording what each query read (§4). A read leaves nothing behind once the query finishes, and it's visible only inside the backend that ran it. Everything else runs outside the engine. Write history goes to a sidecar, storage snapshots to another, and the dependency graph and undo logic to extensions plus a separate metadata store.

Reverts are rare and don't need to be fast, so anything not required at commit time stays off the hot path. Hover any box for detail.

Writes (blue) and the read-set (amber) stream out of the engine after commit. The revert path (green) runs on demand. Amber is the only in-engine piece. Hover for detail.

The in-engine part: a read-capture hook, an append-only file the reads are written to, transient per-backend bookkeeping of what has been read, and the functions that apply compensating DML. The dependency graph is not built in the engine. Captured reads are forwarded to the store, and the graph is derived there when an undo is requested.

Revert latency therefore depends on the transaction rather than on the database.

revert time   scales with     candidate read-edges + the txn's own ops
              independent of  database size, history length, txn age

A months-old target on a million-row database costs about what a fresh one with the same fan-out costs. The work happens at revert time, where seconds are acceptable, rather than at commit time.

3. Capturing writes

Undoing a write needs the row as it was before. Postgres writes one down already, without being asked. An UPDATE doesn't overwrite the row where it sits: it writes a new version and marks the old one dead, so a row that has just been debited physically holds two versions.

accounts, row id = 7
balance = 900   xmin 42, xmax 57 dead, the before-image
balance = 800   xmin 57, xmax live

Each version carries two stamps: xmin, the transaction that created it, and xmax, the transaction that retired it ( while it's live). A reader sees whichever version was live when its own transaction began, so readers and writers never block each other. That's MVCC, and the dead version it leaves behind is exactly what an undo restores.

Each UPDATE appends a version and stamps the old one dead. The reader still sees the old value. VACUUM cleans up.

The last frame is the problem. VACUUM, Postgres's background cleanup, reclaims dead versions, and once it runs the old value is gone. eterDB can't count on a before-image still being in the heap an hour later, so it copies one out of the way at commit time. The before/after pairs come from logical decoding, the same stream that drives logical replication, which reads the WAL: the log Postgres writes every change to before it touches the table itself.

The WAL normally records just enough to redo a change, not the whole prior row. For a true before-image, set REPLICA IDENTITY FULL on the tables you want reversible. One statement per table:

ALTER TABLE orders REPLICA IDENTITY FULL;   -- WAL now carries the whole old row

The figure alternates it on and off. Watch what reaches the history:

The replica identity alternates between FULL and the default. Watch whether the before-image survives, and what it costs.

All of this happens after commit. The transaction commits at normal speed and the history record follows from the WAL, read out of process by a sidecar that appends to the metadata store.

The replication slot needs operational attention. While the sidecar is down, Postgres retains WAL on its behalf. That retention keeps capture gap-free across restarts (exactly-once, tested), and it is also disk that grows until capture catches up. Watch slot lag when you run the stack yourself.

REPLICA IDENTITY FULL logs the entire old row on every update, not just the key. On wide rows or large TEXT/JSONB values that's write amplification, and the meter in the figure shows it. A TOASTed value is logged in full every time, which is why undo restores it even when the update never touched it. It's per-table: a table you never need to undo keeps the default identity and skips the cost.

4. The dependency a backup can't see

This is the part nothing outside the engine can reconstruct. B reads a value A wrote, then writes something derived from it:

A:  UPDATE config SET max_qty = 500;         -- writes 500
B:  SELECT max_qty FROM config;            -- reads A's 500
    INSERT INTO orders(qty) VALUES (500);   -- derives an order from it

undo A  →  config reverts, but B's order still says 500   ✗ silently wrong

B now holds a result computed from a value that no longer exists, and nothing flags it. eterDB records these read-dependencies so it can undo B too, or refuse and report it. Reads are the hard part: a write leaves a row, a read leaves nothing, so triggers and CDC never see one. The figure replays the same undo from both views:

The same undo of A from both viewpoints: what a backup knows about B, and what eterDB knows.

eterDB doesn't add new tracking for this. Postgres already has it, in the machinery behind SERIALIZABLE. The default isolation level, READ COMMITTED, is loose and fast. SERIALIZABLE instead guarantees a result that some serial order of the transactions would have produced, and aborts a transaction with a 40001 error rather than let it break that guarantee.

Deciding what to abort means knowing who read what. So SSI (Serializable Snapshot Isolation), Postgres's algorithm for SERIALIZABLE, records each read as a predicate lock: a note that this transaction read the rows matching some condition, which blocks nobody. When another transaction writes a version of that data, SSI draws an rw-antidependency edge between the two, meaning "A read some data, and B wrote a version of it A didn't see." That edge is the relationship that makes undoing A alone unsafe, which makes it precisely what eterDB wants.

The patch reads the same tracking in observe mode, under ordinary READ COMMITTED:

Predicate locks coarsen under memory pressure, from tuples to pages to whole relations, which can make the graph over-approximate. Coarsening only ever adds candidate edges, never drops a real one, so it costs a review, not a missed dependency. §5 shows how each edge is labelled.

Naming the row that was read

An edge is only useful if it names a row that can still be found later. Postgres's own name for a row is its physical address, the ctid. A table on disk is a stack of fixed-size 8 KB pages; each page keeps an array of line pointers at the top, one per row version stored in it, and the ctid is which page plus which slot:

ctid = (0, 2)
        │   └── slot 2   line pointer inside the page
        └────── page 0   which 8 KB block

A ctid is not stable. When a version dies and vacuum frees its slot, an unrelated row can move in, and the same address comes to mean a different row:

An update, a vacuum, and a slot being reused, one step at a time.

Record "row R is at (0,2)", look it up later, and you may read row S. So eterDB identifies a read by the row it touched, as (table, primary key), never by address. An early build got the timing of that translation wrong.

5. Computing an undo

eter preview <txid> builds the graph from the store and classifies the target. If nothing depends on it, the transaction is clean: eterDB generates the inverse DML from the captured before-images and, with --apply, runs it against your tables. If later transactions read or overwrote those rows, it's dependent, and a blind revert would corrupt them, so eterDB reports the affected set and gives you the choice.

History is in a separate store, so an undo spans two databases. The part that must be atomic stays in one: eterDB reads the plan and before-images from the store, then applies all the compensating DML to your database in a single transaction. Both failure modes resolve safely.

The revert doesn't trust its own preview either. --apply re-runs classification inside the applying transaction, so a dependent that committed after you previewed blocks a clean_only undo instead of slipping through. Concurrent writers to the same rows queue behind ordinary row locks. A concurrent reader doesn't block: it can read a pre-undo value mid-revert, and that read is captured like any other, so it surfaces as a dependent afterwards rather than disappearing.

The three undo modes in turn: which transactions get reverted, skipped, or left to diverge.

A cohort undo (eter undo-cohort) reverses every write since an incident marker. One transaction with a live dependent doesn't abort the batch: eterDB reverts the clean ones and returns reverted_txns, skipped_dependent and skipped_txids, so you or the agent decide about the rest. Every command supports --json and returns stable exit codes.

Reading a blocked undo

A yes/no answer isn't enough when a transaction is dependent. You need to see what depends on it. A config write (txid 4821) was read by later transactions before you caught it, and preview returns everything that depends on it:

$ eter preview 4821 --json{
  "txid": 4821,
  "classification": "dependent",
  "op_count": 1,
  "ops": [
    { "table": "public.app_config", "original_op": "U",
      "compensating_op": "UPDATE", "pk": { "key": "max_order_qty" } }
  ],
  "conflicts": [4822, 4825, 4830],
  "conflict_edges": [
    { "txid": 4822, "kinds": ["rw"],       "precision": "exact",       "rw_granularity": "tuple" },
    { "txid": 4825, "kinds": ["rw"],       "precision": "over-approx", "rw_granularity": "relation" },
    { "txid": 4830, "kinds": ["ww","rw"],  "precision": "exact",       "rw_granularity": "tuple" }
  ],
  "precision": {
    "exact_dependents": 2,
    "over_approx_dependents": 1,
    "coarse_tables": ["public.order_audit"]
  },
  "external_refs": { "count": 0, "kinds": {}, "samples": [] },
  "dependency_basis": "write-write exact + read-write from persisted SSI graph"
}

On a busy database, one write to a hot row (a config, a feature flag) is read by many later transactions, and the dependent set grows fast. Two things keep that from collapsing into "everything depends on everything":

eterDB reverses database state. A reverted row that already charged a card or sent an email surfaces under external_refs in the same preview, so you see the external fallout before you decide.

6. Schema changes and time travel

Row-level undo can't restore a dropped table. A DROP isn't row data. The heap is gone and no before-image in eter.history covers it, so destructive DDL needs a storage answer. Ours was ZFS: put the data directory on a ZFS dataset, let a sidecar take copy-on-write snapshots, and recover by clone + WAL replay + extract. Snapshots copied nothing up front and clones materialized in milliseconds.

Deploying it was the problem. ZFS is a host kernel module, so it wants a privileged container on a Linux host you control (§7 has the full tally). Every deployment pays that, always, to cover a rare event.

The sidecar was already a point-in-time-recovery engine. WAL archiving, replay to an exact position, standing up a throwaway Postgres, extracting one object: all substrate-agnostic. ZFS supplied two things, a base image and its materialization, and both have standard unprivileged Postgres equivalents. So the sidecar now takes pg_basebackup base backups alongside the WAL archive. Recovery copies the newest backup from before the change, replays archived WAL forward to just before the drop, and pulls the object out of the copy. No root, no kernel module, no privileged container.

The deploy drops the table. The database is read as of a moment before it. The table comes back from a base backup plus WAL replay.

Every recovery reads from a copy. The live database stays up and is never restored over. An event trigger records destructive DDL in eter.ddl_log with the WAL position it happened at, so eterDB knows exactly how far to replay: one instant before the drop.

7. Deploying it

eterDB is the Postgres: a patched engine build with observe mode and the eter_ssi background worker, the way Aurora runs on Aurora and Neon runs on Neon. So the thing to know is what deploying it takes. It used to take a lot:

Then, the ZFS era

  • A Linux host you control, with the ZFS kernel module loaded
  • A privileged container with /dev/zfs passed through
  • No serverless runtimes. A kernel module can't ride along in a container.

Now, after the PITR swap

  • Any container runtime: Kubernetes, Cloud Run, Fargate, Docker Desktop on a Mac
  • One storage volume, shared by the engine (WAL archive) and the storage tier (base backups)
  • Ordinary unprivileged containers. No root, no kernel module, no privileged mode.

Concretely: two containers sharing one volume. The engine has the patched build baked in, so you never compile Postgres. It runs with no history trigger on your write path, and pre-creates the capture slot on first boot, which makes change capture gap-free even if the rest of the stack starts late.

The control plane bundles everything else behind one supervisor: the capture sidecar from §3, part of the stack and not an option, reading the slot out of process; the orchestrator carrying the storage machinery; and a separate metadata store holding history, the dependency graph and the backup catalog. That metadata has to survive the tenant failing, so it is a database of its own even inside that one container. The CLI and agents talk to one URL, the orchestrator's API. Recoveries take minutes, so they run as managed background jobs: queued, one at a time, reporting crashes instead of hanging a session.

The patch is nine files and about 390 lines, tracked against upstream. The isolation and regression suites pass on the patched binary, and thirteen extensions, pgvector included, build against it and pass their own suites. It targets one major at a time; today that's 18.

You run it yourself, on anything that runs containers or plain processes: the engine with wal_level=logical and a replication entry in pg_hba.conf (base backups arrive over the replication protocol), plus the control plane next to it. Apps connect over the normal Postgres wire protocol with any driver.

8. Trade-offs and overhead

Capturing writes is nearly free and off the commit path (§3). Observe mode is where the cost was, and the first implementation put it in the wrong place. Profiling found it and took read overhead from ~50% to ~3%.

The first pass reused Postgres's own machinery. A read leaves no row behind, so there's nothing to trigger on, but Postgres already solves that: SSI tracks who-read-what with predicate locks to enforce SERIALIZABLE. So observe mode registered a serializable transaction under each query, let stock SSI take the locks, and harvested them at commit. Reusing a subsystem hardened over a decade produced a correct graph, at about half the read throughput under load: ~27% overhead at 16 clients, ~53% at 100, worsening as concurrency rose.

Where the cost actually was. The assumption, including in the ticket that filed it, was that the predicate locks cost the throughput: an SIREAD lock on every read, the machinery that makes native SERIALIZABLE expensive. Profiling a busy backend under a pure read workload, before optimizing anything, put taking the locks at 0.2%. Two-thirds of the backend's wall-clock sat in semop at commit, asleep on one cluster-wide lock.

The cost was the teardown. Registering that hidden serializable transaction meant unlinking it at commit, which took one global lock (SerializableXactHashLock) exclusively, every time. At tens of thousands of commits a second, every backend queued behind every other, and the wait grows with concurrency, which is the rising curve in the figure below. Three lines of commit-time bookkeeping, and they bite only under load.

The fix. Postgres keeps predicate locks in shared memory for one reason: so other backends' writes can detect a conflict against your reads. Observe mode never needs that. Nobody reads another backend's locks, and dependency matching happens later, out of process, by (table, primary key), so the registration, the shared lock table and the commit-time teardown all come out. Observe now keeps its read-set backend-local. Same coarsening and promotion logic, per-backend memory, no shared transaction to register, nothing to tear down at commit. Nothing touches shared memory on the hot path.

Read overhead after the change:

Read overhead, before and after. The first pass (shared SSI) climbed to ~53% as concurrency rose. Backend-local capture is flat at ~2-3% from 16 to 100 clients. Same binary, observe on vs off, interleaved and order-controlled on one warm cluster.

The workload is pgbench point-select traffic (-S) at 16 to 100 clients against the production-shape (assertions-off, -O2) build, observe on vs off on the same binary. The harness is test/observe-read-scaling.sh. Writes improved too, since the same teardown is gone for them, settling around ~13%. That residual is the work of streaming the read-set out at commit.

Throughput overhead by capture path, first implementation against the current one, at 100 concurrent clients.
Path (@100 clients)First pass (shared SSI)Now (backend-local)
Write capture (logical decoding)~10%, off commit pathunchanged
Observe, writes~15%~13%, flat
Observe, reads~53%, rising~3%, flat

The completeness harness holds 100% read-edge recall across its adversarial seeds, and the patch passes the isolation (119/119) and regression (231/231) suites on PG 18 with observe off. Flip observe on and a read-heavy workload writes tens of thousands of read-set records to disk. Flip it off and it writes zero.

Observe serializes parallel query. Capture is in one backend and a parallel worker's reads would bypass it, so with observe on the planner runs every query without parallel workers. Point reads and OLTP writes never notice. A big analytical aggregate that used four workers does, and for that workload the lost parallelism dominates the 2-3%. It is settable per role: a role whose reads need no capture (a BI tool, a read-only analyst) turns observe off for itself and keeps full parallelism. Capturing from parallel workers is future engine work.

9. What's next

More profiling. Mixed read/write workloads, larger datasets, longer runs, plus the write path's remaining lever: moving commit-time read-set emission off the backend entirely.

Testing at realistic parameters. The recovery suite already runs against real application schemas: an inventory system at ~1M rows with months of backdated history, a CRM with cross-table cohorts. Next are the dials production turns: bigger databases, longer histories, higher churn, more concurrent writers.

Shrinking the dependency radius. Today's dial is §5's exact/over-approx split plus the index hint. The next one is per-source scoping: keeping reads from a read-only analytics role out of the graph, so the dashboard that scans every table doesn't surface as a dependent of every write.

Storage-compute separation. Today's substrate is unprivileged and runs anywhere (§6), and a restore is a replay, so recovery time grows with how far back you reach. The end-state is a page server in the Neon mold, retaining every version of every page and serving reads as of any point in time. "This table as of 13:59" becomes a lookup rather than a replay, and recovery drops from minutes to seconds. It is a large piece of engineering, so it comes after the launch.

Further reading

Everything above is compressed to fit one scroll. These are the sources behind it.

Try it.