How eterDB works

In two parts: first how Postgres already stores and tracks your data, then how eterDB uses exactly that to undo a single transaction — or a whole incident — on a database that stays online. Most diagrams animate on their own; a few let you drag, scrub, or toggle.

Part 1

The parts of Postgres undo relies on

This isn't a tour of all of Postgres — just the few things eterDB's undo is built on. Two matter most: what happens when you save a change, and how a row is actually stored on disk. Part 2 leans on both, so we cover them first. If you write SQL every day and have never thought about either, that's exactly who this part is for.

Already comfortable with Postgres internals? Skip straight to Part 2 — How eterDB works →

1 · A change is a transaction, and a row lives in a page

First, every change is a transaction. You rarely type the word. Run a bare UPDATE and Postgres silently wraps it in one:

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

Why care, if you never type the word? Because sometimes one action is really several writes that must stand or fall together — a bank transfer is a debit and a credit, and you never want one without the other. That's when you reach for a transaction. You still don't write BEGIN/COMMIT by hand — your ORM's transaction block is the same thing, and it lowers to exactly that:

# your ORM's transaction block…
with db.transaction():
    accounts.debit(7, 100)
    accounts.credit(9, 100)

# …is this underneath:
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 7;
  UPDATE accounts SET balance = balance + 100 WHERE id = 9;
COMMIT;   -- both land — or one fails, neither does

Either all of it lands or none of it does. If anything fails partway, Postgres discards the whole unit as if it never ran; once it commits, the change is durable and survives a crash. That all-or-nothing unit is the natural thing to reverse, which is why eterDB undoes a transaction, not a stray row.

A transfer is staged statement by statement, then committed or rolled back. Watch both rows land together, or not at all, in their pages on disk.

Second, a row lives in a page. A table isn't one long list of rows. On disk it's a stack of fixed-size 8 KB blocks called pages, and every row sits inside one of them. To change a row, Postgres loads its page, edits the bytes, and writes the change to a log first so a crash can't lose it. So every row has a physical address — which page, which slot — and, as the next section shows, that address can move.

2 · A row is a stack of versions, and its address can move

You'd expect an UPDATE to overwrite the row where it sits. It doesn't. Instead it writes a new version and marks the old one dead — so right after the transfer above, row 7 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 still live). A reader sees whichever version was live when its own transaction began, so readers and writers never block each other — this is MVCC, multi-version concurrency control. Play it forward:

Click UPDATE to append versions; drag the reader to a moment; VACUUM to clean up.

That dead version is the row exactly as it was before the change — precisely what an undo restores. The catch: VACUUM, Postgres's background cleanup, eventually reclaims dead versions, and once it runs the old value is gone. So eterDB can't count on the dead version still being there an hour later; it copies that before-image out of the way, covered in capturing writes.

Where a version sits, and why its address changes

That physical address from section 1 has a name — the ctid — and a simple shape. Each page keeps a little array of line pointers at the top, one per version, and the ctid is just which page and which of those slots:

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

Here's the wrinkle that returns in part 2: a ctid is not stable. When a version dies and vacuum frees its slot, a later unrelated row can move in — so the same address can come to mean a different row. Step through exactly that:

Press Step ▸ to walk through an update, a vacuum, and a slot being reused.

Record "row R is at (0,2)", look it up later, and you might read row S instead. eterDB shipped exactly this bug and later fixed it; the read-time fix in part 2 comes back to it once there's enough machinery to explain it.

Part 2

How eterDB works

Now the system itself: how it captures writes, and the reads no backup can see; how it computes a safe undo; and how it recovers from destructive schema changes. Almost all of it runs out of process, because reverts are rare and don't need to be fast. Two caveats up front, both detailed below: eterDB is its own engine build — you run it as your Postgres, not on top of a managed one — and recording reads carries a measured overhead.

1 · The shape of the system

Start with the layout before the mechanics. Just one job has to run inside the Postgres process: capturing predicate reads (see the dependency a backup can't see). A predicate read leaves nothing behind once the query finishes, and it's only visible inside the backend that ran it, so nothing outside the process can observe it. Everything else runs outside the engine. Write history lives in a sidecar, storage snapshots in another, and the dependency graph and undo logic in extensions and a separate metadata store.

This split is on purpose. Reverts are rare and don't need to be fast, so anything not required at commit time is kept off the hot path and out of the engine. The figure traces it: writes and the read-set stream out across the engine boundary after each commit, and the revert path flows back in only on demand. 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. The amber box is the only in-engine piece. Hover for detail.

The in-engine footprint is small: the read-capture hook, an append-only file the reads are written to, the patch's transient predicate-lock bookkeeping, and the functions that apply compensating DML. The dependency graph is not built in the engine; the captured reads are forwarded to the store and the graph is derived there, only when an undo is requested.

Building the graph on demand sets an honest — and reassuring — expectation about revert latency. What it costs depends on the transaction, not the database:

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

So a months-old target on a million-row database costs about the same as a fresh one with the same dependency fan-out. The work shows up only at revert time, where seconds are fine — never at commit time, where they aren't.

2 · Capturing writes

Undoing a write needs the row as it was before. Since vacuum will eventually erase the dead version (row versions), eterDB has to record the before-image at commit time and keep it. It gets before/after pairs from Postgres logical decoding (the same stream that drives logical replication), which reads the WAL, the append-only log every change is written to first.

The WAL normally records just enough to redo a change, not the whole prior row. To capture a true before-image, eterDB sets REPLICA IDENTITY FULL on the tables you want reversible — one statement per table, which tells the WAL to log the entire old row:

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

Toggle it in the figure and watch what reaches the history:

Toggle the replica identity to see whether the before-image survives, and what it costs.

This happens after commit, not during it. The transaction commits at normal speed; the history record follows from the WAL. A sidecar reads the replication slot out of process and appends to the metadata store.

That before-image isn't free, as the meter in the figure shows: REPLICA IDENTITY FULL writes the entire old row into the WAL on every update, not just the key. On tables with wide rows or large TEXT/JSONB values that means real write amplification and more work for the capture sidecar (TOASTed values are logged only when they change, which softens the worst case but doesn't remove it). The trade-off is explicit and per-table: a table you don't need to undo can keep the default identity and skip the cost, at the price of not being row-level reversible.

