Путешествие во времени для кода И данных
ИИ-агент пишет код, и для этого у нас есть отличный undo: он называется git. Каждая генерация — это коммит; «вернуться назад» — это checkout. Решено.
Но агенты не только пишут код — они выполняют миграции, наполняют таблицы и
делают UPDATE. И вот тут «вернуться назад» превращается в инцидент с
восстановлением из бэкапа. Агент, который удалил не ту колонку или выполнил
UPDATE без WHERE, сделал то, чего git коснуться не может. Для автономных,
быстро действующих агентов данные — это незащищённая зона.
Поэтому мы дали базе данных то же свойство, что git дал коду: point-in-time undo, привязанный к тем же чекпойнтам. Нажимаете «восстановить» на прошлой версии — и схема, и строки откатываются вместе с кодом, к состоянию, которое реально работало.
Этот пост — о том, как это устроено: модель хранения, которая делает всё дешёвым, чекпойнт, восстановление через copy-on-write и согласованность кеша за repoint-ом compute.
Почему физический PITR, а не «переиграть миграции»
Наивный вариант data-undo — логический: храните DDL, выполняйте down-миграции. Он теряет ваши строки, не способен представить произвольные мутации и расходится с реальностью в тот момент, когда хоть что-то пишет в базу вне миграции. Нам нужны были настоящие байты базы данных в том виде, в каком они были в конкретный момент времени — каждая строка, индекс, последовательность и запись в каталоге — реконструированные точь-в-точь.
Это физическое point-in-time recovery, и оно дёшево только при разделении хранилища и compute. Adorable работает на self-hosted Neon: pageserver хранит версионированные страницы + WAL в объектном хранилище; compute — это stateless-Postgres, который читает страницы с pageserver по требованию.
Таймлайн — это единица истории: база данных Postgres, реконструированная из версий страниц + WAL вплоть до заданного LSN (Log Sequence Number — монотонное байтовое смещение Postgres внутри WAL). Ветвление таймлайна — это copy-on-write: потомок разделяет неизменённые страницы родителя и записывает страницу только тогда, когда расходится с ним. Создание таймлайна — это O(1)-операция над метаданными, не зависящая от размера базы.
Этот единственный примитив — форкнуть таймлайн от другого на конкретном LSN — и есть вся фича. Всё, что ниже, — обвязка вокруг него.
Чекпойнт — это маркер из двух полей, а не копия
При каждом коммите генерации мы записываем чекпойнт. Ключевое проектное
решение: чекпойнт ничего не форкает. Это строка, которая помнит, какой
таймлайн и какой LSN — (timeline_id, lsn) — плюс соответствующий ей git-коммит.
Никакое хранилище не выделяется; форк материализуется лениво, только если вы
когда-нибудь сделаете восстановление.
Хук живёт в конце пайплайна генерации, сразу после git-коммита, и работает строго по принципу best-effort — сбой путешествия во времени никогда не должен ломать сборку:
const commitResult = await commitGeneration(projectDir, { userPrompt, runSummary });
if (commitResult?.commitHash) {
await recordCheckpointSafe(projectId, { commitSha: commitResult.commitHash });
}
recordCheckpoint читает текущий head LSN таймлайна из прокси и записывает одну
строку в 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
});
Какой LSN фиксирует чекпойнт
recordCheckpoint фиксирует last_record_lsn pageserver-а — high-water mark
того WAL, который pageserver уже надёжно принял, — прочитанный один раз через
getBranchLsn. Этот LSN отстаёт от самого свежего коммита compute ровно на тот
WAL, что ещё в пути (compute → safekeeper → pageserver), так что чекпойнт
захватывает всё, что надёжно лежит на page store в этот момент, а не запись,
которая ещё не дошла:
compute commit ─(WAL in flight)→ safekeeper ─→ pageserver: last_record_lsn
▲ checkpoint pins this durable high-water mark
На практике это правильная граница: чекпойнты берутся на коммитах генерации
(recordCheckpointSafe запускается после commitGeneration), а к этому моменту
записи прогона уже распространились на page store — так что более поздний форк на
LSN чекпойнта восстановит ровно то состояние, что было там.
Чекпойнты накапливаются (по одному на коммит), поэтому recordCheckpoint
оставляет только CHECKPOINT_KEEP (50, настраивается через env) самых свежих
строк. Это маркеры, так что удаление строки ничего не освобождает — глубина
восстановления определяется окном PITR в Neon, и именно это — реальная ручка
управления хранением.
Восстановление = форк на LSN, затем repoint compute
Восстановление к чекпойнту C — это два хода: (1) материализовать прошлое
состояние как новый copy-on-write-таймлайн, (2) направить работающий compute на
него.
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
Dev-compute теперь обслуживает T' (данные на S0). T не тронут — восстановление
неразрушающее, так что вы можете снова откатиться вперёд. Вот полный путь
(слой путешествия во времени в бэкенде → его HTTP-клиент Neon → Rust-прокси):
Форк — это примитив pageserver-а, выставленный через прокси. В pageserver-клиенте
прокси create_timeline принимает необязательного предка:
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
}
}
Явный ancestorTimelineId важен для межтаймлайнового восстановления: после
одного восстановления активный таймлайн — это T'; восстановление к более
старому чекпойнту (на T) должно форкать T, а не текущий таймлайн. Строка
чекпойнта несёт в себе id таймлайна, так что restoreToCheckpoint форкает ровно
того предка, что нужно.
Согласованность кеша: repoint-ом владеет прокси
У второго хода — направить compute на новый таймлайн — есть одно неочевидное требование, на котором стоит остановиться, потому что это как раз тот случай, где легко допустить незаметную ошибку.
Спецификация compute пересобирается из credentials ветки, хранящихся в Vault:
(timeline_id, password, …). Так что «направить compute на T'» означает
«проставить таймлайн в credentials, затем перезапустить compute, который их
читает и поднимается на T'».
Подвох: прокси кеширует credentials ветки в памяти на 5 минут
(CACHE_TTL = 300s), чтобы убрать Vault с горячего пути.
Запись, идущая напрямую в Vault — например, из бэкенда — оставляет этот кеш
протухшим, и следующий wake пересобирает compute из закешированного (старого)
таймлайна. Всё выглядит нормально — форк существует, строка DB-ветки указывает
на него, compute перезагружается — а данные при этом не двигаются. Промах кеша,
которого вы не видите.
Поэтому repoint принадлежит прокси, а не бэкенду.
POST /api/branches/{id}/repoint вызывает put_branch_credentials, который
пишет в Vault и обновляет кеш одной операцией, а затем перезапускает compute
на новом таймлайне:
Принцип обобщается: если значение закешировано за сервисом, изменяйте его через этот сервис. Запись в обход кеша — это запись по часам, которые вы не контролируете.
Замечание о подмене
repoint масштабирует Deployment в 0, потом в 1; новый compute поднимается
на T', и мост прокси переподключает к нему клиентов. Соединение, которое
переустанавливается в короткое окно, пока предыдущий под ещё завершает работу, в
итоге оседает на восстановленном таймлайне, как только новый compute начинает
обслуживать запросы — так что вызывающая сторона читает состояние после
восстановления, когда подмена завершилась. (Именно поэтому проверка ниже опрашивает
результат в цикле, а не читает один раз.)
Как это проверяется
Путь fork → repoint → reboot не сделан как особый случай ради путешествия во
времени — это тот же примитив branch-at-LSN, на котором работают деплой и откат,
так что каждый продакшен-промоушн прогоняет его в боевых условиях. Логика
чекпойнта, форка и пост-восстановительной реконсиляции покрыта платформенными
тест-сьютами по веткам и restore-reconcile (db-branches.test.js,
db-restore-reconcile.test.js), которые поднимают реальные операции с ветками, а
не мокают их.
Границы дизайна
Глубина восстановления — это окно PITR. Строки чекпойнтов дёшевы, но форкнуть можно только тот LSN, который pageserver ещё хранит, поэтому то, как далеко назад можно восстановиться, определяется окном истории Neon. Лимит на строки чекпойнтов и окно PITR держатся согласованными с той глубиной, которую мы предлагаем.
Почему оно того стоит
Для человека это приятная кнопка undo. Для агента это ближе к необходимому условию: именно это делает безопасным делегирование уверенных, быстрых, необратимых операций над данными. Агент может выполнить страшную миграцию, потому что платформа способна её отозвать — код и данные, к точке, которая реально работала. Git сделал это для кода уже давно. Базе данных просто нужен был тот же примитив: ветка на LSN.