Гравитация основного приложения
Долгое время проект здесь означал одно: одно приложение, один стек. Вы описывали приложение, мы генерировали проект на React (или React + tRPC + Postgres) — и это была вся вселенная.
Потом у проектов появилось второе приложение. «Добавь маркетинговый блог рядом с моим финансовым трекером». «Сделай админку рядом с витриной магазина». И вот уже один проект — это несколько приложений, каждое со своим изолированным деревом Vite и своим стеком, плюс несколько сервисов — часть из них общая платформенная инфраструктура, часть — дополнения под конкретную фичу, — и всё это живёт в одном namespace Kubernetes и всё разворачивается из чата.
Этот сдвиг легко описать и трудно сделать правильно. Этот пост — о том, как на самом деле устроено provisioning, когда проект становится множественным, и об одном сценарии сбоя, который оказался единым багом в трёх разных костюмах. Сегодня мы выкатили все три исправления; самое интересное в том, что это одно и то же исправление.
Что такое проект на самом деле
Вот анатомия реального проекта из двух приложений (qiwykfu39n: финансовый
трекер, у которого вырос блог):
project: qiwykfu39n
┌──────────────────────────────────────────────────────────────────┐
│ git worktree /data/projects/qiwykfu39n │
│ apps/personal-finance/ kind=fullstack is_primary=true │
│ apps/blog/ kind=react is_primary=false │
│ .adorable/spec.json ← the declared desired state │
└──────────────────────────────────────────────────────────────────┘
│ manifests generated from spec
▼
┌─ k8s namespace: adorable-qiwykfu39n ──────────────────────────────┐
│ Deployment app personal-finance (Vite + tRPC) │
│ Deployment app-blog blog (Vite) │
│ Deployment directus catalog service (shared, 1 per ns) │
│ Deployment neon-compute per-project Postgres compute │
│ Ingress personal-finance.preview… blog-… directus-… │
└──────────────────────────────────────────────────────────────────┘
│ DATABASE_URL / S3 keys / tokens via Vault → K8s Secrets
▼
Neon (shared pageserver) Garage S3 (shared) ← platform services
Три вещи на этой картинке важны для provisioning:
Приложения — это изолированные деревья. Каждое приложение — это самодостаточное Vite-приложение под
apps/{slug}/. Нет общего бандлера, нет импортов между приложениями — символ изapps/blogникогда не достижим изapps/personal-finance. Это жёсткий инвариант, и дальше мы увидим, как он даёт о себе знать.Сервисы делятся на два класса. Платформенные сервисы (Neon Postgres, Garage S3) — это общие инстансы вне namespace, доступные через
ExternalName. Внутри-namespace сервисы — это либо встроенный стек (react-frontend,trpc-api,postgres,s3), либо дополнения из каталога (directus,redis,strapi, …), описанные какconfig/services/{id}/service.json. Это различие не косметическое — именно оно решает, что откату разрешено сносить.Всё выводится из спеки.
.adorable/spec.json— это объявленное желаемое состояние. Рантайм K8s регенерируется из неё; она никогда не является источником истины.
Три хранилища и реляционный клей
Состояние проекта размазано по хранилищам с разными моделями идентичности и долговечности:
| Хранилище | Что хранит | Идентичность |
|---|---|---|
| git worktree | исходники каждого приложения под apps/{slug}/ |
commit SHA |
| K8s namespace | Deployments / Services / Ingress | производное, регенерируемое |
| Neon branch | данные всех приложений + таблицы Directus + adorable_meta |
таймлайн + LSN |
| Postgres (платформа) | project_apps, projects.services, ревизии плана |
реляционные строки |
Реляционные строки — это клей. Две из них несущие:
-- migration 100 (+108 added fs_dir_name): one row per app
CREATE TABLE project_apps (
id TEXT PRIMARY KEY, -- a_xxx
project_id TEXT NOT NULL,
slug TEXT NOT NULL, -- → apps/{slug}/ on disk, app-{slug} in k8s
kind TEXT CHECK (kind IN ('react','fullstack')),
is_primary BOOLEAN NOT NULL,
fs_dir_name TEXT,
archived_at TIMESTAMPTZ -- soft-archive on rollback
);
-- the project's flat, declared service set (jsonb array on the projects row)
SELECT services FROM projects WHERE id = 'qiwykfu39n';
-- → ["react-frontend","trpc-api","postgres","s3","directus"]
project_apps.kind — на уровне приложения; projects.services — на уровне
всего проекта. Запомните эту асимметрию — это тот шов, сквозь который
проскользнула половина сегодняшних багов.
От фразы до второго приложения
Как «добавь блог как отдельное приложение» превращается в строку
project_apps, дерево Vite и Deployment? Через план — никогда через инструмент
чата, который лез бы напрямую в Kubernetes. Архитектор предлагает фичу, несущую
директиву createsApp и сентинел $NEW:<featureId> в качестве её
targetAppId:
Сентинел нужен потому, что на момент планирования приложение ещё не существует,
а план уже должен на него ссылаться. БД не может хранить $NEW:blog как
app_id (внешний ключ), поэтому сентинельные фичи пишут NULL — и литерал
$NEW:blog живёт только в spec.json. В момент подтверждения
resolveCreatesAppSentinels (в state-tools.js):
- создаёт настоящее приложение (
createApp→ строка + скаффолд + триггер пересборки K8s), - переписывает
$NEW:blog→ настоящийa_xxxв спеке и проставляетapp_idна те строки сабтасков, что на него ссылались (включая соседние фичи, указывающие на тот же$NEW:blog), - передаёт разрешённый план в
bucketPlanByAppId, который раскидывает работу в по одной BullMQ-задаче на каждое отдельное приложение.
Этот fan-out — причина, по которой привязка к конкретному приложению должна быть
настоящей: каждая задача запускает producer против дерева ровно одного
приложения (resolveAppDir(projectId, appId)).
От сервисов до работающего namespace
Сервисы идут параллельным путём. Реестр сервисов (service-registry.js)
держит и заданные в коде встроенные сервисы, и записи из каталога. Собирается
ProjectSpec (project-spec.js) и передаётся в k8s-manifest-generator.js,
который выдаёт по одному Deployment/Service/Ingress на приложение плюс по
одному на каждый внутри-namespace сервис. Мульти-аппный нюанс живёт в
project-spec.js: записи реестра react-frontend / trpc-api пропускаются и
вместо этого проигрываются для каждого приложения отдельно, причём kind
каждого приложения решает его форму:
// project-spec.js — the primary app's kind is derived, not trusted
const isTrpcActive = resolved.includes("trpc-api");
const effectiveKind = (app.isPrimary && isTrpcActive) ? "fullstack" : app.kind;
(Сохранённый kind основного приложения может разойтись с реальностью — старые
проекты по умолчанию получали react и так и не обновлялись, когда агент
добавлял trpc-api, — поэтому генерация манифестов каждый раз заново выводит
его из активного набора сервисов, а не доверяет колонке.)
Порядок деплоя не захардкожен. deploy-order.js читает у каждого сервиса
requires и readyCheck из каталога и топологически сортирует их по тирам,
запуская между ними проверки готовности:
deploy-order.js — tiers from `requires` + `readyCheck` (no hardcoded names)
Tier 0 (no deps) Tier 1 (deps healthy first)
────────────────── ─────────────────────────────
neon-compute ─ pg_isready ──┐
redis ├──► directus requires: [postgres, s3]
app / app-blog │ strapi requires: [postgres]
garage (s3) ─ bucket OK ───┘
Учётные данные протягиваются через Vault всю дорогу: getEnvVarsForCompose
агрегирует секреты проекта (DATABASE_URL, ключи S3, креды сервисов), и
каждый вызов buildProjectSpec обязан передать их как vaultEnvVars —
иначе контейнер поднимется без базы данных. Сервисы из каталога вроде Directus
подключаются к той же ветке Neon проекта (DB_CLIENT=pg, собственная схема) —
поэтому их таблицы и платформенный реестр контента adorable_meta едут на одном
таймлайне Neon. Этот факт решает, что именно откатывает откат данных.
Баг, который носит три костюма
Вот тут начинается интересное. Стоит проекту стать множественным — и повторяется один конкретный сбой: «новое соседнее приложение» молча схлопывается в основное. Сегодня мы напоролись на него тремя разными способами, починили каждый и только потом увидели, что это один баг.
THE FAILURE CLASS: multi-app intent → silently degrades to "the primary app"
surface where it leaked what fell back to primary fix (commit)
─────────────────────────── ────────────────────────── ───────────
1. producer's READ context saw a sibling app's file in scope context
(gatherContext) the project-wide code index, to targetAppId
wrote a cross-app import (1ce1b58)
2. app MATERIALIZATION createsApp/$NEW never resolved, reconcile at
(approve happy-path only) subtasks (app_id NULL) routed startGeneration,
to the primary app's tree fail closed (c3afd35)
3. ROLLBACK teardown service + checkpoint not reconcile code +
(code+data only) reconciled; blog infra stayed data + INFRA to
live after "undo the blog" one target (bf3edbd)
Костюм 1 — протечка контекста
Producer'у, генерирующему apps/blog, отдали общепроектный индекс кода и
результаты векторного поиска — без фильтрации по приложению. Структурный
индекс корректно указывает полные пути (code-index.js выдаёт
apps/personal-finance/src/lib/trpc.tsx с тегом appId), но producer
сматчился по символу, а не по границе приложения, и записал
import { TRPCProvider } from './lib/trpc' в блог, где есть только Directus, —
в файл, который существует в дереве другого приложения. Vite не смог его
разрешить; превью умерло.
Приложения — это изолированные деревья, так что файл соседа никогда не
импортируется локально. Исправление ограничивает собранный контекст целевым
приложением этого запуска — граф зависимостей остаётся полным (это та самая
задуманная поверхность взаимодействия между приложениями, и в ней приложения
названы поимённо), но сырой дамп файлов, из которого копирует producer,
отфильтровывается до targetAppId. Это баг знания — контекст был фактически
неправ насчёт того, какому приложению принадлежит файл, — и чинится он
исправлением контекста, а не добавлением правила.
Костюм 2 — материализация только на счастливом пути
Материализация приложения (createApp) жила ровно в одном месте: в резолвере
сентинелов у approve_plan. Но генерация доходит до producer'а через много
дверей — упавший с ошибкой шаг подтверждения, trigger_generation,
возобновление, перенос работы с итерации. В одной трассе шаг подтверждения
упал после планирования; $NEW:blog блога так и не был разрешён, сабтаски
сохранили app_id = NULL, и rowAppId || defaultAppId в bucketPlanByAppId
направил их прямиком в основное приложение. «Отдельный финансовый блог» был
встроен в финансовый трекер. Строки приложения так и не создалось.
У соседних сервисов ответ уже был. startGeneration запускает сходящийся
reconcile сервисов на каждом входном пути — он существует ровно для того,
чтобы ловить двери в обход счастливого пути. Приложения просто не были частью
этого контракта:
Reconcile идемпотентен (успешный approve_plan снимает createsApp со спеки,
так что здесь он становится no-op'ом) и падает с отказом:
объявленное-но-нематериализованное приложение отказывает в генерации, а не
позволяет ей уйти в основное. Мы также инвертировали fan-out: литеральный
сентинел $NEW:, дошедший до bucketPlanByAppId, теперь кидает исключение
вместо отката.
Костюм 3 — путешествие во времени, забывающее два хранилища из трёх
Вот этот заслуживает того, чтобы на нём задержаться, потому что откат мульти-аппного, мульти-сервисного проекта назад во времени — это та же задача provisioning'а, прокрученная в обратную сторону. «Откати блог» должно вернуть проект к моменту перед тем, как блог появился, — и этот момент должен быть одним и тем же во всех трёх хранилищах сразу.
Один проект, один таймлайн — почему откат данных атомарен
Каждый коммит генерации записывает лёгкий checkpoint: id dev-таймлайна Neon
- его головной LSN + SHA коммита (
recordCheckpointвdb-timetravel.js). Никакие данные не копируются — это маркер(timeline, lsn, commit). Затем комбинированное восстановление отматывает три хранилища к этому маркеру:
checkpoints (one per generation commit):
● 6fe2a8b ─────────────────────────── ● ac62c29 (= current HEAD)
pre-blog post-blog
"undo the blog" ⇒ restore to 6fe2a8b
│
┌──────────────────────────────┼──────────────────────────────────┐
▼ CODE (git) ▼ DATA (Neon) ▼ INFRA (reconcile)
restoreVersion forkBranch at the reconcileAfterRestore
read-tree --reset checkpoint LSN: • archive post-target
to 6fe2a8b: whole-branch COW fork plan revisions
deletes apps/blog/* reverts EVERY app's • soft-archive + tear
+ any file added schema, Directus down apps/blog (K8s
since target, across tables, AND teardownApp)
ALL app trees, adorable_meta — • deprovision directus
uniformly ATOMICALLY (catalog add-on, via
(one timeline/project) isCodeDefinedService)
repointDevCompute swaps
the compute → 6fe2a8b
└──────── a pre-restore snapshot is captured FIRST → roll-forward point ──────┘
Мульти-аппный выигрыш — в средней колонке. Поскольку все приложения проекта
делят одну ветку Neon (каждое приложение получает свою схему; Directus и
реестр контента adorable_meta живут в той же ветке), один copy-on-write форк
таймлайна на одном LSN откатывает данные каждого приложения и таблицы
каждого внутри-веточного сервиса атомарно. Нет варианта «откатить данные
приложения A, но не приложения B» — это одна физическая операция. Положив все
данные приложений на одну ветку, вы расплачиваетесь гарантиями изоляции между
приложениями, но бесплатно получаете согласованный откат данных по принципу
«всё или ничего».
Откат кода единообразен по другой причине: один git worktree держит каждое
приложение под apps/{slug}/, так что read-tree --reset к целевому коммиту
удаляет файлы, добавленные с тех пор — по всем деревьям приложений — одним
махом. (Простой git checkout оставил бы добавленные файлы на месте; именно
reset заставляет apps/blog/ действительно исчезнуть.)
Инфра — это хранилище, которым не владеет ни одно из восстановлений
Git отматывает исходники; Neon отматывает данные. Ничто не отматывает
рантайм — и это та брешь, что мы закрыли сегодня. reconcileAfterRestore
приводит производные и реляционные хранилища в соответствие с восстановленным
моментом:
- реляционные строки — архивирует ревизии плана, созданные после цели,
реактивирует ревизию эпохи цели и мягко архивирует строки
project_apps, созданные после цели (чтобы новое приложение вышло из активного набора); - пер-аппный K8s —
teardownAppудаляет Deployment приложения, возникшего после цели (производное, так что это безопасно и регенерируемо); - сервисы каталога — сводит
projects.servicesвниз к объявленному набору цели (читая изspec.jsonв worktree, который восстановление кода уже сбросило) и удаляет дополнения, появившиеся с тех пор. Именно это наконец сносит осиротевший Deploymentdirectus.
Две тонкости сделали это сложным, обе исправлены сегодня:
Выбери правильный момент. Checkpoints сидят на коммитах, так что «откати блог» означает восстановить checkpoint перед ним. Агент же вместо этого выбрал post-blog checkpoint — который был текущим HEAD — превратив всё восстановление в no-op. Список точек восстановления теперь помечает точку, заякоренную на текущем HEAD (
current: true), и направляет вызывающий код к той, что чуть старше.Сноси по таксономии, а не по имени. «Какие сервисы безопасно удалять» — это не захардкоженный список. Реестр уже отделяет встроенный стек от дополнений каталога (
codeServiceIds, захваченные до загрузки каталога); откат читает это черезisCodeDefinedService. Встроенные сервисы (react-frontend,trpc-api,postgres,s3) откатываются кодом и данными и остаются нетронутыми; удаляются только дополнения каталога.
Оставаться обратимым
Путешествие во времени достойно доверия, только если оно двунаправленное.
Прежде чем форкнуться прочь от текущего состояния, restoreToCheckpoint
захватывает pre-restore snapshot на HEAD обслуживающего таймлайна —
реальную текущую схему и данные, а не LSN seed-состояния, — так что «откати
откат» становится полноценным восстановлением к этой точке. И поскольку форк
данных откатывает и DDL тоже, migrationsSince считает изменения схемы после
цели и предупреждает, когда восстановление только данных оставило бы код
впереди его схемы, направляя пользователя к комбинированному откату кода и
данных.
Снос остаётся обратимым по своей природе: всё, что он удаляет, либо
восстановимо из истории git (добавленные с тех пор файлы), либо регенерируемо
из спеки (Deployments K8s, сервисы каталога), либо мягко архивировано, а не
удалено (строки project_apps). Единственное правило за всем этим —
что откату разрешено удалять —
заслуживает собственного поста.
Единственный принцип
Снимите три костюма — и под всеми ними одно и то же предложение:
Каждое место, способное молча откатиться к «основному приложению», — это fail-open, который должен быть fail-closed; а мульти-аппное желаемое состояние принадлежит одному reconcile на входе в генерацию, а не побочному эффекту счастливого пути.
Конкретно это три привычки, на которые теперь опирается кодовая база:
Сходись к объявленному состоянию в одной контрольной точке.
startGeneration— это единственная дверь, через которую проходит каждая генерация. Сервисы реконсайлятся там; теперь там же реконсайлятся и приложения. Спека — это желаемое состояние; точка входа делает его реальным, идемпотентно, независимо от того, по какому пути пришли.Падай с отказом, а не обратно к основному.
NULL/сентинельныйapp_id, нематериализованное приложение, неразрешённая ссылка — это нарушения контракта, которые надо отклонять, а не дефолты, которые надо проглатывать. СоблазнительныйrowAppId || defaultAppId— это ровно тот самый запах.Классифицируй по данным, а не по списку. Встроенный против каталожного берётся из реестра. Идентичность приложения берётся из
project_apps. Возможность импорта между приложениями берётся из раскладки дерева. Когда исправление хочет захардкодить «четыре основных сервиса» или «приложение блога» — это знак, что таксономия уже где-то существует как авторитетный источник и её следует прочитать, а не продублировать.
У основного приложения есть гравитация: каждый неоднозначный дефолт, каждый молчаливый откат, каждое нереконсайленное хранилище утягивает работу обратно к нему. Хороший мульти-аппный provisioning — это в основном дисциплина отказа поддаваться этому притяжению: сделать объявленную множественность реальной в одной точке и относиться к «откатилось к основному» как к багу, на котором надо падать, а не как к безопасному дефолту, на котором можно отдыхать.