The single-process demo and the test suite can fall back to an in-database trigger as an oracle, but logical decoding is the shipping path. test/capture-diff.sh checks that the two produce identical history on the same workload, that undo still works with the trigger removed, and that restarting the sidecar doesn't double-apply.

3 · The dependency a backup can't see

This is the part nothing outside the engine can reconstruct. Suppose 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 — wrong, with nothing to flag it. eterDB records these read-dependencies so it can either undo B as well or refuse and report it. And reads are the hard part: a write leaves a row, but a read leaves nothing, so triggers and CDC never see it. Switch this figure between a backup's view and eterDB's, then press undo:

Flip the view, then press Undo transaction A. Watch what each one can know about B.

eterDB doesn't add new tracking to see a read that leaves no trace; Postgres already has it. To enforce SERIALIZABLE, the engine records which transactions read data another wrote, using predicate locks and the rw-antidependency edges between transactions. The patch reads that same data in observe mode, under ordinary READ COMMITTED:

Terms in this section
Isolation level
How strictly Postgres pretends concurrent transactions ran one after another. READ COMMITTED (the default) is loose and fast; SERIALIZABLE is strict and will reject a transaction rather than let it break that illusion.
SSI (Serializable Snapshot Isolation)
Postgres's algorithm for SERIALIZABLE. To enforce it, the engine already tracks which transactions read data that another transaction wrote. eterDB reuses that tracking.
Predicate lock
Not a lock that blocks anyone. It's a record that a transaction read the rows matching some condition. It's how SSI knows what was read, even though a read leaves no row behind.
rw-antidependency (read-dependency)
An edge that means "A read some data, and B wrote a version of it A didn't see." This is the relationship that makes undoing A alone unsafe, and the thing eterDB captures.
40001
The error Postgres returns under SERIALIZABLE when it can't let a transaction commit safely. The app is expected to retry. Observe mode produces none of these.

Lock coarsening can make the graph over-approximate, occasionally recording a dependency that wasn't really there. Coarsening only ever adds candidate edges, never drops a real one, so it costs an extra review, not a missed dependency. Completeness itself — that no genuine read-edge is ever lost — is the property the adversarial harness in the aside below exists to gate. Computing an undo shows how each edge is labelled.

4 · 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 instead of guessing and gives you the choice.

Because history lives in a separate store, an undo spans two databases — but the part that must be atomic stays in one place. eterDB reads the plan and before-images from the store, then applies all the compensating DML to your database inside a single transaction. Every failure mode then resolves the safe way:

Switch modes to see which transactions get reverted, skipped, or left to diverge.

A cohort undo (eter undo_cohort) reverses every write since an incident marker. If one transaction in the cohort has a live dependent, it doesn't abort the whole batch; it reverts the clean ones and returns reverted_txns, skipped_dependent, and skipped_txids, so you or the agent decide what to do with the rest. Every command supports --json and has stable exit codes.

Reading a blocked undo

When a transaction is dependent, a yes/no answer isn't enough — you need to see what depends on it. Say a config write (txid 4821) was read by later transactions before you caught it. preview returns the full blast radius:

$ 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"
}

There's a real worry here: on a busy database a single write to a hot row (a config, a feature flag) is read by many later transactions, and its dependent set grows fast. Two things keep that from collapsing into "everything depends on everything":

Per-source scoping (for example, ignoring reads from read-only analytical roles so they never enter the graph) is a natural next lever and isn't built yet. Today the dial is the exact/over-approx split plus the index hint.

One boundary to be clear about: eterDB reverses database state. If a reverted row already caused an external effect — a charged card, a sent email — that surfaces under external_refs in the same preview, but eterDB doesn't claim to undo it.

5 · Schema changes and time travel

Row-level undo can't restore a dropped table. A DROP isn't row data — the heap is simply gone, and no before-image in eter.history covers it. Recovering from destructive DDL needs a storage answer, and our first one was ZFS: put the data directory on a ZFS dataset and let a sidecar take copy-on-write snapshots. A snapshot copied nothing up front, a clone materialized in milliseconds, and recovery was clone + WAL replay + extract. It worked.

