textmachine/docs/research/23-engine-platform-seam.md

21 KiB
Raw Blame History

23 — Шов движок ↔ платформа: как индустрия интегрирует batch-движок с control plane

Ревью-шапка (оркестратор №9, 02.08.2026). Статус: ПРИНЯТ, направление ратифицировано D39.85. Метод: 5-агентный воркфлоу — три веб-направления по первоисточникам (официальные доки/блоги авторов инструментов, вторичных статей в выводах нет) + репо-инвентаризация + независимый арбитраж репо-находок (все 9 вердиктов подтверждены, 3 мелкие поправки внесены сюда). Правила internal проверены ЖИВЫМИ сборками на go1.26.4, не пересказом доков. Стоимость $0. Вопрос ресёрча: как чисто организовать read-путь и статус-поток от движка (CLI, процесс-на-прогон, приватный SQLite под EXCLUSIVE flock) к платформе (отдельный Go-модуль, net/http + Postgres + River + SSE) — не нарушая инвариантов «сервер в backend не пишется» (D39.81) и «пользователи/квоты/HTTP не проникают в движок».

§0. Решение одним экраном

Делаем (три канала, каждый со своей ролью):

  1. Живой статус — движок эмитит версионированный однонаправленный NDJSON-поток событий; воркер-обёртка платформы материализует его в СВОЙ Postgres; SSE и все чтения фронта — ТОЛЬКО из Postgres. Движок никогда не опрашивается.
  2. Массивный контент (текст глав, банк, манифест) — машиночитаемые артефакты на границах стадий с явным контрактом.
  3. Пост-фактум чтение и ре-синкtmctl status --json / export (санкционированный CLI-режим по завершённому/остановленному прогону).

НЕ делаем (анти-паттерны, каждый аргументирован ниже): платформа не читает живой SQLite движка (§4) · движок не пушит HTTP и не получает сервер (§2-направление) · публичный Go-read-пакет из ядра не выносится (§5) · человеческий вывод не парсится, неверсионированный ad-hoc JSON не эмитится (§2) · полный CQRS/event-sourcing не строится — достаточно статус-таблицы (§3).

§1. Наша форма — уже индустриальная

«CLI-движок, спавнутый на джобу, + control plane над ним» — форма Bazel (клиент → BES-бэкенд), BuildKit (buildctl → buildkitd → внешние потребители), CI-раннеров (gitlab-runner → Rails, GH Actions runner → orchestrator), terraform (CLI → TFC/автоматизации). Ни один из них не встраивает HTTP-сервер в сам движок сборки/выполнения; все решают наш вопрос одинаково — направленным потоком событий и артефактами. D39.81 (процесс-на-прогон, сервер в platform/) ресёрчем подтверждён дословно.

§2. Канал 1: версионированный NDJSON-поток событий

Эталоны. Bazel Build Event Protocol: protobuf-схема событий как DAG, три транспорта (бинарный файл / NDJSON-файл / gRPC-стрим); упорядочение — гарантией анонса («каждое событие, кроме первого, анонсировано предыдущим»), полнота — только на штатном завершении (bazel.build/remote/bep). BuildKit наружу: docker buildx --progress=rawjson — «режим спроектирован для чтения внешней программой», финальные метаданные отдельным --metadata-file (docs.docker.com).

Контракт потока — шаблон terraform -json (machine-readable-ui): один JSON-объект на строку; первая строка всегда version-хендшейк; semver-обещания — «минорную версию инкрементируем для обратно-совместимых добавлений: игнорируйте незнакомые поля; мажорную — для несовместимых: отвергайте неподдерживаемую». Дисциплина stdout: в машинном режиме stdout принадлежит ТОЛЬКО потоку, человеческие логи — stderr (у нас уже так: slog пишет в stderr, obs/logging.go).

