The databasePart 3 of 6

Time-Travel für Code UND Daten: die Datenbank-Fehler eines KI-Agenten rückgängig machen

Ein KI-Agent führt mit voller Überzeugung eine Migration aus, die die falsche Spalte droppt. Git gibt dir ein Undo für Code; für Daten gibt es keins. So funktioniert Time-Travel für Code und Daten auf Copy-on-Write-Postgres-Timelines — der Checkpoint am LSN, das Copy-on-Write-Restore und die Cache-Kohärenz, die das Repointen des Compute korrekt hält.

Time-Travel für Code UND Daten

Ein KI-Agent schreibt Code, und dafür haben wir ein großartiges Undo: es heißt git. Jede Generierung ist ein Commit; „zurückgehen" ist ein Checkout. Gelöst.

Aber Agenten schreiben nicht nur Code — sie führen Migrationen aus, befüllen Tabellen und setzen UPDATEs ab. Und dort wird „zurückgehen" zu einem Backup-Restore-Vorfall. Ein Agent, der die falsche Spalte droppt oder ein UPDATE ohne WHERE ausführt, hat etwas getan, das git nicht anfassen kann. Für autonome, schnell agierende Agenten sind Daten die ungeschützte Fläche.

Also haben wir der Datenbank dieselbe Eigenschaft gegeben, die git dem Code gab: point-in-time-Undo, an dieselben Checkpoints gebunden. Du klickst „Restore" auf einer vergangenen Version, und dein Schema und deine Zeilen rollen gemeinsam mit dem Code zurück — zu einem Zustand, der tatsächlich gelaufen ist.

Dieser Beitrag erklärt, wie das funktioniert: das Speichermodell, das es günstig macht, der Checkpoint, das Copy-on-Write-Restore und die Cache-Kohärenz hinter dem Repointen des Compute.


Warum physisches PITR statt „die Migrationen erneut abspielen"

Die naive Variante von Daten-Undo ist logisch: behalte das DDL, führe die Down-Migrationen aus. Das verliert deine Zeilen, kann beliebige Mutationen nicht abbilden und divergiert von der Realität in dem Moment, in dem irgendetwas außerhalb einer Migration schreibt. Wir wollten die tatsächlichen Bytes der Datenbank, so wie sie zu einem Zeitpunkt waren — jede Zeile, jeden Index, jede Sequenz und jeden Katalogeintrag — exakt rekonstruiert.

Das ist physisches point-in-time-Recovery, und es ist nur dann günstig, wenn Speicher und Compute getrennt sind. Adorable betreibt ein self-hosted Neon: der pageserver speichert versionierte Pages + WAL im Object Storage; das Compute ist ein zustandsloses Postgres, das Pages bei Bedarf vom pageserver liest.

postgres wire
proj_{id}

scale 0→1
route

pages / WAL

WAL

creds / timeline

App / agent

neon-conn-proxy
Rust, :5432

neon-compute
stateless PG

pageserver

safekeeper

Garage S3
versioned pages

Vault