Then we tried to deploy it. ZFS is a host kernel module: it demanded a privileged container with /dev/zfs passed through, on a Linux host you control — no Cloud Run, no Fargate, no Autopilot, and a "managed Postgres" whose storage tier dictates your host kernel. That is a disproportionate price for destructive-DDL recovery — a rare event — paid by every deployment, always.

The way out was noticing that 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 of it was substrate-agnostic. ZFS supplied only two things, the base image (a snapshot) and its materialization (a clone), and both have standard, unprivileged Postgres equivalents. So today the sidecar 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.

Ship the deploy, then recover the table — or scrub the slider to read the database as of a past moment.

A dropped table, dropped column, or pre-migration value is recovered from a copy: the live database stays up and is never restored over. Destructive DDL is recorded by an event trigger in eter.ddl_log together with the WAL position it happened at, so eterDB knows exactly how far to replay — one instant before the drop.

6 · How to deploy it

eterDB isn't an extension you install into somebody else's Postgres — it is the Postgres, a patched engine build (observe mode, plus the eter_ssi background worker). Aurora runs on Aurora and Neon runs on Neon; eterDB runs on eterDB. So the question that matters isn't which managed database it runs on — it's what deploying it takes. For a while, the honest answer was embarrassing:

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

  • A container runtime — any: Kubernetes, Cloud Run, Fargate, Docker Desktop on a Mac
  • A 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: a few small containers sharing one volume. The engine image — the patched build ships inside it, you never compile Postgres. The capture sidecar from §2 — part of the stack, not an option: the engine runs with no history trigger, and the slot is created at first boot so capture is gap-free even if the sidecar starts late. The orchestrator image, which carries the storage machinery. And the metadata store — a separate small Postgres the stack owns, holding history, the dependency graph and the backup catalog: that data exists to survive the tenant having a very bad day, so it never lives inside the tenant. The CLI and agents talk to one URL, the orchestrator's API; recoveries run as managed background jobs — they take minutes, so they're queued, run one at a time, and report crashes honestly instead of hanging a session.

For the closed alpha we run that stack for you — a hosted instance, a database per tenant; you connect over the normal Postgres wire protocol with any driver, and the patched engine and sidecars are our problem, not yours. Self-hosting is the same ingredients 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 capture sidecar and the orchestrator next to it.

7 · Trade-offs and overhead

A few things worth being clear about, including where the overhead actually landed. The capture of writes is nearly free and off the commit path; the cost is in observe mode, which records read-dependencies. Here's where that cost shows up, stock Postgres against eterDB on the same transaction:

Both engines run the same transaction at the same speed; eterDB's bar is longer by exactly the read-dependency work — the amber segments.

Near-stock is a compatibility claim, not a speed one. This is stock Postgres with a small, upstream-tracked patch set — every driver, ORM, and extension still works, and the patch passes the full regression and isolation suites on both 16.14 (isolation 117/117, regress 220/220) and 18.4, the two version patches differing only in context lines. What it costs is measured, not hand-waved (single host, -O2, median of three runs, PG18.4):

Path@16 clients@100 clientsTrend
Write capture (logical decoding)~10%~10%off the commit path
Observe — writes~20%~15%flat with load
Observe — reads~27%~53%rises with load

The split is the point. Write capture rides logical decoding off the commit path, so it never touches transaction latency. Observe writes stay flat because the commit-time harvest was rewritten to walk only the transaction's own predicate locks, not the whole cluster's. Observe reads are the honest weak spot: recording a read means taking an SSI predicate lock, and that lock manager contends under high read concurrency — the same pressure that makes native SERIALIZABLE expensive under load. Lowering that acquisition cost is the active work; the guarantee throughout is zero serialization aborts.

Observe-mode overhead as read concurrency climbs: reads rise with load (SIREAD predicate-lock contention) while writes stay flat. Measured with an interleaved, order-controlled A/B on one warm cluster — so the rise is the mechanism, not thermal or measurement drift.

That read curve is surprising enough that it's worth saying how we know it's real and not a laptop artifact. The measurement holds one warm cluster and flips observe mode on and off between tightly interleaved runs (so any drift cancels), then repeats each level with the order reversed — running observe first — and gets the same number. During an observe-on read run, Postgres' own pg_locks shows the predicate locks being held; with observe off, none. The overhead is those predicate locks, and nothing else.

It's still Postgres, extensions included. A 13-extension matrix — pgvector among them, built from source — loads, smoke-tests, and passes its own pg_regress suite on the patched build, and works alongside capture: a read served through a GIN, GiST, or HNSW index is recorded as a dependency, and a clean write through any access method undoes correctly. Two rough edges to name: pgcrypto needs an OpenSSL-enabled build, and hstore round-trips through the sidecar history path but not the older in-database trigger path (where it becomes a jsonb shape the compensation code can't reverse — the sidecar is the shipping path).

Build fearlessly.

Closed alpha. Real Postgres with surgical undo — reverse any change down to the exact rows it touched, while it stays live.