33 KiB
Эмиттер прогонных событий (шов движок→платформа) — сборка-норматив (строки 103+165, докс-пак 167)
⚠ ЭМИТТЕР ПОСТРОЕН И ПРИНЯТ (D39.131,
9cfe080, 14.08):events.jsonl(StreamVersion 1.1) · exit 4/5/полоса 10–19 · пер-вызовный репэйр-гейт; развилки §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), плюс полоса отказов 10–19; действующий словарь читать в коде движка и в 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-onlystatusэтого не делает ⇒ 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. Открытые развилки дизайна — отданы эмиттер-сессии
- Формат журнала — главная развилка, «решить дизайном» (строка 103).
research/25:74-76(GPT): «формат — развилка строки 103 (GPT): одиночный JSONL с ftruncate-ремонтом имеет гонку живого читателя и слеп к битой записи с валидным\n; альтернативы — пофайловые события (events/<seq>.json, temp+rename) либо CRC-фрейминг; побайтовая идентичность не требуется». ⚠ Учесть: тейлер и декодер платформы УЖЕ построены под одиночныйevents.jsonl(tail.go:17); выбор пофайловой/CRC-формы = согласованное изменение обеих сторон, пингом через оркестратора. - Политика «журнал не пишется» (PD-60): fsync-политика · ротация · отставание тейлера · заполненный диск; блокировать или деградировать — выбрать и записать явно, «обе позиции законны, молчаливой третьей нет» (
DEFECT_REGISTER.md:24). Смежное: «диск ограничить (ENOSPCна ФС леджера ломает денежные инварианты…)» (research/25:87-88) — опс-слой, но поведение эмиттера при ENOSPC — его дизайн. - fsync каталога — NOTE без строки при приёмке D39.122: «NOTE без строк: … fsync каталога»; решить в дизайне записи (durability rename/append на ext4).
- Значение нового exit-кода для потолка/graceful-stop (строка 165): 0/2/3 заняты (
main.go:30-52) — выбрать и внести в shell-контракт. - Словарь событий: форма
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. Оговорки сборки (снять при постройке)
- research/23 §2 (событийная секция) со словарём
events.goНЕ сверялся — строка 103 на него ссылается; расхождение словарей снять при дизайне, диффом кevents.go, не молча. - «Строка события в той же SQLite-транзакции, что чекпойнт» — ратифицированная ФОРМУЛА (D39.106 п.2), не факт кода: как она ложится на текущие транзакции
backend/internal/store— первая проектная работа сессии. - Знаменатели голосов панелей research/25 различаются между вариантами (0/15 · 0/~18) — это разные плечи панелей, не усреднять.
- Тела D39.84/D39.85/D39.100 в сборку вошли через цитаты D39.106 и доккомменты
events.go— при сомнении открыть первоисточники (живой D-лог/слайсы).