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

33 KiB
Raw Blame History

Эмиттер прогонных событий (шов движок→платформа) — сборка-норматив (строки 103+165, докс-пак 167)

ЭМИТТЕР ПОСТРОЕН И ПРИНЯТ (D39.131, 9cfe080, 14.08): events.jsonl (StreamVersion 1.1) · exit 4/5/полоса 1019 · пер-вызовный репэйр-гейт; развилки §5 решены (одиночный JSONL · PD-60 = громкая деградация · fsync каталога один раз · outbox «факт → outbox → файл», spend внутри settle-транзакции). Док остаётся справочником формы; фактический код первичен (backend/internal/runevents/ · pipeline/events.go · store/outbox.go). Платформенная половина ПОСТРОЕНА P6 и принята D39.132 (15.08) — шов закрыт с обеих сторон, PD-113 закрыт; упоминания «PD-113 открыт / остаток П-15» в теле ниже — историческая фактура эпохи стройки.

Статус: СБОРКА-НОРМАТИВ. Этот док ничего не решает сам — он собирает в одно место уже ратифицированные решения об эмиттере events.jsonl (строка 103 бэклога), разбросанные по D-нотам, research/25, реестру дефектов платформы и коду зоны ingest. Источники первичны; при любом конфликте этого дока с 05-decisions-log.md побеждает D-лог. Каждое утверждение несёт указатель; дословные цитаты — в кавычках-ёлочках или блоках.

Для кого: обязательное пре-чтение бэкенд-сессии эмиттера (строка 103 + деньги шва) — до промта и до первой строчки кода. Вторичный читатель — оркестратор при приёмке пака.

1. Что строим и зачем

Что. Строка 103 бэклога (docs/PROGRESS.md:149), дословно ядро ячейки:

«NDJSON-эмиттер прогонных событий со стабильным словарём (машинный канал живого статуса для платформы, research/23 §2): NDJSON-логи уже есть (LOG_FORMAT=json, авто-оси trace/book/chapter/stage/role — obs/logging.go:15-54), но сообщения прозой, version-хендшейка нет, а событие потолка отсутствует вовсе (деньги только в тексте ошибки stagerun.go:472-494); нужен словарь-enum + version-хендшейк первой строкой (образец terraform -json: минор = ignore-unknown, мажор = reject) + событие потолка; все границы — единичные call-sites в pipeline рядом с готовыми slog; человеческие логи уже на stderr — stdout-дисциплина соблюдена. Форма ратифицирована D39.106 (research/25 §эмиттер): events.jsonl каталога книги = outbox-проекция коммитов SQLite (та же транзакция, что чекпойнт) · fsync/seq/hello-версия · события денег и потолка · развилка формата (JSONL против пофайловых/CRC-фрейминга) — решить дизайном · status --json отдельным каналом (ремонт, тот же flock)».

Зачем (две ноги). (а) Свежесть статуса: по слову владельца 08.08 «эмиттер нужен СВЕЖЕСТИ статуса и деньгам шва, не первым экранам — резерв на это время = периодический status --json-резюнк (построен в P1)» (D39.119 п.1). (б) Деньги шва: пока события нет, «экран покажет „ошибка“ там, где верно „остановлено: лимиты“» (PD-113, DEFECT_REGISTER.md:18), причём после PD-158 «PD-113 остаётся ЕДИНСТВЕННЫМ открытым major до эмиттера; … консервативный потолок останавливает ровно на исчерпании холда, и „деньги кончились“ неотличимо от „инфраструктура упала“» (D39.123 п.2(ж)).

