Что откату позволено удалять
«Откати проект на вчера» звучит как одна операция. Это не так. Проект на этой платформе разнесён по трём хранилищам с совершенно разными моделями надёжности и идентичности:
- Исходники — файлы в отдельном git-репозитории проекта
(
/data/projects/{id}), одно рабочее дерево, содержащее каждое приложение подapps/{slug}/. - Рантайм — Kubernetes Deployments, Services и правила Ingress в неймспейсе проекта, сгенерированные из спеки.
- Данные — ветка Postgres с копированием при записи на общем хранилище Neon,
плюс реляционные строки (
project_apps, ревизии плана), описывающие, что вообще существует.
Откат должен заставить все три хранилища сойтись на одном и том же моменте прошлого. Само восстановление — сделать checkout этого коммита, форкнуть таймлайн Neon на нужном LSN — это лёгкая половина. Половина, которая реально решает, можно ли доверять путешествию во времени, — это снос: когда вы откатываетесь к точке, где фичи ещё не было, что именно вы физически удаляете и как гарантируете, что снова сможете двинуться вперёд?
Ошибитесь в одну сторону — и откат окажется ложью: спека говорит, что фичи нет, но её код всё ещё лежит на диске, а её контейнер всё ещё обслуживает трафик. Ошибитесь в другую сторону — и откат окажется разрушительным: вы удалите то, что не сможете вернуть, и «обратимое путешествие во времени» тихо перестанет быть обратимым.
Этот пост — правило, к которому мы пришли, и почему каждое хранилище ему подчиняется.
Форма задачи
Конкретно: проект — это todo-приложение. Позже у него вырастает второе
приложение — блог — со своими исходниками под apps/blog/, своим Deployment и
Service app-blog, правилом маршрутизации на общем превью-Ingress и несколькими
строками в Postgres. Затем пользователь откатывается к снапшоту, снятому до
того, как блог появился.
Что значит «до блога» для каждого хранилища:
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 ●
Каждое хранилище должно оказаться в левой точке. Но откатываются они по-разному, и наивная операция «по хранилищу» неверна для двух из трёх.
Исходники: «восстановить файлы» — это не «совпасть с коммитом»
Первый инстинкт — git checkout <target> -- .. Он ошибочен, причём неочевидно.
git checkout <commit> -- <pathspec> обновляет рабочее дерево для путей, которые
существуют в <commit>. Файл, добавленный после целевой точки — любой
исходник блога, — в целевом дереве отсутствует, поэтому checkout его просто не
упоминает. Он остаётся на диске. Дальше вы делаете git add -A и коммит, и
коммит-«восстановление» добросовестно фиксирует дерево, в котором блог всё ещё
есть. Откат отработал, дифф выглядел правдоподобно, и ничего не удалилось.
Решение — заставить рабочее дерево совпасть с целевым точь-в-точь, включая удаления. Мы вычисляем набор путей, добавленных между target и HEAD, и удаляем их явно:
// 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> делает то же самое в один ход; мы оставляем
явный checkout-плюс-удаление, потому что баг, который он чинит, ровно про то,
какие файлы не упомянуты, и код читается именно как это намерение.)
Одна деталь здесь чрезвычайно важна, и мы к ней ещё вернёмся: восстановление — это новый коммит поверх HEAD. Мы никогда не двигаем указатель ветки и не переписываем историю. Коммит до отката — тот, что с блогом, — по-прежнему полностью достижим в графе объектов. Именно этот факт делает удаление файлов безопасным: они не исчезли, до них один коммит.
Тот же checkout заодно ведёт и обслуживание индекса. Каждый изменившийся путь —
включая только что удалённые — передаётся в reindexFiles, который убирает
исчезнувшие файлы из графа кода и векторного индекса, вместо того чтобы оставлять
висячие эмбеддинги:
// file-sync.js — reindexFiles()
if (content === null) { // file gone after the checkout
await fanoutSyncRemove(projectId, projectRelPath);
scheduleVectorFlush(projectId, projectRelPath, null);
}
Итак, хранилище исходников достигает цели удалением, которое восстановимо из истории. Запомните эту мысль.
Рантайм: не восстанавливать его, а регенерировать
Deployment app-blog всё ещё работает. Можно было бы попробовать «откатить
Kubernetes» — но откатывать-то не к чему; у K8s нет истории. И она не нужна,
потому что рантайм — это не хранилище-источник истины. Каждый Deployment,
Service и правило Ingress производны — генерируются из спеки проекта
генератором манифестов при каждом старте.
Это меняет вопрос с «как мне восстановить рантайм» на «как сделать так, чтобы вывод перестал включать блог». Спека строится из живого списка приложений, и этот запрос фильтруется:
-- 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;
Так что в момент, когда строка блога заархивирована, она выпадает из спеки, и
следующая генерация манифестов просто не выдаёт ни app-blog, ни его правило
Ingress. Рантайм сходится через регенерацию, а не восстановление.
Остаётся один явный шаг: архивация строки убирает приложение из будущих манифестов, но Deployment, который уже запущен, продолжает работать, пока что-нибудь его не удалит. Поэтому reconcile сносит живой рантайм каждого заархивированного приложения — и больше ничего:
// 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 }), ...);
}
Два предохранителя здесь несущие. Он отказывается трогать основное приложение
app — якорное приложение проекта никогда не сносится reconcile-ом отдельного
приложения. И он идемпотентен (deleteIfExists проглатывает 404), потому что
reconcile должен быть безопасен для повторного запуска. Удаление производного
рантайма ничего не теряет: накат вперёд возвращает строку, и следующий старт
регенерирует манифесты дословно.
Данные: восстановление ветки уже всё сделало
Блог записал таблицы в Postgres. Но приложения делят одну базу Neon на проект, и совмещённый откат восстанавливает эту базу, форкая ветку на LSN снапшота. DDL и DML после этого LSN — таблицы блога и их строки — просто оказываются по другую сторону форка. Никаких данных уровня приложения удалять не надо, потому что point-in-time и есть удаление. (Именно поэтому восстановление только данных сначала обязано проверить расхождение схемы — откат ветки откатывает и схему. Но это отдельный пост.)
Что не удаляется жёстко — это реляционный якорь, строка project_apps. Мы её
мягко архивируем (archived_at = now()), никогда не DELETE. Эта строка —
идентичность, связывающая три хранилища вместе; именно её сохранение позволяет
накату вперёд разархивировать приложение и вернуть всё целиком. Жёсткое удаление
осиротило бы любую историю, которая на неё ссылалась, и сделало бы движение
вперёд невозможным.
Инвариант
Отступите на шаг — и три хранилища подчиняются одному правилу. Откат может физически удалить нечто, только если это нечто:
- Восстановимо из истории git — исходные файлы. Коммит до отката остаётся достижимым, поэтому отмена удаления — всего один указатель.
- Регенерируемо из спеки — Kubernetes Deployments, Services, правила Ingress. Производное состояние; удалять его не жалко, потому что следующий старт пересоберёт его заново.
Всё остальное мягко архивируется, а не уничтожается — строка project_apps,
прежние ревизии плана. А общие данные не удаляются вовсе; они откатываются
форком ветки, который сам по себе обратим (состояние до отката сохраняется как
снапшот для наката вперёд ещё до форка).
Обратимость — это не фича, прикрученная сверху: это свойство, которое вообще определяет, что безопасно удалять. Если вы не можете вернуть что-то — вы это не удаляете; вы это архивируете или откатываете, а не стираете.
Единственное, что небезопасно
У инварианта есть острый край, и стоит проговорить его прямо, чтобы он не обнаружился жёстким способом: правило держится только для состояния, которое либо в git, либо производно от спеки. Всё, что ни то, ни другое, откат не должен удалять вслепую:
- Файлы, которые никогда не коммитились — загруженный пользователем ассет,
лежащий в
public/приложения, который ни одна генерация не зафиксировала. git reset снёс бы его, и не нашлось бы коммита, из которого его вернуть. - Объекты во внешнем ресурсе конкретного приложения — бакет хранилища, в который приложение писало напрямую. Форк ветки Neon откатывает базу проекта, но он ничего не знает о содержимом S3-бакета.
Для обоих случаев правильный ход тот же, что и для реляционной строки: зафиксируй перед удалением (закоммить неотслеживаемые файлы в точку наката вперёд; сделай снапшот внешних ресурсов) — либо оставь их на месте. Инвариант точен относительно собственной границы: восстановимо или производно — и если кусок состояния ни то, ни другое, вы делаете его восстановимым прежде, чем откату позволено его коснуться.
Почему именно такая форма
Глубинная причина, почему всё это работает, — в том, что мы держим строгое разделение между хранилищами-источниками истины и производным состоянием и никогда не позволяем откату их перепутать. Исходники и реляционный якорь — это записи; они откатываются с сохранением истории. Рантайм Kubernetes производен — он регенерируется. Общие данные — это запись со своим собственным примитивом путешествия во времени; они откатываются форком ветки. Единственная задача отката — передвинуть каждое хранилище в один и тот же момент с помощью его собственной обратимой операции и отказаться уничтожать что-либо, что он не сможет реконструировать.
Сделайте это — и «вернуться на вчера» перестаёт быть наивным однострочником и становится тем, чем должно: согласованным, обратимым перемещением через три хранилища, каждое из которых помнит прошлое по-своему.