textmachine/docs/architecture/16-events-emitter.md

69 lines
No EOL
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Эмиттер прогонных событий (шов движок→платформа) — сборка-норматив (строки 103+165, докс-пак 167)
> ⚠ **ЭМИТТЕР ПОСТРОЕН И ПРИНЯТ С ОБЕИХ СТОРОН.** Движковая половина — D39.131 (`9cfe080`, 14.08), платформенная — D39.132 (P6, 15.08); PD-113 закрыт. **Фактический код первичен, этот док — справочник ФОРМЫ:** `backend/internal/runevents/` (журнал и словарь) · `backend/internal/pipeline/events.go` · `backend/internal/store/outbox.go` · `platform/internal/ingest/`.
>
> **Статус: СБОРКА-НОРМАТИВ.** Док ничего не решает сам — он собирает уже ратифицированные решения об `events.jsonl` в одно место. **Источники первичны; при конфликте с `05-decisions-log.md` побеждает D-лог.** Каждое утверждение несёт указатель; дословные цитаты — в кавычках-ёлочках.
>
> **Для кого:** бэкенд- или платформенная сессия, которой нужно ПОНЯТЬ форму шва — что несёт кадр, почему журнал файлом, что запрещено строить на событиях денег. Как заказ на постройку док отработан.
## 1. Что это и зачем
**Что.** Машинный канал живого статуса движок→платформа: NDJSON-журнал `events.jsonl` в каталоге книги — словарь-enum со стабильными типами, version-хендшейк первой строкой (правило terraform), плотная нумерация `seq`, событие потолка и событие денег. Заказ жил строками бэклога **103** (эмиттер) и **165** (различимые exit-коды); обе ЗАКРЫТЫ D39.131 и из живого бэклога сняты — дословные формулировки заказа, если понадобятся, лежат в архивных слайсах `../archive/PROGRESS-*.md`.
**Зачем две ноги.** (а) *Свежесть статуса* — слово владельца 08.08: «эмиттер нужен СВЕЖЕСТИ статуса и деньгам шва, не первым экранам» (D39.119 п.1); резерв на время без него — периодический `status --json`-резюнк. (б) *Деньги шва*: без события потолка «экран покажет „ошибка“ там, где верно „остановлено: лимиты“» (PD-113), а после поправки формулы потолка (D39.123, PD-158) стоп по потолку = ровно исчерпание холда, поэтому цена неразличимости выросла.
**Что эмиттер НЕ решает** (и не решал): реконсилятор и свип — платформа (строки 138·139 закрыты, D39.123 п.3) · write-ahead intent / `uncertain`-эскроу / `closing` — строка 136 бэклога · пиннинг версии бинаря на прогон — исполняется резюмом (PD-143/134 закрыты) · движковая оценка $/глава — строка 166 · тейлер/декодер платформы — `platform/internal/ingest/`, эмиттер пишет ПОД их контракт.
## 2. Ратифицированная форма (D39.106 · research/25)
Канон — D39.106 п.2, дословно:
> «**2. Форма (заменяет ТРАНСПОРТ D39.85 §1; остальное D39.85 в силе):** движок = транзиентный systemd-юнит на прогон (`Restart=no`, SIGTERM); события = `events.jsonl` каталога книги как проекция коммитов SQLite движка (outbox, та же транзакция, что чекпойнт); платформа тейлит, курсор `(engine_run_id, seq)` коммитится с эффектом в одной Postgres-транзакции; `status --json` — ремонт по остановленному прогону. Формат NDJSON/hello/seq, деньги (холд+потолок = защита, события = свежесть, D39.84/D39.100) и запрет на живой SQLite — без изменений.»
Разворачивание по `research/25` (`docs/research/25-seam-cold-review.md`):
- **Журнал = outbox, не вторая запись**: «События — **`events.jsonl` в каталоге книги**, append-only, как **проекция** уже закоммиченных строк SQLite движка (та же транзакция, что чекпойнт chunk×stage; outbox, не вторая запись)» (`25:21-22`). Следствие для эмиттера: строка события рождается в той же SQLite-транзакции, что чекпойнт, — не «после», не «параллельно».
- **Тейл с курсором**: «Платформа **тейлит** журнал; запись курсора — `(engine_run_id, seq)`, байтовое смещение — хинт; курсор коммитится в той же Postgres-транзакции, что эффект события» (`25:23-24`). Смещение — хинт и может быть отброшено; решает курсор (`platform/internal/ingest/tail.go:31-35`).
- **events ≠ status**: «`events` (fsync, seq, деньги) ≠ `status` (atomic rename, косметика, не для биллинга)» (`25:73`). `status --json` — ремонт-канал по остановленному прогону, «берёт тот же flock — проверка „остановлен“ не есть взаимоисключение (GPT)» (`25:79`).
- **Файл — пер-КНИГА, поток — пер-процесс**: имя журнала ратифицировано кодом платформы: `const JournalFile = "events.jsonl"` — «the engine's event journal, in the BOOK's directory (D39.106 §2)» (`tail.go:16-17`). `Seq` — «per PROCESS and starts at 1»; ключ идемпотентности — `(Hello.EngineRunID, seq)`, НЕ прогон платформы: «a resumed run is a new process whose seq restarts» (`events.go:62-70`). Резюм дописывает в тот же файл новый hello; тейлер пропускает строки чужого `engine_run_id` (`tail.go:58-60`).
- **Сброс буфера на всех путях выхода**: «сброс буфера на всех путях выхода (`os.Exit` пропускает defer — теряются финальные события)» (`25:82`); то же PD-61(а) — см. §4.
- **Живость**: «живость = растущий `seq`, не удерживаемый лок; политика обратного давления выбирается явно» (`25:83`).
**Отвергнутые альтернативы** (`25:33-44`; повторены в доккомменте зоны, «so that none of them comes back as a fresh idea» — `events.go:21-23`): платформа-родитель + пайп stdout/fd — **0 из 15** («время жизни движка — подмножество платформы: деплой/рестарт убивает или осиротляет прогон», `25:38`) · выделенный fd 3 — **0** (`25:43`) · stdout/journald как источник событий — **0** («journald молча роняет под всплеском и ротирует сам; людям — да, контракту — нет», `25:44`) · gRPC/HTTP поверх движка — 0 (`25:39`) · push в платформу — 2→0 (`25:40`) · брокер — 0 (`25:41`) · монолит — 0 из ~18 (`25:37`). Пайп-путь `supervisor.go` — ДЕВ-режим и только (D39.106 п.3; `events.go:23`).
**Словарь событий — В КОДЕ, не здесь**, и двигается минорами. Действующий носитель — `backend/internal/runevents/runevents.go` (греп `const StreamVersion`): конверт `{seq, type, time, data}` · типы `hello`/`progress`/`unit_done`/`bank_stop`/`ceiling`/`spend`/`finished` · `Hello{stream_version, engine_run_id, book_id, chunker_version}` · пофазные счётчики `progress` (семантика `done` = «разрешено волной» — D39.122 п.2б) · `Ceiling{halted, scope}` · `Spend{committed_micro_usd}` · `Finished{outcome}` с ледджером объёма. Зеркало-читатель — `platform/internal/ingest/events.go`. ⚠ Версии двух сторон расходятся по построению (минор = ignore-unknown): сверять по коду, а не по этому доку — числа в теле дока не поддерживаются (носитель расхождения — бэклог-строка 243, находка `arch-10`).
## 3. Контракт декодера и дублей (PD-105 · D39.123 п.2(в))
Ратификация — D39.119 п.3, дословно:
> «доставка событий шва = at-least-once; ДУБЛЬ `seq` — норма повторного чтения, декодер обязан принимать идемпотентно без фатала; тот же `seq` с ДРУГИМ payload = карантин прогона (research/25)».
**Расхождение, разрешённое позже:** «карантин прогона» — тот же, что процитирован выше (и `research/25:77-78`) — уточнён D39.123 п.2(в): «**PD-105-реализация: карантин ПРОЕКЦИИ (попытки), не прогона** — уточнение D39.119; деньги прогона сеттлятся ремонт-каналом». Побеждает D39.123 как более свежая нота.
Как это реализовано читателем (тело — `platform/docs/DEFECT_REGISTER.md`, греп `PD-105`): `ingest.Tail` пропускает `seq <= last_seq` идемпотентно и без ошибки, `pgstore.RunSink.Apply` пере-проверяет тот же high-water mark ВНУТРИ транзакции эффекта, сверка дублей — по sha256 сырой строки (`run_attempts.last_line_sha256`). Пропасть (`seq > last+1`) осталась ошибкой: строки потеряны, читать дальше нечего.
- **hello — всегда первая строка потока, seq=1** (`decoder.go:64`: «hello carries seq 1, want 1»); версия — правило terraform, ратифицировано D39.85: «a MINOR bump adds fields and event types — unknown ones are ignored; a MAJOR bump is refused» (`events.go:34-37`); мажор-несовпадение = `ErrUnsupportedVersion` (`events.go:150-163`).
- **Дубль строки безопасен, значит строка обязана быть повторимой байт-в-байт** — сверка дублей идёт по sha256 сырой строки (`tail.go:44-49`); тот же seq с другим payload = `ErrPayloadConflict` (`tail.go:20-24, 181`). Эмиттер не должен пере-генерировать уже записанную строку иначе (например, с другим timestamp при реплее).
- **Пропуск seq фатален для чтения** (`tail.go:190`, `ErrStreamGap`) — нумерация плотная, без дыр.
- **Недописанная последняя строка — не ошибка**: тейлер «deliberately left alone» строку без `\n` — это строка в процессе записи (`tail.go:51-55`). Это смягчает, но не снимает слепое пятно JSONL — битую запись С валидным `\n` (`research/25:74-75`); сама развилка формата решена в пользу одиночного JSONL (⚠-строка в конце §5).
- **Ключ — engine_run_id из hello**, не прогон платформы; чужие run id в том же файле тейлер ПАРКУЕТСЯ на них, а не пропускает — ⚠ **семантика сменилась 03.09 паком P13 (`PD-438`, major), и прежняя формулировка «пропускает» БОЛЬШЕ НЕ ВЕРНА.** Пока платформа НЕ именовала поток попытки или наша первая строка ещё не применена (`pos.LastSeq == 0`), чужой handshake действительно пропускается — наш может лежать ниже по файлу. Но при ИМЕНОВАННОМ потоке и уже применённых своих строках чтение ОСТАНАВЛИВАЕТСЯ на чужом handshake'е (`platform/internal/ingest/tail.go`, греп `ErrForeignStreamAhead`), и курсор на нём остаётся: владение потоком не переживает проход свипа — оно выводится заново каждый проход, и «пройти мимо, двигая байтовый хинт» означало отдать следующему проходу курсор, который говорит «эта чужая область — наша». Цена прежнего поведения замерена: чужой `ceiling` ставил `paused` живому оплаченному прогону, `unit_done` писал главы, которых никто не покупал, а чужой `seq` карантинил здоровую проекцию).
## 4. Деньги и потолок
**Роль событий денег — свежесть, НЕ защита.** Канон D39.106 п.2: «деньги (холд+потолок = защита, события = свежесть, D39.84/D39.100)». Доккоммент `Spend` прямо запрещает обратное: «Building enforcement on this event is forbidden — the stream is at-least-once and a crash truncates it» (`events.go:129-132`). Форма `Spend`: «CUMULATIVE, not a delta: a redelivered or duplicated line is then harmless … Integer micro-USD: money never travels as a float, and the engine's ledger is a lower bound, so the conversion at the seam rounds up» (`events.go:134-137`); это внутренний канал в приватную таблицу — D39.84 правит провод/экран/INFO ПОЛЬЗОВАТЕЛЯ, их это не касается (`events.go:137-138`).
**ИСПР. 05.09 — СОБЫТИЕ ПОТОЛКА ТЕПЕРЬ НЕСЁТ ЧИСЛО.** Минор потока **1.3** (лендинг `81a89e9`, акт D39.206) добавил `Ceiling.ShortfallMicroUSD` — недостающую сумму, и `Finished.Money` — денежный леджер прогона. Ратифицировано владельцем (`D39.203`): движку РАЗРЕШЕНО говорить «сколько добавить», потому что наружу идёт ДО-ВЫЗОВНАЯ ОЦЕНКА, а не СТОИМОСТЬ. ⚠ Читатель, знающий потолок и кумулятивный `spend`, восстанавливает из неё оценку отказанного вызова — это принято ОСОЗНАННО и предъявлено приёмкой живым прогоном. Ниже — редакция ДО минора 1.3, читать как историю. **Событие потолка — факт без цифр.** ⚠ (редакция до 1.3; словарь потока с минора 1.3: `Ceiling.shortfall_micro_usd` — недостающая сумма, и `Finished.Money` — денежный леджер прогона) `Ceiling{halted}`: «It carries the FACT and nothing else: money never reaches the platform's wire or its INFO logs (D39.84), and the stop is resumable, so it is not a failure» (`events.go:123-127`).
**Различимость стопа — ПОСТРОЕНА (D39.131), и это был весь смысл заказа.** До пака потолочный стоп был неотличим от инфраструктурного отказа (оба = exit 1), из-за чего платформа ставила `failed` там, где контракт требует `paused` — открытый major PD-113, закрыт с обеих сторон (движок D39.131, платформа D39.132). Действующий словарь кодов выхода — в коде, а не здесь: `backend/cmd/tmctl/main.go` (греп `exitProjectLocked`; 0 clean · 1 infra · 2 completed-with-flags · 3 signature-stop банка · **4 потолок** · **5 graceful stop** · полоса отказов **1019** с классами `config_invalid`/`source_unreadable`/`project_locked`/`schema_mismatch`/`decisions_rejected`/`write_incomplete`/`book_incomplete`) и его зеркало-читатель `platform/internal/ingest/exit.go`.
**Потолочная окрестность — не здесь.** Семантика `--ceiling-usd` (КНИЖНЫЙ потолок в силе, не бюджет прогона; D39.122 п.2в) и формула аргумента потолка `committed + прирост×ставка` БЕЗ reserved (D39.123 п.2б, PD-158) живут одним маршрутом в `15-money-path.md` §2§3. Для эмиттера важно ОДНО следствие: платформа выставляет потолок впритык к холду, значит стоп по потолку = ровно исчерпание холда — потому событие потолка и различимый exit-код и перестали быть косметикой (D39.123 п.2ж).
**Дефекты-входы, из-за которых форма именно такая** (тела и текущий статус — `platform/docs/DEFECT_REGISTER.md`, греп `PD-79` · `PD-60` · `PD-61` · `PD-99` · `PD-107`): деньги шва едут ЦЕЛЫМИ micro-USD без строковых форм и float (урок PD-79: строковый `"null"` читался как ноль денег) · политика «журнал не пишется» выбирается ЯВНО, молчаливой третьей нет (PD-60) · буфера в эмиттере НЕТ вовсе — это и есть ответ на PD-61(а), потому что `os.Exit`/`log.Fatal`/паника пропускают defer и потеряли бы ровно `finished` и `ceiling` (`backend/internal/runevents/journal.go`, греп `There is no user-space buffer`; ⚠ имя `<jobdir>/events.ndjson` в ТЕЛЕ PD-61 — до-ратификационное и его закрывающий баннер имени не правит: канон — `events.jsonl` каталога книги, D39.106 п.2) · деньги и id книги не текут в INFO и на провод (PD-99, D39.84) · удаление аккаунта против живого холда — гейт перед появлением такой операции (PD-107).
## 5. Оговорки, которые переживают стройку
1. **Знаменатели голосов панелей `research/25` различаются между вариантами** (0/15 · 0/~18) — это РАЗНЫЕ плечи панелей, не усреднять. Оговорка нужна ровно там, где §2 цитирует счёт голосов отвергнутых альтернатив.
2. Тела D39.84/D39.85/D39.100 вошли сюда через цитаты D39.106 и доккомменты кода — при сомнении открывать первоисточники (живой D-лог/слайсы), а не эту сборку.
**Прежние разделы этого файла сняты как отработанный хендофф** (D39.131, `9cfe080`): пять развилок дизайна решены (формат = одиночный JSONL · политика PD-60 = громкая деградация · fsync каталога один раз при создании · значение exit-кодов · словарь) — ответы читать в `backend/internal/runevents/journal.go` и `runevents.go`, не здесь.