69 lines
No EOL
21 KiB
Markdown
69 lines
No EOL
21 KiB
Markdown
# Эмиттер прогонных событий (шов движок→платформа) — сборка-норматив (строки 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), и это был весь смысл заказа.** ⚠ **Пометка 10.09: слово «стоп» с тех пор РАЗДВОИЛОСЬ.** `D39.234` п.1 ратифицировал ДВЕ остановки человека — мягкую и жёсткую, — и обе остаются кодом выхода **5** (полоса заморожена, `D39.233` п.6): какая именно случилась, скажет ПРИЗНАК в кадре `finished`, а не код. Ниже по тексту «5 graceful stop» читать как «оба стопа»; в дереве второй остановки пока нет — строка **381**. До пака потолочный стоп был неотличим от инфраструктурного отказа (оба = 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** · полоса отказов **10–19** с классами `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`, не здесь. |