Eine Timeline ist die Einheit der Historie: eine Postgres-Datenbank, rekonstruiert aus Page-Versionen + WAL bis zu einem gegebenen LSN (Log Sequence Number — Postgres' monotoner Byte-Offset in den WAL). Eine Timeline zu branchen ist Copy-on-Write: das Kind teilt sich die unveränderten Pages des Elternteils und schreibt eine Page erst dann, wenn es divergiert. Eine zu erzeugen ist eine O(1)-Metadatenoperation, unabhängig von der Datenbankgröße.

Dieses eine Primitiv — eine Timeline an einem bestimmten LSN von einer anderen branchen — ist das ganze Feature. Alles weiter unten ist Plumbing drumherum.


Ein Checkpoint ist ein Zwei-Feld-Marker, keine Kopie

Bei jedem Generierungs-Commit zeichnen wir einen Checkpoint auf. Die entscheidende Designentscheidung: ein Checkpoint forkt nichts. Es ist eine Zeile, die sich merkt, welche Timeline und welcher LSN(timeline_id, lsn) — plus den git-Commit, dem sie entspricht. Es wird kein Speicher allokiert; der Fork wird lazy materialisiert, nur falls du jemals ein Restore durchführst.

Der Hook sitzt am Ende der Generierungs-Pipeline, direkt nach dem git-Commit, und ist strikt best-effort — ein Time-Travel-Fehler darf niemals einen Build kaputt machen:

const commitResult = await commitGeneration(projectDir, { userPrompt, runSummary });
if (commitResult?.commitHash) {
  await recordCheckpointSafe(projectId, { commitSha: commitResult.commitHash });
}

recordCheckpoint liest den aktuellen Head-LSN der Timeline vom Proxy und schreibt eine Zeile in project_db_branches:

const lsnInfo = await getBranchLsn(projectId);          // GET /api/branches/{id}/lsn
if (!lsnInfo?.timelineId) return null;                  // frontend-only app: nothing to tag
return recordBranch(projectId, {
  name: checkpointBranchName(commitSha),                // ckpt-<sha12>
  kind: "checkpoint",
  timelineId: lsnInfo.timelineId,
  createdLsn: lsnInfo.lastRecordLsn,
  commitSha,
  servesEnv: null,                                      // serves no compute until restored
});

Welchen LSN ein Checkpoint pinnt

recordCheckpoint pinnt den last_record_lsn des pageservers — den High-Water-Mark des WAL, den der pageserver durabel aufgenommen hat — einmalig gelesen über getBranchLsn. Dieser LSN hinkt dem jüngsten Commit des Compute um genau den WAL hinterher, der noch in flight ist (compute → safekeeper → pageserver), sodass ein Checkpoint alles erfasst, was in diesem Moment durabel im Page-Store liegt, nicht einen Write, der noch nicht angekommen ist:

compute commit ─(WAL in flight)→ safekeeper ─→ pageserver: last_record_lsn
                                                ▲ checkpoint pins this durable high-water mark

In der Praxis ist das die richtige Grenze: Checkpoints werden an Generierungs-Commits genommen (recordCheckpointSafe läuft nach commitGeneration), und zu diesem Zeitpunkt haben sich die Writes des Laufs zum Page-Store propagiert — sodass ein späterer Fork am LSN des Checkpoints exakt den Zustand wiederherstellt, den er dort hatte.

Checkpoints akkumulieren (einer pro Commit), daher prunt recordCheckpoint auf die neuesten CHECKPOINT_KEEP (50, via env einstellbar) Zeilen. Es sind Marker, also gibt das Prunen einer Zeile nichts frei — die Restore-Tiefe wird vom PITR-Fenster von Neon bestimmt, das der eigentliche Retention-Regler ist.


Restore = Fork am LSN, dann das Compute repointen

Auf Checkpoint C wiederherzustellen sind zwei Schritte: (1) den historischen Zustand als neue Copy-on-Write-Timeline materialisieren, (2) das laufende Compute darauf zeigen lassen.

timeline T (dev):  ──S0──●──S1──▶          ● = checkpoint C's LSN (post-S0, pre-S1)
                          │
                          └─ fork ──▶  timeline T' (new dev)   ← compute repointed here
                                       contains S0, NOT S1

Das dev-Compute serviert jetzt T' (Daten bei S0). T bleibt unangetastet — Restore ist non-destruktiv, du kannst also wieder vorwärts rollen. Hier ist der vollständige Pfad (die Time-Travel-Schicht des Backends → ihr Neon-HTTP-Client → der Rust-Proxy):

K8s (compute Deployment)Vaultpageserverneon-conn-proxybackend (time-travel)K8s (compute Deployment)Vaultpageserverneon-conn-proxybackend (time-travel)POST /branches/{id}/fork {lsn, ancestorTimelineId}create_timeline(ancestor_timeline_id, ancestor_start_lsn)T' (copy-on-write, O(1)){timelineId: T'}POST /branches/{id}/repoint {timelineId: T'}put_branch_credentials(timeline=T') %% Vault + in-mem cache, atomicallyscale compute 0 (force_sleeping + stop)rebuild ConfigMap(neon.timeline_id=T') + scale 1readyrecordBranch(dev, T') + setServingBranch(dev)

Der Fork ist das pageserver-Primitiv, durch den Proxy exponiert. Im pageserver-Client des Proxy nimmt create_timeline einen optionalen Ancestor:

if let Some(ancestor) = ancestor_timeline_id {
    body["ancestor_timeline_id"] = json!(ancestor);
    if let Some(lsn) = ancestor_start_lsn {
        body["ancestor_start_lsn"] = json!(lsn);   // pin the branch point
    }
}

Der explizite ancestorTimelineId ist wichtig für Cross-Timeline-Restore: nach einem Restore ist die aktive Timeline T'; eine Wiederherstellung zu einem älteren Checkpoint (auf T) muss T forken, nicht die aktuelle Timeline. Die Checkpoint-Zeile trägt die Timeline-ID, sodass restoreToCheckpoint exakt den richtigen Ancestor forkt.


