The runtimePart 1 of 2

Гравитация основного приложения

Проект на этой платформе — это не одно приложение и один стек, а N изолированных приложений и M сервисов, делящих один namespace в Kubernetes, и всё это разворачивается из сообщения в чате. Стоит это допустить — и почти каждый баг начинает рифмоваться: «новое соседнее приложение» молча схлопывается обратно в основное. Это рассказ о том, почему так происходит снова и снова, о единственном принципе, который чинит весь класс этих багов — сходиться к объявленному состоянию в одной контрольной точке и падать с отказом вместо отката к основному приложению, — и о том, как тот же принцип, прокрученный в обратную сторону, позволяет нам путешествовать во времени со всем стеком сразу (код в git, данные в общей ветке Neon и живая инфраструктура K8s) обратно в один согласованный момент.

Гравитация основного приложения

Долгое время проект здесь означал одно: одно приложение, один стек. Вы описывали приложение, мы генерировали проект на 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:

  1. Приложения — это изолированные деревья. Каждое приложение — это самодостаточное Vite-приложение под apps/{slug}/. Нет общего бандлера, нет импортов между приложениями — символ из apps/blog никогда не достижим из apps/personal-finance. Это жёсткий инвариант, и дальше мы увидим, как он даёт о себе знать.

  2. Сервисы делятся на два класса. Платформенные сервисы (Neon Postgres, Garage S3) — это общие инстансы вне namespace, доступные через ExternalName. Внутри-namespace сервисы — это либо встроенный стек (react-frontend, trpc-api, postgres, s3), либо дополнения из каталога (directus, redis, strapi, …), описанные как config/services/{id}/service.json. Это различие не косметическое — именно оно решает, что откату разрешено сносить.

  3. Всё выводится из спеки. .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:

user approves

chat: add a blog
as a separate app

architect / propose_plan

spec.json feature:
createsApp: {slug: blog, kind: react}
targetAppId: $NEW:blog

resolveCreatesAppSentinels

project_apps row
slug = blog

rewrite spec + subtask rows:
$NEW:blog → a_xxx

bucketPlanByAppId

BullMQ job
app = personal-finance

BullMQ job
app = blog

Сентинел нужен потому, что на момент планирования приложение ещё не существует, а план уже должен на него ссылаться. БД не может хранить $NEW:blog как app_id (внешний ключ), поэтому сентинельные фичи пишут NULL — и литерал $NEW:blog живёт только в spec.json. В момент подтверждения resolveCreatesAppSentinelsstate-tools.js):

  1. создаёт настоящее приложение (createApp → строка + скаффолд + триггер пересборки K8s),
  2. переписывает $NEW:blog → настоящий a_xxx в спеке и проставляет app_id на те строки сабтасков, что на него ссылались (включая соседние фичи, указывающие на тот же $NEW:blog),
  3. передаёт разрешённый план в 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 сервисов на каждом входном пути — он существует ровно для того, чтобы ловить двери в обход счастливого пути. Приложения просто не были частью этого контракта:

yes

ok

fail

no

approve_plan

startGeneration

trigger_generation

resume / retry

iteration carryover

spec declares an
unmaterialized app?

resolveCreatesAppSentinels
reconcile mode

syncProjectServices
converge to spec.services

REFUSE:
status → idle, no enqueue

enqueue producer

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, созданные после цели (чтобы новое приложение вышло из активного набора);
  • пер-аппный K8steardownApp удаляет Deployment приложения, возникшего после цели (производное, так что это безопасно и регенерируемо);
  • сервисы каталога — сводит projects.services вниз к объявленному набору цели (читая из spec.json в worktree, который восстановление кода уже сбросило) и удаляет дополнения, появившиеся с тех пор. Именно это наконец сносит осиротевший Deployment directus.

Две тонкости сделали это сложным, обе исправлены сегодня:

  • Выбери правильный момент. 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 на входе в генерацию, а не побочному эффекту счастливого пути.

Конкретно это три привычки, на которые теперь опирается кодовая база:

  1. Сходись к объявленному состоянию в одной контрольной точке. startGeneration — это единственная дверь, через которую проходит каждая генерация. Сервисы реконсайлятся там; теперь там же реконсайлятся и приложения. Спека — это желаемое состояние; точка входа делает его реальным, идемпотентно, независимо от того, по какому пути пришли.

  2. Падай с отказом, а не обратно к основному. NULL/сентинельный app_id, нематериализованное приложение, неразрешённая ссылка — это нарушения контракта, которые надо отклонять, а не дефолты, которые надо проглатывать. Соблазнительный rowAppId || defaultAppId — это ровно тот самый запах.

  3. Классифицируй по данным, а не по списку. Встроенный против каталожного берётся из реестра. Идентичность приложения берётся из project_apps. Возможность импорта между приложениями берётся из раскладки дерева. Когда исправление хочет захардкодить «четыре основных сервиса» или «приложение блога» — это знак, что таксономия уже где-то существует как авторитетный источник и её следует прочитать, а не продублировать.

У основного приложения есть гравитация: каждый неоднозначный дефолт, каждый молчаливый откат, каждое нереконсайленное хранилище утягивает работу обратно к нему. Хороший мульти-аппный provisioning — это в основном дисциплина отказа поддаваться этому притяжению: сделать объявленную множественность реальной в одной точке и относиться к «откатилось к основному» как к багу, на котором надо падать, а не как к безопасному дефолту, на котором можно отдыхать.

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

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

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