Что уже есть со стороны движка. NDJSON-инфраструктура логов: obs.NewLogger пишет slog в stderr, LOG_FORMAT=json даёт JSONHandler, UTC-время (backend/internal/obs/logging.go:15-31); оси trace_id/book/chapter/chunk/stage/role навешиваются из контекста автоматически (logging.go:41-54). Деньги при стопе по потолку существуют только в тексте ошибки: " (committed=$%.6f reserved=$%.6f, denied estimate=$%.6f, ch%d/chunk%d/%s)" и "pipeline: book USD ceiling reached ($%g)%s — %s or stop: %w" / "pipeline: daily USD ceiling reached ($%g)%s: %w" с сентинелом errReserveCeiling (backend/internal/pipeline/stagerun.go:489-513, сентинел — escalation.go:49). Trace id чеканится на каждый вызов в cmd/tmctl/main.go:67-70 — это сегодняшний кандидат в engine_run_id (так и записано платформой, platform/internal/ingest/events.go:75).

Что эмиттер НЕ решает (не тащить в пак): реконсилятор и свип — построены платформой, строки 138·139 закрыты (D39.123 п.3) · write-ahead intent / uncertain-эскроу / closing — отдельная строка 136 бэклога · пиннинг версии бинаря на прогон — «исполняется и резюмом — PD-143/134 закрыты» (D39.123 п.3) · движковая оценка $/глава — строка 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).

Словарь событий — ПРЕДЛОЖЕНИЕ платформы, записанное кодом: «The vocabulary below is therefore the platform's PROPOSAL, written as code so the engine zone can answer it with a diff» (events.go:5-6). Состав (events.go:37-147): StreamVersion = "1.0" · типы hello / progress / unit_done / bank_stop / ceiling / spend / finished · конверт {seq, type, time, data} · Hello{stream_version, engine_run_id, book_id, chunker_version} · Progress — пофазные счётчики draft/edit + eta_seconds (семантика done = «разрешено волной», ok/flagged/skipped — D39.122 п.2(б)) · UnitDone{chapter, unit, wave, shipped, flagged, reason} · BankStop{terms_proposed} (полная таблица — артефактом, не потоком: events.go:117-118) · Ceiling{halted} · Spend{committed_micro_usd} · Finished{outcome: clean|flagged|bank_stop|failed}.

3. Контракт декодера и дублей (PD-105 · D39.123 п.2(в))

Ратификация — D39.119 п.3, дословно:

«доставка событий шва = at-least-once; ДУБЛЬ seq — норма повторного чтения, декодер обязан принимать идемпотентно без фатала; тот же seq с ДРУГИМ payload = карантин прогона (research/25)».

Расхождение, разрешённое позже: «карантин прогона» (D39.119 п.3 и research/25:77-78 «тот же seq с другим payload = карантин прогона, не тихий no-op») уточнён D39.123 п.2(в): «PD-105-реализация: карантин ПРОЕКЦИИ (попытки), не прогона — уточнение D39.119; деньги прогона сеттлятся ремонт-каналом». Побеждает D39.123 как более свежая нота.

Фактическое поведение читателя (текущий текст PD-105, platform/docs/DEFECT_REGISTER.md:192 — закрыт ратификацией и реализацией):

«Транспорт — тейл events.jsonl с курсором, поэтому повторное чтение строк НОРМА: ingest.Tail пропускает seq <= last_seq идемпотентно и не возвращает ошибку, а pgstore.RunSink.Apply пере-проверяет тот же high-water mark ВНУТРИ транзакции эффекта. Тот же seq с ДРУГИМ payload = ErrPayloadConflict → карантин ПОПЫТКИ, то есть её проекции: материализация останавливается, а жизненный цикл прогона продолжается — движок тратит зарезервированные деньги, и наша неспособность прочитать журнал не повод их выбросить (pgstore.Quarantine пишет только run_attempts.quarantine_reason, свежесть переходит на ре-синк). Сверка по sha256 строки (run_attempts.last_line_sha256). Пропасть (seq > last+1) осталась ошибкой — строки потеряны, читать дальше нечего. … Фатальный ErrStreamGap на дубле в decoder.go остаётся только на ДЕВ-пути пайпа, где передоставки нет».