Cache-Kohärenz: das Repointen gehört dem Proxy

Der zweite Schritt — das Compute auf die neue Timeline zeigen lassen — hat eine nicht offensichtliche Anforderung, bei der es sich lohnt zu verweilen, denn es ist genau die Art Fehler, die sich leicht unbemerkt einschleicht.

Die Compute-Spec wird aus den in Vault gespeicherten Branch-Credentials neu gebaut: (timeline_id, password, …). „Das Compute auf T' zeigen lassen" bedeutet also „die Timeline in den Credentials setzen, dann das Compute neu starten, das sie liest und auf T' bootet."

Der Haken: der Proxy cached Branch-Credentials 5 Minuten lang im Speicher (CACHE_TTL = 300s), um Vault aus dem Hot Path herauszuhalten. Ein Write, der direkt nach Vault geht — etwa vom Backend — lässt diesen Cache stale, und das nächste Wake baut das Compute aus der gecachten (alten) Timeline neu. Alles sieht gut aus — der Fork existiert, die DB-Branch-Zeile zeigt darauf, das Compute rebootet — während sich die Daten nie bewegen. Ein Cache-Miss, den du nicht sehen kannst.

Deshalb gehört das Repointen dem Proxy, nicht dem Backend. POST /api/branches/{id}/repoint ruft put_branch_credentials auf, das in einer Operation Vault schreibt und den Cache aktualisiert, und startet dann das Compute auf der neuen Timeline neu:

proxy-owned /repoint

proxy: put_branch_credentials = T'

Vault + cache updated together

stop → rebuild spec → start

compute boots T'

backend-driven (stale cache)

backend writes Vault = T'

proxy cache still = T
5-min TTL

wake builds spec from cache

compute boots T

Das Prinzip lässt sich verallgemeinern: wenn ein Wert hinter einem Service gecached wird, mutiere ihn durch diesen Service. Ein Write, der am Cache vorbeiläuft, ist ein Write auf einer Uhr, die du nicht kontrollierst.

Eine Anmerkung zum Swap

repoint skaliert das Deployment auf 0, dann auf 1; das neue Compute bootet auf T', und die Bridge des Proxy verbindet die Clients neu damit. Eine Verbindung, die sich in dem kurzen Fenster neu etabliert, während der vorherige Pod noch drained, landet auf der wiederhergestellten Timeline, sobald das neue Compute serviert — sodass Aufrufer den Post-Restore-Zustand lesen, nachdem der Swap abgeschlossen ist. (Deshalb pollt die Verifikation weiter unten das Ergebnis, statt einmalig zu lesen.)


Wie es verifiziert wird

Der Pfad Fork → Repoint → Reboot ist kein Sonderfall für Time-Travel — es ist dasselbe Branch-at-LSN-Primitiv, auf dem Deploy und Rollback fahren, sodass jede Production-Promotion es im Feld durchexerziert. Die Logik für Checkpoint, Fork und Post-Restore-Reconciliation ist durch die Branch- und Restore-Reconcile-Test-Suites der Plattform abgedeckt (db-branches.test.js, db-restore-reconcile.test.js), die die echten Branch-Operationen hochfahren, statt sie zu mocken.


Design-Grenzen

Die Restore-Tiefe ist das PITR-Fenster. Checkpoint-Zeilen sind günstig, aber du kannst nur einen LSN forken, den der pageserver noch vorhält, sodass die Frage, wie weit zurück du wiederherstellen kannst, vom History-Fenster von Neon bestimmt wird. Das Limit für Checkpoint-Zeilen und das PITR-Fenster werden mit der Tiefe, die wir anbieten, abgestimmt gehalten.


Warum es sich lohnt

Für einen Menschen ist das ein netter Undo-Button. Für einen Agenten ist es näher an einer Voraussetzung: es ist das, was selbstbewusste, schnelle, irreversible Datenoperationen sicher delegierbar macht. Der Agent kann die beängstigende Migration ausführen, weil die Plattform sie zurücknehmen kann — Code und Daten, zu einem Punkt, der tatsächlich gelaufen ist. Git hat das vor langer Zeit für Code getan. Die Datenbank brauchte nur dasselbe Primitiv: einen Branch an einem LSN.

Bau auf der Plattform, die diese Beiträge beschreiben.

Beschreib deine App in einfachem Deutsch — Adorable schreibt den Code, richtet die Datenbank ein und bringt sie live.

Kostenlos loslegen