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.
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):
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:
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.