Что это значит для эмиттера, по коду читателя:

  • 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). Это смягчает, но не снимает формат-развилку §5 (битая запись С валидным \n остаётся слепым пятном JSONL — research/25:74-75).
  • Ключ — engine_run_id из hello, не прогон платформы; чужие run id в том же файле тейлер пропускает (events.go:63-65, tail.go:58-60).

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).

Событие потолка — факт без цифр. 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).

PD-113 (открытый major, закрывается этим паком) — текущий текст DEFECT_REGISTER.md:18:

«Стоп по потолку сегодня НЕразличим от инфраструктурного отказа, и контракт при этом запрещает называть его failed. Движок возвращает потолок ошибкой (errReserveCeiling, сверено в HEAD), а exitCode мапит всё нераспознанное в 1 — значит по коду выхода „деньги кончились“ и „упало“ это одно и то же число; события потолка не существует (строка 103). Платформа честно ставит failed, хотя BookStatus требует paused и „никогда не failed“, потому что стоп резюмируем. Единственный путь, которым платформа СЕГОДНЯ узнаёт о потолке, — событие потока, которого нет; ветка под него построена и запинена (TestACeilingHaltPausesTheRunWithItsReason, TestWhatTheUnitDidBecomesTheProductStatus, случай „a ceiling halt survives any exit“). Закрывается приходом эмиттера (строка 103); ⚠ до тех пор экран покажет „ошибка“ там, где верно „остановлено: лимиты“».

Строка 165 — различимые exit-коды, идёт ВМЕСТЕ с эмиттером (docs/PROGRESS.md:113):

«Различимые exit-коды tmctl: потолочная остановка и graceful-stop (сегодня оба = exit 1 ⇒ платформа отдаёт failed и на штатный стоп, и на исчерпание потолка — PD-113 платформы; после поправки формулы потолка (D39.123, PD-158) стоп по потолку = ровно исчерпание холда, цена неразличимости выросла; вскрыто приёмкой раннера на РЕАЛЬНОМ tmctl — фейк пробы умирал от сигнала)».

ЭТОТ АБЗАЦ — СНИМОК ДО ПАКА, а не действующий контракт (испр. 22.08 аудитом доков; исходный текст оставлен, потому что документ — заказ эмиттер-сессии, а не справочник). Пак D39.131 РАСШИРИЛ словарь ровно так, как этот раздел и просил: добавлены exit 4 (потолочная остановка) и exit 5 (graceful stop), плюс полоса отказов 1019; действующий словарь читать в коде движка и в platform/internal/ingest/exit.go. Ниже — состояние на момент постановки задачи: 0 clean · 2 completed-with-flags (*pipeline.CompletedWithFlags) · 3 signature-stop банка (*pipeline.WaveSignatureStop) · 1 — infra failure и всё прочее (backend/cmd/tmctl/main.go:30-52).

Семантика --ceiling-usd (эмиттер-сессия трогает потолочную окрестность — знать обязана). D39.122 п.2(в):

«--ceiling-usd = КНИЖНЫЙ потолок в силе, не бюджет прогона: леджер сравнивает с накопленным committed+reserved книги; пересчёт „пользовательский прирост → абсолют“ — обязанность ПЛАТФОРМЫ, данные уже отдаются (committed_usd/reserved_usd в status --json); вторая денежная ось НЕ заводится».

Поправлено D39.123 п.2(б) для стороны платформы:

«ПОПРАВКА к D39.122(в)/пингу: формула аргумента потолка = committed + прирост×ставка, БЕЗ reserved (PD-158)store.Open движка зануляет утёкший reserved до первой судимой резервации, read-only status этого не делает ⇒ reserved из status всегда leftover».

Следствие: платформа выставляет потолок впритык к холду, стоп по потолку = ровно исчерпание холда — потому событие потолка и различимый exit-код перестали быть «косметикой» (D39.123 п.2(ж)).