Почему версионирование обязательно: прецедент гниения — Docker Engine JSON-stream (jsonmessage): неверсионированные поля депрекейтнулись, пакеты уехали в internal, потребители сломались (moby PR #51153). Ad-hoc JSON без version-поля дрейфует всегда.

Направление, а не транспорт. CI-раннеры пушат по HTTP (gitlab: PATCH /jobs/:id/trace каждые ~3с, сервер даёт backpressure заголовком; GH: push статуса + heartbeat) — но они долгоживущие демоны с сетевым стеком. Нам переносима не механика, а направление: движок ЭМИТИТ (stdout/файл), а пушит дальше адаптер на стороне платформы. HTTP в движок не проникает.

Обязательная пара к потоку — реконсиляция. BEP-каveat: при краше анонсированные события могут не прийти — гарантия полноты действует только на штатное завершение. Следствия: (а) консюмер идемпотентен — апсерт по (run_id, seq); дубликаты at-least-once нормальны (transactional-outbox-грабли задокументированы: microservices.io/outbox); (б) на обрыве материализация пересобирается из status --json и артефактов завершённых стадий — у нас они уже есть как инвариант детерминизма, готовый ре-синк-источник.

§3. Канал 2: материализация = Reporting Database, НЕ CQRS

Паттерн «воркер пушит → платформа складывает в свою БД → UI читает только её» подтверждён работающими системами (BuildBuddy как BES-бэкенд Bazel; GitLab/GitHub — статус джоба в их Postgres, раннер никогда не опрашивается). При этом Фаулер прямо предостерегает от полного CQRS: «для большинства систем CQRS добавляет рискованную сложность» (CQRS); для чтений достаточно ReportingDatabase — статус-таблица, наполняемая из потока. Event-sourcing, реплей истории, отдельные write/read-модели — не строить.

§4. Анти-паттерн: чтение живого SQLite движка платформой

Честная рамка: сам SQLite мультипроцессное чтение ПОДДЕРЖИВАЕТ (FAQ #5; WAL-читатели не блокируют писателя) — вердикт «нет» держится не на «SQLite так нельзя», а на четырёх аргументах:

  1. Локи. У движка locking_mode=EXCLUSIVE/EXCLUSIVE flock (store.go:43) — читатель получит SQLITE_BUSY либо вовсе не откроет файл; ослаблять локинг движка ради платформы = обратная связность. Смешение локинг-протоколов (flock у одного, SQLite-локи у другого) — задокументированный путь к порче БД (howtocorrupt §2.4); обходы nolock/immutable на живом файле — «incorrect results and/or SQLITE_CORRUPT» (uri.html).
  2. Checkpoint starvation. Долгий читатель в WAL блокирует checkpoint — «WAL file will grow without bound» (wal.html): даже «безобидное» чтение платформы операционно меняет поведение и файлы движка. Проникновение не кодом, так локами.
  3. Схемная связка. Чтение приватной схемы v1v14+ = IntegrationDatabase-антипаттерн (Fowler): каждая миграция движка становится breaking change платформы; read-only НЕ амнистируется — schema coupling остаётся целиком (microservices.io/shared-database).
  4. Индустрия единодушна. Litestream (VFS-реплики «do not open the original live SQLite file»), LiteFS (page-репликация), официальный sqlite3_rsync (консистентный снапшот) — даже авторы инструментов, живущие внутри WAL-механики, для потребителей строят копию, не выдают путь к живому файлу. Fossil (SCM от авторов SQLite) объявляет собственную БД «implementation detail», интерфейс — экспортируемый формат поверх. Firefox держит эксклюзив на places.sqlite — профильная БД де-факто приватна.

Допустимая будущая форма «да» (если когда-нибудь понадобится): движок САМ экспортирует консистентный снапшот (VACUUM INTO — механизм уже есть, F4-бэкап) в отдельный файл с явным export-контрактом (стабильные export-таблицы/вьюхи + application_id/user_version-гейт версии), платформа открывает его mode=ro&immutable=1 — на настоящем снапшоте immutable легален и избавляет от локов вовсе. sqlite.org благословляет SQLite-файл как interchange-формат (appfileformat) — но это замораживает экспортированную схему, поэтому чище узкая JSON-проекция, пока не доказана нужда в большем.

CLI-как-API (сабпроцесс-на-запрос) легитимен по трём критериям из первоисточников: явный machine-режим с обещанием стабильности (git --porcelain), версионированная схема (terraform -json format_version), герметичность вызова (kubectl conventions: только машинные форматы, никакого неявного состояния). Но — только для редких чтений завершённого состояния; для continuous-статуса живого прогона индустрия единодушно использует push-стрим уже запущенного процесса, не спавн-на-поллинг.

§5. Почему НЕ «публичный read-пакет» из ядра движка

Репо-факт (проверен арбитражем): NewReadOnlyRunneropenRunner грузит ПОЛНЫЙ стек — LoadBook/LoadModels/LoadPipeline + Pricer + промпт-шаблоны всех стадий + langpack + repair/terminology-шаблоны (runner.go:151-266), потому что честный Status обязан ре-рендерить снапшоты волн (drift/rebill: status.go:492-500rebill.go:95 → snapshot/render), а деноминатор N/M — это $0 ре-ингест источника (status.go:177-195). Читальный срез = 12 из 14 internal-пакетов + распил пакета pipeline. «Тонкой read-библиотеки» не существует; вынос = перенос почти всего ядра из internal. Экосистема подтверждает отсутствие паттерна «библиотека читает живой чужой стор»: bbolt/badger блокируются на локах, prometheus DBReadOnly — только для неживых директорий, живые чтения Prometheus отдаёт исключительно через API процесса-владельца.

§6. Go-модули: текущая структура правильная; один гард

  • Два sibling-модуля (textmachine/backend + textmachine/platform) — то, что нужно, но по честной причине: НЕ ради видимости (правило internal действует по дереву каталогов — один модуль дал бы ту же изоляцию), а ради изоляции графа зависимостей: pgx/river платформы не попадают в go.mod/go.sum движка и не двигают его транзитивные версии через MVS — прямой довод от детерминизма прогона.
  • Живые эксперименты (go1.26.4): sibling-модуль при импорте backend/internal/* получает ошибку компиляции — шов запечатан toolchain'ом. Лазейка: модуль с путём, ВЛОЖЕННЫМ под textmachine/backend/*, импортирует internal успешно (проверка — по префиксу import-пути, golang/go#23970). Ревью-гард: путь Go-модуля платформы/фронта никогда не вкладывать под путь движка.
  • go.work — коммитить, когда у платформы появится Go-код (паттерн kubernetes/grafana: легитимно для деплойных, не-импортируемых-извне модулей; совет ref/mod «не коммитить» — про публичные библиотеки, не наш случай). go.work решает только dev-удобство: ни видимость, ни версии, ни деплой-скью он не меняет; co-deployed модули синхронизируются git SHA.
  • Curated-surface-паттерн (k8s staging/apimachinery, gopls в x/tools) — держать в уме, применять ТОЛЬКО если платформе однажды реально понадобится импортировать Go-код движка; пока платформа зовёт движок процессом — версии между модулями не нужны вовсе.

§7. Зрелость интеграционной поверхности движка (инвентаризация, арбитраж-подтверждено)

Уже готово: exit-контракт 0 чистый / 2 completed-with-flags / 3 банк-стоп / 1 сбой (main.go:30-52) · status --json (StatusReport: enum'ы disposition/flag_reason, деньги committed/reserved/ceiling_pct, дрифт, паспорта — status.go:37-130) · export = детерминированный JSON по умолчанию (+--pairs) · NDJSON-логи LOG_FORMAT=json на stderr с авто-осями trace_id/book/chapter/chunk/stage/role (obs/logging.go:15-54) и живыми событиями (старты волн, calling model с оценкой $ ДО вызова, attempt completed с ценой, банк-стоп) · 4 класса сайдкаров (mined-signature.yaml · bank-stop.txt · auto-bank.yaml · backups/) · деньги per-book одним запросом из spend (ledger.go:314-322); per-run — SUM(request_log.cost_usd) WHERE trace_id (нижняя граница, телеметрия) либо дельта spend.

Малое касание: событийный эмиттер со стабильным словарём — все границы суть единичные call-sites в pipeline рядом с готовыми slog-вызовами (bookrun.go:160, waverun.go:116/171/131-135, unit-done = stagerun.go:296-307); событие потолка — единственное, что надо ДОБАВИТЬ (сейчас деньги только в тексте ошибки, stagerun.go:472-494) → строка 103 · report --json (QualityReport уже json-tagged; RequestLogView тегов НЕ имеет — нужна локальная view, поправка арбитража) · JSON-таблица банка (строка 101) · внешний trace-контекст (строка 102) · пофазный прогресс (строка 99, без миграции).

Большая работа (и почему не делаем): публичный read-пакет (§5 — отклонён) · персист манифеста против ре-ингеста (строка 100 — делаем, нужен и самому движку) · глобальный брокер рейт-лимитов (П-2 — единственная по-настоящему новая механика стыка).

§8. Ратифицировано (D39.85) — носители

  1. Форма шва = три канала §0; входы дизайна контракта API зафиксированы в строке 95: (i) NDJSON-поток (словарь-enum + version-хендшейк + событие потолка), (ii) артефактный контракт (export-JSON · 101 · 100), (iii) правило ре-синка (идемпотентный апсерт (run_id, seq); status --json = канал согласования).
  2. НОВАЯ строка 103 — событийный эмиттер (малое касание; форма — вместе с контрактом 95).
  3. Анти-паттерны §0 — запреты; чтение живого SQLite платформой не проектировать и не предлагать.
  4. Ревью-гард модульных путей (§6); go.work — при появлении кода платформы.
  5. П-1 платформенного бэклога уточнён формой потока (воркер-обёртка супервайзит процесс, ингестит идемпотентно, SSE из Postgres).

§9. Источники (первоисточники)

bazel.build/remote/bep · developer.hashicorp.com/terraform/internals/machine-readable-ui и /json-format · docs.docker.com/reference/cli/docker/buildx/build · git-scm.com/docs/git-status (porcelain) · kubernetes.io/docs/reference/kubectl/conventions · martinfowler.com/bliki/{CQRS,ReportingDatabase,IntegrationDatabase}.html · microservices.io/patterns/data/{shared-database,transactional-outbox}.html · sqlite.org/{faq#q5,wal,howtocorrupt,uri,rsync,appfileformat,pragma#locking_mode}.html · litestream.io/how-it-works (+/vfs) · fly.io/blog/introducing-litefs · fossil-scm.org/home/doc/trunk/www/fileformat.wiki · go.dev/{ref/mod,doc/go1.4#internalpackages,blog/get-familiar-with-workspaces} · kubernetes.dev/blog/2024/03/19/go-workspaces-in-kubernetes · github.com/kubernetes/kubernetes/blob/master/staging/README.md · github.com/grafana/grafana/blob/main/go.work · pkg.go.dev/{go.etcd.io/bbolt,github.com/dgraph-io/badger/v4,github.com/prometheus/prometheus/tsdb} · живые сборки-эксперименты internal-правил: go1.26.4 linux/amd64 (scratchpad, воспроизводимы по §6).