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

21 KiB
Raw Blame History

Эмиттер прогонных событий (шов движок→платформа) — сборка-норматив (строки 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 · полоса отказов 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, не здесь.