PD-79 (закрыт; вход для денежной дисциплины шва)DEFECT_REGISTER.md:90: «Строковый "null" читается как НОЛЬ денег.закрыто: литерал null судится ДО раскавычивания и оставляет значение нетронутым; кавычки снимает encoding/json, а не strings.Trim — слово null, пустая строка и экранированная цифра выходят тем, чем являются». Урок для эмиттера: денежные поля шва — целые micro-USD (events.go:139-141), без строковых форм и float.

PD-60 (открыт; пере-постановка «что делает движок, когда журнал не пишется»)DEFECT_REGISTER.md:24: «⚠ ПЕРЕ-ДИСПОЗИЦИЯ (эррата №15, 07.08): постановка строки устарела. Она рассуждает про 64 КиБ пайпа, а ратифицированный транспорт (D39.106 п.2) — тейл events.jsonl с курсором: у файла обратного давления в этом смысле нет вовсе, зато появляются свои свойства (fsync-политика, ротация, отставание тейлера, поведение при заполненном диске). Строка живёт, но переформулируется вместе со строкой 103 — не „блокировать движок или ронять события“, а „что делает движок, когда журнал не пишется“». Политика выбирается ЯВНО, «молчаливой третьей нет» (там же); ср. research/25:83.

PD-61 (открыт; два свойства ДО постройки)DEFECT_REGISTER.md:72: «(а) Сброс буфера на выходе: bufio.Writer вокруг потока плюс os.Exit/log.Fatal пропускает defer и теряет последние события — ровно те, что сообщают об окончании прогона. (б) Хвост при падении платформы: … ответ — НЕ сокет …, а журнал файлом: движок дописывает NDJSON в <jobdir>/events.ndjson, платформа тейлит его с чекпойнтом смещения в Postgres». Пункт (б) уже ОТВЕЧЕН ратифицированной формой (журнал файлом, D39.106); пункт (а) — живое требование к эмиттеру. ⚠ Имя <jobdir>/events.ndjson в PD-61 — до-ратификационное; канон — events.jsonl каталога книги (D39.106 п.2, tail.go:17).

PD-99 (закрыт; дисциплина логов вокруг спавна)DEFECT_REGISTER.md:110: «INFO-строка старта несёт имя команды и НЕ несёт argv …, поэтому ни id книги, ни потолок в долларах в поток INFO не попадают». Для эмиттер-сессии это норма-зеркало: деньги и book id не текут в INFO и на провод (D39.84); в журнале событий деньги едут только внутри spend (приватная таблица).

PD-107 (открыт; гейт, смежный с деньгами шва)DEFECT_REGISTER.md:118: «Удаление аккаунта обходит защиту PD-25 … прогон, идущий против этого холда, останется без того, кто его закроет. Кода удаления аккаунта в дереве нет вовсе (грепнуто) ⇒ строка = гейт перед появлением такой операции». Прямых обязанностей эмиттеру не назначает; в чек-листе — потому что входит в ратифицированный список пре-чтения (D39.123 п.4).

§5 и §6 ниже — ОТРАБОТАННЫЙ ХЕНДОФФ (пометка 22.08): эмиттер-сессия исполнена и принята (D39.131, 9cfe080), все пять развилок решены, чек-лист входного чтения исполнен. Разделы оставлены как провенанс заказа — исполнять их не нужно.

