The databasePart 6 of 6

Что откату позволено удалять

Откат через путешествие во времени должен привести проект в согласованное состояние сразу в трёх очень разных хранилищах: git-исходниках, живом рантайме Kubernetes и общей ветке Postgres. Интересен не вопрос «как восстановить», а вопрос «что именно нам позволено физически удалить и как при этом остаться обратимыми?» Ответ — единственный инвариант: удаляем только то, что либо восстановимо из истории git, либо регенерируется из спеки, а единственный реляционный якорь, который их связывает, мягко архивируем.

Что откату позволено удалять

«Откати проект на вчера» звучит как одна операция. Это не так. Проект на этой платформе разнесён по трём хранилищам с совершенно разными моделями надёжности и идентичности:

  • Исходники — файлы в отдельном 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. Эта строка — идентичность, связывающая три хранилища вместе; именно её сохранение позволяет накату вперёд разархивировать приложение и вернуть всё целиком. Жёсткое удаление осиротило бы любую историю, которая на неё ссылалась, и сделало бы движение вперёд невозможным.

Инвариант

Отступите на шаг — и три хранилища подчиняются одному правилу. Откат может физически удалить нечто, только если это нечто:

  1. Восстановимо из истории git — исходные файлы. Коммит до отката остаётся достижимым, поэтому отмена удаления — всего один указатель.
  2. Регенерируемо из спеки — Kubernetes Deployments, Services, правила Ingress. Производное состояние; удалять его не жалко, потому что следующий старт пересоберёт его заново.

Всё остальное мягко архивируется, а не уничтожается — строка project_apps, прежние ревизии плана. А общие данные не удаляются вовсе; они откатываются форком ветки, который сам по себе обратим (состояние до отката сохраняется как снапшот для наката вперёд ещё до форка).

recoverable from history

regenerable from spec

pre-fork snapshot captured

rollback to target

source: git reset worktree to target
delete added-since files

runtime: archive row → teardown live Deployment
spec regenerates the rest

data: fork Neon branch at snapshot LSN

roll-forward

Обратимость — это не фича, прикрученная сверху: это свойство, которое вообще определяет, что безопасно удалять. Если вы не можете вернуть что-то — вы это не удаляете; вы это архивируете или откатываете, а не стираете.

Единственное, что небезопасно

У инварианта есть острый край, и стоит проговорить его прямо, чтобы он не обнаружился жёстким способом: правило держится только для состояния, которое либо в git, либо производно от спеки. Всё, что ни то, ни другое, откат не должен удалять вслепую:

  • Файлы, которые никогда не коммитились — загруженный пользователем ассет, лежащий в public/ приложения, который ни одна генерация не зафиксировала. git reset снёс бы его, и не нашлось бы коммита, из которого его вернуть.
  • Объекты во внешнем ресурсе конкретного приложения — бакет хранилища, в который приложение писало напрямую. Форк ветки Neon откатывает базу проекта, но он ничего не знает о содержимом S3-бакета.

Для обоих случаев правильный ход тот же, что и для реляционной строки: зафиксируй перед удалением (закоммить неотслеживаемые файлы в точку наката вперёд; сделай снапшот внешних ресурсов) — либо оставь их на месте. Инвариант точен относительно собственной границы: восстановимо или производно — и если кусок состояния ни то, ни другое, вы делаете его восстановимым прежде, чем откату позволено его коснуться.

Почему именно такая форма

Глубинная причина, почему всё это работает, — в том, что мы держим строгое разделение между хранилищами-источниками истины и производным состоянием и никогда не позволяем откату их перепутать. Исходники и реляционный якорь — это записи; они откатываются с сохранением истории. Рантайм Kubernetes производен — он регенерируется. Общие данные — это запись со своим собственным примитивом путешествия во времени; они откатываются форком ветки. Единственная задача отката — передвинуть каждое хранилище в один и тот же момент с помощью его собственной обратимой операции и отказаться уничтожать что-либо, что он не сможет реконструировать.

Сделайте это — и «вернуться на вчера» перестаёт быть наивным однострочником и становится тем, чем должно: согласованным, обратимым перемещением через три хранилища, каждое из которых помнит прошлое по-своему.

Создавай на той самой платформе, о которой эти посты.

Опиши своё приложение простыми словами — Adorable напишет код, настроит базу данных и выкатит его в онлайн.

Начать бесплатно