Was ein Rollback löschen darf
„Roll das Projekt auf gestern zurück" klingt nach einer Operation. Ist es nicht. Ein Projekt auf dieser Plattform verteilt sich über drei Stores mit völlig unterschiedlichen Modellen für Haltbarkeit und Identität:
- Quellcode — Dateien in einem projekteigenen git-Repo
(
/data/projects/{id}), ein Working Tree, der jede App unterapps/{slug}/enthält. - Runtime — Kubernetes Deployments, Services und Ingress-Regeln im Namespace des Projekts, generiert aus einer Spec.
- Daten — ein Copy-on-Write-Postgres-Branch auf geteiltem Neon-Storage, plus
die relationalen Zeilen (
project_apps, Plan-Revisionen), die beschreiben, was existiert.
Ein Rollback muss alle drei dazu bringen, sich auf denselben Moment in der Vergangenheit zu einigen. Die Wiederherstellung selbst — diesen Commit auschecken, die Neon-Timeline an jenem LSN forken — ist die einfache Hälfte. Die Hälfte, die tatsächlich darüber entscheidet, ob man dem Time-Travel trauen kann, ist der Teardown: Wenn du auf einen Punkt zurückrollst, bevor ein Feature existierte — was löschst du physisch, und wie garantierst du, dass du wieder vorwärtsgehen kannst?
Mach es in die eine Richtung falsch, und der Rollback ist eine Lüge — die Spec sagt, das Feature sei weg, aber sein Code liegt noch auf der Platte und sein Container bedient noch Traffic. Mach es in die andere Richtung falsch, und der Rollback ist destruktiv — du löschst etwas, das du nicht zurückholen kannst, und „umkehrbares Time-Travel" ist es klammheimlich nicht mehr.
Dieser Beitrag ist die Regel, auf die wir uns geeinigt haben, und warum jeder Store ihr gehorcht.
Die Form des Problems
Konkret: Ein Projekt ist eine Todo-App. Später wächst ihr eine zweite App zu —
ein Blog — mit eigenem Quellcode unter apps/blog/, eigenem app-blog
Deployment und Service, einer Routing-Regel auf dem geteilten Preview-Ingress
und ein paar Zeilen in Postgres. Dann rollt der Nutzer auf einen Snapshot
zurück, der aufgenommen wurde, bevor der Blog existierte.
Was „vor dem Blog" pro Store bedeutet:
target snapshot now
git source ───────────●──────────────────────● blog files committed after ●
k8s runtime ───────────●──────────────────────● app-blog Deployment running
neon data ───────────●──────────────────────● blog tables written after ●
project_apps ───────────●──────────────────────● blog app row created after ●
Jeder Store muss am linken Punkt landen. Aber sie rollen nicht auf dieselbe Weise zurück, und die naive Pro-Store-Operation ist für zwei der drei falsch.
Quellcode: „die Dateien wiederherstellen" ist nicht „den Commit treffen"
Der Reflex ist git checkout <target> -- .. Er ist falsch, und zwar auf subtile
Weise.
git checkout <commit> -- <pathspec> aktualisiert den Working Tree für Pfade,
die in <commit> existieren. Eine Datei, die nach dem Ziel hinzugefügt
wurde — jede Blog-Quelldatei — ist nicht im Ziel-Tree, also erwähnt checkout sie
schlicht nicht. Sie bleibt auf der Platte. Du machst dann git add -A und
committest, und der „Restore"-Commit hält brav einen Tree fest, der den Blog
immer noch enthält. Der Rollback lief, der Diff sah plausibel aus, und nichts
wurde entfernt.
Der Fix ist, den Working Tree exakt mit dem Ziel zur Deckung zu bringen, Löschungen inklusive. Wir erfassen die Menge der Pfade, die zwischen Ziel und HEAD hinzugekommen sind, und entfernen sie explizit:
// project-git.js — restoreVersion()
// Files present in HEAD but absent in the target tree (added after the target).
const addedSince = await git(dir, ["diff", "--name-only", "--diff-filter=A", target, "HEAD"]);
await git(dir, ["checkout", target, "--", "."]); // restore modified/deleted paths
if (addedSince.length) {
await git(dir, ["rm", "-f", "--ignore-unmatch", "--", ...addedSince]); // remove added-since
}
await git(dir, ["add", "-A"]);
await git(dir, ["commit", "-m", `restore: reverted to ${short}`]);
(git read-tree -u --reset <target> macht dasselbe in einem Zug; wir behalten
das explizite Checkout-plus-Löschen, weil der Fehler, den es behebt, exakt darum
geht, welche Dateien nicht erwähnt werden, und der Code sich genau als diese
Absicht liest.)
Ein Detail ist hier enorm wichtig, und wir kommen darauf zurück: das Restore ist ein neuer Commit obendrauf auf HEAD. Wir bewegen nie den Branch-Pointer und schreiben nie History um. Der Commit vor dem Rollback — der mit dem Blog — ist im Objektgraph weiterhin voll erreichbar. Genau diese eine Tatsache macht das Löschen der Dateien sicher: Sie sind nicht weg, sie sind einen Commit entfernt.
Dasselbe Checkout treibt auch die Index-Pflege an. Jeder geänderte Pfad — die
gerade gelöschten eingeschlossen — wird an reindexFiles gefüttert, das
verschwundene Dateien aus dem Code-Graph und dem Vektorindex entfernt, statt
hängende Embeddings zurückzulassen:
// file-sync.js — reindexFiles()
if (content === null) { // file gone after the checkout
await fanoutSyncRemove(projectId, projectRelPath);
scheduleVectorFlush(projectId, projectRelPath, null);
}
Der Quellcode-Store erreicht das Ziel also durch Löschen, das aus der History wiederherstellbar ist. Behalte den Gedanken im Hinterkopf.
Runtime: nicht wiederherstellen, sondern regenerieren
Das app-blog Deployment des Blogs läuft noch. Du könntest versuchen,
„Kubernetes zurückzurollen" — aber es gibt nichts, worauf man zurückrollen
könnte; K8s hat keine History. Und du brauchst keine, denn die Runtime ist
kein Store of Record. Jedes Deployment, jeder Service und jede Ingress-Regel
ist abgeleitet — vom Manifest-Generator bei jedem Start aus der Spec des
Projekts generiert.
Das verschiebt die Frage von „wie stelle ich die Runtime wieder her?" zu „wie sorge ich dafür, dass die Ableitung den Blog nicht mehr einschließt?". Die Spec wird aus der Live-App-Liste gebaut, und diese Query ist gefiltert:
-- listProjectApps(): the spec only ever sees live apps
SELECT id, slug, is_primary, fs_dir_name
FROM project_apps
WHERE project_id = $1 AND archived_at IS NULL;
Sobald also die Zeile des Blogs archiviert ist, fällt sie aus der Spec heraus,
und die nächste Manifest-Generierung emittiert app-blog und seine
Ingress-Regel schlicht nicht mehr. Die Runtime konvergiert durch
Regenerierung, nicht durch Wiederherstellung.
Ein expliziter Schritt bleibt: Das Archivieren einer Zeile entfernt die App aus künftigen Manifesten, aber das Deployment, das bereits läuft, läuft weiter, bis etwas es löscht. Also reißt das Reconcile die Live-Runtime jeder archivierten App ab — und sonst nichts:
// orchestrator/cleanup.js — teardownApp()
export async function teardownApp(projectId, name) {
if (!name || name === "app") return { ok: false, reason: "refusing to tear down the primary app" };
await deleteIfExists(() => appsApi.deleteNamespacedDeployment({ name, namespace: ns }), ...);
await deleteIfExists(() => coreApi.deleteNamespacedService({ name, namespace: ns }), ...);
}
Zwei Leitplanken sind tragend. Es weigert sich, die primäre app anzufassen —
die Anker-App des Projekts wird von einem Pro-App-Reconcile niemals abgerissen.
Und es ist idempotent (deleteIfExists schluckt 404), denn ein Reconcile muss
gefahrlos zweimal laufen können. Abgeleitete Runtime zu löschen verliert nichts:
Ein Roll-Forward fügt die Zeile wieder hinzu, und der nächste Start regeneriert
die Manifeste wortgetreu.
Daten: das Branch-Restore hat es schon erledigt
Der Blog schrieb Tabellen in Postgres. Aber Apps teilen sich eine Neon- Datenbank pro Projekt, und der kombinierte Rollback stellt diese Datenbank wieder her, indem er den Branch am LSN des Snapshots forkt. DDL und DML nach diesem LSN — die Tabellen des Blogs und ihre Zeilen — liegen schlicht auf der anderen Seite des Forks. Es gibt keine Pro-App-Daten zu löschen, denn point-in-time ist die Löschung. (Genau deshalb muss ein reines Daten-Restore zuerst auf Schema-Drift prüfen — den Branch zurückzudrehen dreht auch das Schema zurück. Das ist ein eigener Beitrag.)
Was nicht hart gelöscht wird, ist der relationale Anker — die project_apps-
Zeile. Wir archivieren sie weich (archived_at = now()), niemals DELETE.
Die Zeile ist die Identität, die die drei Stores zusammenhält; sie zu behalten
ist das, was einem Roll-Forward erlaubt, die App zu entarchivieren und das Ganze
zurückzubringen. Sie hart zu löschen würde jede History verwaisen lassen, die auf
sie verwies, und Vorwärtsreisen unmöglich machen.
Die Invariante
Tritt einen Schritt zurück, und die drei Stores gehorchen einer Regel. Ein Rollback darf eine Sache nur dann physisch löschen, wenn diese Sache:
- Aus der git-History wiederherstellbar ist — Quelldateien. Der Commit vor dem Rollback bleibt erreichbar, also ist die Löschung einen Pointer von der Rücknahme entfernt.
- Aus der Spec regenerierbar ist — K8s Deployments, Services, Ingress-Regeln. Abgeleiteter Zustand; sie zu löschen ist kostenlos, weil der nächste Start sie wieder aufbaut.
Alles andere wird weich archiviert, nicht zerstört — die project_apps-
Zeile, die früheren Plan-Revisionen. Und geteilte Daten werden gar nicht
gelöscht; sie werden durch den Branch-Fork zurückgedreht, der selbst umkehrbar
ist (der Zustand vor dem Rollback wird vor dem Fork als Roll-Forward-Snapshot
festgehalten).
Umkehrbarkeit ist kein obendrauf geschraubtes Feature — sie ist die Eigenschaft, die überhaupt erst definiert, was sicher zu löschen ist. Wenn du es nicht zurückbekommst, löschst du es nicht; du archivierst es, oder du drehst es zurück, statt es zu löschen.
Die eine Sache, die nicht sicher ist
Die Invariante hat eine scharfe Kante, und es lohnt sich, sie klar auszusprechen, damit sie nicht auf die harte Tour entdeckt wird: Die Regel hält nur für Zustand, der entweder in git ist oder aus der Spec abgeleitet wird. Alles, was keines von beidem ist, darf von einem Rollback nicht blind gelöscht werden:
- Dateien, die nie committet wurden — ein vom Nutzer hochgeladenes Asset, das im
public/einer App liegt und das keine Generierung erfasst hat. Der git reset würde es entfernen, ohne einen Commit, aus dem es sich zurückholen ließe. - Objekte in einer Pro-App-externen Ressource — ein Storage-Bucket, in den die App direkt geschrieben hat. Der Neon-Branch-Fork dreht die Projektdatenbank zurück, aber er weiß nichts vom Inhalt eines S3-Buckets.
Für beide ist der richtige Zug derselbe wie bei der relationalen Zeile: erfasse es, bevor du es entfernst (committe untracked Dateien in den Roll-Forward-Punkt; snapshotte externe Ressourcen) — oder lass sie in Ruhe. Die Invariante ist präzise über ihre eigene Grenze: wiederherstellbar oder abgeleitet — und wenn ein Stück Zustand keines von beidem ist, machst du es wiederherstellbar, bevor ein Rollback es anfassen darf.
Warum diese Form
Der tiefere Grund, warum das alles funktioniert, ist, dass wir eine strikte Trennung zwischen Stores of Record und abgeleitetem Zustand halten und einem Rollback nie erlauben, die beiden zu verwechseln. Quellcode und der relationale Anker sind Records — sie werden mit erhaltener History zurückgedreht. Die Kubernetes-Runtime ist abgeleitet — sie wird regeneriert. Geteilte Daten sind ein Record mit eigenem Time-Travel-Primitiv — sie werden per Branch-Fork zurückgedreht. Die einzige Aufgabe des Rollbacks ist, jeden Store mit dessen eigener umkehrbarer Operation auf denselben Augenblick zu bewegen und sich zu weigern, irgendetwas zu zerstören, das er nicht rekonstruieren kann.
Lande das, und „geh zurück auf gestern" hört auf, ein hoffnungsvoller Einzeiler zu sein, und wird, was es sein sollte: ein kohärenter, umkehrbarer Zug über drei Stores, die sich die Vergangenheit jeweils auf ihre eigene Weise merken.