5. Открытые развилки дизайна — отданы эмиттер-сессии

  1. Формат журнала — главная развилка, «решить дизайном» (строка 103). research/25:74-76 (GPT): «формат — развилка строки 103 (GPT): одиночный JSONL с ftruncate-ремонтом имеет гонку живого читателя и слеп к битой записи с валидным \n; альтернативы — пофайловые события (events/<seq>.json, temp+rename) либо CRC-фрейминг; побайтовая идентичность не требуется». ⚠ Учесть: тейлер и декодер платформы УЖЕ построены под одиночный events.jsonl (tail.go:17); выбор пофайловой/CRC-формы = согласованное изменение обеих сторон, пингом через оркестратора.
  2. Политика «журнал не пишется» (PD-60): fsync-политика · ротация · отставание тейлера · заполненный диск; блокировать или деградировать — выбрать и записать явно, «обе позиции законны, молчаливой третьей нет» (DEFECT_REGISTER.md:24). Смежное: «диск ограничить (ENOSPC на ФС леджера ломает денежные инварианты…)» (research/25:87-88) — опс-слой, но поведение эмиттера при ENOSPC — его дизайн.
  3. fsync каталога — NOTE без строки при приёмке D39.122: «NOTE без строк: … fsync каталога»; решить в дизайне записи (durability rename/append на ext4).
  4. Значение нового exit-кода для потолка/graceful-stop (строка 165): 0/2/3 заняты (main.go:30-52) — выбрать и внести в shell-контракт.
  5. Словарь событий: форма events.go — предложение платформы, «so the engine zone can answer it with a diff» (events.go:5-6); движок вправе ответить диффом (состав полей progress/unit_done, eta_seconds, chunker_version и т.д.), расхождения — пингом, не молча.

6. Чек-лист входного чтения эмиттер-сессии

Ратифицированный список — D39.123 п.4: «промт эмиттера (103 + деньги шва; читать PD-95/105/79/99/107/60/61 + D39.106 + новые 165)»; продублирован в CURRENT-STATE.

# Что Где Зачем
1 Этот док docs/architecture/16-events-emitter.md сборка всего ниже
2 D39.106 (форма шва) грепать D39.106 (тело — слайс) канон транспорта
3 D39.119 п.3 · D39.122 п.2(в) · D39.123 п.2(б,в), п.3 там же :394, :416, :428, :430 at-least-once · семантика --ceiling-usd · формула PD-158 · карантин проекции
4 research/25 docs/research/25-seam-cold-review.md §Форма (:16-31) · §эмиттер (:72-83) · отвергнутое (:33-44) механизмы + чтобы отвергнутое не вернулось
5 PD-95 platform/docs/DEFECT_REGISTER.md:180 история двух устаревших транспортных текстов зоны; что НЕ строить
6 PD-105 там же :192 контракт дублей/пропасти/sha256
7 PD-79 :174 денежная дисциплина шва (null/строки)
8 PD-99 :92 argv/деньги не в INFO
9 PD-107 :56 гейт удаления аккаунта против холда
10 PD-60 · PD-61 :24 · :46 «журнал не пишется» + сброс буфера на выходе
11 PD-113 :18 major, который пак обязан закрыть
12 Строки 103 · 165 бэклога docs/PROGRESS.md:149, :113 сам заказ + exit-коды
13 Код читателя platform/internal/ingest/events.go (словарь) · decoder.go · tail.go писать ПОД существующий контракт чтения
14 Своя сторона backend/internal/obs/logging.go:15-54 · backend/internal/pipeline/stagerun.go:489-513 · backend/cmd/tmctl/main.go:30-52 готовые call-sites, текст потолка, exit-контракт

7. Оговорки сборки (снять при постройке)

  1. research/23 §2 (событийная секция) со словарём events.go НЕ сверялся — строка 103 на него ссылается; расхождение словарей снять при дизайне, диффом к events.go, не молча.
  2. «Строка события в той же SQLite-транзакции, что чекпойнт» — ратифицированная ФОРМУЛА (D39.106 п.2), не факт кода: как она ложится на текущие транзакции backend/internal/store — первая проектная работа сессии.
  3. Знаменатели голосов панелей research/25 различаются между вариантами (0/15 · 0/~18) — это разные плечи панелей, не усреднять.
  4. Тела D39.84/D39.85/D39.100 в сборку вошли через цитаты D39.106 и доккомменты events.go — при сомнении открыть первоисточники (живой D-лог/слайсы).