77 lines
21 KiB
Markdown
77 lines
21 KiB
Markdown
# 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](https://bazel.build/remote/bep)). BuildKit наружу: `docker buildx --progress=rawjson` — «режим спроектирован для чтения внешней программой», финальные метаданные отдельным `--metadata-file` ([docs.docker.com](https://docs.docker.com/reference/cli/docker/buildx/build/)).
|
||
|
||
**Контракт потока — шаблон `terraform -json`** ([machine-readable-ui](https://developer.hashicorp.com/terraform/internals/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](https://microservices.io/patterns/data/transactional-outbox.html)); (б) на обрыве материализация пересобирается из `status --json` и артефактов завершённых стадий — у нас они уже есть как инвариант детерминизма, готовый ре-синк-источник.
|
||
|
||
## §3. Канал 2: материализация = Reporting Database, НЕ CQRS
|
||
|
||
Паттерн «воркер пушит → платформа складывает в свою БД → UI читает только её» подтверждён работающими системами (BuildBuddy как BES-бэкенд Bazel; GitLab/GitHub — статус джоба в их Postgres, раннер никогда не опрашивается). При этом Фаулер прямо предостерегает от полного CQRS: «для большинства систем CQRS добавляет рискованную сложность» ([CQRS](https://martinfowler.com/bliki/CQRS.html)); для чтений достаточно [ReportingDatabase](https://martinfowler.com/bliki/ReportingDatabase.html) — статус-таблица, наполняемая из потока. 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](https://sqlite.org/howtocorrupt.html)); обходы `nolock`/`immutable` на живом файле — «incorrect results and/or SQLITE_CORRUPT» ([uri.html](https://sqlite.org/uri.html)).
|
||
2. **Checkpoint starvation.** Долгий читатель в WAL блокирует checkpoint — «WAL file will grow without bound» ([wal.html](https://sqlite.org/wal.html)): даже «безобидное» чтение платформы операционно меняет поведение и файлы движка. Проникновение не кодом, так локами.
|
||
3. **Схемная связка.** Чтение приватной схемы v1–v14+ = IntegrationDatabase-антипаттерн ([Fowler](https://martinfowler.com/bliki/IntegrationDatabase.html)): каждая миграция движка становится breaking change платформы; read-only НЕ амнистируется — schema coupling остаётся целиком ([microservices.io/shared-database](https://microservices.io/patterns/data/shared-database.html)).
|
||
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](https://sqlite.org/appfileformat.html)) — но это замораживает экспортированную схему, поэтому чище узкая JSON-проекция, пока не доказана нужда в большем.
|
||
|
||
**CLI-как-API** (сабпроцесс-на-запрос) легитимен по трём критериям из первоисточников: явный machine-режим с обещанием стабильности (git `--porcelain`), версионированная схема (terraform `-json format_version`), герметичность вызова (kubectl conventions: только машинные форматы, никакого неявного состояния). Но — только для **редких чтений завершённого состояния**; для continuous-статуса живого прогона индустрия единодушно использует push-стрим уже запущенного процесса, не спавн-на-поллинг.
|
||
|
||
## §5. Почему НЕ «публичный read-пакет» из ядра движка
|
||
|
||
Репо-факт (проверен арбитражем): `NewReadOnlyRunner` → `openRunner` грузит ПОЛНЫЙ стек — LoadBook/LoadModels/LoadPipeline + Pricer + промпт-шаблоны всех стадий + langpack + repair/terminology-шаблоны (`runner.go:151-266`), потому что честный `Status` обязан ре-рендерить снапшоты волн (drift/rebill: `status.go:492-500` → `rebill.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).
|