From ddcbf0c03d1d5eecc19cc7deea04d6b9e4034a65 Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 8 Sep 2026 14:59:05 +0300 Subject: [PATCH] Take in the platform zone's intake work as the zone built it: a cut waits for one of the host's slots, the tail's budget comes from the path, and the zone's own head tells the truth --- platform/cmd/tmplatformd/runner.go | 33 +- platform/cmd/tmplatformd/runner_test.go | 30 + platform/deploy/README.md | 30 + platform/docs/DEFECT_REGISTER.md | 36 +- platform/docs/STACK_DECISIONS.md | 2 +- platform/docs/platform-PROGRESS.md | 434 +++++++++++++- platform/go.mod | 2 +- platform/go.sum | 1 - platform/internal/books/books.go | 111 +++- platform/internal/books/books_test.go | 32 +- platform/internal/books/failfast_test.go | 55 ++ platform/internal/books/limit.go | 207 +++++++ platform/internal/books/limit_test.go | 562 ++++++++++++++++++ platform/internal/books/parse.go | 227 ++++++- platform/internal/books/render_test.go | 6 +- platform/internal/books/walk_test.go | 154 +++++ platform/internal/config/config.go | 45 +- platform/internal/config/effective_test.go | 108 +++- platform/internal/httpapi/capabilities.go | 13 +- platform/internal/httpapi/idempotency.go | 8 +- platform/internal/httpapi/idempotency_test.go | 29 + platform/internal/jobs/jobs.go | 37 +- platform/internal/jobs/jobs_test.go | 43 ++ platform/internal/metrics/metrics.go | 65 ++ platform/internal/metrics/metrics_test.go | 44 ++ platform/internal/pgstore/books.go | 6 + 26 files changed, 2181 insertions(+), 139 deletions(-) create mode 100644 platform/internal/books/limit.go create mode 100644 platform/internal/books/limit_test.go create mode 100644 platform/internal/books/walk_test.go diff --git a/platform/cmd/tmplatformd/runner.go b/platform/cmd/tmplatformd/runner.go index f49ef909..defd34a9 100644 --- a/platform/cmd/tmplatformd/runner.go +++ b/platform/cmd/tmplatformd/runner.go @@ -225,6 +225,21 @@ func startBackup(cfg config.Config, db *pgstore.Store, engine backup.Engine, log } } +// intakeConfig is what an operator chose about intake, in the intake's own terms. +// +// A function of its own, and for the reason the runner's knobs got one: a mapping written inline in +// the wiring is a mapping nothing can witness, and a knob dropped from it fails at nothing — the +// service simply runs on its default while the boot line prints the operator's number. +func intakeConfig(cfg config.Config) books.Config { + return books.Config{ + BooksDir: cfg.Intake.BooksDir, + EngineBinary: cfg.Runner.EngineBinary, + BookTemplate: cfg.Intake.BookTemplate, + MaxCuts: cfg.Intake.MaxCuts, + Pairs: intakePairs(cfg), + } +} + // startIntake wires the book upload, or explains at boot why this deployment takes none. // // A nil service leaves POST /books unmounted, which is the same shape every unbuilt contract route @@ -245,13 +260,8 @@ func startIntake(cfg config.Config, db *pgstore.Store, engine books.Manifester, Store: db, Engine: engine, Reader: reader, - Cfg: books.Config{ - BooksDir: cfg.Intake.BooksDir, - EngineBinary: cfg.Runner.EngineBinary, - BookTemplate: cfg.Intake.BookTemplate, - Pairs: intakePairs(cfg), - }, - Log: log, + Cfg: intakeConfig(cfg), + Log: log, } deps.Intake = svc deps.Upload = httpapi.UploadLimits{ @@ -554,6 +564,15 @@ func sweep(ctx context.Context, s sweeps, every, sweepBudget time.Duration, log // observe publishes the state of the control plane. A failure to measure never fails the sweep: it // is one WARN and the next tick tries again. func observe(ctx context.Context, s sweeps, log *slog.Logger) { + // First, and from memory rather than from the database: an instance whose database is unreachable + // is exactly when an operator wants to know whether its intake is saturated, and every reading + // below returns early on that error. + if s.books != nil { + c := s.books.CutCapacity() + s.metrics.ObserveCuts(metrics.Cuts{ + Limit: c.Limit, InFlight: c.InFlight, Waiting: c.Waiting, Waited: c.Waited, GaveUp: c.GaveUp, + }) + } o, err := s.db.Observe(ctx, runs.StalledAfter) if err != nil { log.Warn("the control plane's own state could not be read", "err", err) diff --git a/platform/cmd/tmplatformd/runner_test.go b/platform/cmd/tmplatformd/runner_test.go index 58402c7c..bc7421dc 100644 --- a/platform/cmd/tmplatformd/runner_test.go +++ b/platform/cmd/tmplatformd/runner_test.go @@ -161,3 +161,33 @@ func TestTheOperatorsExportKnobsReachTheDoor(t *testing.T) { "still running and answer their polls with a failure", got.StaleAfter, jobs.JobTimeout) } } + +// The operator's intake knobs reach the intake, and the cap among them. +// +// Same class as the runner's knobs above and the same reason for existing: a knob dropped from the +// wiring changes nothing visible — the service runs on its package default while the boot line +// prints the number the operator set, so the configuration LOOKS applied. The cap is the one that +// costs memory when it is silently four instead of the operator's figure. +func TestTheOperatorsIntakeKnobsReachTheIntake(t *testing.T) { + cfg := config.Config{} + cfg.Intake.BooksDir = "/srv/tm/books" + cfg.Intake.BookTemplate = "/etc/tm/book.yaml" + // Distinct from the package default (books.DefaultMaxCuts) on purpose: equal to it, this + // assertion would pass on a wiring that dropped the field entirely. + cfg.Intake.MaxCuts = books.DefaultMaxCuts + 3 + cfg.Runner.EngineBinary = "/opt/tm/tmctl" + cfg.LanguagePairs = []config.LanguagePair{{Source: "zh", Target: "ru", Available: true}} + + got := intakeConfig(cfg) + switch { + case got.MaxCuts != cfg.Intake.MaxCuts: + t.Errorf("MaxCuts is %d, want the operator's %d: TM_PLATFORM_MAX_CUTS does nothing and the host cuts on the package default", + got.MaxCuts, cfg.Intake.MaxCuts) + case got.BooksDir != cfg.Intake.BooksDir || got.BookTemplate != cfg.Intake.BookTemplate: + t.Errorf("the intake's paths did not arrive: %+v", got) + case got.EngineBinary != cfg.Runner.EngineBinary: + t.Errorf("EngineBinary is %q, want %q", got.EngineBinary, cfg.Runner.EngineBinary) + case len(got.Pairs) != 1: + t.Errorf("the declared pairs did not arrive: %+v", got.Pairs) + } +} diff --git a/platform/deploy/README.md b/platform/deploy/README.md index 4a19c6dd..356b8ebd 100644 --- a/platform/deploy/README.md +++ b/platform/deploy/README.md @@ -698,6 +698,36 @@ install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine/books (10 минут). Прокси перед сервисом обязан разрешать столько же: у него свои `client_max_body_size` и свои таймауты чтения, и молчаливо режет их он, а не мы. +⛔ **И ПОСЛЕ последнего байта маршрут МОЛЧИТ ещё до 3 мин 40 с — это отдельный таймаут прокси, не тот, +что выше.** Приняв тело, сервис доводит книгу до ответа: переводит строку в `parsing`, синхронно режет +книгу движком, записывает исход, перечитывает строку и пишет квитанцию идемпотентности. Верхняя граница +всего этого — `books.UploadSettle` = **220 с** (`internal/books/parse.go`), и это ГРАНИЦА, а не оценка: +хвост целиком идёт под одним дедлайном (`walk`), и шаг, добавленный в него завтра, её не сдвинет. +⇒ **у прокси таймаут ОТВЕТА (`proxy_read_timeout` у nginx, `timeout server` у HAProxy) обязан быть не +меньше `TM_PLATFORM_UPLOAD_DEADLINE + 220 с`** — на дефолтах это 10 мин + 3 мин 40 с = **13 мин 40 с**, +и брать с запасом. Дефолт у nginx — 60 с, то есть НИЖЕ этой границы более чем вдвое. +⚠ Цена ошибки несимметрична и потому её стоит назвать: прокси, оборвавший ожидание, вернёт человеку +ошибку на книге, которая на самом деле **ПРИНЯТА** — она уже в библиотеке и её дорежет очередь, — а +человек, повторив «неудавшуюся» загрузку, получит вторую копию книги. Поднимаете +`TM_PLATFORM_UPLOAD_DEADLINE` — поднимайте и этот таймаут на ту же величину; сам сервис за прокси не +отвечает и молча его не подвинет. +⚠ Число берётся ИЗ КОДА, не отсюда: `grep -n 'UploadSettle =' internal/books/parse.go` и сумма его +слагаемых там же. Бут отказывается стартовать, если `TM_PLATFORM_UPLOAD_DEADLINE` плюс этот хвост не +влезает в самое узкое из трёх окон приёма (`internal/config/config.go`), так что расходиться с кодом +эта величина может только в одну сторону — в бо́льшую осторожность прокси. + +⚠ **`TM_PLATFORM_MAX_CUTS` (дефолт 4) — сколько книг хост режет ОДНОВРЕМЕННО**, считая все три пути +сразу: загрузку, которая режет свою книгу в запросе, воркеров очереди и страховочный свип. Это +**сайзинг, а не отказ**: упёршаяся загрузка ждёт слот, а не получает ошибку, и если не дождалась — +книга принимается `parsing`, её дорезает очередь. Поднимать имеет смысл ровно настолько, насколько +хост тянет одновременных `tmctl manifest`: разбор идёт обычным дочерним процессом БЕЗ cgroup и без +`MemoryMax` (в отличие от прогонов), то есть этот потолок — единственная граница памяти, какая у +приёма есть. Насыщение видно метриками `tm_platform_cuts_in_flight` / `tm_platform_cuts_waiting` +против `tm_platform_cut_slots` и счётчиками `tm_platform_cut_waits_total`, +`tm_platform_cut_slot_timeouts_total`; растущий второй счётчик означает, что латентность приёма +делает ПОТОЛОК, а не движок. Ноль бут отвергает: «без лимита» и «не режем ничего» — противоположные +прочтения одного значения. + ⚠ **`TM_PLATFORM_STATE_DIR` меняется только когда живых прогонов нет.** Exit-маркер пишется по пути, вычисленному при СПАВНЕ, а читается по пути из текущей конфигурации: после смены каталога конец идущего прогона становится невидим, и реконсилятор перезапускает его как потерянный (PD-155). Путь diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 70ad805a..dec4dc49 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -24,10 +24,10 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-410 | bug | **major** | `internal/pgstore/readmodel.go`, греп `draftWork` (⚠ адрес пере-нацелен 06.09: строка уехала на ~130 позиций правками пака формы заказа); ⚠ **контраст ряда «движку передаётся ТОЛЬКО `--ceiling-usd`» БОЛЬШЕ НЕ ВЕРЕН** — `--max-units` едет в argv с 05.09 (`internal/runs/spawn.go`, греп `maxUnitsFor`), и draft-волна по ВСЕМ чанкам книги больше не фанится: её гейтит `volumeScope.allows` (`backend/internal/pipeline/volume.go`) | **Полоса и платёжная модель считают, что прогон работает над диапазоном «C глав от edit-фронта» в обеих волнах, а движок гонит волны последовательно от СВОИХ фронтов до денег — и на continuation над недочерновленной книгой (`draft_before ≥ chapters_before + C`) формула даёт `draftWork=0`: движок тратит весь потолок на черновики, полоса стоит 0/C весь прогон со stage `editing` на прогоне, который только черновит.** Зеркало дефекта, который `draftWork` чинил (тот закрывал «ready на 50%», этот открывает «0% при сделанной работе»). Это НЕ локальная ошибка формулы: любая полоса из (баз, C, D, E) где-то соврёт, пока платёжная модель («довести до конца C глав от `e0`») и движковый план работ («деньги в порядке волн от фронтов») не согласованы — лечение требует либо передавать движку план (диапазон/волны), либо выводить total полосы из движкового плана; `ceiling_chapters` сегодня до движка не доезжает вовсе. Архитектурное, ОТДЕЛЬНЫМ паком по слову оркестратора 28.08 («остановись и скажи»); подтверждено воркфлоу арифметикой на существующем зелёном пине (`TestARunOverADraftedBacklogOwesOnlyTheLastPass` держит `draftWork=0` в родственной точке) ⚠ **ПЕРЕ-ДИСПОЗИЦИОНИРОВАНА паком P12 (30.08): направление ЕСТЬ** — D39.165 §1, четыре части (цена от объёма исходника · стоп объёма в движке · хвост качества в тариф). ⛔ **(г) константу по сегодняшним числам НЕ калибровать** — гейт строка бэклога 202. Что остаётся открытым: не решение, а МЕХАНИКА, и она — отдельный пак (слово оркестратора 28.08), паком P12 не тронута. ⚠ **ПРИЧИНА СНЯТА ПАКОМ «ФОРМА ЗАКАЗА» (05.09), статус флипает ЛЕНДИНГ; и снята она НЕ так, как обещала строка 280 — расхождение названо.** Строка велела УДАЛИТЬ `draftWork`; зона этого не сделала и объясняет почему: дефект `draftWork` в том, что draft-волна фанилась по ВСЕЙ книге, а формула считала купленный диапазон. С проводкой `translate --max-units` волна больше по всей книге не фанится (`backend/internal/pipeline/volume.go`, `volumeScope.allows` гейтит и draft-волну), то есть **посылка формулы стала истинной, а сама формула — нужной**: непочерновленный остаток заказа это реальная работа прогона. Удалить её значило бы сломать полосу по-новому. ⇒ снята ПРИЧИНА, носитель оставлен. ⛔ **Испр. 06.09: здесь стояло «полоса при этом осталась в ГЛАВАХ, перевод её в юниты в этот минор не входит» — НЕВЕРНО, и это был третий носитель одной моей ошибки (отчёт зоны, состав минора, этот ряд).** `runDone`/`runTotal` первой ветвью считают в ЮНИТАХ, когда `runs.ordered_units` не null (`internal/pgstore/readmodel.go`, греп `ordered_units is not null`), и это пиньнуто `runs.TestACharacterOrdersBarIsCountedInWhatItActuallyBought`. `draftWork` при этом остаётся носителем ГЛАВНОЙ ветви — той, что считает заказ в главах, — и довод выше её и касается. ⇒ настоящее следствие: канон 0.11.0 описывает `Progress` как «in chapters» для ВСЕХ трёх форм прогона, а сборка для символьного заказа шлёт юниты; недостающий случай внесён в состав минора и отдан оркестратору 06.09. | fixed(628cc56) | воркфлоу-ревью P9 28.08 (линза bar:double-count), стоп-решение сессии P9 28.08: тянет архитектуру, не чинится в паке | -| PD-424 | bug | **major** | `internal/runs/reconcile.go` `reopen` (вердикт `deferred`) и `reconcileOne`, `internal/pgstore/runs.go` `StalledRuns`, `internal/pgstore/observe.go` | **ЖИВОЙ прогон с намертво заблокированной расплатой невидим на ВСЕХ поверхностях `PD-385` и не поддаётся `run abandon` — счётчик неудач не растёт НИКОГДА.** Цепь, воспроизведённая охотником приёмки на десяти проходах: юнит исчез без маркера → `restart` → `settle` возвращает `settlementBlocked, nil` (не ошибку) → `reopen` отказывается открыть новую попытку поверх незакрытого холда и возвращает вердикт `deferred` → `case deferred: return nil` → `reconcileOne` считает проход УСПЕШНЫМ и вдобавок зовёт `ClearRunDeferral`. Итог: `reconcile_failures` 0, `reconcile_after` NULL, `StalledRuns(5)` пуст, гейдж 0, холд заморожен, движок дёргается каждым проходом. Вывести из состояния может только пользователь, нажав Stop, — и никто ему об этом не говорит. ⚠ Это ТА ЖЕ болезнь, что `PD-384`/`PD-385`, но в ЖИВОЙ фазе, куда пак P11 не дошёл: он лечил вторую фазу (расплату) и её поверхности. ⚠ **СУЖЕНА пак P12 (30–31.08), НЕ ЗАКРЫТА — половина «невидим» вылечена, половина «ручки нет» стоит.** Приор зоны принят и исполнен: блокированная расплата — НЕУДАЧА владеющей фазы, и она считается. `restart` больше не выбрасывает вердикт `settle`: на `settlementBlocked` он возвращает `errSettlementBlocked` ДО `reopen` (прежний путь платил за запрос, чтобы узнать то, что `settle` только что сказал, писал WARN, винящий рестарт в блокировке расплаты, и отвечал `nil`, который вызывающий считал успешным проходом). `reconcileOne` ключует на этом сентинеле причину — ОДНА фраза на оба фазовых пути — и ЗАДЕРЖКУ: расписание РАСПЛАТЫ, а не живой фазы, потому что открытая резервация и есть resume-гейт пользователя, и получасовой живой бэкофф держал бы его resume за блокировку, с которой он ничего сделать не может. Вердикт `deferred` у `reopen` оставлен как был: после правки он достижим только на `settlementRaced` с моментально открытой резервацией — самоисправляется следующим проходом, и фаза расплаты прямо аргументирует, что гонка не должна попадать на счётчик оператора. Гард выключения (`ctx.Err()`) стоит: счёт, придуманный ОСТАНОВКОЙ демона, — тот самый дефект, который соседняя фаза уже нашла. Итог, проверенный пином `runs.TestALiveRunWhoseSettlementIsBlockedIsCountedAndReachesTheOperator`: `reconcile_failures` доходит до порога, строка появляется в `runs --stalled` (половина `live`, не `settling`), гейдж `tm_platform_runs_stalled` = 1, отсрочка не превышает `settlementBackoffCap`. Посадка M13 КРАСНАЯ адресно. ⚠ **ЧТО ОСТАЁТСЯ ОТКРЫТЫМ и почему не взято этим паком:** терминальная РУЧКА. `run abandon` ветвится по `runs.finished_at` и на живом прогоне уходит в живую ветку, а `AbandonRun` отказывает над попыткой, ещё называющей юнит. Ручка «процесса нет, закрой деньги» — НОВАЯ разрушительная операторская поверхность над деньгами, и её дизайн (кто вправе звать, чем доказывается отсутствие процесса, что будет, если процесс вернётся) — решение своего размера, а не хвост этой правки. Сегодня пользователь выводит прогон из состояния кнопкой Stop, и теперь об этом хотя бы говорят все три поверхности `PD-385`. ⚠ **РУЧКА ПОСТРОЕНА паком «закрыть цикл» (04.09) — строка СУЖЕНА до своего остатка, не закрыта.** Форма: та же команда `tmplatformctl run abandon`, потому что рантбук уже посылает оператора именно к ней; новой команды нет. **Кто вправе звать** — оператор из CLI и только оттуда: терминальный вердикт над деньгами на HTTP-поверхность не выходит, как и `run unquarantine`. **Чем доказывается отсутствие процесса** — систему СПРАШИВАЮТ, а не верят ей на слово: команда сама зовёт `runner.Alive` по имени юнита стоящей попытки, и ответов **ЧЕТЫРЕ**, а не два — «нет» пропускает, «есть» отказывает, **недостижимая шина отказывает тоже** (отсутствие ответа не есть отсутствие процесса), и — четвёртый, найденный адверсариальным проходом уже по готовой работе — **«это НЕ ТОТ systemd» отказывает тоже**: `systemctl --user` отвечает про менеджер СПРАШИВАЮЩЕГО, а прогоны живут у пользователя демона, и там юнит, о котором чужой менеджер не слышал, неотличим от кончившегося (замерено: `ActiveState=inactive`, выход 0). Различитель — владелец `TM_PLATFORM_STATE_DIR`, ⚠ **поэтому переменная деплоя обязана быть экспортирована в оболочке оператора, иначе команда ОТКАЖЕТ** (рантбук об этом теперь говорит). Пины: `cmd/tmplatformctl/abandon_proof_test.go` — четыре ответа различителя, три отказа и путь «gone» через саму команду, плюс отказ над ЖИВЫМ транзиентным юнитом под systemd-гейтом. Второе условие проверяет хранилище ВНУТРИ транзакции: `reconcile_failures >= abandonAfter`, тот же пол, что у settling-ветви, — видеть и иметь право уничтожить это разные разрешения. **Деньги:** попытка с базовой линией и отчитанной цифрой рассчитывается по РАЗНИЦЕ (та же арифметика, что печатает `StalledRuns`), холд закрывается как `settled` с ценой в леджере; попытку без базовой линии ценить нечем — холд возвращается ЦЕЛЫМ, довод settling-ветви, доехавший до живой половины. **Что будет, если процесс вернётся** — названо прямо, потому что это цена ручки: живой движок продолжит тратить против СВОЕГО книжного потолка, а холд аккаунта уже закрыт, значит эту трату не оплатит никто и провайдеру платит ДЕПЛОЙ. Она ограничена (потолком этого же прогона) и не эксплуатируема аккаунтом: попасть в состояние можно только через прогон, который собственный реконсилятор деплоя не смог закончить. Тем же касанием закрыта долговечная половина `PD-418`. Пины: `internal/runs/abandon_orphan_test.go` — отказ без доказательства · отказ ниже пола · вердикт `orphan` со списанием по отчёту · целый холд там, где цены нет · осиротевшая попытка ЖИВОГО прогона. ⚠ **ОСТАТОК, ради которого строка остаётся открытой:** отсутствие процесса доказывается ОДИН раз, в момент команды, и между этим ответом и коммитом транзакции есть окно, в которое юнит может подняться заново. Окно узкое, его цена названа выше, но оно есть, и закрыть его может только фактически транзакционная проба — арбитр в хранилище, а не ещё одна проверка в CLI. ⚠ **ДИСПОЗИЦИЯ ПАКА «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» (06–07.09, в дереве, статус флипает лендинг):** остаток закрыт транзакционным арбитром, и окон оказалось ДВА, а не одно. Второе в ряду не названо: `AbandonRun` под книжной блокировкой читает ЛЮБУЮ живую попытку, поэтому разблокировавшаяся расплата плюс `restart` подставляют под доказательство о попытке N попытку N+1 с живым процессом. Доказательство приходит парой (`AbandonOrder.ProofAttemptID` + `ProofSpawns`) и сверяется в той же транзакции; расхождение — `ErrProofOvertaken`. `unit_name` в свидетели не годится (`ReleaseSpawnClaim` возвращает его в NULL), отсюда монотонная колонка `run_attempts.spawns`, инкремент внутри самого CAS `RecordSpawn` (миграция `00034`).| open | приёмка оркестратора №19 по паку P11 (охотник вне карты, воспроизведено 10 проходами) | +| PD-424 | bug | **major** | `internal/runs/reconcile.go` `reopen` (вердикт `deferred`) и `reconcileOne`, `internal/pgstore/runs.go` `StalledRuns`, `internal/pgstore/observe.go` | **ЖИВОЙ прогон с намертво заблокированной расплатой невидим на ВСЕХ поверхностях `PD-385` и не поддаётся `run abandon` — счётчик неудач не растёт НИКОГДА.** Цепь, воспроизведённая охотником приёмки на десяти проходах: юнит исчез без маркера → `restart` → `settle` возвращает `settlementBlocked, nil` (не ошибку) → `reopen` отказывается открыть новую попытку поверх незакрытого холда и возвращает вердикт `deferred` → `case deferred: return nil` → `reconcileOne` считает проход УСПЕШНЫМ и вдобавок зовёт `ClearRunDeferral`. Итог: `reconcile_failures` 0, `reconcile_after` NULL, `StalledRuns(5)` пуст, гейдж 0, холд заморожен, движок дёргается каждым проходом. Вывести из состояния может только пользователь, нажав Stop, — и никто ему об этом не говорит. ⚠ Это ТА ЖЕ болезнь, что `PD-384`/`PD-385`, но в ЖИВОЙ фазе, куда пак P11 не дошёл: он лечил вторую фазу (расплату) и её поверхности. ⚠ **СУЖЕНА пак P12 (30–31.08), НЕ ЗАКРЫТА — половина «невидим» вылечена, половина «ручки нет» стоит.** Приор зоны принят и исполнен: блокированная расплата — НЕУДАЧА владеющей фазы, и она считается. `restart` больше не выбрасывает вердикт `settle`: на `settlementBlocked` он возвращает `errSettlementBlocked` ДО `reopen` (прежний путь платил за запрос, чтобы узнать то, что `settle` только что сказал, писал WARN, винящий рестарт в блокировке расплаты, и отвечал `nil`, который вызывающий считал успешным проходом). `reconcileOne` ключует на этом сентинеле причину — ОДНА фраза на оба фазовых пути — и ЗАДЕРЖКУ: расписание РАСПЛАТЫ, а не живой фазы, потому что открытая резервация и есть resume-гейт пользователя, и получасовой живой бэкофф держал бы его resume за блокировку, с которой он ничего сделать не может. Вердикт `deferred` у `reopen` оставлен как был: после правки он достижим только на `settlementRaced` с моментально открытой резервацией — самоисправляется следующим проходом, и фаза расплаты прямо аргументирует, что гонка не должна попадать на счётчик оператора. Гард выключения (`ctx.Err()`) стоит: счёт, придуманный ОСТАНОВКОЙ демона, — тот самый дефект, который соседняя фаза уже нашла. Итог, проверенный пином `runs.TestALiveRunWhoseSettlementIsBlockedIsCountedAndReachesTheOperator`: `reconcile_failures` доходит до порога, строка появляется в `runs --stalled` (половина `live`, не `settling`), гейдж `tm_platform_runs_stalled` = 1, отсрочка не превышает `settlementBackoffCap`. Посадка M13 КРАСНАЯ адресно. ⚠ **ЧТО ОСТАЁТСЯ ОТКРЫТЫМ и почему не взято этим паком:** терминальная РУЧКА. `run abandon` ветвится по `runs.finished_at` и на живом прогоне уходит в живую ветку, а `AbandonRun` отказывает над попыткой, ещё называющей юнит. Ручка «процесса нет, закрой деньги» — НОВАЯ разрушительная операторская поверхность над деньгами, и её дизайн (кто вправе звать, чем доказывается отсутствие процесса, что будет, если процесс вернётся) — решение своего размера, а не хвост этой правки. Сегодня пользователь выводит прогон из состояния кнопкой Stop, и теперь об этом хотя бы говорят все три поверхности `PD-385`. ⚠ **РУЧКА ПОСТРОЕНА паком «закрыть цикл» (04.09) — строка СУЖЕНА до своего остатка, не закрыта.** Форма: та же команда `tmplatformctl run abandon`, потому что рантбук уже посылает оператора именно к ней; новой команды нет. **Кто вправе звать** — оператор из CLI и только оттуда: терминальный вердикт над деньгами на HTTP-поверхность не выходит, как и `run unquarantine`. **Чем доказывается отсутствие процесса** — систему СПРАШИВАЮТ, а не верят ей на слово: команда сама зовёт `runner.Alive` по имени юнита стоящей попытки, и ответов **ЧЕТЫРЕ**, а не два — «нет» пропускает, «есть» отказывает, **недостижимая шина отказывает тоже** (отсутствие ответа не есть отсутствие процесса), и — четвёртый, найденный адверсариальным проходом уже по готовой работе — **«это НЕ ТОТ systemd» отказывает тоже**: `systemctl --user` отвечает про менеджер СПРАШИВАЮЩЕГО, а прогоны живут у пользователя демона, и там юнит, о котором чужой менеджер не слышал, неотличим от кончившегося (замерено: `ActiveState=inactive`, выход 0). Различитель — владелец `TM_PLATFORM_STATE_DIR`, ⚠ **поэтому переменная деплоя обязана быть экспортирована в оболочке оператора, иначе команда ОТКАЖЕТ** (рантбук об этом теперь говорит). Пины: `cmd/tmplatformctl/abandon_proof_test.go` — четыре ответа различителя, три отказа и путь «gone» через саму команду, плюс отказ над ЖИВЫМ транзиентным юнитом под systemd-гейтом. Второе условие проверяет хранилище ВНУТРИ транзакции: `reconcile_failures >= abandonAfter`, тот же пол, что у settling-ветви, — видеть и иметь право уничтожить это разные разрешения. **Деньги:** попытка с базовой линией и отчитанной цифрой рассчитывается по РАЗНИЦЕ (та же арифметика, что печатает `StalledRuns`), холд закрывается как `settled` с ценой в леджере; попытку без базовой линии ценить нечем — холд возвращается ЦЕЛЫМ, довод settling-ветви, доехавший до живой половины. **Что будет, если процесс вернётся** — названо прямо, потому что это цена ручки: живой движок продолжит тратить против СВОЕГО книжного потолка, а холд аккаунта уже закрыт, значит эту трату не оплатит никто и провайдеру платит ДЕПЛОЙ. Она ограничена (потолком этого же прогона) и не эксплуатируема аккаунтом: попасть в состояние можно только через прогон, который собственный реконсилятор деплоя не смог закончить. Тем же касанием закрыта долговечная половина `PD-418`. Пины: `internal/runs/abandon_orphan_test.go` — отказ без доказательства · отказ ниже пола · вердикт `orphan` со списанием по отчёту · целый холд там, где цены нет · осиротевшая попытка ЖИВОГО прогона. ⚠ **ОСТАТОК, ради которого строка остаётся открытой:** отсутствие процесса доказывается ОДИН раз, в момент команды, и между этим ответом и коммитом транзакции есть окно, в которое юнит может подняться заново. Окно узкое, его цена названа выше, но оно есть, и закрыть его может только фактически транзакционная проба — арбитр в хранилище, а не ещё одна проверка в CLI. ⚠ **ДИСПОЗИЦИЯ ПАКА «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» (06–07.09, ЗАЛАНДЁН `fda0679`, акт `D39.221`):** остаток закрыт транзакционным арбитром, и окон оказалось ДВА, а не одно. Второе в ряду не названо: `AbandonRun` под книжной блокировкой читает ЛЮБУЮ живую попытку, поэтому разблокировавшаяся расплата плюс `restart` подставляют под доказательство о попытке N попытку N+1 с живым процессом. Доказательство приходит парой (`AbandonOrder.ProofAttemptID` + `ProofSpawns`) и сверяется в той же транзакции; расхождение — `ErrProofOvertaken`. `unit_name` в свидетели не годится (`ReleaseSpawnClaim` возвращает его в NULL), отсюда монотонная колонка `run_attempts.spawns`, инкремент внутри самого CAS `RecordSpawn` (миграция `00034`).| fixed | приёмка оркестратора №19 по паку P11 (охотник вне карты, воспроизведено 10 проходами) | | PD-440 | bug | **major** | ⚠ **два из трёх адресов УМЕРЛИ вместе с лечением, и это пере-написано, а не пере-нацелено:** `DefaultPerChapter` и `BookRunContext.ChaptersLeft` удалены паком формы заказа. Живой носитель лечения — `internal/pricing/pricing.go`, греп `func (m Model) Hold`; аргумент потолка по-прежнему `internal/runs/spawn.go`, греп `func (m meter) bookCap`; движковая половина — `backend/internal/pipeline/stagerun.go` (резерв под шаг при `waves.workers` параллельных вызовах) | **КНИГУ ЧЕРЕЗ API НЕЛЬЗЯ ДОЧИТАТЬ НИКАКИМИ ДЕНЬГАМИ — покупка перестаёт покупать.** Прирост книжного потолка за прогон равен `chaptersLeft × DefaultPerChapter`, то есть максимум `chaptersLeft × $0.03`. Движок перед редакторским шагом РЕЗЕРВИРУЕТ оценку на каждый параллельный вызов (`waves.workers`, на стенде 4 × ≈$0.069 = ≈$0.28) и останавливается на ПЕРВОЙ отказанной резервации, не дожидаясь уже летящих. Как только прирост становится меньше стоимости одного шага волны, каждый следующий прогон встаёт МГНОВЕННО и не продвигает книгу ни на юнит — а прирост только УБЫВАЕТ, потому что `chaptersLeft` уменьшается с каждой дочитанной главой. ⚠ **Воспроизведено живьём дважды подряд, платным прогоном 04.09** (книга `bk_SS5VES2JELESJSTR`, 10 глав, после первой готовой главы `chaptersLeft = 9`, прирост $0.27): прогоны `run_CR2RN76NAKYKAJE5` и `run_VSXPMJE53KJQQAOS` — оба `paused/credit_exhausted` за 10–15 секунд, `committed` не сдвинулся ни на микро-доллар (278319 до и после), холд вернулся целиком. Движковая строка обоих: `book USD ceiling reached ($0.548319) (committed=$0.278319 reserved=$0.207805, denied estimate=$0.069828) … reserve ceiling reached`. Это ПРОДУКТОВЫЙ СТОП, а не «шкала короче»: занижение ставки ×4.47 (`D39.179` п.1) до сих пор читалось как неудобство, и вот порог, за которым оно становится недостижимостью результата. ⚠ **Денег сам стоп НЕ жжёт** — отменённые вызовы этих двух прогонов не успели уйти (латентность 20 и 31 мс, `model_actual` пуст, 0 токенов); неучтённый расход — отдельная строка `PD-441`. ⚠ **Чинится СОГЛАСОВАННО, одной стороной нельзя:** поднять ставку — платформенная половина и она меняет всю шкалу покупки; не бросать летящие вызовы и/или считать резерв по фактической конкуренции — движковая. Проверка без Go: `sqlite3 -header -column "file:?mode=ro" "select trace_id, count(*), round(sum(cost_usd),6) from request_log group by trace_id;"` ⚠ **РАДИУС ПЕРЕ-СНЯТ 04.09 ЗАМЕРОМ ЗА $0 (заказ оркестратора №22): дефект НЕ про хвост длинной книги, а про ПОРОГ, ниже которого книга не переводится ВООБЩЕ.** Порог считается из измеренного, а не из оценки: движок в собственном отказе назвал резерв ОДНОГО редакторского вызова до шестого знака — `committed=$0.278319 reserved=$0.207805, denied estimate=$0.069828, ch5/chunk0/edit` при потолке `$0.548319`. Отсюда волна из четырёх стоит **$0.277633 — наблюдённая величина: три реально стоявших резерва ($0.207805) плюс отказанный ($0.069828)**. ⚠ Не `4 × $0.069828 = $0.279312`: первая редакция ряда взяла именно это произведение, а оно СКОНСТРУИРОВАНО — вызовы волны не равны между собой, промпты чанков разной длины, и `3 × $0.069828 = $0.209484` расходится с наблюдёнными `$0.207805` на `$0.001679` (средний реальный резерв $0.069268 против отказанного $0.069828). Произведение оставлено рядом как ВЕРХНЯЯ оценка, и держать оба стоит: порог считается по обоим одинаково — `$0.277633 / $0.03 = 9.25` и `$0.279312 / $0.03 = 9.31`, — то есть **вывод не зависит от выбора числа**. ⚠ Счёт «три» выведен, а не напечатан: он следует из `waves.workers: 4` минус отказанный, и подтверждается отношением `$0.207805 / $0.069828 = 2.976`. Порог — `chaptersLeft × $0.03 ≥ стоимости волны`, то есть **книга проходит только с 10 непереведённых глав и больше**. Поправка числа — оркестратора №22, 04.09. Следствия: **последние ДЕВЯТЬ глав любой книги недостижимы**, и **книга из ≤9 глав не переводится никогда** — ни первой покупкой, ни повторными, потому что потолок КУМУЛЯТИВНЫЙ (`bookCap = committed + increment`, `internal/runs/spawn.go:212-214`) и запас каждого прогона равен ровно инкременту, сколько бы книга ни потратила раньше. ⚠ **Предъявлено живьём и бесплатно:** пятиглавая книга `bk_P5UCXKHDO4HFWSH3` заведена через настоящий интейк ($0 — `manifest` без ключей), и `GET /v0/books/{id}/run-options` вернул `max_chapters: 5`, то есть **максимум, который пользователь вообще может купить этой книге, — $0.15 против $0.279312 за волну**; для книги пака после первой дочитанной главы тот же вызов вернул `max_chapters: 9` ($0.27). Разрезка короткой книги ПОБАЙТНО та же (юниты по главам 2·1·1·2·1 против тех же 2·1·1·2·1 у первых пяти глав длинной), то есть короткая книга содержит ровно тот кусок `ch5/chunk0`, чей резерв измерен. ⚠ **ЧЕГО В ЗАМЕРЕ НЕТ, называю прямо:** сама короткая книга НЕ запускалась — её прогон способен потратить до $0.15 на черновую волну, а санкция владельца на платные прогоны закрылась вместе с паком; арифметика замкнута, живой прогон короткой книги остаётся неснятым. ⚠ **ЛЕЧЕНИЕ В ДЕРЕВЕ ПАКА «ФОРМА ЗАКАЗА» (05.09), статус флипает ЛЕНДИНГ.** Константа `DefaultPerChapter` удалена целиком: цена берётся из проекции движка (`manifest --json`: `expected_usd` по главам, `step_max_usd`), а холд считается `k × ожидаемое(заказ) + step_max` — **аддитивно, не `max(...)`**, потому что последнему вызову заказа нужен запас под ЦЕЛУЮ резервацию сверх уже списанного. Пины: `pricing.TestTheLastTwoChaptersOfABookAreBuyable` (последние две главы и последняя глава покупаются), `pricing.TestTheHoldIsTheCushionedBillPlusOneWholeReservation`. ⚠ И вторая половина той же стены, найденная этим же паком: книжный `expected_usd` ВКЛЮЧАЕТ `book_once_usd` — плоские $2.00 на боевом `pipeline-c1` независимо от длины книги, — поэтому в основу холда он НЕ кладётся (иначе пятиглавая книга за $0.25 снова непокупаема); пин `pricing.TestAFlatBookLevelBoundDoesNotPutATwoDollarThresholdUnderEveryPurchase`. | fixed(628cc56) | платный сквозной прогон через API, пак «закрыть цикл» 04.09 (воспроизведено дважды) | -| PD-441 | bug | **major, деньги** | движковая половина — `backend/internal/pipeline/stagerun.go`, ветвь с комментарием `No 2xx ever arrived: nothing was billed` (`releaseReservation` + строка `request_log` с нулевой ценой); платформенная — `internal/runs/reconcile.go` `settleOne`, который берёт цифру движка как истину | **ЛЕДЖЕР ДЕНЕГ — НИЖНЯЯ ГРАНИЦА, и потолок сам её создаёт: halt отменяет вызовы, которые УЖЕ УШЛИ к провайдеру, и их цена не записывается никуда.** Замерено 04.09 на `run_FS3O4UO5EDTKVF42`: волна пустила четыре редакторских вызова, один дошёл (6846+13729 токенов, **$0.063404**, 156444 мс) и своим коммитом сорвал книжный потолок, а три оставшихся были отменены В ТОТ ЖЕ МИГ, отработав **120756, 156469 и 156470 мс** — с `cost_usd 0`, нулевыми токенами и пустым `model_actual`. Вызов, проживший 2–2.6 минуты, до провайдера дошёл почти наверняка. **Порядок величин:** соседние ЗАВЕРШЁННЫЕ вызовы того же прогона стоили $0.017409 и $0.063404 ⇒ незаписанное — порядка **$0.05–0.19 на ОДНО срабатывание потолка** против **$0.278319** за всю книгу. То есть механизм, который защищает от перерасхода, сам создаёт неучтённый расход до двух третей стоимости книги за одно срабатывание. ⚠ **Дыра не в принципе, а в ПОКРЫТИИ, и это делает строку заказом, а не наблюдением.** Консервативная оценка в движке уже построена и ратифицирована (`stagerun.go`, «paid 2xx with zero usage; settling the reservation estimate to keep the ceiling honest», pack-13 point-9): проект уже принял правило «нельзя ослеплять потолок нулём там, где вызов был платным». Но условие требует `resp` — ОТВЕТА. Вызов, отменённый в полёте, ответа не получает, уходит другой веткой, и та честно пишет «nothing was billed» — что верно для транспортного отказа, не выпустившего запрос, и НЕВЕРНО для отмены на 156-й секунде. **Направление лечения (именно направление):** сеттлить оценку и при отмене вызова, который успел уйти, различая по ФАКТУ ухода; латентность — кандидат-различитель (20 мс против 156 000 мс разводит два случая без всякого рассуждения), но порог обязан быть обоснован замером, а не назначен. ⚠ Списание провайдером НЕ ДОКАЗАНО: биллинга провайдера у зоны нет, утверждается ровно наблюдаемое — вызовы шли минутами и их цены в леджере нет. ⚠ Правка `stagerun.go` — ЧУЖАЯ зона, отсюда только строка. Смежная строка единого бэклога — **78**. Воспроизведение: `sqlite3 -header -column "file:?mode=ro" "select id, ts, stage, role, latency_ms, cost_usd, err from request_log where err='context canceled' and latency_ms > 1000;"` ⚠ **ДИСПОЗИЦИЯ ПАКА «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» (06–07.09, в дереве, статус флипает лендинг):** платформенная половина закрыта — каждая строка `settlement` несёт БАЗИС (`pgstore.SettlementBasis`, `complete`/`halted` по тому, кончил ли движок попытку сам), операторская таблица печатает `≥` и легенду. ⛔ Формулировка «показать маржу ПОЛЬЗОВАТЕЛЮ» СНЯТА решением оркестратора №23: направление ущерба — по деплою, баланс пользователя завышен в его же пользу, показывать ему нечего. Движковая половина названа поимённо и ушла строкой бэклога.| open | платный сквозной прогон через API, пак «закрыть цикл» 04.09 (усилено разбором оркестратора №22 по коду движка) | -| PD-438 | bug | **major** | `internal/ingest/tail.go` `apply` (греп `ErrForeignStreamAhead`), `internal/ingest/tail.go` `tailFrom` (греп `mine := want`), `internal/runs/reconcile.go` `drainJournal` | **Владение потоком не переживало проход свипа, и чужие события ложились на оплаченную попытку.** `mine` — возвращаемое значение, а не колонка: следующий проход выводит его заново из `pos.LastSeq > 0`. Пока тейлер ПРОХОДИЛ мимо чужого handshake'а, двигая байтовый хинт, следующий проход читал чужую область с курсором, который говорит «это наше»: чужой `ceiling` ставил `paused` живому оплаченному прогону, `unit_done` писал главы, которых никто не покупал, а чужой `seq`, попавший на наш, давал `ErrPayloadConflict` и карантинил здоровую проекцию. ⚠ **Воспроизведено в двух вариантах, до и после правки P13** (копия дерева, `Tail` дважды — форма, в которой его зовёт `drainJournal`): на `HEAD 494c4ef` законный чужой hello травит проекцию (`pass 2 applied a FOREIGN event (seq [2])`), а чужой мажор карантинит; после пере-упорядочивания пункта 3 P13 ОБА травят — то есть заказанная правка расширила старый класс с законных чужих строк на любые. Конфликт payload воспроизводится одинаково на обеих версиях. ⚠ Лечение в дереве пака P13 (03.09): чтение ОСТАНАВЛИВАЕТСЯ на чужом handshake'е, если платформа именовала поток попытки и наши строки уже применены (`pos.LastSeq > 0`), и курсор на нём остаётся — владение выводится заново каждый проход, ценой одного пере-чтения строки. Прежняя семантика «пройти мимо» сохранена там, где она безопасна: пока `LastSeq == 0`, наш handshake ещё может лежать ниже по файлу (пин `TestAForeignStreamBeforeOursIsWalkedPastRatherThanStoppedOn`), и у безымянной легаси-попытки (`want == ""`) — пин `TestAnotherAttemptsStreamInTheSameJournalIsSkipped` зелёный. Пины: `TestAForeignStreamDoesNotOwnOurAttemptOnTheNextPass` (три формы чужого hello × три следующих строки). ⚠ **ДОФИКС 03.09: обоснование «вред ограничен по времени» ОПРОВЕРГНУТО приёмкой исполнением, и оно было моё.** Довод «чужой handshake ⇒ нашего процесса в книге уже нет» неверен: платформа отдаёт КАЖДОМУ спавну одной и той же попытки один и тот же id потока (`internal/runs/spawn.go`, греп `engineStreamID(l.RunID, l.AttemptNo)`), а движок, найдя, что этот id уже писал события книги, минтит СВЕЖИЙ и продолжает (`backend/internal/pipeline/events.go`, греп `fresh := obs.NewTraceID()`). Значит «чужой» hello может писать ЖИВОЙ процесс нашей же попытки, и прогон способен простоять припаркованным весь свой срок. Радиус сужен опровергателем приёмки: путь — конъюнкция двух признанных сбоев (претензия на юнит, отчитавшаяся ошибкой, плюс исчезновение юнита без exit-marker), а не рядовой рестарт. **Наблюдаемость дана в том же дофиксе:** тейлер сообщает о парковке отдельным сигналом (`internal/ingest/tail.go`, греп `ErrForeignStreamAhead`) вместо тихого `nil`; свип пишет WARN с прогоном, попыткой и смещением; и парковка теперь СТОИТ РЯДОМ с карантином в условии канала починки (`maybeResync`, греп `!l.Quarantined && !parked`), то есть у припаркованной попытки снова есть свежесть через `tmctl status`. Пины: `TestTheParkIsReportedSoTheCallerCanTellItFromBeingCaughtUp` (без DSN), `TestAParkedAttemptStillGetsTheRepairChannel` (DSN). ⚠ Чего по-прежнему НЕТ: строки в `runs`, гейджа и колонки — парковка живёт длину одного прохода и в БД не пишется; дать ей строку значит завести колонку, то есть миграцию, а она этим нарядом не заказана ⚠ **ОСТАТОК НАЗВАН 04.09 приёмкой:** лечение в дереве и заленджено (`6ae3e76`), но ПРИПАРКОВАННАЯ попытка не видна ни в `runs`, ни гейджем, ни колонкой — у этого класса нет ни одного из трёх сигналов, которые были у карантина, а `maybeResync` до дофикса такую попытку пропускал. Дофикс дал ей имя (`ErrForeignStreamAhead`), WARN с троттлингом и вход в ресинк; поверхностная видимость для ОПЕРАТОРА остаётся открытой и идёт райдером платформенного пака. ⚠ **ДИСПОЗИЦИЯ ПАКА «ЗАКРЫТЬ ЦИКЛ» (04.09): ОТЛОЖЕНО СОЗНАТЕЛЬНО, и вот довод, а не отсутствие времени.** Все три недостающих сигнала — строка в `runs`, гейдж, колонка — читают ХРАНИЛИЩЕ, а парковка в хранилище не пишется: она живёт длину одного прохода и выводится заново каждым свипом. Дать ей любой из трёх значит завести колонку на `run_attempts`, то есть миграцию и новое состояние, которое надо СНИМАТЬ, — а снимать его некому, кроме того же свипа, который его поставил. Это не райдер, а отдельная работа со своим дизайном («парковка как СОСТОЯНИЕ, а не как исход прохода»), и сделать её заодно с дверью выдачи значило бы решить её мимоходом. Что у класса ЕСТЬ сегодня: имя (`ErrForeignStreamAhead`), WARN с троттлингом и вход в канал починки — оператор, который смотрит в ЖУРНАЛ, парковку видит; невидима она тому, кто смотрит только в таблицу. Строка остаётся открытой ровно на этом. ⚠ **ДИСПОЗИЦИЯ ПАКА «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» (06–07.09, в дереве, статус флипает лендинг):** остаток видимости закрыт колонкой `run_attempts.parked_at` (миграция `00034`), гейджем `tm_platform_parked_attempts` и своей ячейкой PARKED в `tmplatformctl runs`. Довод прошлого пака против колонки на проверке не устоял: парковка не состояние жизненного цикла, а проекция, пере-выводимая из (курсор, журнал) каждым проходом, поэтому снимает её тот же проход — в отличие от карантина, который снимает человек. Метка ставится ОДИН раз, чтобы «припаркован с какого момента» имело ответ.| open | самопроверка пака P13 (веер, линза шва; воспроизведено сессией на копии) | +| PD-441 | bug | **major, деньги** | движковая половина — `backend/internal/pipeline/stagerun.go`, ветвь с комментарием `No 2xx ever arrived: nothing was billed` (`releaseReservation` + строка `request_log` с нулевой ценой); платформенная — `internal/runs/reconcile.go` `settleOne`, который берёт цифру движка как истину | **ЛЕДЖЕР ДЕНЕГ — НИЖНЯЯ ГРАНИЦА, и потолок сам её создаёт: halt отменяет вызовы, которые УЖЕ УШЛИ к провайдеру, и их цена не записывается никуда.** Замерено 04.09 на `run_FS3O4UO5EDTKVF42`: волна пустила четыре редакторских вызова, один дошёл (6846+13729 токенов, **$0.063404**, 156444 мс) и своим коммитом сорвал книжный потолок, а три оставшихся были отменены В ТОТ ЖЕ МИГ, отработав **120756, 156469 и 156470 мс** — с `cost_usd 0`, нулевыми токенами и пустым `model_actual`. Вызов, проживший 2–2.6 минуты, до провайдера дошёл почти наверняка. **Порядок величин:** соседние ЗАВЕРШЁННЫЕ вызовы того же прогона стоили $0.017409 и $0.063404 ⇒ незаписанное — порядка **$0.05–0.19 на ОДНО срабатывание потолка** против **$0.278319** за всю книгу. То есть механизм, который защищает от перерасхода, сам создаёт неучтённый расход до двух третей стоимости книги за одно срабатывание. ⚠ **Дыра не в принципе, а в ПОКРЫТИИ, и это делает строку заказом, а не наблюдением.** Консервативная оценка в движке уже построена и ратифицирована (`stagerun.go`, «paid 2xx with zero usage; settling the reservation estimate to keep the ceiling honest», pack-13 point-9): проект уже принял правило «нельзя ослеплять потолок нулём там, где вызов был платным». Но условие требует `resp` — ОТВЕТА. Вызов, отменённый в полёте, ответа не получает, уходит другой веткой, и та честно пишет «nothing was billed» — что верно для транспортного отказа, не выпустившего запрос, и НЕВЕРНО для отмены на 156-й секунде. **Направление лечения (именно направление):** сеттлить оценку и при отмене вызова, который успел уйти, различая по ФАКТУ ухода; латентность — кандидат-различитель (20 мс против 156 000 мс разводит два случая без всякого рассуждения), но порог обязан быть обоснован замером, а не назначен. ⚠ Списание провайдером НЕ ДОКАЗАНО: биллинга провайдера у зоны нет, утверждается ровно наблюдаемое — вызовы шли минутами и их цены в леджере нет. ⚠ Правка `stagerun.go` — ЧУЖАЯ зона, отсюда только строка. Смежная строка единого бэклога — **78**. Воспроизведение: `sqlite3 -header -column "file:?mode=ro" "select id, ts, stage, role, latency_ms, cost_usd, err from request_log where err='context canceled' and latency_ms > 1000;"` ⚠ **ДИСПОЗИЦИЯ ПАКА «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» (06–07.09, ЗАЛАНДЁН `fda0679`, акт `D39.221`; ⚠ СТАТУС РЯДА ЛЕНДИНГ НЕ ФЛИПАЕТ — ряд двухзонный, и открытым его держит движковая половина, ушедшая строкой единого бэклога **331**):** платформенная половина закрыта — каждая строка `settlement` несёт БАЗИС (`pgstore.SettlementBasis`, `complete`/`halted` по тому, кончил ли движок попытку сам), операторская таблица печатает `≥` и легенду. ⛔ Формулировка «показать маржу ПОЛЬЗОВАТЕЛЮ» СНЯТА решением оркестратора №23: направление ущерба — по деплою, баланс пользователя завышен в его же пользу, показывать ему нечего. Движковая половина названа поимённо и ушла строкой бэклога.| open | платный сквозной прогон через API, пак «закрыть цикл» 04.09 (усилено разбором оркестратора №22 по коду движка) | +| PD-438 | bug | **major** | `internal/ingest/tail.go` `apply` (греп `ErrForeignStreamAhead`), `internal/ingest/tail.go` `tailFrom` (греп `mine := want`), `internal/runs/reconcile.go` `drainJournal` | **Владение потоком не переживало проход свипа, и чужие события ложились на оплаченную попытку.** `mine` — возвращаемое значение, а не колонка: следующий проход выводит его заново из `pos.LastSeq > 0`. Пока тейлер ПРОХОДИЛ мимо чужого handshake'а, двигая байтовый хинт, следующий проход читал чужую область с курсором, который говорит «это наше»: чужой `ceiling` ставил `paused` живому оплаченному прогону, `unit_done` писал главы, которых никто не покупал, а чужой `seq`, попавший на наш, давал `ErrPayloadConflict` и карантинил здоровую проекцию. ⚠ **Воспроизведено в двух вариантах, до и после правки P13** (копия дерева, `Tail` дважды — форма, в которой его зовёт `drainJournal`): на `HEAD 494c4ef` законный чужой hello травит проекцию (`pass 2 applied a FOREIGN event (seq [2])`), а чужой мажор карантинит; после пере-упорядочивания пункта 3 P13 ОБА травят — то есть заказанная правка расширила старый класс с законных чужих строк на любые. Конфликт payload воспроизводится одинаково на обеих версиях. ⚠ Лечение в дереве пака P13 (03.09): чтение ОСТАНАВЛИВАЕТСЯ на чужом handshake'е, если платформа именовала поток попытки и наши строки уже применены (`pos.LastSeq > 0`), и курсор на нём остаётся — владение выводится заново каждый проход, ценой одного пере-чтения строки. Прежняя семантика «пройти мимо» сохранена там, где она безопасна: пока `LastSeq == 0`, наш handshake ещё может лежать ниже по файлу (пин `TestAForeignStreamBeforeOursIsWalkedPastRatherThanStoppedOn`), и у безымянной легаси-попытки (`want == ""`) — пин `TestAnotherAttemptsStreamInTheSameJournalIsSkipped` зелёный. Пины: `TestAForeignStreamDoesNotOwnOurAttemptOnTheNextPass` (три формы чужого hello × три следующих строки). ⚠ **ДОФИКС 03.09: обоснование «вред ограничен по времени» ОПРОВЕРГНУТО приёмкой исполнением, и оно было моё.** Довод «чужой handshake ⇒ нашего процесса в книге уже нет» неверен: платформа отдаёт КАЖДОМУ спавну одной и той же попытки один и тот же id потока (`internal/runs/spawn.go`, греп `engineStreamID(l.RunID, l.AttemptNo)`), а движок, найдя, что этот id уже писал события книги, минтит СВЕЖИЙ и продолжает (`backend/internal/pipeline/events.go`, греп `fresh := obs.NewTraceID()`). Значит «чужой» hello может писать ЖИВОЙ процесс нашей же попытки, и прогон способен простоять припаркованным весь свой срок. Радиус сужен опровергателем приёмки: путь — конъюнкция двух признанных сбоев (претензия на юнит, отчитавшаяся ошибкой, плюс исчезновение юнита без exit-marker), а не рядовой рестарт. **Наблюдаемость дана в том же дофиксе:** тейлер сообщает о парковке отдельным сигналом (`internal/ingest/tail.go`, греп `ErrForeignStreamAhead`) вместо тихого `nil`; свип пишет WARN с прогоном, попыткой и смещением; и парковка теперь СТОИТ РЯДОМ с карантином в условии канала починки (`maybeResync`, греп `!l.Quarantined && !parked`), то есть у припаркованной попытки снова есть свежесть через `tmctl status`. Пины: `TestTheParkIsReportedSoTheCallerCanTellItFromBeingCaughtUp` (без DSN), `TestAParkedAttemptStillGetsTheRepairChannel` (DSN). ⚠ Чего по-прежнему НЕТ: строки в `runs`, гейджа и колонки — парковка живёт длину одного прохода и в БД не пишется; дать ей строку значит завести колонку, то есть миграцию, а она этим нарядом не заказана ⚠ **ОСТАТОК НАЗВАН 04.09 приёмкой:** лечение в дереве и заленджено (`6ae3e76`), но ПРИПАРКОВАННАЯ попытка не видна ни в `runs`, ни гейджем, ни колонкой — у этого класса нет ни одного из трёх сигналов, которые были у карантина, а `maybeResync` до дофикса такую попытку пропускал. Дофикс дал ей имя (`ErrForeignStreamAhead`), WARN с троттлингом и вход в ресинк; поверхностная видимость для ОПЕРАТОРА остаётся открытой и идёт райдером платформенного пака. ⚠ **ДИСПОЗИЦИЯ ПАКА «ЗАКРЫТЬ ЦИКЛ» (04.09): ОТЛОЖЕНО СОЗНАТЕЛЬНО, и вот довод, а не отсутствие времени.** Все три недостающих сигнала — строка в `runs`, гейдж, колонка — читают ХРАНИЛИЩЕ, а парковка в хранилище не пишется: она живёт длину одного прохода и выводится заново каждым свипом. Дать ей любой из трёх значит завести колонку на `run_attempts`, то есть миграцию и новое состояние, которое надо СНИМАТЬ, — а снимать его некому, кроме того же свипа, который его поставил. Это не райдер, а отдельная работа со своим дизайном («парковка как СОСТОЯНИЕ, а не как исход прохода»), и сделать её заодно с дверью выдачи значило бы решить её мимоходом. Что у класса ЕСТЬ сегодня: имя (`ErrForeignStreamAhead`), WARN с троттлингом и вход в канал починки — оператор, который смотрит в ЖУРНАЛ, парковку видит; невидима она тому, кто смотрит только в таблицу. Строка остаётся открытой ровно на этом. ⚠ **ДИСПОЗИЦИЯ ПАКА «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» (06–07.09, ЗАЛАНДЁН `fda0679`, акт `D39.221`):** остаток видимости закрыт колонкой `run_attempts.parked_at` (миграция `00034`), гейджем `tm_platform_parked_attempts` и своей ячейкой PARKED в `tmplatformctl runs`. Довод прошлого пака против колонки на проверке не устоял: парковка не состояние жизненного цикла, а проекция, пере-выводимая из (курсор, журнал) каждым проходом, поэтому снимает её тот же проход — в отличие от карантина, который снимает человек. Метка ставится ОДИН раз, чтобы «припаркован с какого момента» имело ответ.| fixed | самопроверка пака P13 (веер, линза шва; воспроизведено сессией на копии) | ## Открытые — minor @@ -41,17 +41,17 @@ | PD-448 | bug | minor | `internal/runs/runs.go:170`=`ErrNotResumable` (отказ, который получает проигравший) и `internal/runs/control_test.go:846` `TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` (пин гарантии) | **ДВОЙНОЙ КЛИК ПО «ПРОДОЛЖИТЬ»: проигравший гонку получает ОТКАЗ вместо прогона, когда два вызова не успевают пройти проверку состояния в одном окне.** Гарантия зоны сформулирована в шапке самого теста: «Both calls pass the state check together and race for attempt N+1 on the unique index; the loser must answer with the run the winner re-opened, not with a not-found». Замер 05.09: под нагрузкой машины (`load average 42–48` на 8 ядрах, тринадцать чужих `tmmutate`) вызовы СЕРИАЛИЗУЮТСЯ — победитель успевает перевести прогон в `translating` до того, как проигравший дойдёт до своей проверки, — и проигравший получает `runs: the run cannot be continued: it is translating` (`control_test.go:863`). ⚠ **Пользовательский смысл: человек, дважды нажавший «продолжить», видит ошибку, хотя прогон в этот момент СТАРТОВАЛ.** ⚠ **Деньги не затронуты:** отказ приходит ДО взятия холда, так что второй холд не берётся — та половина гарантии («ровно один холд») держится и на отказном пути. ⚠ **Это НЕ `PD-420`:** тот ряд про другой тест (`internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError`); проверено грепом, имени этого теста в реестре не было ни в одном ряду. **Воспроизведение:** полный пакет `go test ./internal/runs/` при загруженной машине — упал 2 раза из 2; ИЗОЛИРОВАННО (`-run TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer -count=5`) — 5/5 `ok` за 10 с. То есть окно существует и открывается голоданием по процессору, а не правкой кода. **Направление лечения (не решение):** проигравшему различать «прогон уже идёт, потому что его только что открыл параллельный резюм» от «прогон идёт сам по себе» — первое обязано вернуть прогон, второе законно отказывает. | open | замер зоны 05.09 при ревизии документации: батарея краснела дважды, разобрано до конкретного теста | | PD-443 | bug | minor | `internal/exports/exports.go` `Build`/`Sweep`, `internal/pgstore/queries/exports.sql`, миграция `00032_exports.sql` (`on delete cascade`) | **Артефакт экспорта может пережить СТРОКУ, которая одна умеет его найти, и тогда его не удалит никто.** Путь артефакта детерминирован (`//.`), но каталог со строками не сверяет ничто: GC удаляет только те пути, которые НЕСУТ строки. Три живых пути, найдены адверсариальным проходом по готовой работе: **(а)** демон убит между `rename` движка и `FinishExport` — путь в строку так и не попал, свип пере-водит её в `failed`, файл остаётся навсегда (не экзотика: `KillMode=mixed` в юните и дренаж очереди на 10 с делают это штатным исходом рестарта под нагрузкой); **(б)** книга удалена — `on delete cascade` сносит строки, файлы и каталог книги под `Dir` не трогает никто (сегодня у `DeleteBook` вызывающих нет, но внешний ключ уже стоит и ловушка взведена); **(в)** артефакт, чей unlink не прошёл, — ⚠ **ЭТОТ ПУТЬ ЗАКРЫТ ТЕМ ЖЕ ПАКОМ**: `path` теперь переживает смену состояния и снимается только после реального удаления (`UnlinkedExports`/`ForgetExportPath`), то есть unlink стал ПОВТОРЯЕМЫМ; пин `TestAFileTheSweepCouldNotRemoveKeepsItsRowPointingAtIt`. Четвёртый путь — временные файлы движка (`.<имя>.tmp-*`) после SIGKILL — тоже закрыт (`exports.Service.discard`), потому что `tmctl build` контекста не читает и его всегда добивает SIGKILL. ⚠ **Лечение (а) и (б) — одно и то же и это НЕ райдер:** сверка каталога со строками, то есть реконсилятор файловой системы со своим дизайном (что считать сиротой, как отличить чужой файл от своего, что делать с каталогом книги, которой нет). Делать его заодно с дверью значило бы решить мимоходом. **Радиус сегодня ограничен:** TTL двери — сутки по умолчанию, файлы лежат под одним каталогом деплоя, и оператор видит рост диска раньше, чем что-либо ломается. ⚠ **ДВА СИБЛИНГА, НАЗВАННЫЕ ПРИЁМКОЙ 04.09 — один закрыт, один остаётся здесь.** (I) STAGING-файл движка (`.<имя>.tmp-*`) переживает смерть демона ПОСРЕДИ сборки: `discard` живёт в том же процессе, поэтому при `kill -9` убирать его некому, а следующая сборка того же экспорта не случится — строка уже не `pending`. Это тот же класс, что (а), и лечится тем же реконсилятором каталога. (II) ⚠ **ВТОРОЙ ВОРКЕР НА ОДНОЙ СБОРКЕ — ЗАКРЫТО ТЕМ ЖЕ ДОФИКСОМ, а не только названо:** оба писали бы по ОДНОМУ пути (он выводится из id экспорта), и уборка проигравшего снесла бы файл, только что опубликованный выигравшим. Клейм сделан ИСКЛЮЧАЮЩИМ — `where … and state = 'pending' and started_at is null`, — поэтому второй воркер получает `ErrExportSettled` и до сборки не доходит. Сегодня недостижимо (River ведёт одно задание рода за раз), но именно это превращает вторую реплику из потерянного артефакта в дубль сборки. Пин — в `TestAQueuedBuildAndAClaimedOneAreJudgedOnDifferentClocks`. Воспроизведение (а): `kill -9` демона между строкой лога `export built` и следующей записью в БД. | open | адверсариальный проход пака «закрыть цикл» по своей же работе, 04.09 | | PD-444 | bug | minor | `internal/httpapi/bank.go:142`=`Invalid(w, r)` (ветка строгого разбора) против `internal/httpapi/bank.go:146`=`Invalid(w, r, items...)` (ветка валидации); механизм — `internal/httpapi/problem.go:181`=`Errors []Item` | **Дверь правок банка отвечает `400 invalid_request` БЕЗ единого указателя на то, ЧТО не так.** Замерено живым прогоном 04.09: две попытки с чужими именами членов (`decisions` вместо `corrections`) вернули голое тело, оба ответа `Content-Length: 119` — ни `errors[]`, ни имени члена, ни позиции. ⚠ **Сам отказ ВЕРЕН и оспаривать его нечего:** схема канона объявляет `additionalProperties: false` на обоих уровнях, и незнакомый член — это клиент, уверенный, что он что-то задал; молчаливая версия этого — правка, применённая наполовину. Дефект в другом: канон нигде не требует МОЛЧАТЬ о том, какой член виноват, а механизм у зоны уже построен и на соседней ветке применяется — `validateCorrections` возвращает `[]Item` и отдаёт его в `Invalid`. Ветка строгого разбора теряет даже то, что у неё в руках: `encoding/json` называет поле в тексте ошибки (`json: unknown field "decisions"`), а обработчик его не только не отдаёт, но и не логирует — соседняя ветка «тело не доехало» логирует. Цена: клиент двери — редактор пользователя, и `400` без адреса отлаживается перебором. ⚠ Лечение НЕ трогает канон: `errors[]` в нём уже объявлен, довести до ответа нужно ветку разбора. | open | живой платный прогон пака «закрыть цикл», наблюдение H12, 04.09 | -| PD-446 | doc | minor | канон `docs/architecture/14-api-contract/` §`PausedReason`; носители в зоне — `internal/pgstore/books.go:1246`=`PausedCreditExhausted = ingest.PausedCreditExhausted` и `internal/httpapi/v0.go:1025`=`ContractHaltReason` | **`paused_reason: credit_exhausted` при 34% НЕТРОНУТОГО баланса — слово называет кредит там, где исчерпан потолок ПРОГОНА.** Замерено 04.09: в один и тот же момент прогон стоял с `paused_reason: credit_exhausted`, а `GET /v0/usage` отвечал `state: ok, halt_reason: null` при остатке `0.102494` из `0.30`. ⚠ **Оба ответа ВЕРНЫ, и счётная половина этого дефекта уже вылечена — пере-открывать её нельзя:** halt читается с АККАУНТА, а не с паузы последнего прогона (`ReadUsage`, разбор в `books.go` над телом), и именно поэтому `halt_reason` здесь честно пуст. Остаётся ОДНО: у `PausedReason` и `AccountHaltReason` разные словари с одним и тем же единственным значением, и это слово — `credit_exhausted`. Пользователь, читающий «кредит исчерпан» рядом с «остаток 34%», получает противоречие, которого в фактах нет. ⚠ Зона правку канона своей рукой не делает: кандидат в состав минора — значение `PausedReason` обязано называть ПРОГОН (`run_ceiling_reached`), словарь аккаунта не трогается. Пока минор не принят, ряд держит вопрос открытым. ⚠ **ЗАКРЫТО ДЕРЕВОМ ПАКА «ФОРМА ЗАКАЗА» (05.09), статус флипает ЛЕНДИНГ.** У паузы прогона появилось СВОЁ слово: `run_limit_reached` (`ingest.PausedRunLimitReached`), и `CeilingPause(ScopeBook)` отдаёт теперь его, а не `credit_exhausted`. Аккаунтное слово осталось за аккаунтом (`ReadUsage`, `balance <= 0`). ⚠ Разделены и ДВА вердикта реконсилятора, которые делили одно слово: `ceilingSpent` → `run_limit_reached`, `creditUnavailable` → `credit_exhausted` (`internal/runs/reconcile.go`, греп `if v == creditUnavailable`) — лечения противоположны (купить снова против пополнить), и пользователь, которому сказали не то, идёт делать не то. Миграция 00033 пере-называет и старые строки. Пины: `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets`, `runs.TestAnInterruptedRunWithNothingLeftIsPausedAndStaysPausedThroughAResume`, `runs.TestAnInterruptedRunThatTheBalanceCannotCarryIsPaused`. | fixed(628cc56) | живой платный прогон пака «закрыть цикл», наблюдение H14, 04.09 | +| PD-446 | doc | minor | канон `docs/architecture/14-api-contract/` §`PausedReason`; носители в зоне — `internal/pgstore/books.go:1252`=`PausedCreditExhausted = ingest.PausedCreditExhausted` и `internal/httpapi/v0.go:1025`=`ContractHaltReason` | **`paused_reason: credit_exhausted` при 34% НЕТРОНУТОГО баланса — слово называет кредит там, где исчерпан потолок ПРОГОНА.** Замерено 04.09: в один и тот же момент прогон стоял с `paused_reason: credit_exhausted`, а `GET /v0/usage` отвечал `state: ok, halt_reason: null` при остатке `0.102494` из `0.30`. ⚠ **Оба ответа ВЕРНЫ, и счётная половина этого дефекта уже вылечена — пере-открывать её нельзя:** halt читается с АККАУНТА, а не с паузы последнего прогона (`ReadUsage`, разбор в `books.go` над телом), и именно поэтому `halt_reason` здесь честно пуст. Остаётся ОДНО: у `PausedReason` и `AccountHaltReason` разные словари с одним и тем же единственным значением, и это слово — `credit_exhausted`. Пользователь, читающий «кредит исчерпан» рядом с «остаток 34%», получает противоречие, которого в фактах нет. ⚠ Зона правку канона своей рукой не делает: кандидат в состав минора — значение `PausedReason` обязано называть ПРОГОН (`run_ceiling_reached`), словарь аккаунта не трогается. Пока минор не принят, ряд держит вопрос открытым. ⚠ **ЗАКРЫТО ДЕРЕВОМ ПАКА «ФОРМА ЗАКАЗА» (05.09), статус флипает ЛЕНДИНГ.** У паузы прогона появилось СВОЁ слово: `run_limit_reached` (`ingest.PausedRunLimitReached`), и `CeilingPause(ScopeBook)` отдаёт теперь его, а не `credit_exhausted`. Аккаунтное слово осталось за аккаунтом (`ReadUsage`, `balance <= 0`). ⚠ Разделены и ДВА вердикта реконсилятора, которые делили одно слово: `ceilingSpent` → `run_limit_reached`, `creditUnavailable` → `credit_exhausted` (`internal/runs/reconcile.go`, греп `if v == creditUnavailable`) — лечения противоположны (купить снова против пополнить), и пользователь, которому сказали не то, идёт делать не то. Миграция 00033 пере-называет и старые строки. Пины: `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets`, `runs.TestAnInterruptedRunWithNothingLeftIsPausedAndStaysPausedThroughAResume`, `runs.TestAnInterruptedRunThatTheBalanceCannotCarryIsPaused`. | fixed(628cc56) | живой платный прогон пака «закрыть цикл», наблюдение H14, 04.09 | | PD-435 | bug | minor | `internal/pgstore/readmodel.go` `runDone`/`runTotal`/`runStage` (читают монотонный `editWave`), `internal/pgstore/runs.go` `StartRun` (снятие базлайнов) | **Полоса прогона на деплое, где редактора УБРАЛИ, не доходит до единицы: знаменатель тарифицирует edit-волну, которой не будет.** Вторая половина `PD-403`; первая (счёт книги) закрыта паком P12 эпохой формы конвейера, эта — нет, и попытка закрыть её тем же носителем была ОТКАЧЕНА тем же паком: полоса, переведённая на ПРИСВАИВАЕМУЮ эпоху, перестаёт быть монотонной — замерено, один прогон читал 4/4, затем 2/2 на своих же двух попытках при неизменной `structure_version`, что канон запрещает прямо (строка 200, «одна монотонная дробь на всю работу прогона»). ⚠ **Почему это не однострочник:** правильный носитель — форма, под которой работает ЭТОТ прогон, записанная на самом прогоне; а в `StartRun` она ещё НЕ ИЗВЕСТНА — движок объявляет её первым progress-событием прогона, и попытка снять её раньше это ровно `PD-401`, закрытый пином. Значит запись должна происходить на ПЕРВОМ объявлении прогона, и тогда нужен разбор, что делать со второй попыткой того же прогона, объявившей другую форму (сегодняшний ответ — ничего, потому что флаг монотонен). Лечение: колонка формы на `runs`, заполняемая первым объявлением, плюс решение о смене формы между попытками одного прогона. Пин обратной стороны уже стоит и покраснеет, если кто-то снова переведёт полосу на эпоху: `pgstore.TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans`. ⚠ **Почему `minor`, когда родительская `PD-403` была `major` — вопрос приёмки, отвечаю доводом, а не весом.** У `PD-403` мажорной её делали ДЕНЬГИ: шкала покупки продавала уже переведённые главы повторно. Эта половина ЗАКРЫТА — `ChaptersLeft` считается через эпоху, дважды не продаётся ничего, и `PD-410` (тоже про деньги, ручка «купить N глав» ограничивает не главы, а доллары) остаётся `major` именно поэтому. Здесь не двигается ни один микро-доллар: прогон делает всю купленную работу, закрывается `ready`, леджер сходится, следующая покупка предлагает правильный остаток. Врёт ДРОБЬ и подпись (`editing` на деплое без редактора) — контрактно видимо и потому не `info`, но это отчёт о работе, а не её оплата. Плюс достижимость: нужна смена ФОРМЫ деплоя под книгой, которая уже прошла редактирующий пайплайн, — не обычный путь, в отличие от `PD-410`, который кусает на КАЖДОЙ покупке. Если приёмка сочтёт довод слабым — поднять до `major` дешевле, чем спорить: работа от веса не меняется | open | адверсариальный проход пака P12 (30–31.08), замерено на живом Postgres через продовые пути записи | | PD-433 | hardening | minor | `internal/pgstore/identity.go:106`=`if !errors.Is(err, errIdentityRace)` (ретрай), `:118`=`var errIdentityRace`, ветвь `on conflict … do nothing` в `upsertIdentityOnce` | **Ветвь, обслуживающая ЛЕГИТИМНУЮ гонку двух первых логинов одной новой личности, не исполняется НИ ОДНИМ тестом.** Форма та же, что у `PD-380` и `PD-86`: единственная точка принуждения объявленного свойства, мутация переживает полную батарею, транзитивной страховки нет. Механика: под READ COMMITTED `for update` в `LockIdentity` не блокирует строку, которой ещё нет, поэтому оба гонщика проходят; проигравший видит `created == 0`, получает `errIdentityRace` и ретраит — и именно ретрай спасает его от 500 на пути логина. ⛔ **Снос ветви — только с доказательством недостижимости:** она обслуживает легитимную гонку, и её снос = 500 на легитимном пути (класс `PD-369`, который этот же пак и закрывал). Диспозиция: запинить конкурентным тестом ЛИБО доказать недостижимость. Форма теста, если пинить: в пакете `pgstore`, ~40 раундов, в каждом СВЕЖАЯ личность и 4 горутины на `UpsertIdentity`; утверждать, что все четыре вернули `nil`, что userID у всех ОДИН, что `identities` держит одну строку, что грант посева записан ОДИН раз и что число строк в `users` равно числу раундов — последнее ловит настоящую утечку, осиротевшего пользователя от проигравшего, который успел `CreateUser` до проигрыша. Проверять посадкой (снять арм `errIdentityRace` — тест обязан покраснеть), потому что вероятностный тест без посадки не доказывает, что он вообще кусает | open | пак P12 (30.08), греп непокрытых ветвей по своим путям | | PD-434 | bug | minor | `internal/pgstore/sink.go` `ApplyStatus`, `internal/runs/reconcile.go` `maybeResync` | **Канал ПОЧИНКИ прогресса не чинит прогресс: ресинк не материализует ничего, что читает экран.** `maybeResync` существует ровно для прогона, чей поток в карантине или чей курсор не двигался — «это теперь единственный источник», говорит его собственный комментарий. Но `ApplyStatus` писал пер-волновые цифры отчёта ТОЛЬКО в `runs.draft_done/draft_total/edit_done/edit_total`, у которых не было ни одного читателя (`PD-411`), и не трогает ни `unit_resolutions`, ни `chapters` — а вся полоса выведена из `chapters`. Значит полоса такого прогона стоит всю его жизнь, как бы исправно ни отвечал `tmctl status`. ⚠ **Строка заведена паком P12 при сносе `PD-411`, и это главное в ней:** мёртвые колонки ПРЯТАЛИ гап — код выглядел так, будто канал починки чинит, и комментарий `maybeResync` прямо утверждал «ApplyStatus now materializes the same four counters as the stream». Обе лгущие фразы исправлены, сам гап НЕ лечится сносом и вынесен сюда, чтобы снос не выдал себя за лечение. Лечение: либо ресинк складывает отчёт в `chapters` (и тогда нужен разбор, как он не спорит с потоком, который те же строки пишет из `unit_resolutions`), либо зона признаёт, что карантинный прогон полосы не показывает, и говорит об этом на поверхности. Пин формы «ресинк НЕ двигает счётчики глав» уже стоит — `pgstore.TestTheResyncRecordsFreshnessAndShapeAndNoProgress` — и он покраснеет, если канал научится материализовать: это указатель на строку, а не её лечение | open | пак P12 (30.08), вскрыто сносом `PD-411` | -| PD-375 | bug | minor | `internal/runs/runs.go`, греп `quote.Hold > acct.Balance`, `internal/httpapi/v0_test.go:359`, `internal/runs/control_test.go` | **Верхнюю половину проверки `ceiling_chapters` не исполняет НИ ОДИН тест: снятие второго операнда `in.CeilingChapters > bounds.Max` проходит ВСЮ батарею.** Собственный доккомментарий называет проверку несущей — «число, решающее СКОЛЬКО ДЕНЕГ резервируется, не может быть выбрано вызывающим односторонне», — и канон требует того же от сервера (`max_chapters` уже прижат к остатку, «a client MUST NOT clamp it again»). Единственный тест, называющий `ErrCeilingOutOfBounds`, это таблица соответствия ошибки коду 409 в `httpapi/v0_test.go`, которая `Start` не зовёт, а самая тесная фикстура просит 10 глав у книги, где их 100. Код сегодня ВЕРЕН; дефект в том, что править эту строку можно безнаказанно. Замерено на живом стенде: чистая сборка отвечает `409 ceiling_unavailable/bounds_moved`, сборка с мутацией отдаёт 202 и открывает резервацию `24000000` микро на книге из ТРЁХ глав, а движку уходит `--ceiling-usd 24.200000`. Вес: рефутер сузил major → minor, потому что код верен и ни один сегодняшний клиент до вреда не доходит. Воспроизведение: `docs/p8-review/axis1-money/a1-mut5-ceiling-bound.sh`, готовый пин `docs/p8-review/axis1-money/r1_ceiling_bound_test.go.txt` ⚠ **НОСИТЕЛЬ УМЕР ВМЕСТЕ С ФОРМОЙ ЗАКАЗА (05.09), строка пере-написана ПО СУЩЕСТВУ, а не пере-нацелена.** Проверки `in.CeilingChapters < bounds.Min || > bounds.Max` больше НЕТ: шкала в главах и `pricing.Bounds` удалены вместе с per-chapter константой (строка бэклога 280), заказ судится ценой — `Quote` клампит объём остатком книги, а `quote.Hold > acct.Balance` отбивает то, что баланс не несёт. **Класс дефекта — «несущую половину не исполняет ни один тест» — закрыт НОВЫМ пином, а не исчезновением кода:** `TestAnOrderTheBalanceCannotCarryIsRefusedBeforeTheHold` держит обе половины (неподъёмный заказ отбит ДО холда и деньги не двинулись; заказ больше книги = вся книга, а не бОльшая резервация). Проверено посадкой: снятие отказа краснит этот тест ИМЕНЕМ. | fixed(628cc56) | ревью-пак P8-REVIEW, ось 1 (посадка мутации + живая проба, подтверждено рефутером) | +| PD-375 | bug | minor | `internal/runs/runs.go`, греп `quote.Hold > acct.Balance`, `internal/httpapi/v0_test.go:359`, `internal/runs/control_test.go` | **Верхнюю половину проверки `ceiling_chapters` не исполняет НИ ОДИН тест: снятие второго операнда `in.CeilingChapters > bounds.Max` проходит ВСЮ батарею.** Собственный доккомментарий называет проверку несущей — «число, решающее СКОЛЬКО ДЕНЕГ резервируется, не может быть выбрано вызывающим односторонне», — и канон требует того же от сервера (`max_chapters` уже прижат к остатку, «a client MUST NOT clamp it again»). Единственный тест, называющий `ErrCeilingOutOfBounds`, это таблица соответствия ошибки коду 409 в `httpapi/v0_test.go`, которая `Start` не зовёт, а самая тесная фикстура просит 10 глав у книги, где их 100. Код сегодня ВЕРЕН; дефект в том, что править эту строку можно безнаказанно. Замерено на живом стенде: чистая сборка отвечает `409 ceiling_unavailable/bounds_moved`, сборка с мутацией отдаёт 202 и открывает резервацию `24000000` микро на книге из ТРЁХ глав, а движку уходит `--ceiling-usd 24.200000`. Вес: рефутер сузил major → minor, потому что код верен и ни один сегодняшний клиент до вреда не доходит. Воспроизведение: `docs/p8-review/axis1-money/a1-mut5-ceiling-bound.sh`, готовый пин `docs/p8-review/axis1-money/r1_ceiling_bound_test.go.txt` ⚠ **НОСИТЕЛЬ УМЕР ВМЕСТЕ С ФОРМОЙ ЗАКАЗА (05.09), строка пере-написана ПО СУЩЕСТВУ, а не пере-нацелена.** Проверки `in.CeilingChapters < bounds.Min \|\| > bounds.Max` больше НЕТ: шкала в главах и `pricing.Bounds` удалены вместе с per-chapter константой (строка бэклога 280), заказ судится ценой — `Quote` клампит объём остатком книги, а `quote.Hold > acct.Balance` отбивает то, что баланс не несёт. **Класс дефекта — «несущую половину не исполняет ни один тест» — закрыт НОВЫМ пином, а не исчезновением кода:** `TestAnOrderTheBalanceCannotCarryIsRefusedBeforeTheHold` держит обе половины (неподъёмный заказ отбит ДО холда и деньги не двинулись; заказ больше книги = вся книга, а не бОльшая резервация). Проверено посадкой: снятие отказа краснит этот тест ИМЕНЕМ. | fixed(628cc56) | ревью-пак P8-REVIEW, ось 1 (посадка мутации + живая проба, подтверждено рефутером) | | PD-377 | doc | minor | `internal/pgstore/credits.go:162`=`The ceiling to hand the engine is the amount held`, `internal/runs/spawn.go` `bookCap`, `cmd/tmplatformctl/main.go` `balance` | **Доккомментарий `Hold` на входе в денежный путь неверен ОБЕИМИ половинами с миграции 00011.** Он обещает «the ceiling to hand the engine is the amount held; read it back with OpenReservations». Движку передаётся не сумма холда, а `run_attempts.ceiling_arg_micro_usd` = committed книги плюс прирост (`spawn.go` `bookCap`), и сама 00011 говорит это прямым текстом («It is NOT ceiling_micro_usd»); начиная со ВТОРОГО прогона книги числа расходятся тем сильнее, чем дороже книга — на стенде до 17 раз (`60000` холда против `1060000` в аргументе). Вторая половина не работает даже механически: `Reservation.Ceiling` — поле, которое только сканируется и не читается никем, единственный потребитель `OpenReservations` печатает `Amount`/`BookID`/`OpenedAt`/`EngineRunID`. ⚠ Рефутер снял два из трёх исходных якорей: доккомментарии в применённых миграциях `00007`/`00009` зона не правит после лендинга (у всех 25 файлов миграций ровно по одному коммиту), а исправление уже лежит в следующем файле того же каталога. Остаётся Go-доккомментарий. Воспроизведение: `docs/p8-review/axis1-money/a1-ceiling-column-doc.sql` | open | ревью-пак P8-REVIEW, ось 1 (живой стенд, сужено рефутером до одного якоря) | -| PD-386 | bug | minor | `cmd/tmplatformd/runner.go:474`=`out := []sweepPass{{"runs", sweepBudget, s.runs.Sweep}}` и четыре следующих прохода того же `one()`, `cmd/tmplatformd/runner_test.go` | **Такт свипа последователен, и бюджеты пяти проходов СКЛАДЫВАЮТСЯ: сумма объявленных — 37 минут при `TM_PLATFORM_SWEEP_EVERY` 15 секунд.** Пак P8-FIX дал каждому проходу свой бюджет и закрыл «один проход съедает дедлайн другого», но проходы по-прежнему идут подряд в одной горутине: runs 2м, readmodel 10м, intake 21м, idempotency 2м, observe 2м. Ничто эту сумму не ограничивает и ничто её не пинит — у функции `sweep` нет ни одного теста (в пакете два теста, оба про другое), и посадка «фаза runs уходит из головы такта в хвост» пережила ПОЛНУЮ батарею. Замерено пробой на реальной функции: за 4 секунды при такте 100 мс фаза прогонов отработала 40 раз сама по себе и 9 раз позади прохода материализации. Следствия по оси: калибровки самого лечения заданы в проходах и минутах и молча растягиваются (`StalledAfter` 5 «неудач подряд» и бэкофф, про который комментарий обещает «about a quarter of an hour», превращаются в часы); обещание `Stop` «the reconciler re-issues the stop on its next pass» задерживается на ту же величину; телеметрия стоит ПОСЛЕДНЕЙ. ⚠ Рефутер сузил: 37 минут — сумма ОБЪЯВЛЕННЫХ бюджетов, а не достижимая длительность (idempotency это один индексированный DELETE, а проход интейка сам себя режет). Воспроизведение: `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (проба на реальной функции + посадка мутации, сужено рефутером) | +| PD-386 | bug | minor | `cmd/tmplatformd/runner.go:484`=`out := []sweepPass{{"runs", sweepBudget, s.runs.Sweep}}` и четыре следующих прохода того же `one()`, `cmd/tmplatformd/runner_test.go` | **Такт свипа последователен, и бюджеты пяти проходов СКЛАДЫВАЮТСЯ: сумма объявленных — 37 минут при `TM_PLATFORM_SWEEP_EVERY` 15 секунд.** Пак P8-FIX дал каждому проходу свой бюджет и закрыл «один проход съедает дедлайн другого», но проходы по-прежнему идут подряд в одной горутине: runs 2м, readmodel 10м, intake 21м, idempotency 2м, observe 2м. Ничто эту сумму не ограничивает и ничто её не пинит — у функции `sweep` нет ни одного теста (в пакете два теста, оба про другое), и посадка «фаза runs уходит из головы такта в хвост» пережила ПОЛНУЮ батарею. Замерено пробой на реальной функции: за 4 секунды при такте 100 мс фаза прогонов отработала 40 раз сама по себе и 9 раз позади прохода материализации. Следствия по оси: калибровки самого лечения заданы в проходах и минутах и молча растягиваются (`StalledAfter` 5 «неудач подряд» и бэкофф, про который комментарий обещает «about a quarter of an hour», превращаются в часы); обещание `Stop` «the reconciler re-issues the stop on its next pass» задерживается на ту же величину; телеметрия стоит ПОСЛЕДНЕЙ. ⚠ Рефутер сузил: 37 минут — сумма ОБЪЯВЛЕННЫХ бюджетов, а не достижимая длительность (idempotency это один индексированный DELETE, а проход интейка сам себя режет). Воспроизведение: `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (проба на реальной функции + посадка мутации, сужено рефутером) | | PD-387 | doc | minor | `deploy/README.md:608`=`сколько всего может занять один проход свипа`, `cmd/tmplatformd/runner.go` `one`, `internal/config/config.go` `SweepBudget` | **Рантбук называет `TM_PLATFORM_SWEEP_BUDGET` ручкой «одного прохода свипа» и не говорит, КАКОГО из пяти, — а два бюджета из пяти оператору недоступны вовсе.** Один такт прогоняет ПЯТЬ проходов подряд, и только три берут `sweepBudget`; проход материализации берёт `refreshSweepBudget` 10 минут, проход интейка — `intakeSweepBudget` = `jobs.JobTimeout` + `readmodel.MaterializeBudget` + минута = 21 минута, и обе константы оператору недоступны вовсе. Собственный доккомментарий кода при этом ТОЧЕН («SweepBudget is what ONE pass of the RUN sweep may take») — расходится именно операторская проза, и расходится в разделе «Застрявшая работа: что оператор делает, когда свип не справляется», то есть там, где по ней и будут действовать. Родня `PD-368`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ Арифметика 37 минут пере-проверена и держится: 3×2 + 10 + 21. Воспроизведение — `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` (последовательность тактов) плюс `sed -n '264,272p' deploy/README.md` | open | ревью-пак P8-REVIEW, ось 3 (находка рефутера) | -| PD-389 | hardening | minor | `cmd/tmplatformd/runner.go:562`=`s.metrics.ObserveRunner(metrics.Runner{`, `cmd/tmplatformd/runner.go` `pass`, `internal/metrics/metrics_test.go` `TestTheRunnersStateIsExposedWithItsUnits` | **Шов телеметрии не покрыт НИЧЕМ, и это доказуемо без прогона батареи: `sweep` и `observe` — неэкспортируемые функции пакета `main`, то есть из другого пакета их не может вызвать ни один тест в принципе,** а единственный тест-файл каталога несёт два теста, оба про другое. Проверено тремя посадками, пережившими полную батарею: `StalledRuns: o.StalledRuns` в ноль (наблюдаемая половина закрытого BLOCKER `PD-346`), `errors.Is(err, context.DeadlineExceeded)` в false (`sweep_unfinished_total` больше не может вырасти — `PD-351` со стороны ВЫЗЫВАЮЩЕГО, куда пин `TestAPassThatRanOutOfTimeSaysSo` по построению не достаёт), и перестановка `queue_depth` с `live_runs`. Дыра шире шва: пин формы, на который ссылается `STACK_DECISIONS` §24, задаёт литерал `metrics.Runner` из ШЕСТИ полей из восьми — `StalledRuns` и `AbandonedSurfaces` в него не входят, поэтому мутация внутри самого `ObserveRunner` тоже выживает. Пере-проверено координатором пака независимо: снятие `m.stalledRuns.Set(...)` и снятие `m.abandonedSurfaces.Set(...)` по отдельности проходят ПОЛНУЮ батарею (18 пакетов), при том что снятие соседнего инкремента `sweepUnfinished` тем же пином ловится. То есть операторская ручка, построенная паком P8-FIX в ответ на `PD-169`, не пиньётся ничем. Воспроизведение: `docs/p8-review/mutations-full.log` и `docs/p8-review/axis4-metrics/60-mutations.sh` | open | ревью-пак P8-REVIEW, ось 4 (посадки финдера, рефутера и координатора) | -| PD-390 | hardening | minor | `cmd/tmplatformd/runner.go:559`=`log.Warn("the control plane's own state could not be read", "err", err)`, `internal/pgstore/observe.go` `Observe`, `internal/metrics/metrics.go` `ObserveRunner` | **Гейджи замирают при отказе телеметрического чтения, и признака устаревания в экспозиции нет.** `STACK_DECISIONS` §24 объявляет «значения снимает СВИП, а не скрейп», но у снятого значения нет ни отметки свежести, ни счётчика неудач: `observe()` при ошибке пишет один WARN и возвращается, НЕ тронув ни одного гейджа, а Prometheus такой ряд устаревшим не помечает — цель жива, ряд на месте, значение старое. Живой замер: при сломанном чтении и одновременно вылеченном мире экспозиция продолжала утверждать «1 застрявший прогон, 1 карантин, 1 живой прогон, холд возрастом 1201 с», тогда как в базе застрявших было 0; при этом `sweep_duration_seconds_count` рос по всем четырём проходам, то есть все «жив ли свип» сигналы оставались зелёными. `Observe` — ОДИН стейтмент на все восемь чисел, поэтому любая его поломка гасит все гейджи разом, а сам `observe()` вызывается ВНЕ `pass()`, поэтому своего ряда в `sweep_duration_seconds` у него нет и его отказ там не виден. Обратное направление хуже: процесс, у которого чтение не удалось НИ РАЗУ, отдаёт нули как здоровье, и `/readyz` с `/healthz` при этом зелёные. ⚠ Вторая половина того же корня, найденная рефутером: инстанс-ЧИТАТЕЛЬ (пустой `TM_PLATFORM_ENGINE_BIN` — объявленная форма деплоя) не запускает свип вовсе, `observe()` не зовётся ни разу, а метрики созданы раньше и регистрируют все восемь гейджей безусловно, поэтому реплика уверенно отвечает `runs_stalled 0`, `queue_depth 0`, `oldest_open_hold_seconds 0` про контрол-плейн, который она не измеряет — и тут нет даже WARN-строки. Лечится дёшево: `*_last_success_timestamp_seconds` либо счётчик неудач наблюдения плюс проведение `observe` через тот же `pass`. Воспроизведение: `docs/p8-review/axis4-metrics/30-stale-gauges.sh`, `r1-boot-with-blind-telemetry.sh`, `r6-read-replica-zeroes.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, расширено рефутером) | -| PD-392 | hardening | minor | `internal/metrics/metrics.go:92`=`Namespace: namespace, Name: "quarantined_attempts"`, `internal/metrics/metrics.go` `oldest_open_hold_seconds`, `cmd/tmplatformctl/runs.go` `listRuns` | **Две метрики без ручки — тот самый класс, за который `PD-169` стоял BLOCKER'ом, в двух других местах.** Help гейджа карантина сам называет цену («Such a run keeps going and keeps spending»), но команды, называющей строку за этим числом, нет: `grep -rn quarantine cmd/` не даёт ни одного хита, и в таблице `runs` колонки карантина тоже нет. У возраста холда ручка формально есть — `balance --user`, — но она требует идентификатор аккаунта, которого гейдж не даёт, а документированный случай самого гейджа («A hold outlives its run only when a settlement could not be made») — это холд ЗАКОНЧЕННОГО прогона, которого список не показывает по построению. Живая проба на состоянии, произведённом ШТАТНЫМ операторским сценарием (abandon застрявшего прогона): при `tm_platform_oldest_open_hold_seconds 10813` команды отвечают «no run is live», «no run is failing to reconcile» и «no book has been given up on», а единственный путь к строке — psql, то есть ровно то, что эти числа заводились заменить. Глобального списка открытых холдов в CLI нет. Воспроизведение: `docs/p8-review/axis4-metrics/50-gauges-without-a-handle.sh` и `r5-hold-without-a-handle.sh` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major ⚠ **ПАК P13 03.09: половина про КАРАНТИН закрыта в дереве, строка сужается до возраста холда.** `grep -rn quarantine cmd/` теперь даёт хиты: `tmplatformctl runs` печатает колонку QUARANTINE с причиной, `tmplatformctl run unquarantine --run ` снимает её (`PD-426`). Ручки для `oldest_open_hold_seconds` по-прежнему нет — этот остаток и держит строку открытой. Статус — акт лендинга | open | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером) | +| PD-389 | hardening | minor | `cmd/tmplatformd/runner.go:581`=`s.metrics.ObserveRunner(metrics.Runner{`, `cmd/tmplatformd/runner.go` `pass`, `internal/metrics/metrics_test.go` `TestTheRunnersStateIsExposedWithItsUnits` | **Шов телеметрии не покрыт НИЧЕМ, и это доказуемо без прогона батареи: `sweep` и `observe` — неэкспортируемые функции пакета `main`, то есть из другого пакета их не может вызвать ни один тест в принципе,** а единственный тест-файл каталога несёт два теста, оба про другое. Проверено тремя посадками, пережившими полную батарею: `StalledRuns: o.StalledRuns` в ноль (наблюдаемая половина закрытого BLOCKER `PD-346`), `errors.Is(err, context.DeadlineExceeded)` в false (`sweep_unfinished_total` больше не может вырасти — `PD-351` со стороны ВЫЗЫВАЮЩЕГО, куда пин `TestAPassThatRanOutOfTimeSaysSo` по построению не достаёт), и перестановка `queue_depth` с `live_runs`. Дыра шире шва: пин формы, на который ссылается `STACK_DECISIONS` §24, задаёт литерал `metrics.Runner` из ШЕСТИ полей из восьми — `StalledRuns` и `AbandonedSurfaces` в него не входят, поэтому мутация внутри самого `ObserveRunner` тоже выживает. Пере-проверено координатором пака независимо: снятие `m.stalledRuns.Set(...)` и снятие `m.abandonedSurfaces.Set(...)` по отдельности проходят ПОЛНУЮ батарею (18 пакетов), при том что снятие соседнего инкремента `sweepUnfinished` тем же пином ловится. То есть операторская ручка, построенная паком P8-FIX в ответ на `PD-169`, не пиньётся ничем. Воспроизведение: `docs/p8-review/mutations-full.log` и `docs/p8-review/axis4-metrics/60-mutations.sh` | open | ревью-пак P8-REVIEW, ось 4 (посадки финдера, рефутера и координатора) | +| PD-390 | hardening | minor | `cmd/tmplatformd/runner.go:578`=`log.Warn("the control plane's own state could not be read", "err", err)`, `internal/pgstore/observe.go` `Observe`, `internal/metrics/metrics.go` `ObserveRunner` | **Гейджи замирают при отказе телеметрического чтения, и признака устаревания в экспозиции нет.** `STACK_DECISIONS` §24 объявляет «значения снимает СВИП, а не скрейп», но у снятого значения нет ни отметки свежести, ни счётчика неудач: `observe()` при ошибке пишет один WARN и возвращается, НЕ тронув ни одного гейджа, а Prometheus такой ряд устаревшим не помечает — цель жива, ряд на месте, значение старое. Живой замер: при сломанном чтении и одновременно вылеченном мире экспозиция продолжала утверждать «1 застрявший прогон, 1 карантин, 1 живой прогон, холд возрастом 1201 с», тогда как в базе застрявших было 0; при этом `sweep_duration_seconds_count` рос по всем четырём проходам, то есть все «жив ли свип» сигналы оставались зелёными. `Observe` — ОДИН стейтмент на все восемь чисел, поэтому любая его поломка гасит все гейджи разом, а сам `observe()` вызывается ВНЕ `pass()`, поэтому своего ряда в `sweep_duration_seconds` у него нет и его отказ там не виден. Обратное направление хуже: процесс, у которого чтение не удалось НИ РАЗУ, отдаёт нули как здоровье, и `/readyz` с `/healthz` при этом зелёные. ⚠ Вторая половина того же корня, найденная рефутером: инстанс-ЧИТАТЕЛЬ (пустой `TM_PLATFORM_ENGINE_BIN` — объявленная форма деплоя) не запускает свип вовсе, `observe()` не зовётся ни разу, а метрики созданы раньше и регистрируют все восемь гейджей безусловно, поэтому реплика уверенно отвечает `runs_stalled 0`, `queue_depth 0`, `oldest_open_hold_seconds 0` про контрол-плейн, который она не измеряет — и тут нет даже WARN-строки. Лечится дёшево: `*_last_success_timestamp_seconds` либо счётчик неудач наблюдения плюс проведение `observe` через тот же `pass`. Воспроизведение: `docs/p8-review/axis4-metrics/30-stale-gauges.sh`, `r1-boot-with-blind-telemetry.sh`, `r6-read-replica-zeroes.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, расширено рефутером) | +| PD-392 | hardening | minor | `internal/metrics/metrics.go:105`=`Namespace: namespace, Name: "quarantined_attempts"`, `internal/metrics/metrics.go` `oldest_open_hold_seconds`, `cmd/tmplatformctl/runs.go` `listRuns` | **Две метрики без ручки — тот самый класс, за который `PD-169` стоял BLOCKER'ом, в двух других местах.** Help гейджа карантина сам называет цену («Such a run keeps going and keeps spending»), но команды, называющей строку за этим числом, нет: `grep -rn quarantine cmd/` не даёт ни одного хита, и в таблице `runs` колонки карантина тоже нет. У возраста холда ручка формально есть — `balance --user`, — но она требует идентификатор аккаунта, которого гейдж не даёт, а документированный случай самого гейджа («A hold outlives its run only when a settlement could not be made») — это холд ЗАКОНЧЕННОГО прогона, которого список не показывает по построению. Живая проба на состоянии, произведённом ШТАТНЫМ операторским сценарием (abandon застрявшего прогона): при `tm_platform_oldest_open_hold_seconds 10813` команды отвечают «no run is live», «no run is failing to reconcile» и «no book has been given up on», а единственный путь к строке — psql, то есть ровно то, что эти числа заводились заменить. Глобального списка открытых холдов в CLI нет. Воспроизведение: `docs/p8-review/axis4-metrics/50-gauges-without-a-handle.sh` и `r5-hold-without-a-handle.sh` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major ⚠ **ПАК P13 03.09: половина про КАРАНТИН закрыта в дереве, строка сужается до возраста холда.** `grep -rn quarantine cmd/` теперь даёт хиты: `tmplatformctl runs` печатает колонку QUARANTINE с причиной, `tmplatformctl run unquarantine --run ` снимает её (`PD-426`). Ручки для `oldest_open_hold_seconds` по-прежнему нет — этот остаток и держит строку открытой. Статус — акт лендинга | open | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером) | | PD-101 | bug | minor | `internal/login/login.go:507` | `login_events.ip_prefix` берётся из `r.RemoteAddr`, а в задуманном деплое перед сервисом стоит edge-прокси ⇒ префикс всегда сеть прокси. Журнал входов заведён как ответ на «откуда примерно я входил» — в шипуемой форме он систематически отвечает неверно. `X-Forwarded-For`/`Forwarded` нигде не читаются и доверенного прокси в конфиге нет (это правильный дефолт: доверять заголовку без edge нельзя) — значит решение про edge и про этот столбец принимается вместе ⚠ **ПАК P8-REVIEW 24.08: якорь дрейфанул и носителей ДВА.** `internal/login/login.go:507` сегодня это `h.mu.Lock()` внутри `checkIssuer`; чтение адреса живёт на `:527`=`ev.IPPrefix = ipPrefix(r.RemoteAddr)`, и второй, строкой не названный, — `internal/login/dev.go:195` с тем же выражением. Суть верна | open | приёмка P2 (панель) | | PD-102 | doc | minor | `internal/httpapi/serve.go:36-38` | Доккоммент `DefaultTimeouts` утверждает, что «an upload extends its own deadline as it makes progress» — это НЕВЕРНО: `ReadTimeout` в `net/http` (Go 1.26.5, `server.go:990` `wholeReqDeadline = t0.Add(ReadTimeout)`) выставляется один раз и по мере прихода байтов не продлевается. Комментарий несущий: он объясняет, почему `Read` короткий, и на нём будущая ручка загрузки книги (23 МБ по контракту) построит неверное ожидание — ей понадобится собственный дедлайн через `ResponseController`, а не «прогресс продлевает» | open | приёмка P2 (панель, сверено с исходником Go) | | PD-103 | hardening | minor | `internal/auth/middleware.go:43,66` | У обращений к БД на аутентифицированном пути (`Lookup`/`Touch`) нет собственного дедлайна — только голый `r.Context()`, а `WriteTimeout` у сервера отсутствует по проекту (SSE) и `TimeoutHandler` в цепочке нет. Зависший Postgres паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт. `readyz` свой таймаут получил (PD-14) — горячий путь нет | open | приёмка P2 (панель) | @@ -79,20 +79,20 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-465 | bug | info | `internal/pgstore/runs.go` (греп `ProofSpawns`), `internal/runs/spawn.go` (греп `RecordSpawn` и `Runner.Start`) | **ТРЕТИЙ СПОСОБ УЕХАТЬ ПОД ДОКАЗАТЕЛЬСТВОМ `run abandon` НЕ ЛОВИТСЯ: заявка на спавн коммитится ДО подъёма юнита.** Арбитр, построенный паком «деньги и правда на экране», сверяет id живой попытки и монотонный счётчик заявок под книжной блокировкой и закрывает два способа из трёх. Третий: `RecordSpawn` пишет `unit_name` и инкрементирует `spawns` одним оператором, а `Runner.Start` идёт СЛЕДУЮЩЕЙ строкой — доказательство, снятое между ними, читает счётчик УЖЕ увеличенным и слышит от systemd «юнита нет», потому что юнит ещё не создан; обе сверки сходятся, и списание попадает на попытку, чей юнит вот-вот поднимется. ⚠ Окно — миллисекунды между двумя операторами одной функции, и достижимо только если оператор снимает доказательство ровно в них. Цена та же, что у всякого неверного доказательства: живой движок тратит против своего книжного потолка, холд аккаунта закрыт, провайдеру платит деплой (ограничено потолком этого прогона, аккаунтом не эксплуатируемо). ⛔ **Лечение требует НОВОГО состояния — «заявка в полёте», которого сегодня не пишет никто**, поэтому пак его не строил: заморозка скоупа, и добавление способности — не закрытие находки. Кандидаты: писать `spawns` ПОСЛЕ подъёма юнита (меняет смысл счётчика на «успешно поднятых»), либо отдельная отметка, снимаемая на `Start`/`unclaim`. Найдено приёмкой оркестратора №23 по коду | open | приёмка пака «деньги и правда на экране», 07.09 | -| PD-464 | bug | minor | `internal/books/parse.go` (греп `const UploadSettle`), бутовый гейт `internal/config/config.go` (греп `UploadSettle`) | ⛔ **КОНСТАНТА ОБЪЯВЛЯЕТ ИНВАРИАНТ, КОТОРОГО НЕ ДЕРЖИТ, И ТРИ ВЫВОДА ЕЁ ЗНАЧЕНИЯ РУКАМИ ПОДРЯД БЫЛИ НЕВЕРНЫ.** `UploadSettle` обещает покрыть всё, что загрузка делает ПОСЛЕ тела, и бутовый гейт на этом обещании допускает дедлайн. Замер 07.09 (двоичный поиск по `Load()`, хвост выведен ПО КОДУ пути): объявлено **3m30s**, реальный худший хвост **3m40s** (`StartParsing` 30 + разрез 90 + `FinishParse` 30 + `ReleaseParseClaim` 30 + `ReadBook` 30 + квитанция 10), недобор **10 с**. Гейт принимает дедлайн до **26m29s**, ложь начинается с **26m21s** — окно шириной восемь секунд, достижимое только ручной настройкой почти вплотную к потолку гейта; на дефолте `10m` запас **16m20s**. ⚠ **Наблюдаемое следствие — ДУБЛЬ КНИГИ, а не потеря:** претензия на ключ идемпотентности, пережившая `ClaimStale`, перехватывается повтором с новым токеном (`internal/pgstore/idempotency.go`, ветка сравнения возраста претензии с `ClaimStale`), и человек, повторивший «висящую» загрузку, получает две книги вместо реплея одной. ⛔ **ЛЕЧЕНИЕ — НЕ ПОДНЯТЬ ЧИСЛО:** оно выводилось руками трижды и трижды было неверным, каждый раз по новой причине (пропущен шаг · неверный бюджет квитанции · два последовательных шага записаны как альтернативы), поэтому четвёртый вывод руками — подпорка, а не починка (`D39.216`). Границу надо выводить ИЗ кода пути. ⚠ Достижимость худшего пути БЕЗ искусственного замедления **не измерена**: она требует конъюнкции «большая книга» и «три полных `writeBudget` подряд», а гейт живого движка на хосте замера не закрыт. Первый из семи пунктов, с которыми синхронный разрез уезжает отдельным паком | open | пак «деньги и правда на экране», круг 5 самопроверки, 07.09 | +| PD-464 | bug | minor | `internal/books/parse.go` (греп `const UploadSettle`), бутовый гейт `internal/config/config.go` (греп `UploadSettle`) | ⛔ **КОНСТАНТА ОБЪЯВЛЯЕТ ИНВАРИАНТ, КОТОРОГО НЕ ДЕРЖИТ, И ТРИ ВЫВОДА ЕЁ ЗНАЧЕНИЯ РУКАМИ ПОДРЯД БЫЛИ НЕВЕРНЫ.** `UploadSettle` обещает покрыть всё, что загрузка делает ПОСЛЕ тела, и бутовый гейт на этом обещании допускает дедлайн. Замер 07.09 (двоичный поиск по `Load()`, хвост выведен ПО КОДУ пути): объявлено **3m30s**, реальный худший хвост **3m40s** (`StartParsing` 30 + разрез 90 + `FinishParse` 30 + `ReleaseParseClaim` 30 + `ReadBook` 30 + квитанция 10), недобор **10 с**. Гейт принимает дедлайн до **26m29s**, ложь начинается с **26m21s** — окно шириной восемь секунд, достижимое только ручной настройкой почти вплотную к потолку гейта; на дефолте `10m` запас **16m20s**. ⚠ **Наблюдаемое следствие — ДУБЛЬ КНИГИ, а не потеря:** претензия на ключ идемпотентности, пережившая `ClaimStale`, перехватывается повтором с новым токеном (`internal/pgstore/idempotency.go`, ветка сравнения возраста претензии с `ClaimStale`), и человек, повторивший «висящую» загрузку, получает две книги вместо реплея одной. ⛔ **ЛЕЧЕНИЕ — НЕ ПОДНЯТЬ ЧИСЛО:** оно выводилось руками трижды и трижды было неверным, каждый раз по новой причине (пропущен шаг · неверный бюджет квитанции · два последовательных шага записаны как альтернативы), поэтому четвёртый вывод руками — подпорка, а не починка (`D39.216`). Границу надо выводить ИЗ кода пути. ⚠ Достижимость худшего пути БЕЗ искусственного замедления **не измерена**: она требует конъюнкции «большая книга» и «три полных `writeBudget` подряд», а гейт живого движка на хосте замера не закрыт. Первый из семи пунктов, с которыми синхронный разрез уезжает отдельным паком ⚠ **ДИСПОЗИЦИЯ ПАКА «РАЗРЕЗ ПРИЁМА ДО ГОТОВНОСТИ» (08.09, в дереве, статус флипает лендинг): ЗАКРЫТО ОБЕИМИ ПОЛОВИНАМИ.** (1) Значение приведено к замеру: квитанция стала ТЕРМИНОМ суммы, `UploadSettle = CutBudget + 4*writeBudget + ReceiptBudget` = **220 с** — ровно тот худший хвост, что намерен слева; носитель у величины квитанции теперь ОДИН (`books.ReceiptBudget`, тратит её `httpapi.settleCtx`), и разойтись им нечем. (2) ⭐ И это НЕ то, что закрывает строку, потому что четвёртый вывод суммы руками был бы четвёртой заплатой: хвост целиком идёт под ОДНИМ отсоединённым дедлайном (`books.walk`), каждый шаг берёт `min(свой бюджет, остаток хвоста)` (`books.step`), и добавленный завтра шаг границу НЕ ДВИГАЕТ ПО ПОСТРОЕНИЮ — сумма теперь отвечает лишь за то, чтобы в обычном худшем случае ни один шаг не был урезан. Предъявлено: `TestNoStepOfAnUploadsTailOutlivesTheWalk` (в т.ч. 50 вложенных шагов), `TestTheCutOfAnUploadIsBoundedByTheWalkAndNotByItsOwnBudget`, `TestTheWalkLeavesTheReceiptItsShareOfTheSettleBudget`, `TestTheReceiptSpendsTheShareTheIntakeSetAsideForIt`; посадки M1/M2/M3/M7 красные с топичным текстом. ⚠ Попутно закрыт СОСЕДНИЙ промах того же гейта, которого строка не называла: он сверял дедлайн с `min(UploadGrace, ClaimStale)` и пропускал `ClaimGrace` — свип мог забрать claim у книги, чья загрузка ещё идёт (п.7 десятки). Окон в гейте теперь ТРИ. | fixed | пак «деньги и правда на экране», круг 5 самопроверки, 07.09 | | PD-463 | bug | minor | `internal/books/parse.go` (греп `ChaptersTotal < 2 && atIntake`), контракт `RejectReason` | **ВХОД СУЖЕН ДО ТОГО, ЧТО УМЕЕТ ВЫДАЧА, ТОЛЬКО НА СИНХРОННОЙ ВЕТВИ ПРИЁМА.** Отказ книге, которую движок нарезал в одну главу, стоит под условием `atIntake`: на ветви, куда книга уходит при деплойном классе или превышении бюджета разреза, тот же манифест доезжает до `FinishParse`, и «книга одним полотном» попадает в библиотеку молча. ⚠ **Закрыть на асинхронной ветви СЕГОДНЯ НЕЧЕМ, и это не недосмотр:** словарь `RejectReason` контракта закрыт четырьмя значениями, и ни одно не описывает «прочли, нарезали, а такую форму мы пока не отдаём» — ближайшее по словам `source_unreadable` ТЕРМИНАЛЬНО и УДАЛЯЕТ исходник, то есть уничтожает файл пользователя из-за НАШЕГО ограничения. ⇒ нужен либо пятый `RejectReason` (контрактный минор, зона оркестратора), либо решение, что асинхронная ветвь остаётся проницаемой. Достижимость узкая: ветвь берётся только когда синхронный разрез не дал вердикта. Найдено опровергателем по готовой работе | open | пак «деньги и правда на экране», 07.09 | | PD-459 | bug | info | `internal/runs/reconcile.go:327`=`const settlementBackoffCap`, пины `internal/runs/stuck_settlement_test.go:202,582` | **Потолок отсрочки расплаты НЕ ЗАПИНЕН ЗНАЧЕНИЕМ: оба теста, которые его читают, сравниваются С САМОЙ КОНСТАНТОЙ.** Замер 06.09: `grep settlementBackoffCap` даёт 6 хитов в `reconcile.go` (строки 157, 162, 172, 309, 312, 327) и 4 в тестах, и в обоих тестах он стоит по обе стороны сравнения (`wait > settlementBackoffCap`) ⇒ мутация значения константы не краснит ничего. Константа несёт продуктовое свойство — отсрочка расплаты и есть resume-гейт пользователя, и её удлинение держит человека за блокировку, с которой он ничего сделать не может (довод в самом комментарии константы). ⇒ нужен пин на ЧИСЛО, а не на границу. Найдено чтением журнала при выносе эры P9–P13 | open | зона, 06.09, триаж журнала | | PD-460 | bug | info | `cmd/tmplatformctl/seed.go:44`=`usd := fs.String("usd", "25",` | **`seed` по умолчанию начисляет $25 и на стенде с ЖИВЫМИ ключами провайдера.** Команда дев-интейка не различает стенд с заглушкой и стенд, поднятый против настоящих моделей, а $25 на таком стенде — это разрешение потратить их. Кандидат лечения, названный ещё при наблюдении за живым потоком: дефолт 0 и обязательный `--usd`. ⚠ Не срочно и не эксплуатируемо аккаунтом: команда админская и требует доступа к БД. Найдено чтением журнала при выносе эры | open | зона, 06.09, триаж журнала | | PD-461 | bug | info | `docs/DEFECT_REGISTER.md` ряды `PD-99` и `PD-197`; гейт `docs/scripts/counts.py --check` | **ЧЕТЫРЕ СТРОКИ РЕГИСТРА НЕСУТ ЛИШНИЕ ПОЛЯ, И ГЕЙТ ФОРМЫ ИХ НЕ ВИДИТ.** Замер 07.09 (`awk` по разделителю полей): `PD-99` — 9 полей, `PD-197` — 8, `PD-375` — 9, `PD-422` — 11, контрольная строка `PD-441` — 7, как и шапка; то есть строк ЧЕТЫРЕ, а не две — первая редакция этого ряда назвала две, потому что искала только среди рядов, которые читала. Причина — неэкранированный вертикальный разделитель внутри бэктиков в теле ячейки; ⚠ первая редакция ЭТОГО ряда несла его сама (9 полей) — цитата команды с разделителем внутри бэктиков и есть тот же дефект, и он воспроизвёлся на строке, которая его описывает; `counts.py --check` печатает `битая форма: []`, то есть считает по форме строки и лишние поля за дефект не держит. Следствие ограничено (счёт по номерам не врёт), но любая будущая проверка ПО КОЛОНКАМ на этих двух строках прочтёт не то поле. Найдено чтением журнала при выносе эры | open | зона, 06.09, триаж журнала | | PD-462 | hardening | info | `internal/runs/*_test.go` фикстуры с рукописным `insert into`; гейт `internal/pgstore/sqlgate_test.go` | **ФИКСТУРЫ ПАКЕТА `runs` ПИШУТ КОЛОНКИ `pgstore` РУКОПИСНЫМ SQL, И ЭТОТ SQL НЕ СУДИТ НИ ОДИН ГЕЙТ.** `sqlgate` планирует против схемы только операторы пакета `pgstore`, поэтому фикстура, отставшая от миграции, краснеет не сообщением о схеме, а поведением теста, который она готовила. Цена сегодня мала (фикстуры правятся вместе с миграцией), растёт с числом колонок. Найдено чтением журнала при выносе эры | open | зона, 06.09, триаж журнала | | PD-445 | doc | info | `internal/httpapi/bank.go:386`=`Undecided int` (проекция квитанции); честный счёт — `internal/pgstore/readmodel.go:324` | **`signature.undecided` не сдвинулся после ПРИНЯТОЙ правки (82 → 82), и оператору нечем отличить «правка не зашла» от «зашла, но счёт про другое».** Замерено 04.09: `preview` и `apply` вернули `signature.surfaces 82, undecided 82` при `accepted[0].state = applied`. ⚠ **Арифметика ВЕРНА:** счёт неопределённости — движковый и считает МАЙНЕННЫЕ поверхности, а термин правки был не из них, поэтому двинуться числу было не от чего. Дефект — в обратной связи: единственное число, которое оператор видит после правки, на успешную правку не реагирует вовсе, и чтобы убедиться, что правка ЗАШЛА, потребовалась отдельная сверка по другому пути. ⚠ Вес info, потому что квитанция уже несёт точный ответ (`accepted[].state`) — это не ложь механизма, а то, что глаз читает первым. | open | живой платный прогон пака «закрыть цикл», наблюдение H13, 04.09 | -| PD-396 | standards | info | `internal/pgstore/books.go:355`=`chunker_version = $4, parse_started_at = null,`, `internal/pgstore/sink.go:127`=`update books set chunker_version = $2 where id = $1`, `internal/ingest/resync.go:36`=`UnsignedBankTerms int `json:"unsigned_bank_terms"`` | **Мёртвые поля шва: у `books.chunker_version` ДВА писателя и НОЛЬ читателей, и это второй экземпляр класса, первый назвал оркестратор.** Колонку пишет интейк из манифеста движка (`FinishParse`) и пишет тейлер из хендшейка потока (`effect`); ни одного `select` по ней в зоне нет — грепом ноль. Сегодня это безвредно, но следствие названо ЗАРАНЕЕ, потому что оно семантическое, а не техническое: в день, когда читатель появится, РАСХОЖДЕНИЕ двух писателей (чанкер прогона против чанкера разбора) станет значением, и решать, какой из них правда, придётся задним числом — по колонке, у которой уже накоплена история из обоих источников. Родня — п.4 пинга оркестратора №19: `ingest.StatusReport.UnsignedBankTerms` разбирается из ответа движка и не используется НИ ОДНОЙ строкой продакшн-кода (грепом — только объявление и его доккомментарий, где поле описано как опора экрана подписи). Формулировка оркестратора применима дословно к обоим: мёртвое поле в структуре шва читается как контракт. ⚠ Заведено ОТДЕЛЬНОЙ строкой, а не дописком в `PD-166`: та про потерю значения между автокоммитами, и её свойство построено. Диспозиция — вопрос владельца, а не зоны: либо назначить владельца колонки (один писатель), либо записать расхождение как ожидаемое до появления читателя Воспроизведение — две команды без конвейера (символ вертикальной черты в ячейку регистра не влезает): `grep -rn chunker_version platform/internal --include=*.go` даёт два `update` и ни одного `select`, `grep -rn UnsignedBankTerms platform/internal platform/cmd --include=*.go` даёт только объявление и его доккомментарий | open | ревью-пак P8-REVIEW (побочная находка сверки реестра, оформлена по указанию закрывающего ревью) | -| PD-378 | bug | info | `internal/pgstore/books.go:1225`=`u.RemainingPercent = int(balance * 100 / granted)`, `internal/httpapi/v0.go` `usageState`, канон `14-api-contract` `remaining_percent` | **`/v0/usage` отдаёт `remaining_percent` вне контрактных 0..100 и зажигает предупреждение «low» на полном счёте: `balance * 100` переполняет int64.** Порог измерен точно: баланс 92 233 720 368 547 758 микро ещё даёт 99%, следующий микро-доллар даёт минус 99. Ответ нарушает схему (`minimum: 0`, `maximum: 100`), и хуже того `usageState` видит отрицательное значение ниже порога `lowCredit` и отдаёт `state: "low"` — «денег почти нет» счёту на сто миллиардов. Замерено на проводе: до гранта `{"state":"ok","remaining_percent":96}`, после `grant --usd 100000000000` → `{"state":"low","remaining_percent":-84}`; соседняя арифметика (`pricing.Scale`, `balance`) при том же балансе отвечает верно, то есть переполнение локально именно в этой строке. ⚠ Рефутер сузил minor → info: чтобы туда попасть, оператор должен добавить на счёт не меньше 92.23 млрд долларов, ни одна пользовательская ручка кредит не пишет; прецедент веса — `PD-39`. ⚠ Оговорка рефутера в другую сторону: более правдоподобный носитель — не разовая команда, а конфиг `TM_PLATFORM_SIGNUP_GRANT_USD`, у которого верхней границы нет и значение НАМЕРЕННО не печатается в стартовый лог, так что промах в нём сломал бы `/usage` каждому новому аккаунту невидимо. Воспроизведение: `docs/p8-review/axis1-money/a1-usage-overflow.sh` (сам откатывает грант) | open | ревью-пак P8-REVIEW, ось 1 (живой провод, сужено рефутером с измеренным порогом) | +| PD-396 | standards | info | `internal/pgstore/books.go:361`=`chunker_version = $4, parse_started_at = null,`, `internal/pgstore/sink.go:127`=`update books set chunker_version = $2 where id = $1`, `internal/ingest/resync.go:36`=`UnsignedBankTerms int `json:"unsigned_bank_terms"`` | **Мёртвые поля шва: у `books.chunker_version` ДВА писателя и НОЛЬ читателей, и это второй экземпляр класса, первый назвал оркестратор.** Колонку пишет интейк из манифеста движка (`FinishParse`) и пишет тейлер из хендшейка потока (`effect`); ни одного `select` по ней в зоне нет — грепом ноль. Сегодня это безвредно, но следствие названо ЗАРАНЕЕ, потому что оно семантическое, а не техническое: в день, когда читатель появится, РАСХОЖДЕНИЕ двух писателей (чанкер прогона против чанкера разбора) станет значением, и решать, какой из них правда, придётся задним числом — по колонке, у которой уже накоплена история из обоих источников. Родня — п.4 пинга оркестратора №19: `ingest.StatusReport.UnsignedBankTerms` разбирается из ответа движка и не используется НИ ОДНОЙ строкой продакшн-кода (грепом — только объявление и его доккомментарий, где поле описано как опора экрана подписи). Формулировка оркестратора применима дословно к обоим: мёртвое поле в структуре шва читается как контракт. ⚠ Заведено ОТДЕЛЬНОЙ строкой, а не дописком в `PD-166`: та про потерю значения между автокоммитами, и её свойство построено. Диспозиция — вопрос владельца, а не зоны: либо назначить владельца колонки (один писатель), либо записать расхождение как ожидаемое до появления читателя Воспроизведение — две команды без конвейера (символ вертикальной черты в ячейку регистра не влезает): `grep -rn chunker_version platform/internal --include=*.go` даёт два `update` и ни одного `select`, `grep -rn UnsignedBankTerms platform/internal platform/cmd --include=*.go` даёт только объявление и его доккомментарий | open | ревью-пак P8-REVIEW (побочная находка сверки реестра, оформлена по указанию закрывающего ревью) | +| PD-378 | bug | info | `internal/pgstore/books.go:1231`=`u.RemainingPercent = int(balance * 100 / granted)`, `internal/httpapi/v0.go` `usageState`, канон `14-api-contract` `remaining_percent` | **`/v0/usage` отдаёт `remaining_percent` вне контрактных 0..100 и зажигает предупреждение «low» на полном счёте: `balance * 100` переполняет int64.** Порог измерен точно: баланс 92 233 720 368 547 758 микро ещё даёт 99%, следующий микро-доллар даёт минус 99. Ответ нарушает схему (`minimum: 0`, `maximum: 100`), и хуже того `usageState` видит отрицательное значение ниже порога `lowCredit` и отдаёт `state: "low"` — «денег почти нет» счёту на сто миллиардов. Замерено на проводе: до гранта `{"state":"ok","remaining_percent":96}`, после `grant --usd 100000000000` → `{"state":"low","remaining_percent":-84}`; соседняя арифметика (`pricing.Scale`, `balance`) при том же балансе отвечает верно, то есть переполнение локально именно в этой строке. ⚠ Рефутер сузил minor → info: чтобы туда попасть, оператор должен добавить на счёт не меньше 92.23 млрд долларов, ни одна пользовательская ручка кредит не пишет; прецедент веса — `PD-39`. ⚠ Оговорка рефутера в другую сторону: более правдоподобный носитель — не разовая команда, а конфиг `TM_PLATFORM_SIGNUP_GRANT_USD`, у которого верхней границы нет и значение НАМЕРЕННО не печатается в стартовый лог, так что промах в нём сломал бы `/usage` каждому новому аккаунту невидимо. Воспроизведение: `docs/p8-review/axis1-money/a1-usage-overflow.sh` (сам откатывает грант) | open | ревью-пак P8-REVIEW, ось 1 (живой провод, сужено рефутером с измеренным порогом) | | PD-381 | hardening | info | `internal/auth/middleware.go:38`=`a.Deny.ServeHTTP(w, r)` и та же строка на `:52`, `internal/auth/cookie.go` `ClearSession` | **401 по мёртвой сессии не стирает куку: браузер продолжает слать отозванный токен до конца её `Max-Age` (по умолчанию 14 суток).** Обе ветки отказа зовут `a.Deny.ServeHTTP` и к `a.Cookies` не обращаются, перекрытия выше по стеку нет — живой 401 не несёт ни одной строки `Set-Cookie`. Норму формулирует сам код: комментарий `ClearLogin` говорит, что кука, пережившая свой круг, это «a replay waiting for an accident», а `PD-88` заведена ровно на тот исход, при котором кука переживает сессию. Дешёвое лечение — чистить куку на пути отказа, где она была предъявлена. Отдельно от `PD-88` (та про `Max-Age` меньше секунды) и от `PD-5`/`PD-70`/`PD-74`/`PD-103` Воспроизведение: `docs/p8-review/axis2-auth/csrf-matrix.sh` и `session-clocks-probe.sh` (обе пробы поднимают демон и печатают ПОЛНЫЕ заголовки ответа, включая отсутствие `Set-Cookie` на 401); проверка чтением — `grep -n 'Cookies' internal/auth/middleware.go`, ни одного вхождения на путях отказа | open | ревью-пак P8-REVIEW, ось 2 (чтение + живая проба, подтверждено рефутером) | | PD-382 | hardening | info | `internal/auth/cookie.go:52`=`func (c Cookies) ClearSession(w http.ResponseWriter) { c.set(w, c.SessionName(), "", -time.Second) }`, `internal/auth/cookie.go` `ClearLogin` | **Путь ИСТЕЧЕНИЯ куки не запинен: две мутации, стирающие выход из браузера, прошли батарею целиком.** `ClearSession` и `ClearLogin` — единственные места, где кука получает отрицательный `Max-Age`, и порча этого выражения ничего не роняет. ⚠ Рефутер поправил ЦЕНУ, названную первой редакцией находки: `set` пишет значение вызывающего, а обе `Clear`-ручки передают ПУСТУЮ строку, поэтому мутант не перевыпускает куку с живым токеном — он оставляет пустую куку, и следующий запрос всё равно приходит без сессии. То есть вреда сегодня нет, а не запинено СВОЙСТВО «выход удаляет куку из браузера», и это класс `PD-1`, родня `PD-86`/`PD-87`. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутации M13 и M14), и по дороге поймана СВОЯ ошибка метода:** первый прогон M13 дал красный, но упавшим оказался `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` — известный флейк `PD-369`, к `ClearSession` отношения не имеющий. То есть вердикт, вынесенный по ЦВЕТУ батареи, а не по ТОПИЧНОСТИ упавшего теста, даёт ложное «пойман» и тихо теряет находку. Пере-прогон обеих мутаций даёт пустую дельту против чистой копии. Правило записано здесь, потому что цена его забывания — потерянная находка о недостающем пине: `docs/p8-review/mutations-round2.log` | open | ревью-пак P8-REVIEW, ось 2 (посадка мутации, цена поправлена рефутером) | | PD-383 | hardening | info | `cmd/tmplatformd/main.go:257`=`const loginJournalRetention = 180 * 24 * time.Hour`, `cmd/tmplatformd/main.go` `sweepLogins`, `internal/pgstore/identity.go` `DeleteOldLoginEvents` | **Ретенция журнала входов работает и не запинена ничем: и срок хранения, и сам свип переживают батарею.** Механизм построен (константа 180 суток, тикер 15 минут, `delete from login_events where at < $1`, монтируется в обеих ветках входа) и проверен ЖИВЬЁМ: строка возрастом 200 суток исчезла на ближайшем тике, демон напечатал `"login sweep" events=1`. Но ни срок, ни вызов не пинятся: `DeleteOldLoginEvents` не зовёт ни один тест, а `sweepLogins` — неэкспортируемая функция пакета `main` без теста. Класс `PD-1` на механизме, который ЛЕЧИТ уже закрытую строку. Воспроизведение: `docs/p8-review/pd23-retention-probe.sh` и `pd23-result.txt` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутации M15 и M16):** срок хранения поднят со 180 суток до 180 ЛЕТ и, отдельно, предикат свипа обезврежен (`delete from login_events where at < $1 and 1=0`) — обе дельты против чистой копии ПУСТЫ на всех 18 пакетах. Лог — `docs/p8-review/mutations-round2.log` ⚠ **ПОЛОВИНА ЗАКРЫТА паком `sqlc` (`63fcee5`), и обе половины пере-проверены посадками — строка остаётся `open` ровно на остатке.** ЗАКРЫТ сам свип-предикат: `DeleteOldLoginEvents` теперь зовёт `pgstore.TestTheLoginJournalRetentionDeletesOnlyWhatIsOlderThanTheCutoff`, и он утверждает ЧИСЛО удалённых строк плюс границу (в фикстуре есть строка РОВНО на отсечке, потому что предикат строгий и без неё `<` неотличимо от `<=`). Мутация M16 (`and 1=0`) теперь красная адресно — «deleted 0 rows, want exactly 1». **НЕ закрыто и остаётся живым: сам СРОК хранения и вызов свипа.** Мутация M15 — `loginJournalRetention` 180 суток → 180 ЛЕТ (`cmd/tmplatformd/main.go:257`) — пере-прогнана 29.08 на полной батарее конвертированного дерева: **EXIT=0, ноль красных**. Причина остатка структурная и не лечится в `pgstore`: константа и `sweepLogins` живут в пакете `main`, куда тест `pgstore` не достаёт. То есть класс `PD-1` здесь снят с ЗАПРОСА и стоит на КОНФИГУРАЦИИ | open | ревью-пак P8-REVIEW, ось 2 (находка рефутера, живая проба координатора) | | PD-388 | hardening | info | `internal/readmodel/readmodel.go:198`=`if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < MaterializeBudget {`, `cmd/tmplatformd/runner.go` `refreshSweepBudget`, `internal/books/parse.go` (запиненный близнец) | **Пара чисел в разных пакетах, не выводимая и не запиненная, и на неверной её стороне материализатор молча не делает ничего.** `Drain` начинает книгу, только если у прохода осталось не меньше ЦЕЛОГО `MaterializeBudget` (5 минут), а число прохода живёт в `cmd/tmplatformd` голым литералом 10 минут, без ссылки на константу, которую обязано превышать. Это единственный член семьи без страховки: `intakeSweepBudget` ВЫВЕДЕН формулой и разъехаться не может, а `claimGrace` и выведен, и запинен отдельным тестом. Цена неверной стороны — не деградация, а полное молчаливое отключение: замерено пробой, проход 4м59с даёт claims=0, вызовов движка 0 и nil, ошибки нет, лога нет, `sweep_unfinished_total` не растёт. Мутация «`refreshSweepBudget` 10м → 1м» пережила ПОЛНУЮ батарею. ⚠ Вторая половина, найденная рефутером: сам гейт бюджета между книгами — живой носитель ЗАКРЫТОЙ `PD-293`, чья эррата прямо на него ссылается, — не покрыт ни одним тестом (во всём дереве нет теста, который даёт `Drain` дедлайн), поэтому его можно выключить целиком, и батарея останется зелёной; точный близнец у интейка при этом запинен своим `TestAPassTooShortForAParseStartsNoneAtAll`. ⚠ Рефутер опроверг приписку финдера «проход по построению начинает максимум 2 книги из 4»: гейт сравнивает остаток перед КАЖДОЙ книгой, и при проходе 10 минут стартуют все четыре. Воспроизведение: `docs/p8-review/axis3-queue/probe_drain_budget_pair_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (посадка мутации, расширено рефутером на носитель PD-293) | -| PD-393 | hardening | info | `internal/metrics/metrics.go:225`=`if unfinished {`, `internal/metrics/metrics.go` (два счётчика из двенадцати коллекторов), `deploy/README.md` (рецепт алерта) | **Счётчиков событий на весь демон два, и оба отвечают не на тот вопрос: «ошибки» из четырёх золотых сигналов закрыты только для HTTP.** `sweep_unfinished_total` поднимается ИСКЛЮЧИТЕЛЬНО на `context.DeadlineExceeded`, поэтому проход, упавший обычной ошибкой, регистрируется как быстрый здоровый проход — замерено: 4 строки ERROR «runs sweep failed» в журнале и НИ ОДНОГО изменения в экспозиции, кроме счётчика длительности. Ни у чего остального счётчика нет вовсе: отказ спавна, неудача расчёта, карантин, ненулевой выход движка, упавший прогон. Всё состояние снимается гейджами раз в такт, поэтому событие, уместившееся между двумя проходами, в экспозиции не существует, и на вопрос «сколько прогонов сегодня упало» ответить нечем. Отдельная мелочь того же корня: `sweep_unfinished_total` — `CounterVec`, и пока он ни разу не вырос, семейства в экспозиции НЕТ вовсе, поэтому готовый рецепт алерта рантбука («растёт `tm_platform_sweep_unfinished_total`») даёт «no data», а не ноль; практика Prometheus велит инициализировать известные наборы лейблов нулём. ⚠ Речь о СЧЁТЧИКАХ СОБЫТИЙ, не о суммах денег: запрет `D39.84` на денежные числа в метриках соблюдён, проверено. Воспроизведение: `docs/p8-review/axis4-metrics/40-exposition-check.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, заголовок сужен рефутером) | +| PD-393 | hardening | info | `internal/metrics/metrics.go:290`=`if unfinished {`, `internal/metrics/metrics.go` (два счётчика из двенадцати коллекторов), `deploy/README.md` (рецепт алерта) | **Счётчиков событий на весь демон два, и оба отвечают не на тот вопрос: «ошибки» из четырёх золотых сигналов закрыты только для HTTP.** `sweep_unfinished_total` поднимается ИСКЛЮЧИТЕЛЬНО на `context.DeadlineExceeded`, поэтому проход, упавший обычной ошибкой, регистрируется как быстрый здоровый проход — замерено: 4 строки ERROR «runs sweep failed» в журнале и НИ ОДНОГО изменения в экспозиции, кроме счётчика длительности. Ни у чего остального счётчика нет вовсе: отказ спавна, неудача расчёта, карантин, ненулевой выход движка, упавший прогон. Всё состояние снимается гейджами раз в такт, поэтому событие, уместившееся между двумя проходами, в экспозиции не существует, и на вопрос «сколько прогонов сегодня упало» ответить нечем. Отдельная мелочь того же корня: `sweep_unfinished_total` — `CounterVec`, и пока он ни разу не вырос, семейства в экспозиции НЕТ вовсе, поэтому готовый рецепт алерта рантбука («растёт `tm_platform_sweep_unfinished_total`») даёт «no data», а не ноль; практика Prometheus велит инициализировать известные наборы лейблов нулём. ⚠ Речь о СЧЁТЧИКАХ СОБЫТИЙ, не о суммах денег: запрет `D39.84` на денежные числа в метриках соблюдён, проверено. Воспроизведение: `docs/p8-review/axis4-metrics/40-exposition-check.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, заголовок сужен рефутером) | | PD-395 | doc | info | `internal/gates/contract_test.go:14`=`const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"`, `docs/ENGINEERING_STANDARDS.md` §3, промты ревью-паков зоны | **Копия `platform/`, вынутая из репозитория, даёт красную батарею по причине, не имеющей отношения к коду.** Гейт контрактной версии читает канон по пути ВЫШЕ модуля (`internal/gates/contract_test.go:14`=`const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"`) и при его отсутствии `t.Fatalf`, а не сообщает, что запущен вне репозитория. Стоило времени КАЖДОМУ, кто работал в копии — все девять субагентов пака и координатор, — и один агент едва не завёл ложный красный находкой. ⚠ **ДИСПОЗИЦИЯ 24.08: лечение — РЕЦЕПТ, а не гейт. Три довода, каждый проверен исполнением:** **(1)** носители названы шире, чем есть — `ENGINEERING_STANDARDS` §3 копий на момент находки НЕ требовал (`grep -ci 'копи'` давал 0; ПОСЛЕ правки этого пака даёт 6 — рецепт туда и записан, см. ниже), норма копий живёт только в промте ревью-пака и в `D39.113`; значит сталкиваются не гейт и стандарт зоны, а гейт и РЕЦЕПТ ОДНОГО ПРОМТА. **(2)** «точечное решение, а не стиль» — НЕВЕРНО: в `backend/internal/standdata/standdata.go` живёт целый репо-паттерн чтения выше корня модуля с поиском корня по МАРКЕРУ и env-переопределением, и его потребители читают даже чужую зону (`internal/miner/miner_parity_test.go` читает `eval/`). Платформа реализовала тот же паттерн грубее — голым счётом `..`, — и комментарий `standdata` объясняет, чем именно это хуже. **(3)** довод «модуль обязан быть самодостаточным» к этой зоне не применяется: её собственный DoD определяет полную приёмку через ТРИ ВНЕШНИХ условия. Ценность зоны — не герметичность, а громкий учёт непроверенного. **ЛЕЧЕНИЕ — РЕЦЕПТ, А НЕ ГЕЙТ** (сам рецепт и его довод — `ENGINEERING_STANDARDS` §3 п.3). Второй, необязательный шаг: резолвить канон маркер-обходом по образцу `standdata.Root()` и дописать в сообщение `Fatalf` вторую гипотезу «ты вне репозитория» — это убивает стоимость повторной диагностики и остаётся падением, а не скипом. **ОТВЕРГНУТО с доводами:** гейт-переменная батареи с названным скипом (вне `make` гейт выключался бы сам — зона уже осудила эту форму словами `internal/gates/toolchain_test.go:55-57` «the Makefile is a convenience… a build that skips the battery gets a toolchain the battery would have refused»; плюс это легализует поломку рецепта как «условие среды») · переезд проверки на репо-уровень в `counts.py` как ЗАМЕНА (единственная репо-точка принуждения НИКОГДА не блокирует, только предупреждает — гейт остался бы без зубов; как ВТОРАЯ сеть законен) · кодоген из спеки (посылка протухла: `oapi-codegen` пере-подписан `D39.132` в КАНДИДАТА и P7 решил НЕ БРАТЬ с доводом; и класс он не убивает, а переносит — генерат коммитится внутрь модуля, то есть второй источник истины) · вендорить снапшот канона в модуль (то же самое) ⚠ **Рецепт вместе с доводом про маскировку дельты и правилом топичности вердикта записан пунктом 3 в `ENGINEERING_STANDARDS` §3** — долговечный зонный носитель, а не промт пака (промты после лендинга архивируются) | open | ревью-пак P8-REVIEW (координатор пака, цена замерена этой же сессией) | | PD-281 | bug | info | `internal/pgstore/readmodel.go` `runProgress` | **Полоса прогона над книгой, уже полной в считаемом проходе, стоит на `0/N` и не достигает единицы** — против канона §Progress («the fraction always reaches one»). Штатный цикл: книга доведена до конца → пользователь подписал решения → запустил прогон, чтобы движок их применил → прогресс платной работы невидим до `ready`. Остаток подхода «числитель считает ГЛАВЫ, законченные проходом», а не регрессия: до правки PD-263 бар был неверен в другую сторону. Лечение требует продуктового решения (считать главы, ПЕРЕразрешённые после `started_at`), а не тихой правки ⚠ **Дописка пака P9 (27.08), диспозиция: НЕ ТРОНУТА.** Сквозная полоса (строка 200) переписала `runProgress` в `runDone`/`runTotal`, но ровно для сценария этой строки — книга полна в считаемом (последнем) проходе, значит и в черновом — ничего не меняет: обе базы равны счёту глав, `draftWork = C`, полоса стоит `0/2C` и до единицы не доходит; числитель по-прежнему считает завершения глав от баз прогона. Знаменатель сменился только для промежуточного класса «черновик впереди редактуры», который эта строка не описывает. Живой факт того же пробоя: старт нового прогона над ПОЛНОЙ книгой сегодня вообще отвечает 409 (шкале нечего продать, `ChaptersLeft = 0`) — то есть у правки готовой книги нет и входа, которым её сложили бы в прогон; это смежный продуктовый вопрос той же строки. Лечение прежнее — продуктовое решение «считать пере-разрешения после `started_at`». Канонному минору к полосе НЕ наследовать обещание «the fraction always reaches one» без этой оговорки | open | приёмка правок P7 (fable-5) | | PD-297 | bug | info | `internal/pgstore/readmodel.go` `writeChapters`/`writeUnits` | **Материализация дерева делает один round-trip на СТРОКУ под эксклюзивной блокировкой книги** — на корпусной книге (2283 главы, ~7 тыс. пар) это ≈11 тыс. последовательных обращений, и всё это время за блокировкой стоят `emitFrame` потока, `StartRun` и фолд юнитов. Штатный инструмент — `tx.SendBatch` (pgx v5, уже драйвер модуля) или `CopyFrom` во временную таблицу. НЕ сделано осознанно: рефутеры первой приёмки понизили до DOUBT/LOW, цена не замерена на форме этого деплоя (unix-сокет против управляемого PG по TCP — разница на два порядка), а путь — самый опасный на запись. Мерить прежде правки: время удержания блокировки на 2283-главной книге до и после ⚠ **P8-FIX: НЕ ВЗЯТ, причина названа и она не «не успели».** Сама эта строка объявляет замер на здешнем стенде НЕпредставительным (unix-сокет против управляемого PG по TCP — разница на два порядка), а корпусной книги нет: она появляется на холодном прогоне движка, которым гейчена строка 202 единого бэклога (решение владельца 20.08). Мерить нечем и не на чем, а правка самого опасного на запись пути без замера — ровно то, что эта строка запрещает. Берётся вместе с холодным прогоном | open | доработка 20.08 (сверка находок против дерева) | @@ -147,7 +147,7 @@ | PD-408 | doc | info | `internal/runs/bank.go` (бюджет двери = `s.runBudget()`), `internal/runner/bankapply.go` (`errOut` без лимита; `Stderr: firstLine`) | **Две операционные оговорки двери правок, названные воркфлоу-ревью; обе — цена конфигурации, не дефект пути.** (1) Бюджет двери — та же ручка `TM_PLATFORM_RUN_BUDGET`, что у прохода свипа; движок выбирал потолок 5000 решений против ЖЁСТКИХ 60 с («пять раз внутри бюджета»), и оператор, понизивший ручку (к чему соседние комментарии подталкивают), делает легальный документ-максимум навсегда неприменимым — вечный `503` вместо «разбей документ»; связка ручки и капа нигде не названа. (2) stderr глагола читается в НЕограниченный `bytes.Buffer`, хотя потребляется только первая строка, — не-тот бинарь по сконфигурированному пути (полудеплой, обёртка) может раздуть демона до OOM за 60-секундный бюджет; stdout той же команды капнут 64 МиБ | open | воркфлоу-ревью P9 28.08 (линзы door:lock-lifecycle · door:crash-windows), диспозиция оркестратора 28.08: строкой | | PD-428 | doc | info, деньги | `internal/pricing` (`TM_PLATFORM_USD_PER_CHAPTER`, `Pricing.Ceiling`) | **Цена продажи не знает о накладных, которые масштабируются КНИГОЙ, а не грантом.** Замер движкового охотника (лендинг `6ec9f8a`): терминолог переигрывается на КАЖДОЙ покупке ЦЕЛИКОМ по книге — три покупки по одному юниту дали три полнокнижных консолидации по $0.005460 каждая, при том что сам юнит дешевле. То есть книга на 500 юнитов, проданная по одному, оплатит 500 полнокнижных проходов. ⚠ **Сегодня это НЕ дефект платформы и заведено только как калибровка:** продажа идёт ГЛАВАМИ (`Ceiling(chapters)`), а не юнитами, так что нарезки, при которой накладные обгоняют полезную работу, в продукте нет. Строка существует, чтобы факт не потерялся к моменту, когда мелкая нарезка появится: любая будущая единица продажи мельче главы обязана нести в цене эту книжную составляющую, иначе COGS растёт быстрее выручки на самых дешёвых покупках. Носитель — константа цены, а не код движка ⚠⚠ **НОСИТЕЛЬ УМЕР И ПОСЫЛКА ПЕРЕВЕРНУЛАСЬ — паком «форма заказа» 05.09, строка пере-написана ПО СУЩЕСТВУ.** Названные тут `TM_PLATFORM_USD_PER_CHAPTER` и `Pricing.Ceiling(глав)` удалены вместе со ставкой (строка бэклога 280). ⚠ И оговорка ряда «сегодня это НЕ дефект платформы: продажа идёт ГЛАВАМИ, так что нарезки, при которой накладные обгоняют полезную работу, в продукте нет» — **больше не верна**: этот же пак ввёл заказ В ЗНАКАХ, который разрешается в ПРЕФИКС ЮНИТОВ, то есть единицу МЕЛЬЧЕ главы, и ровно её ряд и ждал. **Что с этим сделано и чего НЕ сделано, раздельно.** Книжная составляющая ТЕПЕРЬ ВИДНА и названа: движок публикует её отдельным числом `book_once_usd` (плоские $2.00 на боевом `pipeline-c1`), платформа читает его и НЕ кладёт в основу холда — иначе короткая книга непокупаема, — а кладёт СВЕРХ, когда баланс несёт, и говорит покупателю `term_consistency_funded: false`, когда не несёт (`pricing.Model.Hold`, пин `TestAFlatBookLevelBoundDoesNotPutATwoDollarThresholdUnderEveryPurchase`). **НЕ сделано главное, ради чего ряд заведён:** цена мелкой покупки по-прежнему не несёт книжной составляющей ПРОПОРЦИОНАЛЬНО — заказ в один юнит и заказ во всю книгу видят один и тот же бонд, поэтому COGS на самых дешёвых покупках растёт быстрее выручки ровно так, как ряд и предупреждал. Ряд остаётся `open` и с этого дня ПРЕДМЕТЕН, а не гипотетичен. Носитель — `pricing.Model.Hold` и `ingest.BookPrice.BookOnceUSD`. | open | движковый пак «деньги» (охотник), передано оркестратором №19 сессии P11 | | PD-421 | hardening | info | `internal/pgstore/sessions.go` `StillLive` и `SweepSessions`, `docs/STACK_DECISIONS.md` §13 | **Открытый поток теряет свою сессию по ПОДМЕТАНИЮ строки, а не по клаузе бездействия, — и это остаток закрытия `PD-379`, названный прямо.** Проверка живости потока намеренно НЕ содержит клаузы `idle_expires_at`: окно бездействия скользит на ЗАПРОСЕ, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Но `SweepSessions` раз в час УДАЛЯЕТ строки и по бездействию тоже, а «строки нет» ОБЯЗАНО значить «мертва» — иначе отозванная сессия держала бы поток до свипа, то есть дыра ровно в час. Следствие: сессия, протухшая по бездействию и подметённая, теряет поток с опозданием до часа. Это не idle гасит поток, а отсутствие строки; к тому моменту любой другой запрос того же вызывающего — 401. Лечение, если сочтётся недопустимым, — скольжение окна бездействия ИЗ потока, но это правка ПОЛИТИКИ §13: открытая вкладка держала бы сессию до абсолютного потолка, а это слово владельца | open | пак P11 (назван при закрытии `PD-379`) | -| PD-422 | bug | info | `internal/runs/runs.go:408`=`resnapshot := book.BankMoved || book.HasPriorRun`, `internal/pgstore/books.go:1098`=`HasPriorRun bool` | **`--resnapshot` платформа передаёт УСЛОВНО, а условие ставит только ПРАВКА банка — рост авто-банка от майнинга его не ставит.** Флаг выводится под `if book.BankMoved`, а единственный писатель `bank_moved_at` — дверь правок банка. На книге, которая МАЙНИТ банк, вторая покупка без правок банка идёт без флага, и движковый джоб-гард останавливает прогон (`exit 1` ⇒ `failed` на стороне платформы): авто-банк растёт от покупки к покупке, edit-снапшот съезжает, а гард банк-онли-движение от смены конфига не отличает. ⚠ Сегодня БЕСПРЕДМЕТНО: проводка `tmctl translate --max-units` на платформе гейчена оркестратором до лечения, а без неё вторая покупка этой формы не возникает. Строка заведена, чтобы условность не всплыла сюрпризом при снятии гейта. Найдено бэкенд-сессией `textmachine-e4` (пак «деньги»), проверено чтением платформенной стороны сессией P11 ⚠ **БЕСПРЕДМЕТНОСТЬ КОНЧИЛАСЬ И ДЕФЕКТ ЗАКРЫТ ТЕМ ЖЕ ПАКОМ (05.09), статус флипает ЛЕНДИНГ.** Гейт на проводку `--max-units` снят строкой 280, флаг едет в argv — значит условность `--resnapshot` перестала быть теоретической ровно в тот момент. Условие расширено: `resnapshot := book.BankMoved || book.HasPriorRun` (`internal/runs/runs.go`, греп `book.HasPriorRun`). Довод, почему флаг на КАЖДОМ продолжении безопасен: гард срабатывает ПО ДЖОБУ, то есть только на главах, которых прогон касается, а объёмный потолок допускает НОВУЮ книгу прежде пере-делки (`backend/internal/pipeline/volume.go`, проход `unitFresh` затем `unitRework`) — продолжение тратит грант на недоставленные главы. Согласие при этом фондированное: собственный холд прогона, никогда бланкетная форма. Пин: `runs.TestASecondPurchaseCarriesResnapshotEvenWithoutABankCorrection` (первая покупка флага НЕ несёт, вторая несёт, правки банка не было). | fixed(628cc56) | бэкенд-пак «деньги» + пак P11 (сверка шва) | +| PD-422 | bug | info | `internal/runs/runs.go:408`=`resnapshot := book.BankMoved`, `internal/pgstore/books.go:1104`=`HasPriorRun bool` | **`--resnapshot` платформа передаёт УСЛОВНО, а условие ставит только ПРАВКА банка — рост авто-банка от майнинга его не ставит.** Флаг выводится под `if book.BankMoved`, а единственный писатель `bank_moved_at` — дверь правок банка. На книге, которая МАЙНИТ банк, вторая покупка без правок банка идёт без флага, и движковый джоб-гард останавливает прогон (`exit 1` ⇒ `failed` на стороне платформы): авто-банк растёт от покупки к покупке, edit-снапшот съезжает, а гард банк-онли-движение от смены конфига не отличает. ⚠ Сегодня БЕСПРЕДМЕТНО: проводка `tmctl translate --max-units` на платформе гейчена оркестратором до лечения, а без неё вторая покупка этой формы не возникает. Строка заведена, чтобы условность не всплыла сюрпризом при снятии гейта. Найдено бэкенд-сессией `textmachine-e4` (пак «деньги»), проверено чтением платформенной стороны сессией P11 ⚠ **БЕСПРЕДМЕТНОСТЬ КОНЧИЛАСЬ И ДЕФЕКТ ЗАКРЫТ ТЕМ ЖЕ ПАКОМ (05.09), статус флипает ЛЕНДИНГ.** Гейт на проводку `--max-units` снят строкой 280, флаг едет в argv — значит условность `--resnapshot` перестала быть теоретической ровно в тот момент. Условие расширено: `resnapshot := book.BankMoved \|\| book.HasPriorRun` (`internal/runs/runs.go`, греп `book.HasPriorRun`). Довод, почему флаг на КАЖДОМ продолжении безопасен: гард срабатывает ПО ДЖОБУ, то есть только на главах, которых прогон касается, а объёмный потолок допускает НОВУЮ книгу прежде пере-делки (`backend/internal/pipeline/volume.go`, проход `unitFresh` затем `unitRework`) — продолжение тратит грант на недоставленные главы. Согласие при этом фондированное: собственный холд прогона, никогда бланкетная форма. Пин: `runs.TestASecondPurchaseCarriesResnapshotEvenWithoutABankCorrection` (первая покупка флага НЕ несёт, вторая несёт, правки банка не было). | fixed(628cc56) | бэкенд-пак «деньги» + пак P11 (сверка шва) | | PD-439 | standards | info | `docs/DEFECT_REGISTER.md` (строки `PD-59`, `PD-115`, `PD-122`, `PD-273`, `PD-380`, `PD-407`, `PD-44`), шапка регистра (словарь статусов), `internal/gates/register_test.go` (греп `registerStatus`) | **Семь строк несут в колонке статуса не статус, и каждая из них невидима для всякого счёта, который эту колонку читает.** Словарь шапки — `open` · `fixed()` · `accepted-risk(<кем, когда>)`; в дереве встречаются `open (наблюдаемость закрыта P5; ops и конфигурация — нет)`, `open (грейс-половина закрыта D39.162)`, `open (добавление закрыто P5; удаление — нет)`, `closed (решение владельца 17.08)`, `fixed` без коммита (дважды) и `**закрыт ратификацией**, работа уходит строкой 103`. Зонный `awk` сверяет ячейку с `open` ТОЧНО, поэтому три аннотированных открытых ряда не попадают ни в одно число, которое зона печатала (включая «open=96»), а `fixed` без коммита нарушает правило «закрытие — только с коммитом фикса». ⚠ Найдено новым гейтом класса: он читает статус по словарю с аннотацией, считает такие ряды открытыми, называет нечитаемые поимённо и краснеет, если нечитаемый ряд стоит под заголовком «Открытые» и несёт маркеры тревоги. Строки НЕ правлю: смена статуса — акт лендинга, а здесь под вопросом и форма, и содержание вердикта | open | самопроверка пака P13 (гейт класса, первый прогон) | | PD-457 | bug | info | `sqlc.yaml` (`overrides`), колонка `books.source_chars` | **У NULLABLE `bigint` НЕТ ПОДСТАНОВКИ ТИПА, ХОТЯ КОНФИГ ОБЕЩАЕТ УКАЗАТЕЛЬ ДЛЯ NULLABLE-ЦЕЛЫХ.** Миграция 00033 делает `books.source_chars` нуллабельной намеренно («движок не сказал» — не «ноль»), а `sqlc` для неё подстановки не имеет. Сегодня не ломает ничего: рукописный код читает колонку как `*int64` и конвертирует сам, `sqlc diff` чист. ⚠ Риск ОТЛОЖЕННЫЙ, а не отсутствующий, и наступит на ПЕРВОМ запросе `queries/*.sql`, который её выберет, — скан NULL в не-указатель. **Тот же класс, что и денежные колонки** (строка бэклога 304, заведена оркестратором), но другая колонка и другая подстановка: та про `*_micro_usd`, эта про размер текста. ⚠ Названо в отчёте пака (пункт ⑵) без носителя вне него | open | второй круг пака «форма заказа», 06.09 | | PD-458 | bug | info | `Makefile` цель `check`, подметание в первом шаге; гейт `internal/gates.TestTheBatterySweepsOnlyLogsWhoseWriterIsGone` | **ПОЧИНКА `PD-454` ЗАБРАЛА СВОЙСТВО, КОТОРОГО НИКТО НЕ НАЗЫВАЛ: ЛОГИ СТАЛИ НАКАПЛИВАТЬСЯ.** Прежнее ФИКСИРОВАННОЕ имя лога само себя ограничивало — сколько бы прогонов ни падало, оставался ОДИН несвежий файл, и следующий его перезаписывал. Попрогонное имя убрало коллизию ВМЕСТЕ с этим ограничением: каждый упавший или прерванный прогон оставляет свой `.check.log.` навсегда. ⚠ Замер, а не опасение: за одну ночь накопилось **четыре** (все нулевого размера, от прерванных прогонов зоны). В коммит не попадают (`.gitignore` = `.check.log*`), и цена НЕ в этом: рецепт НАРОЧНО сохраняет лог при падении, чтобы его прочли, и куча мусора от прерванных прогонов от этого одного файла НЕОТЛИЧИМА. То есть гниёт ровно та читаемость, ради которой лог и сохраняется. ⇒ подметание перед прогоном, и оно **условно по живости писателя** (`kill -0` — POSIX, без `/proc`): голое `rm -f .check.log.*` снесло бы лог ПАРАЛЛЕЛЬНОГО прогона и вернуло бы ровно ту коллизию, ради устранения которой заводилось попрогонное имя. Перезанятый PID оставляет файл — консервативное направление, единственное, что не уничтожает улику. Нечисловой хвост пропускается. ⚠ Следствие, названное, а не оставленное на открытие: лог, сохранённый УПАВШИМ прогоном, подметается СЛЕДУЮЩИМ — читать до пере-запуска. Три мутации ловятся: голое `rm -f .check.log.*` · цикл без проверки живости · подметание убрано совсем. ⚠ Найдено ЗОНОЙ при ответе на вопрос оркестратора «остались ли сомнения» — то есть вопросом, а не гейтом; ни один сторож второго порядка у починки не стоял | fixed(`0632a30`) | зона, 06.09, при закрытии смены | @@ -324,7 +324,7 @@ | ID | Класс | Вес | Где | Что и чем закрыто | Статус | Кем найдено | |---|---|---|---|---|---|---| -| PD-197 | standards | **major (гейт)** | `Makefile` `tools-check`, `go.mod` | **Гейт тулчейна не гейтил, а три дока утверждали обратное.** Подъём floor 1.26.5 → 1.26.6 (пять адвизори stdlib) был сделан переменной `GO_MIN_VERSION`, которую читало только сообщение об ошибке, тогда как проверкой оставался регекс `go1\.26\.([5-9]|[0-9]{2,})` — он принимал ровно ту 1.26.5, ради отказа от которой floor и поднимали, и ставил 1.26.10 ниже 1.26.9. Клейм «хост на 1.26.5 получит отказ» стоял в журнале ×2, `STACK_DECISIONS` и комменте Makefile — **закрыто:** цель `version-check` СРАВНИВАЕТ версии (`sort -V`, пререлизы `rc`/`devel` отвергаются отдельно) и берёт версию из переменной, чтобы её судили версиями, которых на хосте нет; `go.mod` получил `toolchain go1.26.6` — его читает всякая сборка, мимо make тоже (`GOTOOLCHAIN=auto` скачает, `=local` остановится). Пины `gates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem` (таблица из 11 версий) и `gates.TestGoModPinsTheSameToolchainTheBatteryDemands`, обе посадки падают; три клейма переписаны на описание механизма | fixed(третий раунд, дерево сессии) | ре-чек оркестратора (FP5-9) | +| PD-197 | standards | **major (гейт)** | `Makefile` `tools-check`, `go.mod` | **Гейт тулчейна не гейтил, а три дока утверждали обратное.** Подъём floor 1.26.5 → 1.26.6 (пять адвизори stdlib) был сделан переменной `GO_MIN_VERSION`, которую читало только сообщение об ошибке, тогда как проверкой оставался регекс `go1\.26\.([5-9]\|[0-9]{2,})` — он принимал ровно ту 1.26.5, ради отказа от которой floor и поднимали, и ставил 1.26.10 ниже 1.26.9. Клейм «хост на 1.26.5 получит отказ» стоял в журнале ×2, `STACK_DECISIONS` и комменте Makefile — **закрыто:** цель `version-check` СРАВНИВАЕТ версии (`sort -V`, пререлизы `rc`/`devel` отвергаются отдельно) и берёт версию из переменной, чтобы её судили версиями, которых на хосте нет; `go.mod` получил `toolchain go1.26.6` — его читает всякая сборка, мимо make тоже (`GOTOOLCHAIN=auto` скачает, `=local` остановится). Пины `gates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem` (таблица из 11 версий) и `gates.TestGoModPinsTheSameToolchainTheBatteryDemands`, обе посадки падают; три клейма переписаны на описание механизма | fixed(третий раунд, дерево сессии) | ре-чек оркестратора (FP5-9) | | PD-198 | doc | info | `internal/pgstore/runs.go` `PauseRun`, `internal/books/parse.go` | **Комментарии описывали до-фиксное поведение — в том числе на пути, который удаляет файл пользователя.** `PauseRun` обещал проверку стопа «в том же стейтменте» (стоит отдельный `select … for update` в той же транзакции); `parse.go` объявлял отказ источника «терминальным с первого ответа», хотя дофикс провёл КАЖДЫЙ ответ движка через бюджет попыток — **закрыто:** оба текста приведены к коду; правок поведения не потребовалось. ⚠ Дописка: P6 переписал текст `parse.go` ещё раз под полосу отказов, где терминален ровно один класс (PD-196). ⚠ **испр. оркестратором №16 15.08 при лендинге:** переименование этой строки в PD-199 откачено (ID стабилен навсегда, коммит-первоисточник `69d485a`), содержимое заведённой рядом второй строки слито сюда | fixed(третий раунд, дерево сессии) | ре-чек оркестратора (хвосты а, б) | ## Закрытые — дофикс-2 P5 (кросс-семейное ревью дофикса, 13.08) @@ -553,7 +553,7 @@ ## Закрытые — эра P11 (отзыв сессии на открытом потоке · застрявший расчёт · денежные пины) -| PD-379 | vuln | **major** | `internal/httpapi/stream.go:160`=`h.pump(r.Context(), s, who, bookID, state, last, resuming)` ⚠ якорь пере-нацелен паком P11: сигнатуру `pump` изменил он сам (принципал вместо голого id — в этом и лечение), `internal/httpapi/server.go` `guard`, `internal/auth/session.go` `SessionStore` | **Открытый поток событий переживает и отзыв сессии, и оба её потолка: «выйти везде» не выключает уже установленный канал.** Аутентификация происходит РОВНО ОДИН РАЗ, в `auth.Authenticator.Require` внутри `guard`; дальше `streamEvents` уходит в `pump`, и цикл до конца соединения читает только `ReadFrames`/`ReadStream`, строку сессии не смотрит ни разу. Значит `POST /auth/logout`, `POST /auth/logout-all`, `tmplatformctl revoke` и оба потолка (idle и абсолютный) уже открытый `GET /v0/books/{bookId}/events` не прекращают. Пере-проверено трижды независимо (финдер, рефутер в отдельной копии, координатор); на демоне с `SESSION_IDLE=5s`/`SESSION_MAX_AGE=10s` поток жил +40 с после отзыва. Бьёт по объявленной норме: ASVS 5.0 7.4.1 — требование УРОВНЯ 1 при объявленном зоной L2, и `STACK_DECISIONS` §13 отказывается от лимита одновременных сессий ИМЕННО в обмен на мгновенный отзыв. ⚠ Побочно, тем же прогоном: `/auth/logout-all` на ДЕВ-профиле не смонтирован вовсе (404) — из пары ручек, которой §13 обосновывает свою политику, на стенде доступна одна. Воспроизведение: `docs/p8-review/axis2-auth/sse-outlives-revocation.sh` и `sse-outlives-absolute-ceiling.sh`, снимок координатора `docs/p8-review/sse-outlives-revocation.txt` ⚠ **ВЕС ПОДНЯТ minor → major ПОСЛЕ РЕВЬЮ СТАРШЕЙ МОДЕЛЬЮ (fable-5), и поднят по трём доводам, которых сужение не учло.** **(1)** Граница «соединение само закрывается, когда книга приходит в покой» — не гарантия кода: у книги, чей долг материализации списан как неоплатный, поток НЕ КОНЧАЕТСЯ НИКОГДА, и это собственный комментарий зоны — `internal/pgstore/books.go:679`=`a book whose event stream can NEVER end`. То есть окно утечки не ограничено прогоном. **(2)** Вес отказавшего КОМПЕНСИРУЮЩЕГО контроля наследуется от рисков, которые он компенсирует, а не от схемы кадра: `STACK_DECISIONS` §13 отказывается и от лимита одновременных сессий, и от собственной границы федеративной сессии ИМЕННО в обмен на мгновенный отзыв и два срока — а открытый поток ускользает от всех трёх разом, и у §13 не остаётся содержания. **(3)** Провалено требование УРОВНЯ 1 при объявленном зоной L2 — это дыра ниже собственного пола, а не отклонение от лучших практик. ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `auth.Principal` несёт непубличную способность пере-спросить свою сессию, `pump` зовёт её ПЕРВЫМ ДЕЛОМ на каждом тике, отказ даёт терминальный кадр `session_ended` (канон 0.8.0) с watermark СОЕДИНЕНИЯ, а не головой истории. Запрос стора `StillLive` намеренно БЕЗ клаузы idle: окно бездействия скользит на запросе, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Доказано ДВУМЯ раздельными живыми сценариями: (а) длинные потолки + `tmplatformctl revoke` → поток кончился через 1 с (`~/tm-p11/probes/a-revocation-ends-the-stream.txt`); (б) idle 10 с / абсолютный 40 с, БЕЗ отзыва → поток пережил окно бездействия и кончился ровно на потолке (`b-the-ceiling-ends-the-stream.txt`). Посадки `r_nocheck`, `r_idle`, `r_head`, `r_open`, `r_wirename` — пойманы. Эррата `STACK_DECISIONS` §13 снята, галочка ASVS 7.4.1 в архиве восстановлена. ⚠ Остаток отдельной строкой: строку сессии удаляет часовой свип и по бездействию тоже, поэтому «строки нет» обязано значить «мертва» | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной по норме §3.6 «закрытие дефекта — коммит + пинящий тест») | ревью-пак P8-REVIEW, ось 2 (живая проба, подтверждено рефутером и координатором) | +| PD-379 | vuln | **major** | `internal/httpapi/stream.go:160`=`h.pump(r.Context(), s, who, bookID, state, last, resuming)` ⚠ якорь пере-нацелен паком P11: сигнатуру `pump` изменил он сам (принципал вместо голого id — в этом и лечение), `internal/httpapi/server.go` `guard`, `internal/auth/session.go` `SessionStore` | **Открытый поток событий переживает и отзыв сессии, и оба её потолка: «выйти везде» не выключает уже установленный канал.** Аутентификация происходит РОВНО ОДИН РАЗ, в `auth.Authenticator.Require` внутри `guard`; дальше `streamEvents` уходит в `pump`, и цикл до конца соединения читает только `ReadFrames`/`ReadStream`, строку сессии не смотрит ни разу. Значит `POST /auth/logout`, `POST /auth/logout-all`, `tmplatformctl revoke` и оба потолка (idle и абсолютный) уже открытый `GET /v0/books/{bookId}/events` не прекращают. Пере-проверено трижды независимо (финдер, рефутер в отдельной копии, координатор); на демоне с `SESSION_IDLE=5s`/`SESSION_MAX_AGE=10s` поток жил +40 с после отзыва. Бьёт по объявленной норме: ASVS 5.0 7.4.1 — требование УРОВНЯ 1 при объявленном зоной L2, и `STACK_DECISIONS` §13 отказывается от лимита одновременных сессий ИМЕННО в обмен на мгновенный отзыв. ⚠ Побочно, тем же прогоном: `/auth/logout-all` на ДЕВ-профиле не смонтирован вовсе (404) — из пары ручек, которой §13 обосновывает свою политику, на стенде доступна одна. Воспроизведение: `docs/p8-review/axis2-auth/sse-outlives-revocation.sh` и `sse-outlives-absolute-ceiling.sh`, снимок координатора `docs/p8-review/sse-outlives-revocation.txt` ⚠ **ВЕС ПОДНЯТ minor → major ПОСЛЕ РЕВЬЮ СТАРШЕЙ МОДЕЛЬЮ (fable-5), и поднят по трём доводам, которых сужение не учло.** **(1)** Граница «соединение само закрывается, когда книга приходит в покой» — не гарантия кода: у книги, чей долг материализации списан как неоплатный, поток НЕ КОНЧАЕТСЯ НИКОГДА, и это собственный комментарий зоны — `internal/pgstore/books.go:685`=`a book whose event stream can NEVER end`. То есть окно утечки не ограничено прогоном. **(2)** Вес отказавшего КОМПЕНСИРУЮЩЕГО контроля наследуется от рисков, которые он компенсирует, а не от схемы кадра: `STACK_DECISIONS` §13 отказывается и от лимита одновременных сессий, и от собственной границы федеративной сессии ИМЕННО в обмен на мгновенный отзыв и два срока — а открытый поток ускользает от всех трёх разом, и у §13 не остаётся содержания. **(3)** Провалено требование УРОВНЯ 1 при объявленном зоной L2 — это дыра ниже собственного пола, а не отклонение от лучших практик. ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `auth.Principal` несёт непубличную способность пере-спросить свою сессию, `pump` зовёт её ПЕРВЫМ ДЕЛОМ на каждом тике, отказ даёт терминальный кадр `session_ended` (канон 0.8.0) с watermark СОЕДИНЕНИЯ, а не головой истории. Запрос стора `StillLive` намеренно БЕЗ клаузы idle: окно бездействия скользит на запросе, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Доказано ДВУМЯ раздельными живыми сценариями: (а) длинные потолки + `tmplatformctl revoke` → поток кончился через 1 с (`~/tm-p11/probes/a-revocation-ends-the-stream.txt`); (б) idle 10 с / абсолютный 40 с, БЕЗ отзыва → поток пережил окно бездействия и кончился ровно на потолке (`b-the-ceiling-ends-the-stream.txt`). Посадки `r_nocheck`, `r_idle`, `r_head`, `r_open`, `r_wirename` — пойманы. Эррата `STACK_DECISIONS` §13 снята, галочка ASVS 7.4.1 в архиве восстановлена. ⚠ Остаток отдельной строкой: строку сессии удаляет часовой свип и по бездействию тоже, поэтому «строки нет» обязано значить «мертва» | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной по норме §3.6 «закрытие дефекта — коммит + пинящий тест») | ревью-пак P8-REVIEW, ось 2 (живая проба, подтверждено рефутером и координатором) | | PD-385 | bug | **major** | `internal/pgstore/runs.go:556`=`where a.ended_at is null and a.reconcile_failures >= $1` ⚠ якорь пере-нацелен паком P11: прежняя строка (`where r.finished_at is null and …`) была ОДНИМ предикатом на обе половины и её больше нет — выборка разложена на две ветви, и это ровно лечение, `internal/pgstore/observe.go` (гейдж), `internal/pgstore/runs.go` `AbandonRun` | **Прогон, чей РАСЧЁТ доведён до `StalledAfter`, не виден операторским поверхностям порога, а лог-строка на пересечении порога шлёт оператора именно туда.** Обе фазы делят один счётчик через общий `deferItem`, но операторская половина построена только для ЖИВЫХ прогонов: `StalledRuns` джойнит `a.ended_at is null` и фильтрует `r.finished_at is null`, гейдж `tm_platform_runs_stalled` считает по тому же предикату, а `AbandonRun` читает `where id = $1 and finished_at is null` и отвечает `ErrNoRun`. Живая проба на состоянии, выращенном штатными путями (интейк, HTTP-старт, отказ спавна, `run abandon`): `runs --stalled` отвечает «no run is failing to reconcile», `runs` — «no run is live», `run abandon` — «is not a live run», гейдж 0, при этом в базе `settled_at` NULL, `reconcile_failures` 5 и открытая резервация на 90000 микро. Тот же слепой угол закрывает прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта. ⚠ Рефутер опроверг заголовочный абсолют «невидим ВСЕМ поверхностям»: `tmplatformctl balance --user` печатает этот холд строкой, `tm_platform_oldest_open_hold_seconds` растёт без потолка, а `tmplatformctl books` показывает «WHY NOT: unsettled hold»; ноль в улике финдера был артефактом его фикстуры. Остаётся то, ради чего строка заведена: три поверхности ПОРОГА слепы, терминальной ручки для такого прогона нет, а ERROR на пороге называет команду, которая на нём молчит. Воспроизведение: `docs/p8-review/axis3-queue/live-stalled-settlement.sh` ⚠ **ВЕС ПОДНЯТ minor → major ЗАКРЫВАЮЩИМ РЕВЬЮ СТАРШЕЙ МОДЕЛИ, и довод не про эту строку в одиночку, а про КРУГОВОЕ сужение четырёх строк пака.** `PD-384` сужен до minor тем, что холд «виден» гейджу `tm_platform_oldest_open_hold_seconds` и команде `balance --user`. Но `PD-392` доказывает ЖИВОЙ ПРОБОЙ, что у этого гейджа ручки НЕТ: идентификатора он не даёт, `balance --user` требует аккаунт, которого гейдж не называет, глобального списка открытых холдов в CLI нет, а документированный случай самого гейджа это ровно данная популяция — при `oldest_open_hold_seconds 10813` все три команды отвечают «no run is live», «no run is failing to reconcile», «no book has been given up on». `PD-390` доказывает, что тот же гейдж умеет ЗАМИРАТЬ и отдавать нули как здоровье. `PD-389` — что его сеттеры не запинены ничем. То есть каждое из четырёх сужений держится поверхностью, несостоятельность которой доказывает соседняя строка ТОГО ЖЕ пака, и по кругу. А терминальной ручки для этой популяции нет ПО ПОСТРОЕНИЮ: `internal/pgstore/runs.go:721`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги. Следствие, названное прямо: для всей популяции «закончен, но не рассчитан» деньги пользователя заморожены бессрочно, поверхности ПОРОГА слепы, ERROR на пороге называет команду, которая откажет, и единственный выход — сырой SQL в проде. Составной инвариант, на котором принят пак P8-FIX (`D39.154`: гейдж плюс `runs --stalled` плюс `run abandon` как ответ на `PD-169`), для этой популяции ЛОЖЕН ЦЕЛИКОМ — а «решается до следующего пака» есть определение major-секции самого регистра. Носителем major сделана ЭТА строка как самая полная по улике (живая проба на состоянии из штатных путей плюс отказ ручки); `PD-384` и `PD-392` несут ссылку сюда, чтобы не плодить второй major на тот же корень ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Популяция «кончился, а деньги нет» вошла в `StalledRuns` (колонка `PHASE`), в гейдж `tm_platform_runs_stalled` и получила терминальную ручку: `run abandon` закрывает КАЖДЫЙ осиротевший холд прогона, снимает отсрочку и штампует `settled_at`; допуск сужен до `reconcile_failures >= 1`, иначе команда отдавала бы целиком холд расчёта, который просто ещё не закрылся. Доказано до/после на состоянии из ШТАТНЫХ путей (интейк → HTTP-старт → спавн → выход движка → снят запиненный бинарь): было «no run is live» / «is not a live run» / гейдж 0 при открытой резервации 90000 микро, стало строка `settling` с холдом и возврат денег целиком (`~/tm-p11/probes/pd385-before.txt`, `pd385-after.txt`). ⚠ Вторая названная строкой популяция — ЖИВОЙ прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой — получила обе поверхности видимости, но не ручку: отдельной строкой | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (живая проба, сужено рефутером) | | PD-376 | bug | minor, деньги | `internal/pgstore/runs.go:1274`=`select min(a.spend_baseline_micro_usd)`, пин `internal/runs/sweep_test.go` `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` | **`PD-159` стоит `fixed`, а её пин доказывает свойство СЛАБЕЕ, чем читается: слово `min` не исполняет никто, и мутация `min` → `max` проходит батарею.** Строка PD-159 закрывает двойную оплату формулой «SpendBound — НАИМЕНЬШАЯ базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ», а названный ею пин кладёт в книгу РОВНО ОДНУ более позднюю попытку — на множестве из одного элемента `min` и `max` совпадают. Прогон `./internal/pgstore/` и `./internal/runs/` под мутацией зелёный; независимый пин на той же мутации падает (`the bound is 0.500000, want 0.200000`), то есть мутация поведенческая, а не эквивалентная. Достижимость: при монотонном росте книжного счётчика `min` и `max` расходятся уже при ДВУХ более поздних попытках, а две даёт один преемник, переживший рестарт или резюм; тогда границей становится базовая линия, УЖЕ содержащая трату предыдущего прогона — это ровно PD-159 на одну попытку дальше. Переплата ограничена холдом. Класс — «реестр умеет врать», тот же разбор, каким был найден PD-169. Готовый пин: `docs/p8-review/axis1-money/a1_spendbound_test.go.txt` и независимый `r1_spendbound_test.go.txt`. ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M11):** улика пере-снята на полной батарее — прогон `go test ./... -count=1` со всеми тремя гейтами под той же мутацией даёт ПУСТУЮ дельту против чистой базовой линии той же копии — то есть мутацию не ловит ни один из 18 пакетов. Лог — `docs/p8-review/mutations-round2.log` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Взят готовый пин пака — вариант `r1_` как более сильный (ходит настоящими дверями `StartRun`/`RecordSpawn`, наименьшая базовая линия стоит В СЕРЕДИНЕ, так что «первая поздняя» и «последняя поздняя» тоже падают) — и усилен ВТОРОЙ книгой того же аккаунта, чтобы исполнялся и фильтр `r.book_id`. Посадка `min`→`max`: чистая копия EXIT=0, с мутацией EXIT=1, единственный красный — этот пин. ⚠ **ДИСПОЗИЦИЯ, которую строка оставляла приёмке: `PD-159` НЕ пере-открывается.** Основание прежнее (D39.159 §5): дефект из кода ушёл, недоставало ПИНА, — а теперь пин есть, то есть пробел закрыт там, где он был. Двусторонняя ссылка сохраняется: `PD-159` несёт `ОСПОРЕНО(PD-376)`, эта строка называет `PD-159` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации, подтверждено рефутером собственным пином) | | PD-384 | bug | minor | `internal/runs/reconcile.go:278`=`case overran:` (`settleOne`) ⚠ якорь пере-нацелен паком P11: прежнее `if !overran {` было САМИМ дефектом — судить по цене вместо вердикта — и заменено свитчем по вердикту, `internal/runs/reconcile.go` `settle` (три тихих `return nil`) | **Расчёт денег, упавший ДЁШЕВО, не считается никогда: порог `StalledAfter` для него недостижим.** Вторая фаза считает неудачу ТОЛЬКО по исчерпанию бюджета (`overran := errors.Is(item.Err(), context.DeadlineExceeded)`, дальше `if !overran { return }`), а `settle` возвращает nil БЫСТРО в трёх случаях: движок не ответил, в отчёте нет committed, попытка без базовой линии. Каждый может быть ПОСТОЯННЫМ — запиненный бинарь движка снесён при выкате, проект заменён под платформой, попытка старой схемы. Тогда цикл вечен: `reconcile_failures` остаётся 0, `reconcile_after` NULL, гейдж и `tmplatformctl runs --stalled` пусты, холд заморожен. Замерено пробой: пять проходов одного нерассчитываемого прогона дали 5 вызовов движка, `reconcile_failures=0`, `StalledRuns(5)=0`. Плюс цена: `settle` зовёт `tmctl status` НА КАЖДОМ проходе без рейт-лимита, тогда как соседний `maybeResync` имеет `dueForResync` ровно из-за этой цены. ⚠ Рефутер сузил вес major → minor: холд ВИДЕН двум поверхностям, которых финдер не спросил — гейдж `tm_platform_oldest_open_hold_seconds` и `tmplatformctl balance --user`, печатающий каждый открытый холд суммой, книгой и id прогона; плюс каждый проход пишет WARN с id прогона. Воспроизведение: `docs/p8-review/axis3-queue/probe_settlement_surface_test.go.txt` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Неудачей считается ВЕРДИКТ расчёта, а не только исчерпание бюджета: `settle` вернул три состояния (закрыт · гонка · не вычислим), `settleOne` судит по ним, поэтому все три тихих `return nil` теперь доходят до порога. Цена, названная строкой, закрыта тем же ходом: отсрочка ограничивает `tmctl status` вместо вызова каждым проходом. ⚠ Первая неудача НЕ откладывается — открытая резервация это ворота РЕЗЮМА пользователя (`reopen` отказывает, пока холд предыдущей попытки открыт), и минута ожидания после секундной аварии была бы регрессом; бэкофф идёт со второй и капнут пятью минутами, а не тридцатью. Посадка `r_firstfast` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (проба на реальном сторе, сужено рефутером) | @@ -609,7 +609,7 @@ | PD-367 | bug | minor | `internal/books/parse.go:129`, `internal/ingest/manifest.go` `Whole` | **Пол на самосогласованность манифеста стоит только у материализатора, а интейк тот же документ ПРИНИМАЕТ.** Манифест `{ChaptersTotal: 120, UnitsTotal: 400}` с пустым списком глав `Whole()` отвергает, а `books.Parse` заводит книгу `not_started` с `chapter_count=120` и пустым деревом — по такой книге можно СТАРТОВАТЬ и ОПЛАТИТЬ прогон (потолок считается от `chapter_count`). Воспроизведено ревью на живом Postgres. ⚠ **ЗАКРЫТА пак P12 (30–31.08) по решению оркестратора: пол самосогласованности СИММЕТРИЧЕН, интейк отказывает.** `ingest.Manifest.Readable()` — ВТОРОЙ предикат рядом с `Whole()`, не расширение `DecodeManifest` (у того четыре пина намеренно декодируют частичные и чужие документы, и его собственная дока объявляет правило «значение не гейтим» решением). Стоит ПЕРВЫМ в `books.Parse`, до ветвей по `ChaptersTotal`: ниже этой черты документ, прочитанный неверно, неотличим от книги, в которой ничего нет, а ЭТО чтение удаляет аплоад. Класс — `parser_unavailable`: бюджет попыток тратится, файл остаётся. ⚠ **ПРАВКА ЗАПИНЕННОГО КОНТРАКТА, заказанная промтом P12 §3.8** — не подгонка под зелень: батарея интейка ездила на документах без списка глав, и фикстуры РАСШИРЕНЫ (`wholeManifest`), а не обойдены; поимённо пере-подписаны фикстуры `books_test.newFixture` и два манифеста `render_test`. Пин — `books.TestTheIntakeRefusesTheDocumentItsOwnMaterialiserWouldReject` (ровно документ строки: 120 глав, 400 пар, пустой список; проверяет и что `chapter_count` НЕ записан, и что файл цел); посадка M15 КРАСНАЯ адресно. | fixed(пак P12) | воркфлоу-ревью волны 2 (P8-FIX); рефутер подтвердил механику и опроверг предложенное лекарство | | PD-213 | hardening | info | `internal/ingest/manifest.go`, `internal/books/parse.go` | **Форма манифеста не версионируется на стороне платформы — латентная мина на УДАЛЕНИЕ файла.** `DecodeManifest` не сверяет `manifest_version` ни с чем; `json.Unmarshal` тихо игнорирует незнакомые поля и оставляет отсутствующие нулями, поэтому смена формы движком (`tm-manifest-v2` → v3, переименование `chapters_total`) даст валидный разбор с `ChaptersTotal = 0`. А ноль глав интейк трактует как «источник прочли, книги нет» — тот же терминал и тот же бюджет, что exit 11, то есть после пяти попыток файл пользователя УДАЛЯЕТСЯ, хотя движок книгу прекрасно разобрал. Сегодня формы совпадают поле-в-поле, так что не эксплуатируется; в отличие от потока (мажор отвергается) и от полосы кодов (незнакомый номер безопасен), у манифеста аналога нет. Лечить сверкой `manifest_version` с известной, где незнакомая версия даёт НЕ-деструктивный класс ⚠ **ЗАКРЫТА пак P12 (30–31.08) тем же классом «незнакомое → не-деструктивно»,** как и предписывала строка. `ingest.KnownManifestVersion` (`tm-manifest-v2`, зеркало `backend/internal/pipeline/manifest.go:46`) сверяется в `Readable()` перед всем остальным, и незнакомая версия даёт `parser_unavailable` — файл цел. Это ФОРМЕННЫЙ гейт, а не пин значения: сама строка и дока поля правы, что пиннинг значения везде сделал бы каждый релиз движка релизом платформы, поэтому версия сверяется РОВНО в одном месте — там, где неверное чтение разрушительно. Пин — `books.TestAManifestShapeThisBuildDoesNotKnowNeverDeletesTheUpload`: v3-манифест переживает весь бюджет попыток с целым файлом, а ЗНАКОМАЯ форма с честно нулевыми счётчиками по-прежнему удаляет каталог (иначе гейт съел бы настоящий вердикт о тексте пользователя). Посадка M14 (снять сверку версии) КРАСНАЯ адресно. | fixed(пак P12) | адверсариальное ревью P6 (линза шва) | | PD-427 | doc | minor | `internal/ingest/resync.go:37-43` (аллоулист `StatusReport`), опровергнуто `backend/internal/pipeline/status.go` `projectStoredMemory` (лендинг `6ec9f8a`, D39.170) | **Комментарий несёт ПОСЫЛКУ, которую сняли, и читается как действующий довод.** Он объясняет, почему платформа сознательно НЕ берёт `rebill_units`/`rebill_usd` через шов: «status проецирует СОХРАНЁННУЮ память, и сразу после `bank-apply` — в единственный момент, когда согласие хотело бы цифру, — он честно читает ноль». Это было верно и ратифицировано (эррата 28.08-к). Движковый пак «деньги» починил ровно это: `foldMemoryForRead` стал ПЕРВЫМ ответом читающего пути, а `projectStoredMemory` понижена до фолбэка, и комментарий движка объявляет это дословно — «IT IS NO LONGER THE READ PATH'S FIRST ANSWER». Слепое окно закрыто, `status` отвечает «сколько будет стоить» ДО покупки, оставаясь $0-глаголом без записи. ⚠ **Комментарий неверен ДВАЖДЫ:** не только посылка, но и предсказанное лечение — он обещает, что «пара вернётся с движковым ГЛАГОЛОМ, который умеет свернуть и оценить коррекцию ВНЕ прогона», а нового глагола не появилось: починили существующий `status`. ⚠ **ПРОВОДКУ ПОЛЕЙ ЭТА СТРОКА НЕ ОТКРЫВАЕТ** (слово оркестратора при передаче): она гейчена вместе с `tmctl translate --max-units`, и тот гейт в силе — движковый потолок объёма на майнящей банк книге пробивался, лечение легло, но проводка ждёт отдельного решения. То есть предмет строки — ровно устаревший ДОВОД, а не отсутствие полей. Класс — «указатель пережил то, на что указывал», тот же, что `PD-310`/`PD-326`/`PD-366`, только в прозе шва. Зеркалит строку 234 единого бэклога ⚠ **ЗАКРЫТА пак P12 (30–31.08) — акт закрытия, не работа:** комментарий `internal/ingest/resync.go` уже исправлен 29.08 аудитом доков, снятая посылка из него ушла, предсказание про «новый движковый глагол» тоже. Проверено чтением обеих сторон. Проводку полей строка не открывала и не открывает — гейт `--max-units` в силе. | fixed(пак P12, акт закрытия) | оркестратор №19 при лендинге движкового пака (`6ec9f8a`), проверено чтением обеих сторон сессией P11 | -| PD-203 | bug | info | `internal/pgstore/books.go` `ReadUsage` | **Аккаунт объявляется исчерпанным по паузе ОДНОГО прогона.** `/usage` ставит `paused_reason` аккаунта, если у какой-нибудь книги последний прогон стоит `paused/credit_exhausted` — а это потолок ПРОГОНА (сколько глав купил пользователь), а не баланс: на аккаунте может лежать сколько угодно денег, и другой прогон стартует. Контракт про это поле говорит «Set when the account itself is in a halted state». Существовало до этого пака и не им создано; отдельной строкой, потому что различение потолков (PD-199) сделало вопрос «чей это потолок» отвечаемым ⚠ **СУЖЕНО P7:** `Usage.halt_reason` получил СВОЙ словарь (`AccountHaltReason`), и `PD-241` убрал самый частый ложный источник — стоп пользователя, приезжавший `credit_exhausted` ⚠ **ПАК P8-REVIEW 24.08 ПРЕДЛОЖИЛ ЗАКРЫТЬ, проверив предикат по коду:** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта, а не сканирует книги (`platform/internal/pgstore/books.go:1217`=`case balance <= 0:`), а ⚠-комментарий рядом прямо описывает замену (`platform/internal/pgstore/books.go:1186`=`It used to be read off the`); единственный писатель `Usage.PausedReason` — эта же строка (греп `PausedCreditExhausted` по `internal/pgstore/books.go`), приехало `9b23e8c` ⚠ **ЗАКРЫТА пак P12 (30–31.08): предикат пере-проверен и предложение P8-REVIEW подтверждено.** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта и ставит аккаунтную причину только при `balance <= 0`; книги не сканируются. Канон на той же стороне: `AccountHaltReason` — «a state of the account, not of a run», отдельный словарь ровно затем, чтобы прогонная причина не зажигала аккаунтный флаг. То есть дефект строки не воспроизводится, и это закрытие, а не пере-открытие. | fixed(пак P12) | сессия P6 (самопроверка вокруг PD-199) | +| PD-203 | bug | info | `internal/pgstore/books.go` `ReadUsage` | **Аккаунт объявляется исчерпанным по паузе ОДНОГО прогона.** `/usage` ставит `paused_reason` аккаунта, если у какой-нибудь книги последний прогон стоит `paused/credit_exhausted` — а это потолок ПРОГОНА (сколько глав купил пользователь), а не баланс: на аккаунте может лежать сколько угодно денег, и другой прогон стартует. Контракт про это поле говорит «Set when the account itself is in a halted state». Существовало до этого пака и не им создано; отдельной строкой, потому что различение потолков (PD-199) сделало вопрос «чей это потолок» отвечаемым ⚠ **СУЖЕНО P7:** `Usage.halt_reason` получил СВОЙ словарь (`AccountHaltReason`), и `PD-241` убрал самый частый ложный источник — стоп пользователя, приезжавший `credit_exhausted` ⚠ **ПАК P8-REVIEW 24.08 ПРЕДЛОЖИЛ ЗАКРЫТЬ, проверив предикат по коду:** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта, а не сканирует книги (`platform/internal/pgstore/books.go:1223`=`case balance <= 0:`), а ⚠-комментарий рядом прямо описывает замену (`platform/internal/pgstore/books.go:1192`=`It used to be read off the`); единственный писатель `Usage.PausedReason` — эта же строка (греп `PausedCreditExhausted` по `internal/pgstore/books.go`), приехало `9b23e8c` ⚠ **ЗАКРЫТА пак P12 (30–31.08): предикат пере-проверен и предложение P8-REVIEW подтверждено.** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта и ставит аккаунтную причину только при `balance <= 0`; книги не сканируются. Канон на той же стороне: `AccountHaltReason` — «a state of the account, not of a run», отдельный словарь ровно затем, чтобы прогонная причина не зажигала аккаунтный флаг. То есть дефект строки не воспроизводится, и это закрытие, а не пере-открытие. | fixed(пак P12) | сессия P6 (самопроверка вокруг PD-199) | | PD-380 | hardening | minor | `internal/pgstore/sessions.go:96`=`AbsoluteExpiresAt: now.Add(maxAge),`, `internal/config/config_test.go` | **Абсолютный потолок сессии не запинен в единственном месте, где он становится фактом в базе.** `CreateSession` — единственный писатель `sessions.absolute_expires_at`, и мутация этого выражения проходит ПОЛНЫЙ пакет `pgstore`: ни один сессионный тест не краснеет. Пин, который `STACK_DECISIONS` §13 называет носителем потолка, смотрит только на результат `config.Load()` (что значение конфигурации не выше ASVS-предела), то есть проверяет НАСТРОЙКУ, а не то, что она доезжает до строки. Родня `PD-86`, но на шаг раньше: там не запинены клаузы ЧТЕНИЯ и потолок держится транзитивно через `Touch`, здесь не запинена сама ЗАПИСЬ, а транзитивной страховки у неё нет. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M12):** `now.Add(maxAge)` → `now.Add(100*maxAge)` в `CreateSession`, дельта против чистой копии ПУСТА на всех 18 пакетах. ⚠ **ВЕС ПОДНЯТ info → minor закрывающим ревью, довод — симметрия с `PD-375`:** форма идентична (единственная точка принуждения объявленной границы не исполняется ни одним тестом, мутация переживает ПОЛНУЮ батарею, транзитивной страховки нет — `Touch` зажимает по значению ИЗ ТОЙ ЖЕ испорченной строки), а вес расходился только по валюте: там деньги и minor, здесь механизм ASVS 7.3.2 уровня 2 при объявленной зоной базовой линии L2 и info. Асимметрия была отпечатком того самого храповика «вреда сегодня нет», который это ревью и нашло ⚠ **Якорь пере-нацелен паком `sqlc` (29.08):** прежний токен — `s.pool.Exec` в `CreateSession` — исчез, потому что SQL этого запроса уехал в `internal/pgstore/queries/sessions.sql` и исполняется генерённым кодом. Новая цель — строка, где `maxAge` СТАНОВИТСЯ значением (`AbsoluteExpiresAt: now.Add(maxAge)`): именно она несёт факт, о котором строка, и она переживёт следующую генерацию. Сам дефект не тронут — потолок по-прежнему не запинен. ⚠ **ЗАКРЫТО паком `sqlc` (`63fcee5`, D39.172), пере-проверено ПОСАДКОЙ, а не рассуждением.** Пин — `pgstore.TestTheTwoSessionDeadlinesAreNotInterchangeable` (`sessions_test.go`): он создаёт сессию с РАЗНЕСЁННЫМИ сроками (`idleTTL` 1 ч против `maxAge` 24 ч — фикстура, где они совпадают, здесь ничего не доказывает, потому что `Touch` зажимает idle к absolute) и утверждает `AbsoluteExpiresAt == now.Add(maxAge)` после `CreateSession`, то есть ровно в единственном месте, где потолок становится фактом в базе. Именно та мутация, которой строка заведена — M12, `now.Add(maxAge)` → `now.Add(100*maxAge)` — теперь КРАСНАЯ адресно (замер 29.08: `AbsoluteExpiresAt = 2026-12-07…, want 2026-08-30…`). Пак строку не искал: тест писался против перестановки двух сроков, и потолок оказался запинен тем же утверждением — поэтому закрытие подтверждено пере-прогоном ИМЕННО M12, а не сходством формулировок | fixed | ревью-пак P8-REVIEW, ось 2 (посадка мутации, пере-посажена рефутером на полном пакете) | | PD-44 | hardening | info | `internal/pgstore/` | `sqlc` не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx ⚠ **P8-FIX: половина, которая ЛЕЧИТ класс, построена; сам инструмент — вопрос владельцу.** Рантайм-ошибки «нет такой колонки» (`r.stop_for_signing`, `chapters_before`) случились в СКЛЕЕННОМ SQL read-модели, куда sqlc по построению не доходит, поэтому тем же пунктом заведён постоянный гейт, который доходит: `pgstore.TestEverySQLStatementParsesAgainstTheMigratedSchema` сворачивает КАЖДЫЙ SQL пакета из исходника (литералы, конкатенации, именованные константы) и планирует его Postgres'ом (`explain (generic_plan)`) против мигрированной схемы — **162 оператора, все планируются**. Посадка мутации в СКЛЕЕННЫЙ фрагмент (`c.units_edit_done` → несуществующая колонка) гейтом ловится; несворачиваемый SQL — ОШИБКА гейта, а не пропуск (единственное исключение — `store.go` `Ready`, где имя таблицы принадлежит goose, и оно выписано таблицей в самом гейте). Тем же гейтом закрыт открытый вопрос фикс-листа «есть ли в read-модели запрос, которого не касается ни один тест»: теперь его касаются все, на каждом прогоне батареи. **ГРАНИЦА sqlc:** read-модель для него недостижима по построению — склеек в пакете **25 мест из 147**; конвертируем только блок из пяти файлов без склейки (`credits`·`identity`·`idempotency`·`sessions`·`observe`). ⚠ **РЕШЕНО владельцем 22.08 (D39.154): sqlc берётся ОТДЕЛЬНОЙ СЕССИЕЙ, не внутри пака** — генерённый код в дереве, пин версии инструмента, гейт актуальности, `WithTx` для денежных запросов; мешать это с содержательной работой нельзя. ⚠ **ЗАКРЫТО: инструмент взят и заленджен** — `63fcee5`, ратификация `D39.172`, отдельным паком, как решил владелец 22.08 (`D39.154`). Конвертировано 40 запросов из этого самого блока; носители — `platform/sqlc.yaml`, `internal/pgstore/queries/*.sql`, генерённые `*.sql.go` в том же пакете. Актуальность генерации гейчена дважды: `sqlc diff` пререквизитом `make check` и `pgstore.TestEveryGeneratedQueryMatchesItsSourceFile` в батарее (работает без установленного sqlc). ⚠ **Замер набора на 29.08** (прежний счёт «41 запрос в 5 файлах» снят — он был сделан до пака P11, добавившего `StillLive`): в наборе **42 места вызова / 41 различный текст SQL** (константа `read` в `idempotency.go` исполнялась из двух мест), из них **конвертируемых 40**. Сороковой не `observe.go`: `Observe` спрашивает `river_job` через `to_regclass`, а эту таблицу мигрирует River сам, вне goose-миграций, поэтому sqlc отвергает запрос — и добавить схему River в конфиг значило бы завести ВТОРОЙ носитель чужой схемы. Гейт `sqlgate` после конверсии видит **172** оператора против пола 140 | fixed | ревью «вне карты»; гейт и граница — P8-FIX | @@ -629,4 +629,4 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-442 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql` (таблица `exports`), канон `14-api-contract/openapi.yaml` §Export | **В схеме с самой первой миграции читающей модели жила таблица `exports`, у которой НЕТ ни одного читателя и ни одного писателя, и её форма ПРОТИВОРЕЧИТ ратифицированному контракту.** Она несла `ready boolean` плюс `failed_reason text`, а канон объясняет прямо, почему так нельзя: «A state and not a boolean: a boolean merges three situations into "not ready" and a poll on it never ends» (§`Export.state`). То есть будущая дверь выдачи, честно построенная на существующей таблице, отдала бы поллинг, который не кончается — ровно тот дефект, который канон и запрещает. Обнаружено при постройке двери: `sqlc generate` отказался с `relation "exports" already exists`, и это единственный сторож, который у класса был. Проверка отсутствия потребителей: `grep -rn '\bexports\b' --include=*.go internal/ cmd/` до пака давал ОДИН хит и тот в комментарии (`internal/httpapi/capabilities.go`). ⚠ Мигрировать было нечего — ни строки ни разу не записывалось, — поэтому `00032_exports.sql` таблицу СНОСИТ и создаёт заново в форме канона; down-путь восстанавливает форму `00002` дословно, потому что откат обязан вернуть то, что выпущенная миграция оставила. Пин формы: `internal/pgstore/exports_test.go` (четыре состояния, партиальные индексы свипа, каскад с книгой) | fixed(дерево пака «закрыть цикл», 04.09) | пак «закрыть цикл» 04.09 (найдено постройкой двери выдачи) | -| PD-447 | doc | minor | `docs/STACK_DECISIONS.md` §«Как поднять локально» и `deploy/README.md` шаг 3–4 (оба исправлены); носители дефолта — `internal/config/config.go:330`=`TM_PLATFORM_ADDR` и `cmd/tmplatformctl/seed.go:41`=`base URL of the running tmplatformd` | **Оба стендовых рецепта зоны принимали ОТВЕТ ПО АДРЕСУ за доказательство того, что отвечает СВОЙ процесс, и потому проходили при мёртвом собственном демоне.** Замерено исполнением 04.09: на машине разработки четвёртые сутки жил чужой `tmplatformd` на `127.0.0.1:8080` со своей базой; демон рецепта умирает в этой ситуации на `bind: address already in use` — молча, он запущен фоном, — а смоук следующей строкой получает `healthz=200` и `readyz=ready` ОТ ЧУЖОГО ПРОЦЕССА. ⚠ **Дороже смоука — шаг сида:** `tmplatformctl seed` дарит `$25` кредита и грузит книгу ЧЕРЕЗ ЖИВОЙ ИНТЕЙК, то есть по этому рецепту деньги и данные уезжают в чужой деплой. Второй адрес там же был написан отдельным литералом (`--url http://127.0.0.1:8080`), что и есть штатный способ разъехаться с `TM_PLATFORM_ADDR` незаметно. ⚠ **Три лечения на три РАЗНЫЕ половины, ни одно не заменяет другое:** явный `_ADDR` уводит с общего дефолта; проба по `pid` слушателя отвечает на вопрос, на который `200` не отвечает в принципе — ЧЕЙ это процесс; `--noproxy '*'` нужен потому, что при заданных `http_proxy` голый `curl` на `127.0.0.1` уходит во внешний прокси. Проба предъявлена в обе стороны: свой pid на своём порту — проходит, чужой демон против своего pid — отвергается. ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение:** коллизия — `tmplatformd` против `tmplatformd`, любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки. Чинится посылка, а не номер. **Вес minor, а не major, по радиусу:** пишет только дев-стенды, боевого пути этим рецептом нет, деньги — стендовый грант. | fixed(`adf5e53`) | найдено оркестратором №22 при независимой проверке отзыва причины смертей демона, 04.09; починено зоной | +| PD-447 | doc | minor | `docs/STACK_DECISIONS.md` §«Как поднять локально» и `deploy/README.md` шаг 3–4 (оба исправлены); носители дефолта — `internal/config/config.go:337`=`TM_PLATFORM_ADDR` и `cmd/tmplatformctl/seed.go:41`=`base URL of the running tmplatformd` | **Оба стендовых рецепта зоны принимали ОТВЕТ ПО АДРЕСУ за доказательство того, что отвечает СВОЙ процесс, и потому проходили при мёртвом собственном демоне.** Замерено исполнением 04.09: на машине разработки четвёртые сутки жил чужой `tmplatformd` на `127.0.0.1:8080` со своей базой; демон рецепта умирает в этой ситуации на `bind: address already in use` — молча, он запущен фоном, — а смоук следующей строкой получает `healthz=200` и `readyz=ready` ОТ ЧУЖОГО ПРОЦЕССА. ⚠ **Дороже смоука — шаг сида:** `tmplatformctl seed` дарит `$25` кредита и грузит книгу ЧЕРЕЗ ЖИВОЙ ИНТЕЙК, то есть по этому рецепту деньги и данные уезжают в чужой деплой. Второй адрес там же был написан отдельным литералом (`--url http://127.0.0.1:8080`), что и есть штатный способ разъехаться с `TM_PLATFORM_ADDR` незаметно. ⚠ **Три лечения на три РАЗНЫЕ половины, ни одно не заменяет другое:** явный `_ADDR` уводит с общего дефолта; проба по `pid` слушателя отвечает на вопрос, на который `200` не отвечает в принципе — ЧЕЙ это процесс; `--noproxy '*'` нужен потому, что при заданных `http_proxy` голый `curl` на `127.0.0.1` уходит во внешний прокси. Проба предъявлена в обе стороны: свой pid на своём порту — проходит, чужой демон против своего pid — отвергается. ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение:** коллизия — `tmplatformd` против `tmplatformd`, любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки. Чинится посылка, а не номер. **Вес minor, а не major, по радиусу:** пишет только дев-стенды, боевого пути этим рецептом нет, деньги — стендовый грант. | fixed(`adf5e53`) | найдено оркестратором №22 при независимой проверке отзыва причины смертей демона, 04.09; починено зоной | diff --git a/platform/docs/STACK_DECISIONS.md b/platform/docs/STACK_DECISIONS.md index 8a85b2bf..cc667b46 100644 --- a/platform/docs/STACK_DECISIONS.md +++ b/platform/docs/STACK_DECISIONS.md @@ -522,7 +522,7 @@ system_messages not found in type config.CapabilitiesConfig`. Диагноз с состояние `cgroup.subtree_control` среза `tm-runs.slice` в момент прогона — назван КАНДИДАТОМ и только: диагноз `PD-423` не установлен, и вносить его в рецепт как проверку нельзя. -Ожидание при всех четырёх: 19 пакетов, exit 0, **скипов 0**, линтер «0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD +Ожидание при всех четырёх: 20 пакетов (`go list ./...`), exit 0, **скипов 0**, линтер «0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD `fbe6cf3`**, **304 на дереве пака P11** (пак добавил 17 пинов, гейченных тем же DSN). Число зависит от дерева, и переносить его между ними нельзя. diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 1b7fc5ae..8bed15c9 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -3,17 +3,445 @@ > **Что это.** Состояние зоны и её живые остатки. Обратно-хронологический: свежее выше. > Отработавшие эры вынесены срезами в [`archive/`](archive/) — читать только по конкретной ссылке. -## Состояние зоны на 07.09.2026 +## Состояние зоны на 08.09.2026 | Вопрос | Ответ | |---|---| -| последний заленджённый пак | «форма заказа перевода» (05–06.09, `628cc56`+`36ea8b8`, акты **D39.208**, **D39.211**–**D39.214**), канон контракта `0.12.0` | -| пак в дереве, не закоммиченный | «деньги и правда на экране» — отчёт ниже | +| последний заленджённый пак | «деньги и правда на экране» (06–07.09, `fda0679`, акт **D39.221**), канон контракта `0.13.0`. ⚠ Обе величины берутся ПРИБОРОМ, а не отсюда: `git log --oneline -1 -- platform/` и `grep '^ version:' ../../docs/architecture/14-api-contract/openapi.yaml` — эта строка стареет, они нет | +| пак в дереве, не закоммиченный | «разрез приёма до готовности и правда о себе» (08.09) — отчёт ниже | | открытые дефекты | `DEFECT_REGISTER.md` (счёт — `python3 docs/scripts/counts.py` от корня) | | нормы и приёмка | `ENGINEERING_STANDARDS.md` · направление — `PLATFORM_DIRECTION.md` · стек и стенд — `STACK_DECISIONS.md` | | незакрытые куски работы | `../BACKLOG.md` (`П-N`) | | как разворачивается | `../deploy/README.md` | +## ПАК «РАЗРЕЗ ПРИЁМА ДО ГОТОВНОСТИ И ПРАВДА О СЕБЕ» — ОТЧЁТ (08.09, `textmachine-fa`) + +> Промт `docs/PLATFORM_INTAKE_TRUTH_SESSION_PROMPT.md`, вход HEAD `3f4680c`, дерево на входе чисто. +> Зона НЕ коммитит — дерево передано оркестратору №23 (`textmachine-a8`). Пак $0, платных вызовов **0**. +> **Работа завершена, править не планирую.** Сказано ПОСЛЕ адверсариального круга, а не до него: первая +> редакция этого отчёта несла ту же фразу при шести неисправленных дефектах, введённых этим паком. +> Дерево — 26 файлов, все в `platform/` (`git status --porcelain -- platform/`), 23 правленых и 3 новых. + +### Исход по каждому пункту §4 + +| Пункт | Исход | +|---|---| +| §4.1 ограничитель параллелизма | **сделано**; форма — `x/sync/semaphore` на общей точке порождения, ожидание с деградацией в очередь. Предъявлено НАГРУЗКОЙ | +| §4.2 бюджет хвоста из кода | **сделано, форма Б (структурная)**; попутно вскрыт и закрыт ЧЕТВЁРТЫЙ промах суммы — квитанция не входила в неё | +| §4.3 рантбук | **сделано**; правок рантбука ДВЕ, вторая объявлена ниже с доводом | +| §4.4 три места неразличимого сбоя | **сделано все три**, каждое своим лечением; свип вылечен БЕЗ миграции | +| §4.5 правда о себе | **сделано**: шапка, два ряда флипнуты, маркер третьего починен, черты заэкранированы | +| §4.6 честная причина человеку | **закрыто по построению на моей стороне** — и ПРЕМИСА пака при этом опровергнута замером (ниже) | +| §4.7 живой гейт | **рецепт исполнен и РАБОТАЕТ**; довод зоны опровергнут, разрез впервые встретился с настоящим движком | +| §4.8 п.2 (комментарий `cutNow`) | **взят вместе с §4.4**, как и предписано | +| §4.8 п.4 (мёртвый `enqueue`) | **взят вместе с §4.1**: предикат сведён в одно названное место | +| §4.8 п.6 («ВСЕГДА» контракта) | **не беру — пинг оркестратору** с моим выбором из двух (ниже) | +| §4.8 остальное | **не делаю**, как объявлено паком | +| ⚠ сверх пака | константа контракта `0.12.0` → `0.13.0` — красное на входе, взято по явному указанию оркестратора (ниже) | + +### Что стало с деревом — находка → что сделано → чем предъявлено + +| Находка | Что стало с деревом | Чем предъявлено | +|---|---|---| +| §4.1 у синхронного входа нет ограничителя параллелизма | `internal/books/limit.go`: потолок на `semaphore.Weighted`, взводится в `s.manifest` — ОДНОЙ строке, через которую к движку идут все три входа. Дефолт — `DefaultMaxCuts = jobs.DefaultWorkers` (4), то есть число, подо что хост уже рассчитан, и носитель у него ОДИН. Конфиг `TM_PLATFORM_MAX_CUTS`; ноль отвергает читатель чисел (`loader.number`), а не отдельная проверка интейка. Наблюдаемость: 3 гейджа + 2 счётчика, публикует существующий телеметрический проход | `TestTheHostRunsNoMoreCutsAtOnceThanItsCapAllows` — 6 загрузок при потолке 2, пик **2**, и это НЕ вакуум: тест сперва дожидается контрольной величины «4 из 6 стоят в очереди» из счётчиков самого потолка · парный `TestWithRoomForEveryCutTheHostRunsThemAllAtOnce` — та же нагрузка при потолке 6 даёт пик **6** (иначе первый тест проходил бы и на фикстуре, где ничего не совпало по времени) · посадка M4 | +| упор в потолок не должен стоить пользователю загрузки | ожидание, а не отказ: не дождался ⇒ `errNotConclusive` ⇒ `201 parsing`, дорезает очередь. На очередном пути — claim обратно, НОЛЬ потраченных попыток и `river.JobSnooze`, то есть задание возвращается, не тратя единственную попытку (`giveBack` + `jobs.ErrTryAgainLater`) | `TestAnUploadThatRunsOutOfBudgetWaitingForASlotIsAcceptedRatherThanRefused` · `TestAnUploadTheHostCouldNotCutStillLeavesSomebodyToFinishTheBook` (claim ОТДАН и задание ПОСТАВЛЕНО — то, чего первая редакция не утверждала) · `TestAQueuedParseThatCannotCutSpendsNothingAndGivesTheBookBack` (оба исхода: упор в потолок и нехватка бюджета) · `TestAPassThatEstablishedNothingGetsItsJobBackInsteadOfSpendingIt` · посадки M9, N7, N8 | +| §4.2 граница хвоста выводилась руками и трижды была неверна | ОДИН отсоединённый дедлайн на весь хвост (`walk`), каждый шаг берёт `min(свой бюджет, остаток)` (`step`). Шаг, добавленный завтра, границу не двигает ПО ПОСТРОЕНИЮ | `TestNoStepOfAnUploadsTailOutlivesTheWalk` — в т.ч. **50** вложенных шагов, каждый просит час · `TestTheCutOfAnUploadIsBoundedByTheWalkAndNotByItsOwnBudget` (по дедлайну, который движок РЕАЛЬНО получил, а не по секундомеру) · `TestAStepOutsideAWalkKeepsItsOwnBudgetAndSurvivesItsCaller` · посадки M1, M2 | +| ⚠ ЧЕТВЁРТЫЙ промах той же суммы, найден моей же посадкой | квитанция идемпотентности (10 с) писалась ПОСЛЕ `Accept` и в сумму не входила ⇒ хвост был длиннее объявленного ровно на неё. Квитанция стала ТЕРМИНОМ: `UploadSettle = CutBudget + 4*writeBudget + ReceiptBudget` = **220 с** (ровно замер 07.09), носитель величины ОДИН — `books.ReceiptBudget`, тратит её `httpapi.settleCtx` | `TestTheWalkLeavesTheReceiptItsShareOfTheSettleBudget` · `TestTheReceiptSpendsTheShareTheIntakeSetAsideForIt` (httpapi) · посадки M3, M7'' | +| §4.3 рантбук молчал про таймаут ОТВЕТА прокси | `deploy/README.md`: молчание названо числом (220 с), требование к прокси — `TM_PLATFORM_UPLOAD_DEADLINE + 220 с` = 13 мин 40 с на дефолтах, цена ошибки названа (человек получает ошибку на ПРИНЯТОЙ книге и повтором делает вторую) | директивы сверены с вендор-доками ЭТОЙ сессией: nginx `proxy_read_timeout`, дефолт **60 с** (nginx.org, ngx_http_proxy_module) · HAProxy `timeout server` (docs.haproxy.org 3.0, индекс ключевых слов) | +| §4.4 `ClaimParse` в `cutNow` молчал о сбое БД | гонка и «хранилище не спросили» разведены: первая — INFO, вторая — ERROR, и текст называет следствие (книга `parsing` без задания до свипа) | `TestTheIntakeTellsALostRaceApartFromAStoreItCouldNotAsk` — обе фикстуры, и каждое сообщение проверено ОТСУТСТВУЮЩИМ в чужой | +| §4.4 ветвь `RowsAffected()==0 ⇒ задание НЕ ставить` не запинена | пин на ОБЕ стороны: чужой claim ⇒ задания нет и чужой claim цел; свой ⇒ задание есть и claim снят | `TestOnlyAClaimThatWasReallyGivenBackQueuesTheJobThatFinishesTheBook` · посадка M6' красная ТЕКСТОМ про задание | +| §4.4 свип `StuckIntake` считает от `added_at` (штамп ДО тела) | ⭐ вылечено БЕЗ миграции и без колонки: бутовый гейт сверял дедлайн с `min(UploadGrace, ClaimStale)`; добавлено ТРЕТЬЕ окно `books.ClaimGrace` (20 мин — самое узкое). Условие «свип забирает claim у идущей загрузки» стало недостижимо настройкой | посадка M5 красная текстом «does not fit the parse claim's grace» + `TestEveryWindowAnUploadMustFitInsideIsActuallyConsulted`. ⚠ **Исправление к первой редакции этой строки:** я написала, что у каждого из трёх окон свой случай, недостижимый двум другим — это БЫЛО НЕВЕРНО и найдено адверсариальным проходом. `ClaimGrace` сегодня самое узкое, поэтому все пять случаев ловит один терм, и два других можно было удалить из гейта при зелёной батарее. Вылечено выносом выбора окна в `intakeWindow(...)`, который тест кормит значениями, делающими каждое окно самым узким по очереди | +| §4.8 п.2 комментарий `cutNow` лгал о том, кто ставит задание | снят тем же движением, что и правка ветви | `grep -rn "the queue job is already enqueued" platform/ --include=*.go` → **0** при 200 осмотренных `.go` (единственный хит по дереву — цитата находки в этом журнале, и она историческая) | +| §4.8 п.4 предикат «кто ставит задание» размазан по двум местам | `cutsItsOwnUploads()` — одно названное место, читают оба; doc параметра `StartParsing` больше не выдаёт его за штатный путь | `grep "s.Engine != nil" internal/books/*.go` (без тестов, 4 файла) → **1 хит, и он внутри самого предиката**. ⚠ Рядом остаётся `s.Engine == nil` в `s.manifest` — это НЕ тот предикат, а nil-гард самого вызова, и он не решает, кто ставит задание | +| §4.5 шапка журнала лгала о том, где зона | пере-снята прибором: последний пак «деньги и правда» `fda0679`, коммитов в `platform/` после него 0, канон `0.13.0`; в шапку вписаны КОМАНДЫ, которыми числа берутся | `git log --oneline fda0679..HEAD -- platform/ \| wc -l` → **0** · `grep '^ version:' openapi.yaml` → `0.13.0` | +| §4.5 три ряда `open` при легшем лечении | `PD-424` и `PD-438` → `fixed`; у `PD-441` починен МАРКЕР, статус оставлен `open` и в ячейке названо, что держит его движковая половина (строка **331**). Фраза «в дереве» снята во всех трёх | `grep -c 'статус флипает лендинг'` → **0** при 465 рядах | +| §4.5 незаэкранированные `\|` | заэкранированы в трёх рядах (`PD-375`, `PD-422`, `PD-197`) | эскейп-аware счёт колонок: рядов с числом колонок ≠ 7 — **0** из 465 | +| `PD-464` (строка регистра, моя зона) | закрыт ОБЕИМИ половинами и переведён в `fixed` с диспозицией | см. ячейку ряда | + +### §6 ось 4: существующее ПРЕЖДЕ велосипеда — что рассмотрено и чем отвергнуто + +| Кандидат | Исход | Довод | +|---|---|---| +| **`golang.org/x/sync/semaphore`** | **ВЗЯТ** | `Acquire(ctx, 1)` — ровно нужная семантика: ждёт до слота или до конца контекста, очередь **FIFO** (в отличие от буферизованного канала, где поздний может обогнать раннего и часть загрузок ждала бы весь бюджет). `TryAcquire` у него не «барджит»: `success := s.size-s.cur >= n && s.waiters.Len() == 0` — быстрый путь отказывает, пока список ожидающих непуст, и слот у стоящих в очереди не ворует. ⚠ Прочитано в ИСХОДНИКЕ пинованной версии (`$(go env GOMODCACHE)/golang.org/x/sync@v0.22.0/semaphore/semaphore.go`, `TryAcquire`), а не по памяти: на этом свойстве держится довод про FIFO. Уже был в `go.sum` **косвенной** зависимостью той же версии `v0.22.0`; правка `go.mod` — перевод в прямые, БЕЗ смены версии (`git diff platform/go.mod`: одна строка вверх, одна вниз; `go.sum` −1 строка) | +| `errgroup.SetLimit` | отвергнут | ограничивает горутины, которые запускает САМА группа. Здесь группы нет и быть не может: вызывающие независимы и приходят из разных мест (HTTP-обработчик, воркер River, свип). Форма не подходит по существу, а не по вкусу | +| `netutil.LimitListener` | отвергнут | ограничивает СОЕДИНЕНИЯ на слушателе — то есть весь API разом, включая чтения, листинги и логин, из-за нагрузки на приём. И не накрывает ни воркера, ни свип: это ровно «потолок в маршруте», который пак запрещает | +| `MaxWorkers` очереди (уже стоит) | отвергнут как ЕДИНСТВЕННОЕ средство, но учтён как число | ограничить синхронный вход им нельзя: этот путь намеренно НЕ ставит задание, чтобы воркер не гонялся с разрезом за claim. Зато он назвал дефолт: 4 — то, подо что хост уже рассчитан, и потолок сказан один раз для всех способов запустить движок, а не только для того, что идёт через очередь | +| самописный счётчик / буферизованный канал | отвергнут | норма зоны «stdlib или устоявшаяся библиотека прежде своего», и здесь у своего есть конкретная цена — отсутствие FIFO | + +### Посадки мутаций — вердикт по ТЕКСТУ падения, а не по цвету + +Копия дерева с каноном (`cp -a --parents platform docs/architecture/14-api-contract`), базовая линия копии зелёная на всех четырёх пакетах. + +| Посадка | Вердикт | Текст, по которому он вынесен | +|---|---|---| +| M1 разрез снова отсоединён от хвоста | **RED** | `the cut was granted 1m29.99s inside a 700ms walk` | +| M2 `step` перестаёт капать по хвосту | **RED** | `the walk is not capping it` + `adding a step moves the boundary` | +| M3 хвост съедает долю квитанции | **RED** | `want UploadSettle (3m30s) less the receipt's share (10s)` | +| M4 потолок не применяется вовсе | **RED** | контрольная величина легла в ноль: `{Limit:2 InFlight:0 Waiting:0 Waited:0 GaveUp:0}` | +| M5 гейт забывает окно claim'а | **RED** | `an upload deadline of 21m0s was accepted, though ... does not fit the parse claim's grace` | +| M6′ release ставит задание, ничего не вернув | **RED** | `a release that gave back nothing still queued a job (2 in total)` | +| M7 значение `ReceiptBudget` изменено | ⚠ **ВЫЖИЛА, и это ВЕРНЫЙ исход** | пере-сайзинг остаётся согласованным по обе стороны шва; ловить надо не значение, а ДРЕЙФ — см. M7'' | +| M7'' квитанция возвращается к своему литералу | **RED** (после того, как по находке M7 заведён пин) | `the receipt was given 30.0s, want the share the intake declared for it (10s)` | +| M9 занятый хост тратит попытку книги | **RED** | `a queued parse that found no slot answered , want the cap` | +| M10 два способа не получить claim свёрнуты обратно в один молчаливый `return` | **RED** | `losing the race said nothing` + `a claim that could not be asked for said nothing, so the book sits `parsing` with no job and nobody knows` + `was not logged at ERROR` | + +**ТРЕТИЙ круг посадок — по коду, ПЕРЕПИСАННОМУ после адверсариального прохода.** Первые две редакции +двух пинов оказались тавтологичны, и посадка это показала, а не рассуждение. + +| Посадка | Вердикт | Текст | +|---|---|---| +| N1 у ожидания удалена строка INFO | **RED** | `a cut that queued for a slot and got one said nothing` | +| N2 переименована причина `host_at_cut_capacity` | ⚠ ВЫЖИЛА → **N2′ RED** | первая редакция пина сверяла КОНСТАНТУ с самой собой и проходила при любом значении; переписана на литерал: `the reason is "MUTATED", want the stable "host_at_cut_capacity"` | +| N4 два гейджа потолка поменяны местами | **RED** | `the exposition is missing "tm_platform_cuts_in_flight 4"` | +| N5 из проводки выброшен `MaxCuts` оператора | **RED** | `MaxCuts is 0, want the operator's 7: TM_PLATFORM_MAX_CUTS does nothing` | +| N6 `cutsItsOwnUploads` всегда истинен | ⚠ ВЫЖИЛА → **N6′ RED** | счёт заданий РАЗЛИЧИТЬ НЕ МОЖЕТ (сломанный предикат ставит то же одно задание через релиз); переписано на лог с положительным контролем: `a deployment with no engine attempted a cut anyway` | +| N7 разрез перестаёт оставлять хвосту резерв | **RED** | `the intake's parse claim was NOT given back` + `the queue was handed 0 jobs` + `has spent 1 attempts` | +| N8 `giveBack` перестаёт возвращать задание очереди | **RED** | `the error does not tell the queue to bring the job back, so the single attempt is spent` | +| N9 разрез запускается даже когда места на него нет | **RED** | `a cut was started with no room for it (1 calls)` + `the upload is "not_started", want ` + `the pass does not say WHY it did not cut ("no_time_to_cut")` | + +⚠ **Единственная выжившая, которую я НЕ чиню и объявляю:** смена значения `DefaultMaxCuts`. Это +сайзинг, а не свойство: изменённый потолок остаётся согласованным по всей системе, и «поймать» его +можно было бы только пином на литерал, то есть запретом менять число. Что запинено — ПРОВОДКА +(оператор получает своё число) и ЕДИНСТВЕННОСТЬ носителя (`DefaultMaxCuts = jobs.DefaultWorkers`). + +⚠ **Первая редакция M6 и M7 НЕ КОМПИЛИРОВАЛАСЬ** (`declared and not used: tag`, `imported and not used`). Это не вердикт, а его отсутствие: посадка, которая не собралась, красит батарею по причине, не имеющей отношения к предмету. Обе пере-посажены компилирующимися. + +### Классы и знаменатели — «закрыт в N из M», M посчитан командой + +| Класс | Знаменатель | Как посчитан | +|---|---|---| +| входы, порождающие процесс движка на разрезе | **3 из 3** (интейк · `parseWorker` · свип `Sweep`) | `s.manifest` имеет РОВНО ОДНОГО вызывающего (`grep -rn 's\.manifest(' --include=*.go internal/ \| grep -v _test` → 1: `parse.go:178`), у `parseClaimed` их два (`Parse`, `cutNow`), у `Parse` — воркер `jobs.go:117` и `Sweep`. Все три сходятся в одну строку | +| порождения движка ВНЕ потолка | **1** — `internal/readmodel/readmodel.go:156` | `grep -rn '\.Manifest(' --include=*.go \| grep -v _test` → 2 вызывающих, один из них мой. Оставлен снаружи сознательно, довод — ниже | +| шаги хвоста под общим дедлайном | **все** (в `Accept` их 5 + квитанция) | построением, а не перечнем: `writeCtx` идёт через `step`, `step` капает по хвосту. Проверено на 50 шагах, которых в коде нет | +| ряды регистра с диспозицией «статус флипает лендинг» | **3 из 3** | `grep -c 'статус флипает лендинг'` → было 3, стало **0** | +| ряды с числом колонок ≠ 7 | **3 из 3** | эскейп-аware счёт: было 3, стало **0** при 465 осмотренных | +| открытые ряды регистра по МОИМ файлам | 10 путей осмотрено, совпадений — 20 рядов, из них МОЙ предмет **1** (`PD-464`, закрыт); остальные 19 — соседние классы, не тронутые этим паком | `grep` по ПОЛНЫМ путям (норма §3 п.8), контроль: открытых рядов всего **109** | + +### Числа и команды — сняты ПОСЛЕ последней правки + +``` +$ python3 docs/scripts/counts.py --check → EXIT=1, и это ОЖИДАЕМО: ровно два расхождения, + ✗ docs/PROGRESS.md: «открытых рядов регистра платформы — 112» против пере-счёта 110 + ✗ docs/PROGRESS.md: «... (major 3)» против пере-счёта 1 + ⚠ оба литерала — в ЧУЖОЙ зоне (`docs/PROGRESS.md`), туда не лезу. После лендинга и закрытия + PD-464 верные числа: **открытых 109, major 1, minor 36, info 72** (`counts.py` по дереву). + +$ git log --oneline fda0679..HEAD -- platform/ | wc -l → 0 +$ grep '^ version:' docs/architecture/14-api-contract/openapi.yaml → 0.13.0 +$ go list ./... | wc -l → 20 (носитель скипов чинен первым движением) + +$ TM_PLATFORM_TEST_DSN=… TM_PLATFORM_TEST_ENGINE_BIN=… TM_PLATFORM_TEST_BOOK_TEMPLATE=… \ + TM_PLATFORM_TEST_PGDUMP=… TM_PLATFORM_TEST_PGRESTORE=… make check + MAKE-EXIT=0 · пакетов `ok` **20** · строк FAIL **0** · линтер «0 issues» · скипов **5** + ⚠ Прогон ПОСЛЕДНИЙ — после адверсариального круга и после последнего добавленного пина. Прежние + редакции этих чисел (до круга) в отчёте не оставлены: они были верны и уже не про этот код. +``` + +⚠ **Скипы 5, и условие у всех одно, названное** (§10 ниже): нет деплой-артефакта +`backend/configs/mining-contrast.zh.txt`. Из четырёх гейтов батареи на этом хосте закрыты все: +Postgres · движковый бинарь + шаблон (собран из `git archive HEAD backend`, §4.7) · достижимый +пользовательский `systemd` · `MemoryMax` — судится самим `TestARunIsBoundedByItsOwnCgroup` (`STACK_DECISIONS` §«Гейты батареи»: прямой пробы у этого условия нет), и он ОТРАБОТАЛ: в полном перечне скипов, который печатает `check`, его нет, а `FAIL` в прогоне нет вовсе. +⚠ Числа Go-батареи сняты ПОСЛЕ последней правки кода; доковые правки после них Go-батарею не касаются. + +### Живой гейт (§4.7) — довод зоны опровергнут ИСПОЛНЕНИЕМ + +Зона писала, что второй гейт батареи требует `$0`-пайплайна рядом с `backend/prompts/`, то есть записи в +чужую зону или полной копии дерева. **Проверено исполнением — неверно.** Рецепт: + +``` +$ git archive HEAD backend | tar -x -C $W # снапшот чужой зоны, ни байта записи в неё +$ cd $W/backend && go build -o $W/tmctl ./cmd/tmctl +$ sed -e 's|pipeline: ../configs/...|pipeline: $W/backend/configs/pipeline-c1.yaml|' \ + -e 's|models: ../configs/...|models: $W/backend/configs/models.yaml|' \ + $W/backend/example/book.yaml > $W/template.yaml # пути абсолютные +$ TM_PLATFORM_TEST_ENGINE_BIN=$W/tmctl TM_PLATFORM_TEST_BOOK_TEMPLATE=$W/template.yaml go test ./internal/books/ +``` +`TestTheRenderedConfigurationIsOneTheEngineActuallyLoads` — **PASS**. + +⭐ **И где именно рассуждение зоны свернуло не туда:** «нужен `$0`-пайплайн» верно для теста +`internal/runner`, который гоняет `translate` и падает на `missing API keys`. Оно было ОБОБЩЕНО на гейт +целиком — а `manifest` есть `$0`-глагол и ключей не требует по `D20.4`, поэтому боевой `pipeline-c1.yaml` +(«платный») загружается и режет без единого ключа. То есть довод был верен про один тест и ложен про гейт, +и разница видна только исполнением. + +### ⚠ Правки, вызванные заказанной сменой поведения (объявляю по `D39.183`) + +1. **`TestAnUploadDeadlineIsRefusedUnlessTheWholeUploadFitsTheTighterWindow` → `...TightestWindow`.** Гейт + получил третье окно (§4.4, п.7 десятки) — прежний тест утверждал, что дедлайн `26m29s` ПРИНИМАЕТСЯ, и + после лечения это неверно. Тест не «починен под зелень»: он пере-написан строже — у каждого из трёх окон + свой случай, недостижимый двум другим, и посадка M5 краснит его именем нового окна. +2. **`ClaimGrace` экспортирована** (была `claimGrace`) — переименование затронуло 3 тестовых файла зоны + механически; утверждений не тронуто. Основание — то же, по которому экспортирована `UploadGrace`: бут + обязан отказывать конфигурации, которая её нарушает. +3. **Вторая правка рантбука сверх §4.3** — абзац про `TM_PLATFORM_MAX_CUTS`. Довод: §4.1 требует, чтобы + потолок был «конфигурируемым и наблюдаемым», а ручка, о которой рантбук молчит, оператору не доступна; + документировать ручку, которую этот же пак и завёл, — часть §4.1, а не «остальное про выкат». + +### ⚠ Константа контракта `0.12.0` → `0.13.0` — что было сломано и кем + +Батарея была КРАСНОЙ на входе, до единой моей правки: `internal/gates` +`TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` — «this build announces contract 0.12.0 and the +ratified canon is 0.13.0». Улика: файл-носитель (`internal/httpapi/capabilities.go:36`) в моём диффе +отсутствовал (`git diff --name-only HEAD | grep -i contract` → пусто). Канон увёл на `0.13.0` коммит +оркестратора `3d90943`, константу за собой не потянув; последняя правка константы — `6ceb133`, до него. +То есть гейт красен с момента ратификации, **сутки**, и заметила это входная сверка следующей сессии зоны. +Взято мной по ЯВНОМУ указанию оркестратора с названным основанием: ратифицированный порядок `D39.208` п.1 +— код первым с честно красным гейтом, канон вторым; здесь порядок был обратный, и правка возвращает мир к +гейту, а не гейт к миру (`D39.183`, обслуживание). **Авторство ошибки — оркестратор, не прошлый пак:** на +`fda0679` канон и константа обе были `0.12.0`, гейт был зелёным, и число батареи в акте `D39.221` честное. + +### §4.6 — ответ (а): закрыто по построению НА МОЕЙ СТОРОНЕ, но премиса пака опровергнута + +Пункт 10 десятки предполагал, что причина отказа не доезжает. **Опровергнуто:** перечислены ВСЕ семь +пользовательских отказов приёма — `payload_too_large` и `request_timeout` (корневые коды), `malformed` ×2, +`unsupported_pair` ×2, `no_book`, `no_chapter_structure`, `too_long`, `missing_or_late`. Причины, не +выразимой перечислимым кодом, я не нашла; расширять `errors[]`/`cause.code` нечем, и минор не нужен. +⚠ **Собственную первую находку снимаю:** я решила, что слишком длинное поле формы уходит с ПУСТЫМ +`errors[]` — неверно, оно названо на месте чтения (`v0.go:843`, `ItemTooLong`), а ветвь `Invalid(w, r)` без +элемента до него не доходит. + +⛔ **А вот премиса пака про клиента ЗАМЕРОМ НЕ ПОДТВЕРЖДАЕТСЯ, и следующая смена не должна её унаследовать.** +Пак пишет: «Таблица „код → русская фраза“ у клиента уже есть (`14-api-contract/README.md`, и фронт её +рисует)». Замер: `no_chapter_structure` в живых доках — **11 хитов при 1104 осмотренных `.md`**, и НИ ОДИН +не таблица фраз; в `14-api-contract/README.md` нет ни `no_book`, ни `no_chapter_structure`. В зоне фронта +`no_book`/`unsupported_pair` — **0 хитов при 8114 осмотренных `.ts`/`.tsx`**. Что там есть на самом деле — +правило «клиент диспетчеризует по СТАТУСУ и показывает одну нейтральную фразу» (README §вход) и решение +владельца 16.08 о машинном коде. ⇒ вывод пака (моя работа тут закончена, клиентская половина — фронт, а он +заморожен) остаётся ВЕРНЫМ, но не потому, что таблица есть, а потому, что платформа дала клиенту всё, по +чему её можно нарисовать. Разница существенна: с премисой пака работа выглядит сделанной у обеих сторон. + +### Пинги оркестратору + +1. ⛔ **Литералы в `docs/PROGRESS.md` под гардом `counts.py --check` протухли моим флипом — двигать их + тебе.** После лендинга верно: **открытых 109, major 1** (было «112 (major 3)»). Разница в три ряда: + `PD-424` и `PD-438` переведены в `fixed` по твоему же маркеру, `PD-464` закрыт этим паком. Гейт красен + ОЖИДАЕМО и ровно на этих двух строках — других расхождений он не даёт. +2. **§4.8 п.6, «ВСЕГДА» в дельте контракта — моё мнение, как просил пак: нужна ОГОВОРКА В КАНОНЕ, а не + структурная гарантия.** Довод из кода, а не из вкуса. Между `FinishParse` и `ReadBook` свип + материализатора может взять долг (он записан ИМЕННО `FinishParse`) и дописать строке `source_chars` и + `structure` (`readmodel.refresh` → `SaveStructure`). Но он НЕ МОЖЕТ ни снять вердикт разреза, ни + изменить `status`/`chapter_count`: единственный писатель `reject_reason` — `RejectBook` + (`pgstore/books.go:394`, один хит по всему коду), а статус двигают только `FinishParse`/`reject`. + ⇒ расхождение возможно только В СТОРОНУ БОЛЬШЕГО: ответ либо уже несёт поверхностные поля, либо ещё + нет. Структурная гарантия потребовала бы держать что-то поперёк `FinishParse`→`ReadBook` на горячем + пути ради полей, которые клиент всё равно перечитывает карточкой. ⚠ И отдельно: **саму фразу «ВСЕГДА» я + в каноне не нашла** — `grep 'ВСЕГДА' 14-api-contract/README.md` даёт 0, `grep -i always` в + `openapi.yaml` — 12 хитов, все про другое (SSE-кадры, `about:blank`). Назови предложение адресом, и + если оно живёт не там, где я искала, мой довод надо перепроверить против него. +3. ⚠ **Премиса пака в §4.6 неверна — см. секцию выше.** «Таблица код → русская фраза у клиента уже есть, и + фронт её рисует» замером не подтверждается (0 хитов `no_book`/`unsupported_pair` при 8114 осмотренных + `.ts`/`.tsx`; в `14-api-contract/README.md` ни `no_book`, ни `no_chapter_structure`). Вывод пака устоял, + основание — нет. Стоит поправить, иначе следующая смена унаследует «у клиента всё готово». +4. **Мелкая неточность адреса в §4.7:** «а 35 строками ниже в том же журнале лежит рецепт снапшота» — + реально 261 строкой ниже. Адреса в дереве, которое я сдаю: довод — `platform-PROGRESS.md:452`, рецепт — + `:713` (на входном `HEAD` это были `:173` и `:434`; расстояние то же). На существо не влияет: рецепт там + и есть, и он работает. +5. **Твой вопрос «есть ли дешёвый способ закрыть сутки красноты между ратификацией и следующей сессией + зоны» — есть, и он в ТВОЕЙ зоне.** Пара «канон ↔ объявленная константа» сегодня судится только Go-тестом, + который гоняет зона. А `docs/scripts/counts.py --check` уже читает оба дерева, уже висит на зонном + pre-commit и уже срабатывает **именно на коммитах с D-логом или PROGRESS** — то есть ровно на + ратификационных. Добавить туда одну проверку — `grep '^ version:' openapi.yaml` против + `const ContractVersion` в `platform/internal/httpapi/capabilities.go` — стоит десятка строк и ловит + ровно тот класс, который стоил суток: он предупреждает того, КТО ДВИГАЕТ КАНОН, в момент движения. + Заказом не делаю (файл в `docs/`), рекомендацию записываю. + +### Адверсариальный проход по СВОЕЙ готовой работе — восемь находок, и они были настоящие + +Проход заказан §5.4 и выполнен субагентом (author ≠ reviewer) по готовому диффу, с направлением на +классы, которые уже стоили зоне денег. **Круги НЕ сошлись с первого раза: он нашёл восемь, и шесть из +них — дефекты, которые ввёл ЭТОТ пак.** Каждую я пере-проверила по коду прежде, чем чинить. + +| # | Находка | Чем оказалась | Что сделано | +|---|---|---|---| +| **F1** | комментарий `giveBack` обещал повтор задания с бэкоффом | ⛔ ЛОЖЬ: `ParseArgs.InsertOpts` — `MaxAttempts: 1`, повтора нет вовсе; книга на занятом хосте ждала свип **20 минут** | заведён `jobs.ErrTryAgainLater`; воркер переводит его в `river.JobSnooze(RetryDelay)`, который НЕ тратит единственную попытку. Комментарий приведён к правде. Пин — `TestAPassThatEstablishedNothingGetsItsJobBackInsteadOfSpendingIt` | +| **F2** | у выигравшего слот не проверялось, осталось ли время на разбор | ⛔ настоящий: слот, выигранный в конце бюджета, отдавал движку миллисекунды; убитый процесс читается как `parser_unavailable`, а он ТРАТИТ попытку — пять таких удаляют файл пользователя | `takeCutSlot(ctx, reserve)`: `worthStarting` до и ПОСЛЕ ожидания, `waitCtx` обрывает ожидание на резерв раньше. Резерв — `CutBudget` на очередном пути, `0` на интейке (там попытка не тратится) | +| **F3** | отказ бута при `MaxCuts < 1` | ⛔ МЁРТВЫЙ КОД: `l.number` уже отвергает всё непозитивное, и мой тест пинил чужой охранник, а не мой | ветвь удалена; в тесте названо, ГДЕ живёт отказ | +| **F4** | тест трёх окон | ⛔ ВАКУУМЕН для двух окон из трёх, и его комментарий утверждал обратное — ровно тот класс, который он якобы чинил | выбор окна вынесен в `intakeWindow(...)`; новый тест делает каждое окно самым узким по очереди. Ревьюер пере-мутировал независимо: теперь красный | +| **F5** | терминальная запись могла родиться истёкшей | ⛔ настоящий и злой: при спетом хвосте claim НЕ отдавался и задание НЕ ставилось — книга «принята», а доделать её некому 20 минут. Мой тест этого не утверждал | `stepLeaving` + `cutTailReserve`: слабину забирает РАЗРЕЗ, а не записи. Пин — `TestAnUploadTheHostCouldNotCutStillLeavesSomebodyToFinishTheBook` | +| **F6** | комментарий потолка обещал больше, чем потолок делает | верно: `readmodel` порождает те же процессы мимо него; плюс `DefaultMaxCuts` был вторым литералом числа воркеров | комментарий сужен до правды и называет, что осталось снаружи; `DefaultMaxCuts = jobs.DefaultWorkers` — один носитель, и `config.Runner.Workers` берёт его же | +| **F7** | шесть поверхностей пережили мутацию | верно все шесть | закрыты пинами (ниже), кроме значения `DefaultMaxCuts` — это САЙЗИНГ, и его смена не дефект; названо в §10 | +| **F8** | баннер `capabilities.go` противоречил себе | верно, и сломала его Я этой же сменой | баннер разводит два порядка: код первым (канон отстаёт) — ратифицированный, канон первым (код отстаёт) — тот, что стоил суток | + +### ⛔ НАХОДКА №9 — класс, которого не ловит НИ батарея, НИ мутация + +Поймана мной при починке F5: **моя починка была дефектной, и её дефект не имел цвета.** + +`cutTailReserve` был КОНСТАНТОЙ `3 * writeBudget`, а бюджет записи в тестах — полем сервиса +(`s.writeBudget`, который фикстуры укорачивают, чтобы достать случаи, недостижимые за 30 секунд). В +фикстуре с хвостом 300 мс резерв оставался 90 с — больше всего хвоста ⇒ шаг разреза рождался истёкшим, +движок не звался НИКОГДА, и пакет `books` **зависал навсегда** на `<-first`. + +⭐ **Почему это отдельный класс.** Батарея его не ловит, потому что зелёного вердикта просто не +наступает — но и красного тоже: прогон висит до таймаута `go test`, и в CI это читается как «долго», а +не как «сломано». Мутация его не ловит по той же причине: у посадки нет вердикта, есть тайм-аут. +Единственное, что его назвало — прогон с УКОРОЧЕННЫМ `-timeout` и чтение стека упавшего по нему +процесса (`limit_test.go:162`, `<-first`); по цвету он неотличим от медленной машины. Две вещи из этого: +- резерв сделан производным от бюджета В СИЛЕ (`s.cutTailReserve()` = `3 * s.write()`), иначе фикстура + молча моделирует не то; +- «нет места для разреза» больше не притворяется отказом хранилища: claim берётся на СВОЁМ бюджете + записи, разрез — на своём, и пустой разрез отвечает «вердикта нет» (`ReasonNoTimeToCut`), а не падает + внутри `ClaimParse`. Это тот же класс, что F1: диагноз, который называет не то, что случилось. + Запинено `TestAnUploadWithNoRoomLeftForACutSaysThatAndHandsTheBookOver` + посадка N9. + +⚠ **И правило, которое стоит пережить пак:** число, которое фикстура умеет укорачивать, и число, +выведенное из него, обязаны быть выведены ОДИНАКОВО. Константа рядом с полем — это две величины, +которые совпадают в бою и расходятся в тесте, то есть ровно то, что фикстура сделать не может увидеть. + +⚠ **Урок, который стоит пережить этот пак:** шесть из восьми находок — в коде, который я СДАВАЛА как +готовый, с зелёной батареей, десятью посадками и отчётом, где написано «круги сошлись». Батарея была +зелёной на всех восьми. Ловит их не цвет, а второй читатель, которому названо, ГДЕ у этого пака мягко. + +### Якоря, убитые моим переездом — норма §3 п.8 + +Мои правки сдвинули строки в `internal/config/config.go`, `internal/pgstore/books.go`, +`internal/metrics/metrics.go` и `cmd/tmplatformd/runner.go` (везде вставки, сдвиг +6 в первых двух). + +**Замер дифференциальный, а не «посмотрела»:** линтер на входном `HEAD` даёт **8** проблемных якорей, +моё дерево давало **26**. Чтобы отделить своё от унаследованного и от WIP чужой сессии, собрала +`git archive HEAD` в /tmp, подменила в копии ТОЛЬКО `platform/` своим и сравнила списки `comm`-ом. +⚠ Кап вывода линтера — 25 строк; на 26 проблемах обрезка читается как отсутствие, поэтому в копии +скрипта кап поднят до 500. Появившихся из-за меня — **18**. + +| Где | Сколько | Что сделано | +|---|---|---| +| `platform/docs/DEFECT_REGISTER.md` | **14 из 14** | пере-наведены механически: токен найден в цели, адрес заменён; ни одного «руками» | +| `docs/PROGRESS.md`, `docs/architecture/05-decisions-log.md` | **4** | ЧУЖАЯ зона — ушли пингом с готовыми адресами и токенами | + +⭐ **Находка из этого же хода:** экранирование `\|` в ячейке регистра (§4.5) **ломает якорь**, если черта +попала в его токен. `PD-422` держал `internal/runs/runs.go:408`=`resnapshot := book.BankMoved || book.HasPriorRun`; +после экранирования токен перестал совпадать с кодом. Вылечено укорочением токена до +`resnapshot := book.BankMoved` (единственный хит в файле). Счёт колонок и сверка токена тянут ячейку в +разные стороны — следующий, кто пойдёт экранировать черты, наступит на то же. Ушло пингом. + +Итог: мой лес **12** проблемных якорей против **8** на `HEAD`; остаток — ровно те 4 чужой зоны. + +### §10 — что НЕ удалось и что НЕ проверено (это разные исходы) + +- **Скипов 5 (было 6), и условие у всех ОДНО и названное:** `backend/configs/mining-contrast.zh.txt` нет + на этом хосте, и это деплой-артефакт, которого нет в репозитории (снапшот `git archive HEAD backend` + его не несёт — `ls backend/configs` даёт `langpacks pairs models.yaml pipeline-*.yaml`, и всё). + Скипающиеся: `TestTheRealEngineNamesItsRestorePointInTheLineThisPlatformParses`, + `TestALivePreviewWritesNothingAndALiveApplyWrites`, + `TestALiveBuildOfAHollowBookWritesTheMarkedCopyInsteadOfRefusing`, + `TestWithoutPartialTheSameBookIsRefusedWithTheBuildsOwnNumber`, + `TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem`. Шестой (`TestARestorePointCanActuallyBeRestored`) + закрыт: `pg_dump`/`pg_restore` есть в `~/.local/pgsql/bin`, переменные выставлены. + ⚠ Это НЕ «не проверено» про мой предмет: ни один из пяти не касается разреза приёма — они про банк, + выдачу и снапшот-гард. Живой гейт МОЕГО предмета закрыт и зелёный. +- ⛔ **ЭТО МЕСТО БЫЛО «НЕ ПРОВЕРЕНО» И ОКАЗАЛОСЬ ДЕФЕКТОМ — оставляю как след.** Первая редакция отчёта + писала: «полагаюсь на то, что River повторит задание с бэкоффом; сколько попыток он даёт, я не + измеряла». Замер (адверсариальный проход, подтверждён мной по коду): `ParseArgs.InsertOpts` — + `MaxAttempts: 1`, повтора НЕТ ВООБЩЕ, задание просто списывается, и книга ждала свип 20 минут. То + есть моё «рассуждение, а не замер» было не осторожностью, а неверным утверждением в комментарии кода. + Вылечено `river.JobSnooze`, который возвращает задание не тратя единственную попытку. ⭐ Урок ровно + тот, что записан в каноне зоны: строка «не проверено» — это не смягчение, это место, где ещё не + посмотрели, и смотреть надо ДО сдачи. +- **НЕ ЗАПИНЕНО ИМЕНЕМ, но покрыто исполнением** (проверено посадками, не грепом по именам): + `cutTailReserve` — посадкой N7, `worthStarting` и `waitCtx` — обоими исходами + `TestAQueuedParseThatCannotCutSpendsNothingAndGivesTheBookBack` (ожидание обрывается на резерв раньше, + поэтому случай «упор в потолок» кончается за ~1,5 с, а не за весь бюджет), `engineNotAsked` — обоими + ветвями там же, `jobs.RetryDelay` — новым тестом очереди. Собственного теста по имени у них нет. +- **НЕ ПРОВЕРЕНО экспериментально: сколько памяти реально держит один `tmctl manifest`.** Дефолт 4 выбран + как число, под которое хост уже был рассчитан (`MaxWorkers` очереди), а не измерен на большой книге. + Ручка конфигурируема именно поэтому. +- **Опровержение премисы §4.6 сделано ГРЕПОМ ПО КОДАМ**, а не чтением рендера фронта: я искала строки + `no_book`/`unsupported_pair` в 8114 `.ts`/`.tsx`. Таблица, ключуемая иначе (например, по корневому + `code`), таким грепом не нашлась бы. Утверждаю ровно замеренное. +- **Одно новое условное сообщение НЕ запинено:** `«the parse claim could not be given back; the backstop + sweep takes the book»` в `giveBack` — ветвь, где отказ `ReleaseParseClaim` накладывается на упор в + потолок. Четыре остальных новых сообщения запинены ОБЕИМИ фикстурами (где обязано прозвучать и где + обязано молчать), это пятое — нет: чтобы его достать, нужен отказ хранилища ВНУТРИ уже насыщенного + потолка, и фикстуру такой конъюнкции я не построила. Называю прямо, а не выдаю шесть из семи за семь. + Остальные шесть — включая обе новые («движок не спрошен» и «не осталось места на разрез») — запинены + фикстурой, где сообщение обязано прозвучать, И фикстурой, где обязано молчать. +- **Ограничитель НЕ накрывает `readmodel`** (`internal/readmodel/readmodel.go:156` — второй и последний + вызывающий `Engine.Manifest`) и глаголы `export`/`status`. Это осознанная граница, а не пропуск: + материализатор и `status` идут внутри очереди, которая уже ограничена одним `MaxWorkers` на все три + типа заданий (`internal/jobs/jobs.go:170`), свипы последовательны, а `readEngine` накрыл бы ещё + `Status` на пути СТАРТА платного прогона (`internal/runs/spawn.go:242`) и связал бы запуск прогонов с + нагрузкой приёма. Единственным неограниченным источником процессов был синхронный интейк — он и закрыт. + ⚠ Если приёмка считает, что хосту нужен потолок на ВСЕ порождения, это отдельная работа со своим + дизайном (развязка денежного пути), а не райдер к этому паку. + +### Попутно: совместимость с новой секцией `bank.json` (пришло пингом от движковой зоны, проверено моим кодом) + +Движковый пак добавил в `bank.json` секцию `consolidation` (полнота банка) и поле `never_asked`, версия +`tm-bank-v1` НЕ бампнута. **Пере-проверено на моей стороне, не принято на слово:** +- `bank.json` разбирает `internal/ingest/bank.go:78` `DecodeBank` — простой `json.Unmarshal`, неизвестный + член игнорируется. ⚠ Строгий декодер в зоне ЕСТЬ ровно один (`internal/httpapi/bank.go:137`, + `grep -rn DisallowUnknownFields --include=*.go` → **1 хит при 200 `.go`**), но он на ДРУГОМ пути — тело + запроса на правки ОТ КЛИЕНТА, где строгость требует сам канон. Пути не пересекаются ⇒ лендинг движка + приём банка не ломает. +- Читателей секции у зоны нет. ⚠ По подстроке их **2 при 186 `.go` в `internal/`** + (`internal/ingest/manifest.go:97`, `internal/pricing/pricing.go:112`) — и оба английская ПРОЗА про + «terminology consolidation» в денежных комментариях, а не чтение поля. Счёт по подстроке и счёт по + владению здесь расходятся на два: следующему, кто будет снимать этот ноль, читать хиты, а не число. +- ⛔ **Закон на будущее:** бит `complete` брать ГОТОВЫМ из артефакта, не выводить у себя (п.6 закона + входной двери шва). У движка он считается от среза рендер-паса, а срез классификатора полноты банка не + означает — самостоятельный вывод разошёлся бы с движковым молча. + +Строку под читателя НЕ завожу: это следующий пак зоны, и решение оркестратора — не торопить. + +### Вопросы оркестратору + +- **Нужен ли ряд регистра на остаток §4.1** (порождения вне потолка: `readmodel` + `export`/`status`)? + Я его НЕ завела: это не дефект сегодняшнего поведения, а названная граница механизма, и заводить ряд + «мы решили иначе» — засорять регистр. Скажи, если хочешь ряд. +- **`ReasonHostAtCapacity` — константа, которая НИКОГДА не пишется в БД** (`reject` — единственный писатель + причины, а класс потолка возвращает claim до него). Я оставила её строкой рядом с пятью + `ReasonX`-константами, потому что читатель приходит за ними туда же, и написала это в комментарии. + Если считаешь, что не-хранимой причине там не место — скажу, куда унести. + +## ПАК «РАЗРЕЗ ПРИЁМА ДО ГОТОВНОСТИ И ПРАВДА О СЕБЕ» — ЗАПИСКА-ПЛАН (08.09, `textmachine-fa`) + +> Промт `docs/PLATFORM_INTAKE_TRUTH_SESSION_PROMPT.md`, вход HEAD `3f4680c`, дерево на входе чисто +> (`git status --porcelain` — пусто). Зона НЕ коммитит. Пак $0. +> Baseline снят сам: `python3 docs/scripts/counts.py --check` → «Литералы сходятся с пере-счётом +> (8 проверок)», регистр 465 рядов / open 112 / major 3. + +**Что беру и в каком порядке.** Сначала то, что стоит $0 и является предусловием остального (шапка +этого журнала, носитель числа пакетов, ряды регистра), потом код в порядке связности: бюджет хвоста — +ограничитель — три неразличимых сбоя, потому что первые два связаны структурно и чинить их по +отдельности значит ломать один другим. Живой гейт и рантбук — последними, они судят уже построенное. + +**Разметка решений, принятых ДО кода — чтобы их можно было опровергнуть по этой записке.** + +1. **Бюджет (§4.2) — форма Б, структурная.** Перечень шагов подвёл трижды, и четвёртый перечень был бы + той же заплатой (`D39.216`). Беру ОДИН отсоединённый контекст хвоста с дедлайном `UploadSettle`, + от которого наследуются все шаги: `context.WithTimeout` на потомке с более ранним дедлайном сам даёт + `min(шаг, остаток)`, поэтому добавленный шаг границу не двигает ПО ПОСТРОЕНИЮ, а не по внимательности + следующего автора. Пин утверждает САМО свойство (§5.3), а не сумму слагаемых. +2. **Ограничитель (§4.1) — на `books.Service.manifest`.** Замер входов, а не память: `Engine.Manifest` + зовут ДВА места (`internal/books/parse.go:440`, `internal/readmodel/readmodel.go:156`), а `s.manifest` — + ровно те три, что названы заказом (интейк · `parseWorker` · свип `Sweep`). Шире (`runner.readEngine`, + общий на `manifest`/`export`/`status`) НЕ ставлю, и довод замером: очередь у платформы ОДНА и уже + ограничена (`internal/jobs/jobs.go:170`, `MaxWorkers` дефолт 4) на все три типа заданий сразу, свипы + последовательны — то есть единственный неограниченный источник процессов на хосте это и есть + синхронный интейк; а `readEngine` накрыл бы ещё `Status`, который стоит на пути СТАРТА платного + прогона (`internal/runs/spawn.go:242`, `bookMeter`), и связал бы запуск прогонов с нагрузкой приёма. + Что осталось снаружи — называю в отчёте числом, а не умолчанием. +3. **Форма — ожидание, а не немедленный отказ.** Ожидание внутри уже стоящего `CutBudget` к хвосту + ничего не добавляет (приор оркестратора, проверяю кодом), а упор даёт штатную деградацию: не уложился + ⇒ `errNotConclusive` ⇒ `201 parsing` ⇒ книгу доделывает очередь. Это строго лучше `503`: пользователь + получает книгу. Существующее прежде своего (§6): `x/sync/semaphore`, `errgroup.SetLimit`, + `netutil.LimitListener`, `MaxWorkers` — рассматриваю и отвергнутое называю с доводом. +4. **Свип `StuckIntake` (§4.4) — гипотеза лечения БЕЗ миграции.** `StartParsing` не штампует + `parse_started_at` (`internal/pgstore/books.go:213`), поэтому `coalesce(parse_started_at, added_at)` + в предикате свипа — это `added_at`, поставленный ДО прихода тела; бутовый гейт + (`internal/config/config.go:729`) сверяет дедлайн только с `min(UploadGrace, ClaimStale)` = 30 мин и + пропускает дедлайн до 26m29s, а `claimGrace` = 20 мин ⇒ условие достижимо настройкой. Кандидат — + добавить `claimGrace` третьим окном в тот же `min()`: колонки не нужно, форма гейта уже ровно эта. + Не выйдет — пинг, а не полумера молча (§4.8). + +**Что считаю рискованным.** (а) Форма Б трогает контексты на ВСЁМ пути приёма — класс ошибок здесь +«тихо-зелёный»: путь продолжает работать, а гарантия исчезает, поэтому пин обязан быть структурным +(дедлайны шагов), а не «уложились по часам». (б) Ограничитель на общей точке касается и очередного +входа — нагрузочное предъявление обязано считать ОДНОВРЕМЕННЫЕ процессы, а не суммарные. (в) Фикстуры +зоны уже делали два разных числа одним (`D39.208` п.5): везде, где в фикстуре встречаются `writeBudget`, +`CutBudget` и `UploadSettle`, беру ТРИ РАЗНЫХ значения. + +**Чего не делаю:** п.6 десятки (текст контракта) — пинг оркестратору; всё из §4.8. + ## ПАК «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» — ОТЧЁТ (06–07.09, `textmachine-bf`) > Промт `docs/PLATFORM_MONEY_TRUTH_SESSION_PROMPT.md`, вход HEAD `e4097cb`, дерево на входе чисто. diff --git a/platform/go.mod b/platform/go.mod index 13e3f5f9..fb34e8e4 100644 --- a/platform/go.mod +++ b/platform/go.mod @@ -20,6 +20,7 @@ require ( github.com/riverqueue/river v0.42.0 github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.0 golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.22.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -49,7 +50,6 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect go.uber.org/goleak v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/platform/go.sum b/platform/go.sum index 11e19567..7cb6f4ed 100644 --- a/platform/go.sum +++ b/platform/go.sum @@ -93,7 +93,6 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= diff --git a/platform/internal/books/books.go b/platform/internal/books/books.go index ee330002..a0e7af93 100644 --- a/platform/internal/books/books.go +++ b/platform/internal/books/books.go @@ -19,6 +19,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "time" "unicode" "unicode/utf8" @@ -65,6 +66,10 @@ type Config struct { // which is what every deployment did before the form was ratified: an unprovisioned book then // WAITS rather than being rejected. BookTemplate string + // MaxCuts caps how many books this deployment lets the engine cut at once, across the upload that + // cuts its own book, the queue's workers and the backstop sweep together. Zero takes + // DefaultMaxCuts. See limit.go for why the number is the only memory bound this path has. + MaxCuts int // Pairs is what this deployment declares it can translate, from its configuration — the AVAILABLE // half of it. EMPTY refuses every upload: "declares nothing" is not "declares this pair", and the // boot refuses to mount an intake with an empty list at all, so this is the second half of one @@ -86,6 +91,13 @@ type Service struct { // writeBudget shortens what a terminal write gets. Unexported and zero by default: only this // package's own tests set it, to reach the case where the cut outlives that budget. writeBudget time.Duration + // uploadSettle shortens the whole tail of an upload (walk). Unexported and zero by default, for + // the same reason as writeBudget: reaching the end of a three-and-a-half-minute budget is a test + // nobody would run otherwise, and the boot compares the CONSTANT, never this. + uploadSettle time.Duration + // The host's cap on concurrent engine cuts, built on first use — see limit.go. + cutsOnce sync.Once + cutSlots *cutSlots } func (s *Service) now() time.Time { @@ -216,12 +228,14 @@ func (s *Service) Accept(ctx context.Context, in Intake) (pgstore.Book, error) { s.abandon(c, id, dir) return pgstore.Book{}, err } - // On a context that outlives the request: every byte is in, and a client that hung up while - // waiting for the 201 must not cost the upload it already finished. The ROW is what makes the - // book findable, so it is written even when nobody is left to read the answer. Bounded on its own - // (writeCtx): detached is not the same as unlimited, and a hung statement here would hold the - // goroutine of a request that is already over. - start, cancelStart := s.writeCtx(ctx) + // Every byte is in, so the upload's TAIL starts here and everything past this line is spent from + // one budget. It outlives the request — the ROW is what makes the book findable, and a client + // that hung up while waiting for the 201 must not cost the upload it already finished — and it is + // bounded as a whole rather than step by step, which is what keeps its end where the boot was + // promised no matter how many steps it grows (walk, step, UploadSettle). + walk, cancelWalk := s.walk(ctx) + defer cancelWalk() + start, cancelStart := s.writeCtx(walk) defer cancelStart() // The job is enqueued here ONLY where no cut runs in this request. Where one does, a job that // exists while the cut is going races it for the parse claim — and both outcomes cost the book: @@ -230,17 +244,17 @@ func (s *Service) Accept(ctx context.Context, in Intake) (pgstore.Book, error) { // needs it (ReleaseParseClaim). // // The price, named: a process that dies between this row and the cut leaves the book `parsing` - // with no job, and the backstop sweep takes it after claimGrace rather than at once. - enqueue := s.enqueue - if s.Engine != nil { - enqueue = nil + // with no job, and the backstop sweep takes it after ClaimGrace rather than at once. + var enqueue func(context.Context, pgstore.Tx, string) error + if !s.cutsItsOwnUploads() { + enqueue = s.enqueue } book, err := s.Store.StartParsing(start, id, streamRunes, enqueue) if err != nil { // The row is gone or unreachable, and the directory holds a file nothing points at — which is // the one thing the row-first order exists to prevent, so it is undone here too. The ordinary // cause is the sweep having abandoned this upload while it was still arriving. - c, cancel := s.writeCtx(ctx) + c, cancel := s.writeCtx(walk) defer cancel() s.abandon(c, id, dir) return pgstore.Book{}, err @@ -249,8 +263,8 @@ func (s *Service) Accept(ctx context.Context, in Intake) (pgstore.Book, error) { // book in this file" and the book's size are both knowable before the upload is over (backlog row // 285). Only the engine's own verdict about the SOURCE refuses; a deployment fault falls through // to the asynchronous path this route always had — see cutNow. - if cut := s.cutNow(ctx, book); cut.err != nil { - c, cancel := s.writeCtx(ctx) + if cut := s.cutNow(walk, book); cut.err != nil { + c, cancel := s.writeCtx(walk) defer cancel() // Nothing was accepted, so nothing is left behind: no row, no file (row 285 closes row 254). s.discard(c, id, dir, cut.claimedAt) @@ -261,7 +275,7 @@ func (s *Service) Accept(ctx context.Context, in Intake) (pgstore.Book, error) { // On its OWN budget: `start` was opened before the bytes were received and the cut runs inside // this call, so by now that context can be spent — and a re-read on a dead context answers with // the pre-cut row, which is the one thing this line exists to avoid. - read, cancelRead := s.writeCtx(ctx) + read, cancelRead := s.writeCtx(walk) defer cancelRead() fresh, err := s.Store.ReadBook(read, id) if err == nil { @@ -292,25 +306,58 @@ type cutResult struct { // a user as a verdict about their file. // // Bounded by its own budget, which keeps a large book from holding the request open: past it the -// upload is accepted `parsing`. The claim is taken here so this and the queue cannot run the engine -// over one project directory at once (see Parse). +// upload is accepted `parsing`. That budget is a STEP of the upload's walk, so a cut cannot spend +// what the rest of the tail still needs (step). The claim is taken here so this and the queue cannot +// run the engine over one project directory at once (see Parse). func (s *Service) cutNow(ctx context.Context, book pgstore.Book) cutResult { - if s.Engine == nil { + if !s.cutsItsOwnUploads() { return cutResult{} } - c, cancel := context.WithTimeout(context.WithoutCancel(ctx), CutBudget) - defer cancel() + // The claim is a WRITE and gets a write's budget, not the cut's: it is what lets this pass end the + // book at all, and tying it to the budget of the work it guards was what made «no room for a cut» + // arrive as «the store could not be asked». + cl, cancelClaim := s.writeCtx(ctx) + defer cancelClaim() now := s.now() - claim, err := s.Store.ClaimParse(c, book.ID, now, now.Add(-claimGrace)) - if err != nil { - return cutResult{} // already claimed, or unreachable: not an answer about the file + claim, err := s.Store.ClaimParse(cl, book.ID, now, now.Add(-ClaimGrace)) + // The two ways this does not produce a claim are told apart, because one of them needs a human + // and the other is how the walk is supposed to go. Neither is an answer about the FILE, so both + // leave the upload to be accepted `parsing`. + switch { + case errors.Is(err, pgstore.ErrParseClaimed): + // Somebody else holds this book: on this route that is the backstop sweep, since no job was + // enqueued for a book the intake cuts itself. Whoever holds it finishes the walk. + s.log().InfoContext(ctx, "the intake did not get the parse claim; the pass that holds it finishes the book") + return cutResult{} + case err != nil: + // NOT the race above: the claim could not be ASKED for. Said out loud, because the book is now + // `parsing` with no claim and no job — this route enqueues none — and nothing comes back for it + // until the backstop sweep does, a claim grace later. Silent, this is indistinguishable from + // the ordinary line above, which needs no operator at all. + s.log().ErrorContext(ctx, "the parse claim could not be taken, so this upload is not cut here; the backstop sweep finishes the book after its grace", "err", err) + return cutResult{} } - switch err := s.parseClaimed(c, claim, true); { + // The cut itself, on what the walk can spare after the writes that must follow it (stepLeaving). + // Below zero there is no cut to run, and it is not ATTEMPTED: a pass started on a spent context + // fails somewhere inside itself and is diagnosed as whatever failed first, which is never the + // truth. Answered as «no verdict», which is what the queue finishing the book already means. + c, cancel := s.stepLeaving(ctx, CutBudget, s.cutTailReserve()) + defer cancel() + cut := c.Err() + if cut == nil { + cut = s.parseClaimed(c, claim, true) + } else { + s.log().InfoContext(ctx, "what is left of this upload is shorter than a cut plus the writes that follow it; the queue takes the book", + "reason", ReasonNoTimeToCut) + cut = notConclusive(ReasonNoTimeToCut) + } + switch err := cut; { case errors.Is(err, ErrBadIntake): return cutResult{err: err, claimedAt: claim.At} case err != nil: // errNotConclusive, or a context this pass could not finish inside - // Nothing was established, so the claim goes back at once: the queue job is already enqueued - // and a standing claim would make it do nothing (ReleaseParseClaim). + // Nothing was established, so the claim goes back at once — and the job that finishes the book + // goes in WITH it, in the one transaction: this route enqueued none at StartParsing, precisely + // so that no worker could race this cut for the claim (Accept above, and ReleaseParseClaim). s.log().WarnContext(ctx, "the intake's own cut was not conclusive; the queue takes the book", "err", err) w, wcancel := s.writeCtx(ctx) defer wcancel() @@ -405,6 +452,20 @@ func (c *counter) Write(p []byte) (int, error) { return n, err } +// cutsItsOwnUploads reports whether this deployment answers an upload with a verdict about the file, +// or hands the book to the queue and answers `parsing`. +// +// ONE function because it decides TWO things that must never disagree: whether the row's transaction +// carries a job (Accept), and whether a cut runs at all (cutNow). Written twice, the two readings of +// one predicate drift, and each way of drifting costs the book — a job enqueued for a cut that then +// runs races it for the claim, and a cut skipped where no job was enqueued leaves the book waiting +// out the backstop sweep's whole grace. +// +// In a deployment it is always true: the boot refuses to mount an intake without an engine binary +// (config.IntakeEnabled), so the false branch belongs to the development path and to this package's +// own tests, where it stands for a deployment that has a queue and no engine. +func (s *Service) cutsItsOwnUploads() bool { return s.Engine != nil } + func (s *Service) enqueue(ctx context.Context, tx pgstore.Tx, bookID string) error { if s.Queue == nil { return nil // no queue configured: the sweep picks the book up on its next pass diff --git a/platform/internal/books/books_test.go b/platform/internal/books/books_test.go index 5348e5be..6c089977 100644 --- a/platform/internal/books/books_test.go +++ b/platform/internal/books/books_test.go @@ -500,7 +500,7 @@ func TestASourceTheEngineRefusesIsRejectedAndItsFileRemoved(t *testing.T) { t.Fatalf("the source was deleted on the first refusal: %v", err) } for range parseAttempts { - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } @@ -554,7 +554,7 @@ func TestOnlyTheOneRefusalClassAboutTheTextEverCostsTheUpload(t *testing.T) { dir := f.provision(t, book) f.engine.set(ingest.Manifest{}, refusal(t, tc.code)) for range parseAttempts + 1 { - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } @@ -618,7 +618,7 @@ func TestAnEngineThatCannotBeRunIsRetriedAndThenGivenUpOn(t *testing.T) { if f.engine.called() != before { t.Fatalf("attempt %d was retried before the grace passed", i) } - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) } if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) @@ -650,7 +650,7 @@ func TestABookWithNoEngineConfigurationWaitsRatherThanDies(t *testing.T) { if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } - f.now = f.now.Add(claimGrace + time.Minute) // the claim spaces the retries + f.now = f.now.Add(ClaimGrace + time.Minute) // the claim spaces the retries } if f.engine.called() != 0 { t.Fatalf("the engine was asked %d times about a book it has no configuration for", f.engine.called()) @@ -668,7 +668,7 @@ func TestABookWithNoEngineConfigurationWaitsRatherThanDies(t *testing.T) { } // And the moment somebody provisions it, the very next pass parses it. f.provision(t, pgstore.Book{ID: book.ID}) - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } @@ -953,9 +953,9 @@ func TestTheSweepNeverRemovesTheSourceOfABookItCouldNotDelete(t *testing.T) { // loser dies on the engine's exclusive lock with exit 1 — and exit 1 is how the engine says "I cannot // cut this source". func TestTheClaimGraceOutlivesTheQueuesJobTimeout(t *testing.T) { - if claimGrace <= jobs.JobTimeout { - t.Fatalf("claimGrace %s does not outlive the queue's job timeout %s: the sweep would steal a claim from a running parse", - claimGrace, jobs.JobTimeout) + if ClaimGrace <= jobs.JobTimeout { + t.Fatalf("ClaimGrace %s does not outlive the queue's job timeout %s: the sweep would steal a claim from a running parse", + ClaimGrace, jobs.JobTimeout) } } @@ -1043,7 +1043,7 @@ func TestWaitingForAConfigurationDoesNotBringDeletionCloser(t *testing.T) { if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) } var attempts int if err := f.store.Pool().QueryRow(f.ctx, @@ -1144,7 +1144,7 @@ func TestAVanishedStorageRootIsNotEveryBooksFault(t *testing.T) { t.Fatal(err) } } - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) } if f.engine.called() != 0 { t.Fatalf("the engine was asked %d times while the storage root was gone", f.engine.called()) @@ -1245,7 +1245,7 @@ func TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault(t *test if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) } if f.engine.called() != 0 { t.Fatalf("the engine was asked %d times about storage that is not mounted", f.engine.called()) @@ -1344,7 +1344,7 @@ func TestTheIntakeRefusesTheDocumentItsOwnMaterialiserWouldReject(t *testing.T) // NON-DESTRUCTIVE, through the whole budget: a document this build cannot read says nothing about // the user's text. for range parseAttempts + 1 { - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } @@ -1381,7 +1381,7 @@ func TestAManifestShapeThisBuildDoesNotKnowNeverDeletesTheUpload(t *testing.T) { // reader that does not ask which shape it is holding. f.engine.set(ingest.Manifest{Version: "tm-manifest-v3"}, nil) for range parseAttempts + 1 { - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } @@ -1398,7 +1398,7 @@ func TestAManifestShapeThisBuildDoesNotKnowNeverDeletesTheUpload(t *testing.T) { emptyDir := f.provision(t, empty) f.engine.set(ingest.Manifest{Version: ingest.KnownManifestVersion}, nil) for range parseAttempts + 1 { - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) if err := f.svc.Parse(f.ctx, empty.ID); err != nil { t.Fatal(err) } @@ -1429,7 +1429,7 @@ func TestAManifestThatContradictsItselfNeverCostsTheUpload(t *testing.T) { // No chapters, yet units and chunks were cut: no book produces this and no engine reports it. f.engine.set(ingest.Manifest{Version: "tm-manifest-v9", UnitsTotal: 4402}, nil) for range parseAttempts + 1 { - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } @@ -1445,7 +1445,7 @@ func TestAManifestThatContradictsItselfNeverCostsTheUpload(t *testing.T) { emptyDir := f.provision(t, empty) f.engine.set(ingest.Manifest{Version: "tm-manifest-v2"}, nil) for range parseAttempts + 1 { - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) if err := f.svc.Parse(f.ctx, empty.ID); err != nil { t.Fatal(err) } diff --git a/platform/internal/books/failfast_test.go b/platform/internal/books/failfast_test.go index 53f842ac..54faef58 100644 --- a/platform/internal/books/failfast_test.go +++ b/platform/internal/books/failfast_test.go @@ -348,3 +348,58 @@ func remove(t *testing.T, path string) { t.Fatal(err) } } + +// The job goes in ONLY where the claim was really given back, and the half that decides is +// `RowsAffected` on the release itself (pgstore.ReleaseParseClaim). +// +// Both halves are one transaction because either alone is a state nobody finishes. The guard is the +// side nothing covered: a pass whose claim has been taken over by another has nothing to hand the +// queue, and a job enqueued for a book somebody else is already parsing is a worker that wakes up, +// finds the claim held and does nothing — while the pass that DOES hold it still owes the book its +// finish. Both directions are asserted here, because a case that only ever proves the job is ABSENT +// passes just as well when the release stopped enqueueing altogether. +func TestOnlyAClaimThatWasReallyGivenBackQueuesTheJobThatFinishesTheBook(t *testing.T) { + f := newFixture(t) // no template: the intake's own pass reaches no verdict and releases once + queue := &countingQueue{} + f.svc.Queue = queue + book := f.accept(t, "蛊真人.txt", "первая глава") + if queue.n != 1 { + t.Fatalf("the intake handed the queue %d jobs, want the one that finishes this book", queue.n) + } + // Somebody takes the book: from here a release carrying any OTHER stamp is a pass talking about + // work it is no longer doing. + claim, err := f.store.ClaimParse(f.ctx, book.ID, f.now, f.now.Add(-ClaimGrace)) + if err != nil { + t.Fatal(err) + } + if err := f.store.ReleaseParseClaim(f.ctx, book.ID, claim.At.Add(-time.Second), f.svc.enqueue); err != nil { + t.Fatalf("a release carrying a stale stamp failed instead of doing nothing: %v", err) + } + if queue.n != 1 { + t.Errorf("a release that gave back nothing still queued a job (%d in total): the worker it wakes will find the claim held and do nothing, and the pass that holds it is still the one that owes the book", + queue.n) + } + var stamp *time.Time + if err := f.store.Pool().QueryRow(f.ctx, + `select parse_started_at from books where id = $1`, book.ID).Scan(&stamp); err != nil { + t.Fatal(err) + } + if stamp == nil { + t.Fatal("a release carrying a stale stamp cleared somebody else's claim") + } + // And the holder's OWN release does both halves: without this the assertion above would hold on a + // release that has stopped queueing anything at all. + if err := f.store.ReleaseParseClaim(f.ctx, book.ID, claim.At, f.svc.enqueue); err != nil { + t.Fatal(err) + } + if queue.n != 2 { + t.Errorf("the holder gave the claim back and the queue has %d jobs, want a second one: the book has nobody to finish it", queue.n) + } + if err := f.store.Pool().QueryRow(f.ctx, + `select parse_started_at from books where id = $1`, book.ID).Scan(&stamp); err != nil { + t.Fatal(err) + } + if stamp != nil { + t.Errorf("the holder's own release left the claim standing (%v): the job it queued will do nothing", stamp) + } +} diff --git a/platform/internal/books/limit.go b/platform/internal/books/limit.go new file mode 100644 index 00000000..0bd72d16 --- /dev/null +++ b/platform/internal/books/limit.go @@ -0,0 +1,207 @@ +package books + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "time" + + "golang.org/x/sync/semaphore" + + "textmachine/platform/internal/jobs" +) + +// DefaultMaxCuts is how many books this platform lets the engine cut AT ONCE — counted across every +// way of starting one: the upload that cuts its own book, the queue's workers and the backstop sweep. +// +// The number is small on purpose, and the reason is what a cut IS on this host. `tmctl manifest` is +// spawned as a plain child process (runner.readEngine) — no transient unit, no cgroup, no MemoryMax, +// unlike a translation run, which is wrapped in all three. +// +// ⚠ What this cap bounds, said exactly, because the loose version of it is false: it bounds the CUTS, +// on every path that starts one — the upload that cuts its own book, the queue's workers and the +// backstop sweep. It is NOT a bound on every engine process the host may hold: the materializer reads +// a manifest and an export of its own through the same uncapped `runner.readEngine` +// (readmodel.refresh), and the run reconciler reads `status` the same way. Those are bounded by the +// queue's worker count and by their sweeps being sequential, which is a different bound and a looser +// one. The path that had NO bound at all was the synchronous cut — N uploads were N engine processes +// — and that is the one this closes. +// +// The figure is the queue's own default worker count and is taken from there rather than repeated +// here (jobs.DefaultWorkers): the host was already sized for that many engine processes, and this is +// the same number said once for every way of starting one. +const DefaultMaxCuts = jobs.DefaultWorkers + +// ErrHostAtCutCapacity is a cut that never started because the host is already running as many as it +// will run at once. +// +// Not a fact about the book and not a fault of the deployment, and no path treats it as either: at +// intake it leaves the upload to the queue, and on the queue's own path it gives the claim back +// without spending an attempt of a budget that exists for a BROKEN host. A host at its cap is a +// working host. +var ErrHostAtCutCapacity = errors.New("books: the host is already cutting as many books as it will cut at once") + +// ErrNoTimeToCut is a cut that was not started because what is left of the caller's budget is less +// than a cut needs. +// +// The SAME class as the cap and treated identically — the engine was never asked, so this pass knows +// nothing about the book and records nothing — and it exists because waiting for a slot spends the +// caller's budget. Without it a pass could win a slot with seconds left, hand the engine those +// seconds, and have the killed process read back as `parser_unavailable`: a verdict about the +// DEPLOYMENT, which spends an attempt of a budget that five times over deletes the user's file. +var ErrNoTimeToCut = errors.New("books: what is left of this pass is shorter than a cut") + +// cutSlots is the cap itself, plus what an operator has to be able to see of it: a cap nobody can +// watch is indistinguishable from latency somebody has to guess at. +type cutSlots struct { + sem *semaphore.Weighted + limit int64 + // inFlight and waiting are the state RIGHT NOW — how saturated the host is, and how deep the line + // for it is. Gauges, because the question they answer stops being true the moment it changes. + inFlight atomic.Int64 + waiting atomic.Int64 + // waited and gaveUp are cumulative, because their question is the opposite one: how often has this + // cap been reached at all, and how often did reaching it cost a cut. A gauge would answer it only + // for whoever happened to be looking. + waited atomic.Uint64 + gaveUp atomic.Uint64 +} + +// cuts builds the cap on first use. +// +// Lazily, because this service is assembled as a struct literal by its deployment (cmd/tmplatformd) +// and by every test that exercises intake, and a cap that only exists when a constructor was called +// is a cap absent from exactly the paths nobody remembered to route through one. +func (s *Service) cuts() *cutSlots { + s.cutsOnce.Do(func() { + limit := int64(s.Cfg.MaxCuts) + if limit <= 0 { + limit = DefaultMaxCuts + } + s.cutSlots = &cutSlots{sem: semaphore.NewWeighted(limit), limit: limit} + }) + return s.cutSlots +} + +// takeCutSlot holds one of the host's cut slots for the caller and returns what gives it back. +// +// It WAITS rather than refusing, and the caller's own context is what bounds the wait — the upload's +// cut budget, the queue job's timeout, the sweep's per-book slice. Waiting is right here because +// every one of those already has a milder answer than a refusal for running out: the upload is +// accepted `parsing` and the queue finishes it; a queued pass gives the book straight back. A +// refusal at the door would turn a host that is merely busy into an upload the user has to do again. +// +// It cannot lengthen the walk it is called inside, either — that is not a promise about this code +// but a property of the context it takes: the cut runs on a step of the upload's walk (books.step), +// so time spent here is time NOT spent on the engine, never time added to the tail. +// +// ⛔ `reserve` is what a WON slot must still be worth. Waiting spends the caller's budget, so a slot +// won at the very end of it buys a cut the engine has no time to finish — and a killed engine reads +// back as a fault of the DEPLOYMENT, which spends an attempt of the budget that deletes a user's +// file after five. Zero means the caller has nothing at stake in losing (the intake spends no +// attempts), and then waiting to the very end is free. +func (s *Service) takeCutSlot(ctx context.Context, reserve time.Duration) (func(), error) { + c := s.cuts() + release := func() { + c.inFlight.Add(-1) + c.sem.Release(1) + } + if c.sem.TryAcquire(1) { + c.inFlight.Add(1) + return release, nil + } + if err := worthStarting(ctx, reserve); err != nil { + // Every slot is taken and there is not enough left to make winning one worth it. Counted as a + // give-up, because from the operator's side it is the cap that cost this cut. + c.gaveUp.Add(1) + return nil, err + } + c.waited.Add(1) + c.waiting.Add(1) + started := time.Now() + wait, stopWaiting := waitCtx(ctx, reserve) + err := c.sem.Acquire(wait, 1) + stopWaiting() + c.waiting.Add(-1) + if err != nil { + c.gaveUp.Add(1) + return nil, fmt.Errorf("%w: waited %s for one of %d slots: %w", + ErrHostAtCutCapacity, time.Since(started).Round(time.Millisecond), c.limit, err) + } + // Won — but the wait spent time, so the question of whether it is still worth cutting is asked + // AGAIN. A slot handed back unused is a slot the next caller gets. + if err := worthStarting(ctx, reserve); err != nil { + c.sem.Release(1) + c.gaveUp.Add(1) + return nil, err + } + c.inFlight.Add(1) + // The fact and the wait, at INFO: this is the host doing what it was configured to do, and an + // operator reading it learns the cap is the thing shaping their latency. What it must NOT do is + // carry the book — a cut waits because of the HOST, and the book it happens to be for is no more + // at fault than any other (ENGINEERING_STANDARDS §Наблюдаемость). + s.log().InfoContext(ctx, "a cut waited for one of the host's cut slots", + "waited_seconds", time.Since(started).Seconds(), "slots", c.limit) + return release, nil +} + +// CutCapacity is one reading of the cap, for the telemetry pass that publishes it. +// +// Read from the service rather than collected on scrape, which is the same rule the rest of this +// deployment's gauges follow: a scrape must not be able to set the load on anything. +type CutCapacity struct { + Limit int + InFlight int + Waiting int + // Waited and GaveUp are cumulative counts of cuts that had to wait at all, and of cuts whose + // caller ran out of budget while waiting. + Waited uint64 + GaveUp uint64 +} + +// CutCapacity reports where the host's cut capacity stands. +func (s *Service) CutCapacity() CutCapacity { + c := s.cuts() + return CutCapacity{ + Limit: int(c.limit), + InFlight: int(c.inFlight.Load()), + Waiting: int(c.waiting.Load()), + Waited: c.waited.Load(), + GaveUp: c.gaveUp.Load(), + } +} + +// waitCtx bounds a wait so that what is left when it ends is still worth a cut. With no reserve, or +// with no deadline to take it out of, the caller's own context is the bound. +func waitCtx(ctx context.Context, reserve time.Duration) (context.Context, context.CancelFunc) { + deadline, ok := ctx.Deadline() + if !ok || reserve <= 0 { + return ctx, func() {} + } + // The parent stays the parent, so its cancellation still ends the wait; only the deadline is + // pulled in by the reserve. + return context.WithDeadline(ctx, deadline.Add(-reserve)) +} + +// worthStarting reports whether a cut started now would have the time a cut needs. +func worthStarting(ctx context.Context, reserve time.Duration) error { + if reserve <= 0 { + return nil + } + deadline, ok := ctx.Deadline() + if !ok { + return nil + } + if left := time.Until(deadline); left < reserve { + return fmt.Errorf("%w: %s left, a cut is given %s", + ErrNoTimeToCut, left.Round(time.Millisecond), reserve) + } + return nil +} + +// engineNotAsked reports whether an error means the engine was never asked at all — the host was at +// its cap, or what was left of the pass was shorter than a cut. Neither says anything about the book. +func engineNotAsked(err error) bool { + return errors.Is(err, ErrHostAtCutCapacity) || errors.Is(err, ErrNoTimeToCut) +} diff --git a/platform/internal/books/limit_test.go b/platform/internal/books/limit_test.go new file mode 100644 index 00000000..a9456e9e --- /dev/null +++ b/platform/internal/books/limit_test.go @@ -0,0 +1,562 @@ +package books + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "testing" + "time" + + "textmachine/platform/internal/jobs" +) + +// waitUntil polls a condition and fails with what it last saw, so a test that never contended reads +// as a test that never contended rather than as a passing one. +func waitUntil(t *testing.T, what string, saw func() string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if ok() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("%s did not happen within 10s; last reading: %s", what, saw()) +} + +// upload runs one intake and reports what it answered. +func (f *fixture) uploadAsync(name string) <-chan error { + done := make(chan error, 1) + go func() { + _, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: name, File: strings.NewReader("первая глава\fвторая глава")}) + done <- err + }() + return done +} + +// The host never runs more cuts at once than its cap allows, and this is asserted under LOAD rather +// than read off the code: six uploads arrive together at a cap of two. +// +// ⚠ The control value is what makes the assertion mean anything. "The peak never exceeded two" is +// also true of a fixture where two uploads never managed to overlap at all, so the test first waits +// until FOUR cuts are provably queued for a slot — that reading comes from the cap's own counters, +// which is the number an operator reads too. +func TestTheHostRunsNoMoreCutsAtOnceThanItsCapAllows(t *testing.T) { + const slots, uploads = 2, 6 + f := newFixture(t) + templated(t, f) + f.svc.Cfg.MaxCuts = slots + + var mu sync.Mutex + inFlight, peak := 0, 0 + hold := make(chan struct{}) + f.engine.onManifest = func(context.Context) { + mu.Lock() + inFlight++ + if inFlight > peak { + peak = inFlight + } + mu.Unlock() + <-hold + mu.Lock() + inFlight-- + mu.Unlock() + } + + done := make([]<-chan error, uploads) + for i := range done { + done[i] = f.uploadAsync("book.txt") + } + waitUntil(t, "four of six uploads queued for one of two cut slots", + func() string { return fmt.Sprintf("%+v", f.svc.CutCapacity()) }, + func() bool { return f.svc.CutCapacity().Waiting == uploads-slots }) + // Only now: with the line proven, whatever the peak turns out to be is a fact about the cap. + close(hold) + for _, ch := range done { + if err := <-ch; err != nil { + t.Errorf("an upload under the cap failed: %v", err) + } + } + if peak != slots { + t.Errorf("%d cuts ran at once at a cap of %d", peak, slots) + } + if c := f.svc.CutCapacity(); c.Waited < uploads-slots { + t.Errorf("the cap counted %d waits for %d uploads over %d slots: an operator would not see the saturation", + c.Waited, uploads, slots) + } + if c := f.svc.CutCapacity(); c.InFlight != 0 || c.Waiting != 0 { + t.Errorf("after every upload finished the cap still reads in-flight %d waiting %d: a slot was not given back", + c.InFlight, c.Waiting) + } +} + +// The same load with room for all of it reaches all of it at once. +// +// Without this the test above would also pass on a fixture that never got two cuts to overlap — the +// vacuous shape that made a whole class of this zone's assertions invisible (D39.208 §5). Here the +// cap is the ONLY thing changed between the two, so the difference in the peak is the cap's doing. +func TestWithRoomForEveryCutTheHostRunsThemAllAtOnce(t *testing.T) { + const uploads = 6 + f := newFixture(t) + templated(t, f) + f.svc.Cfg.MaxCuts = uploads + + var mu sync.Mutex + inFlight, peak := 0, 0 + hold := make(chan struct{}) + f.engine.onManifest = func(context.Context) { + mu.Lock() + inFlight++ + if inFlight > peak { + peak = inFlight + } + mu.Unlock() + <-hold + mu.Lock() + inFlight-- + mu.Unlock() + } + done := make([]<-chan error, uploads) + for i := range done { + done[i] = f.uploadAsync("book.txt") + } + waitUntil(t, "all six uploads cutting at once", + func() string { return fmt.Sprintf("%+v", f.svc.CutCapacity()) }, + func() bool { return f.svc.CutCapacity().InFlight == uploads }) + close(hold) + for _, ch := range done { + <-ch + } + if peak != uploads { + t.Errorf("only %d of %d cuts ran at once with a slot for each: the load never contended and the cap's test proves nothing", + peak, uploads) + } +} + +// An upload that runs out of budget waiting for a slot is ACCEPTED `parsing` and finished by the +// queue — never refused. A busy host must not cost a user the upload they already made. +// +// This is also the fixture where the cap's log line MUST sound: a message pinned only by its silence +// says nothing about the case where it is wrong. +func TestAnUploadThatRunsOutOfBudgetWaitingForASlotIsAcceptedRatherThanRefused(t *testing.T) { + f := newFixture(t) + templated(t, f) + f.svc.Cfg.MaxCuts = 1 + // Short enough that the second upload's whole walk expires while the first holds the only slot, + // and different from every other budget in this fixture so the two cannot be confused. + // THREE distinct numbers, and the third one is load-bearing rather than tidy: the reserve a cut + // must leave for the writes after it is three write budgets, so a fixture that shortens only the + // walk leaves no room for a cut at all and models nothing (see stepLeaving). + f.svc.uploadSettle = 300 * time.Millisecond + f.svc.writeBudget = 20 * time.Millisecond + var buf bytes.Buffer + f.svc.Log = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + + hold := make(chan struct{}) + first := make(chan struct{}) + var once sync.Once + f.engine.onManifest = func(context.Context) { + once.Do(func() { close(first) }) + <-hold + } + held := f.uploadAsync("held.txt") + <-first + waitUntil(t, "the only slot taken", + func() string { return fmt.Sprintf("%+v", f.svc.CutCapacity()) }, + func() bool { return f.svc.CutCapacity().InFlight == 1 }) + + book, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "waited.txt", File: strings.NewReader("первая глава\fвторая глава")}) + if err != nil { + t.Fatalf("an upload that met a busy host was refused: %v", err) + } + if book.Status != "parsing" { + t.Errorf("an upload that met a busy host is %q, want `parsing` with the queue to finish it", book.Status) + } + if c := f.svc.CutCapacity(); c.GaveUp != 1 { + t.Errorf("the cap counted %d cuts that ran out of budget waiting, want 1", c.GaveUp) + } + if got := buf.String(); !strings.Contains(got, "the engine was not asked") || !strings.Contains(got, ReasonHostAtCapacity) { + t.Errorf("nothing in the log says the engine was not asked and why (%q), so an operator sees latency and no cause; log: %s", + ReasonHostAtCapacity, got) + } + close(hold) + <-held + + // And the same message is SILENT on a host with room: a line that is always there is a line that + // tells an operator nothing. + f.engine.onManifest = nil + buf.Reset() + f.svc.uploadSettle = 0 + if _, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "free.txt", File: strings.NewReader("первая глава\fвторая глава")}); err != nil { + t.Fatalf("an upload on an idle host failed: %v", err) + } + if got := buf.String(); strings.Contains(got, ReasonHostAtCapacity) || strings.Contains(got, "waited for one of the host's cut slots") { + t.Errorf("an idle host still logged about its cap: %s", got) + } +} + +// A queued pass that cannot cut gives the book back and spends NOTHING, and the two reasons it can +// have are told apart: the host is at its cap, or what is left of the pass is shorter than a cut. +// +// Both matter, and the second is what makes the first safe. Waiting for a slot spends the caller's +// budget, so without a reserve a pass could win a slot with seconds left, hand the engine those +// seconds, and have the killed process read back as a fault of the DEPLOYMENT — which DOES spend an +// attempt, and five of those delete the user's file. +func TestAQueuedParseThatCannotCutSpendsNothingAndGivesTheBookBack(t *testing.T) { + f := newFixture(t) + f.svc.Cfg.MaxCuts = 1 + // Two books that reached `parsing` without any engine call: with no template configured the + // intake's own pass stops before the engine is asked, which is the state the queue picks up. + held := f.accept(t, "held.txt", "первая глава\fвторая глава") + waiting := f.accept(t, "waiting.txt", "первая глава\fвторая глава") + f.provision(t, held) + f.provision(t, waiting) + + hold, running := make(chan struct{}), make(chan struct{}) + var once sync.Once + f.engine.onManifest = func(context.Context) { + once.Do(func() { close(running) }) + <-hold + } + parsed := make(chan error, 1) + go func() { parsed <- f.svc.Parse(f.ctx, held.ID) }() + <-running + + for _, c := range []struct { + what string + budget time.Duration + want error + }{ + // Enough left that a cut would be worth starting, so this pass WAITS — and the wait is bounded + // by what it must leave the engine, which is why it ends in about a second rather than in the + // whole budget. + {"the host is at its cap", CutBudget + 1500*time.Millisecond, ErrHostAtCutCapacity}, + // Less left than a cut needs, so no slot is even waited for. + {"there is no time left for a cut", 300 * time.Millisecond, ErrNoTimeToCut}, + } { + before, _ := f.parseState(t, waiting.ID) + ctx, cancel := context.WithTimeout(f.ctx, c.budget) + err := f.svc.Parse(ctx, waiting.ID) + cancel() + if !errors.Is(err, c.want) { + t.Fatalf("%s: the pass answered %v, want %v", c.what, err, c.want) + } + if !errors.Is(err, jobs.ErrTryAgainLater) { + t.Errorf("%s: the error does not tell the queue to bring the job back, so the single attempt is spent and the book waits out the sweep's grace", c.what) + } + after, claimed := f.parseState(t, waiting.ID) + if after != before { + t.Errorf("%s: a pass that never asked the engine spent the book's budget: attempts %d → %d", c.what, before, after) + } + if claimed { + t.Errorf("%s: a pass that established nothing kept the claim, so the next pass waits out the whole grace for it", c.what) + } + } + close(hold) + if err := <-parsed; err != nil { + t.Errorf("the parse that held the slot failed: %v", err) + } +} + +// parseState reads how much of a book's attempt budget is spent and whether a claim stands on it. +func (f *fixture) parseState(t *testing.T, id string) (attempts int, claimed bool) { + t.Helper() + var stamp *time.Time + if err := f.store.Pool().QueryRow(f.ctx, + `select parse_attempts, parse_started_at from books where id = $1`, id).Scan(&attempts, &stamp); err != nil { + t.Fatal(err) + } + return attempts, stamp != nil +} + +// The two ways an intake gets no parse claim are told apart in the log, because one of them needs a +// human and the other is how the walk is meant to go. +// +// Both messages are asserted in a fixture where each MUST sound, and each is asserted ABSENT from the +// other's: a line pinned only by its silence says nothing about the case where it is wrong, and these +// two are one `if` apart — the shape that made this branch silent about a database it could not reach +// for a whole claim grace. +func TestTheIntakeTellsALostRaceApartFromAStoreItCouldNotAsk(t *testing.T) { + f := newFixture(t) + var buf bytes.Buffer + f.svc.Log = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + // No template, so the intake's own pass reaches no verdict: the book is left `parsing` with its + // claim given back, which is the state another pass can take it in. + book := f.accept(t, "蛊真人.txt", "первая глава") + + // (1) Somebody else holds the claim. The ordinary race, and the only other claimant an upload can + // meet on this route: no job was enqueued for a book the intake cuts itself. + if _, err := f.store.ClaimParse(f.ctx, book.ID, f.now, f.now.Add(-ClaimGrace)); err != nil { + t.Fatal(err) + } + buf.Reset() + f.svc.Cfg.BookTemplate = "" // still no verdict to reach; what is asserted is which line is written + walk, cancel := f.svc.walk(f.ctx) + defer cancel() + if got := f.svc.cutNow(walk, book); got.err != nil { + t.Fatalf("losing the race was answered as a verdict about the file: %v", got.err) + } + lost := buf.String() + if !strings.Contains(lost, "did not get the parse claim") { + t.Errorf("losing the race said nothing: %s", lost) + } + if strings.Contains(lost, "could not be taken") { + t.Errorf("an ordinary race was reported as a store that could not be asked: %s", lost) + } + if strings.Contains(lost, `"level":"ERROR"`) { + t.Errorf("an ordinary race was logged at ERROR, which is a page for an operator who has nothing to do: %s", lost) + } + + // (2) The store could not be ASKED at all — here because the walk this cut belongs to is already + // over, which is what a request whose whole tail is spent looks like from inside cutNow. + buf.Reset() + f.svc.uploadSettle = time.Nanosecond + spent, cancelSpent := f.svc.walk(f.ctx) + defer cancelSpent() + <-spent.Done() + if got := f.svc.cutNow(spent, book); got.err != nil { + t.Fatalf("a store that could not be asked was answered as a verdict about the file: %v", got.err) + } + unreachable := buf.String() + if !strings.Contains(unreachable, "could not be taken") { + t.Errorf("a claim that could not be asked for said nothing, so the book sits `parsing` with no job and nobody knows: %s", unreachable) + } + if !strings.Contains(unreachable, `"level":"ERROR"`) { + t.Errorf("a store that could not be asked was not logged at ERROR: %s", unreachable) + } + if strings.Contains(unreachable, "did not get the parse claim") { + t.Errorf("a store that could not be asked was reported as an ordinary race, which needs no operator: %s", unreachable) + } +} + +// A cut that WAITS and then gets a slot says so, and a cut that never waited does not. +// +// The line was asserted only by its absence before, which is the vacuous half: a message pinned by +// silence alone is a message nothing defends where it matters. Here the waiter actually wins its +// slot — the holder lets go — so the line is one the fixture FORCES. +func TestACutThatWaitedAndThenGotASlotSaysSo(t *testing.T) { + f := newFixture(t) + templated(t, f) + f.svc.Cfg.MaxCuts = 1 + var buf bytes.Buffer + f.svc.Log = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + + hold, running := make(chan struct{}), make(chan struct{}) + var once sync.Once + f.engine.onManifest = func(context.Context) { + once.Do(func() { close(running) }) + <-hold + } + first := f.uploadAsync("held.txt") + <-running + second := f.uploadAsync("waited.txt") + waitUntil(t, "the second upload queued for the only slot", + func() string { return fmt.Sprintf("%+v", f.svc.CutCapacity()) }, + func() bool { return f.svc.CutCapacity().Waiting == 1 }) + // The holder lets go, so the waiter WINS rather than times out — the case the earlier fixture + // never reached, because there the waiter always ran out of budget. + close(hold) + if err := <-first; err != nil { + t.Fatalf("the upload that held the slot failed: %v", err) + } + if err := <-second; err != nil { + t.Fatalf("the upload that waited for a slot failed: %v", err) + } + if got := buf.String(); !strings.Contains(got, "a cut waited for one of the host's cut slots") { + t.Errorf("a cut that queued for a slot and got one said nothing, so an operator reading latency has no cause to read: %s", got) + } + + // And an idle host does not say it: a line that is always there tells nobody anything. + buf.Reset() + f.engine.onManifest = nil + if _, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "free.txt", File: strings.NewReader("первая глава\fвторая глава")}); err != nil { + t.Fatalf("an upload on an idle host failed: %v", err) + } + if got := buf.String(); strings.Contains(got, "waited for one of the host's cut slots") { + t.Errorf("an upload that never queued still reported a wait: %s", got) + } +} + +// A deployment with no engine hands the book to the queue in the row's OWN transaction, and runs no +// cut. This is the false branch of cutsItsOwnUploads, which nothing exercised: with every fixture +// carrying an engine, the predicate could be replaced by `true` and the whole battery stayed green. +func TestADeploymentWithNoEngineQueuesTheBookWithTheRowAndCutsNothing(t *testing.T) { + f := newFixture(t) + templated(t, f) + queue := &countingQueue{} + f.svc.Queue = queue + f.svc.Engine = nil + var buf bytes.Buffer + f.svc.Log = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + + book := f.accept(t, "蛊真人.txt", "первая глава\fвторая глава") + if book.Status != "parsing" { + t.Errorf("a deployment that cannot cut answered %q, want `parsing` for the queue to finish", book.Status) + } + if queue.n != 1 { + t.Errorf("the queue was handed %d jobs, want the one that goes in with the row: nothing else enqueues on this path", queue.n) + } + // ⚠ Counting jobs CANNOT tell the two apart, and that is why the log is read instead. With the + // predicate broken to «this deployment always cuts», the cut is attempted, stops at «no engine is + // configured», reaches no verdict — and its release enqueues the very same one job. The observable + // difference is that a cut was ATTEMPTED at all. + if got := buf.String(); strings.Contains(got, "the intake's own cut was not conclusive") { + t.Errorf("a deployment with no engine attempted a cut anyway: %s", got) + } + if _, claimed := f.parseState(t, book.ID); claimed { + t.Error("a book nobody cut carries a parse claim") + } + + // The positive control, in the same fixture: an engine that reaches no verdict DOES take the path + // above and DOES say so. Without it the assertion is satisfied by a log that never says anything. + buf.Reset() + f.svc.Engine = f.engine + f.svc.Cfg.BookTemplate = "" // no configuration to cut against: the pass reaches no verdict + second := f.accept(t, "второй.txt", "первая глава\fвторая глава") + if got := buf.String(); !strings.Contains(got, "the intake's own cut was not conclusive") { + t.Errorf("a deployment WITH an engine did not report its inconclusive cut, so the assertion above proves nothing: %s", got) + } + if queue.n != 2 { + t.Errorf("the second book got %d jobs in total, want one of its own", queue.n) + } + _ = second +} + +// The reason an unfinished intake carries is the one an operator greps for. Its VALUE is asserted, +// not just its presence: the constant could be renamed to anything and every other test stayed green. +func TestAnIntakeStoppedByTheCapNamesTheReasonInWordsAnOperatorCanFind(t *testing.T) { + f := newFixture(t) + templated(t, f) + f.svc.Cfg.MaxCuts = 1 + f.svc.uploadSettle = 300 * time.Millisecond + f.svc.writeBudget = 20 * time.Millisecond // the reserve follows it — see the fixture above + var buf bytes.Buffer + f.svc.Log = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + + hold, running := make(chan struct{}), make(chan struct{}) + var once sync.Once + f.engine.onManifest = func(context.Context) { + once.Do(func() { close(running) }) + <-hold + } + held := f.uploadAsync("held.txt") + <-running + if _, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "waited.txt", File: strings.NewReader("первая глава\fвторая глава")}); err != nil { + t.Fatalf("an upload that met a busy host was refused: %v", err) + } + // The LITERAL and not the constant. Asserting `strings.Contains(log, ReasonHostAtCapacity)` renames + // both sides at once and passes at any value — the tautology this assertion replaced, and the + // mutation that survived it. What is being defended is a word an operator's runbook and grep can + // hold still, so the word is written out here. + const word = "host_at_cut_capacity" + if ReasonHostAtCapacity != word { + t.Errorf("the reason is %q, want the stable %q that operators and the runbook grep for", ReasonHostAtCapacity, word) + } + if got := buf.String(); !strings.Contains(got, word) { + t.Errorf("the pass that did not cut carries no %q anywhere an operator would grep: %s", word, got) + } + close(hold) + <-held +} + +// An upload the host had no room to cut still leaves the book with somebody to finish it: the claim +// goes back and the job goes in, in the one transaction. +// +// ⛔ This is what the reserve buys, and it is the assertion the first edition of these tests left out +// — it checked only that the upload was ACCEPTED, which is true either way. Without the reserve the +// release is the step the spent walk truncates, and then the book sits `parsing`, claimed, with no +// job: nothing comes back for it until the backstop sweep's grace runs out, twenty minutes later, +// and every surface says the upload went fine. +func TestAnUploadTheHostCouldNotCutStillLeavesSomebodyToFinishTheBook(t *testing.T) { + f := newFixture(t) + templated(t, f) + queue := &countingQueue{} + f.svc.Queue = queue + f.svc.Cfg.MaxCuts = 1 + f.svc.uploadSettle = 300 * time.Millisecond + f.svc.writeBudget = 20 * time.Millisecond + + hold, running := make(chan struct{}), make(chan struct{}) + var once sync.Once + f.engine.onManifest = func(context.Context) { + once.Do(func() { close(running) }) + <-hold + } + held := f.uploadAsync("held.txt") + <-running + + book, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "waited.txt", File: strings.NewReader("первая глава\fвторая глава")}) + if err != nil { + t.Fatalf("an upload that met a busy host was refused: %v", err) + } + if book.Status != "parsing" { + t.Fatalf("the upload is %q, want `parsing`", book.Status) + } + attempts, claimed := f.parseState(t, book.ID) + if claimed { + t.Error("the intake's parse claim was NOT given back: the book sits `parsing`, claimed, with nobody to finish it until the claim grace runs out") + } + if queue.n != 1 { + t.Errorf("the queue was handed %d jobs for a book the intake could not cut, want the one that finishes it", queue.n) + } + if attempts != 0 { + t.Errorf("a book the engine was never asked about has spent %d attempts of its budget", attempts) + } + close(hold) + <-held +} + +// An upload whose walk cannot hold a cut AT ALL says so in those words, and still leaves the book to +// the queue. +// +// The branch is reachable only when what remains of the walk is shorter than the writes that must +// follow a cut, which no other fixture produces — and it is the branch that used to arrive as «the +// parse claim could not be taken», a diagnosis pointing at the store for a walk that had simply run +// out. A message that names the wrong thing is worse than none: it sends whoever reads it to the +// database. +func TestAnUploadWithNoRoomLeftForACutSaysThatAndHandsTheBookOver(t *testing.T) { + f := newFixture(t) + templated(t, f) + queue := &countingQueue{} + f.svc.Queue = queue + // The walk is SHORTER than the reserve three writes need, so no cut can be started at all. + f.svc.writeBudget = 25 * time.Millisecond + f.svc.uploadSettle = 60 * time.Millisecond + var buf bytes.Buffer + f.svc.Log = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + + book, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "late.txt", File: strings.NewReader("первая глава\fвторая глава")}) + if err != nil { + t.Fatalf("an upload with no room left for a cut was refused: %v", err) + } + if book.Status != "parsing" { + t.Errorf("the upload is %q, want `parsing` for the queue to finish", book.Status) + } + if f.engine.called() != 0 { + t.Errorf("a cut was started with no room for it (%d calls)", f.engine.called()) + } + got := buf.String() + if !strings.Contains(got, ReasonNoTimeToCut) { + t.Errorf("the pass does not say WHY it did not cut (%q): %s", ReasonNoTimeToCut, got) + } + if strings.Contains(got, "the parse claim could not be taken") { + t.Errorf("a walk that ran out was reported as a store that could not be asked, which sends an operator to the database: %s", got) + } + if queue.n != 1 { + t.Errorf("the queue was handed %d jobs, want the one that finishes a book nobody cut", queue.n) + } + if _, claimed := f.parseState(t, book.ID); claimed { + t.Error("the claim was not given back, so nothing comes for the book until the grace runs out") + } +} diff --git a/platform/internal/books/parse.go b/platform/internal/books/parse.go index 06b7f7b4..772a7b6f 100644 --- a/platform/internal/books/parse.go +++ b/platform/internal/books/parse.go @@ -18,17 +18,23 @@ import ( // walk itself, and every one of them is a number an operator would have to reason about the // reconciler to choose. const ( - // claimGrace is how long a parse may be somebody's business before another pass may take it. It + // ClaimGrace is how long a parse may be somebody's business before another pass may take it. It // covers the ordinary case — the queue job is claimed in milliseconds — and the failure it exists // for: the process holding the claim was restarted mid-parse. // + // Exported for the same reason UploadGrace below is, and against the same kind of configuration: + // the sweep's staleness for a `parsing` book falls back to `added_at` when no claim is stamped + // (pgstore.StuckIntake), and `added_at` is stamped when the row is created — BEFORE the body has + // arrived. So an upload allowed to take longer than this grace is one the sweep may claim while + // its own request is still walking, and the boot refuses that configuration outright. + // // ⚠ It MUST outlive the queue's own job timeout, and it is written as that constant plus a margin // so the two cannot drift apart. Shorter, the sweep steals the claim from a parse that is still // legitimately running: thief and holder meet on one project directory and the loser dies on the // engine's exclusive lock. That is no longer the data-loss it was — the lock has its own exit code // now (`project_locked`, ingest.ExitProjectLocked) and reads as the host's state rather than as a // verdict about the book — but it still spends an attempt of the budget on nothing. - claimGrace = jobs.JobTimeout + 5*time.Minute + ClaimGrace = jobs.JobTimeout + 5*time.Minute // UploadGrace is how long a book may stay `uploading`. A request that is still arriving holds the // row, so anything older than this is an upload whose request is gone — and that reasoning holds // only while the route's own read deadline is SHORTER. Exported so the boot can refuse a @@ -74,6 +80,17 @@ const ( // would otherwise reject every book it holds. It is also the state the deploy note's own step // exists to prevent (`tmplatformctl books --migratable`). ReasonSchemaMismatch = ingest.RejectSchemaMismatch + // ReasonHostAtCapacity is why a pass did nothing, and it is the one name here that is NOT a + // rejection reason: it never reaches a book's row. `reject` is the only writer of a reason, and + // the class that carries this one gives the claim back before any of that (parseClaimed, + // atCutCapacity) — so it exists for the operator's log and for the message an unfinished intake + // carries, and nothing stores it. + ReasonHostAtCapacity = "host_at_cut_capacity" + // ReasonNoTimeToCut is the other half of the same class and is stored no more than the one above: + // what remained of the upload's walk was less than a cut plus the writes that have to follow it, + // so no cut was started. Told apart from the cap because the remedies are opposite — one is a + // busier host than usual, the other a walk that had already spent itself. + ReasonNoTimeToCut = "no_time_to_cut" ) // ErrNotProvisioned is a book with no usable engine configuration and no way for this platform to @@ -117,7 +134,7 @@ var ErrStorageGone = errors.New("books: the book storage root is gone") // the engine holds exclusively. func (s *Service) Parse(ctx context.Context, bookID string) error { now := s.now() - claim, err := s.Store.ClaimParse(ctx, bookID, now, now.Add(-claimGrace)) + claim, err := s.Store.ClaimParse(ctx, bookID, now, now.Add(-ClaimGrace)) if errors.Is(err, pgstore.ErrParseClaimed) { return nil } @@ -163,7 +180,16 @@ func notConclusive(reason string) error { // (parseAttempts). At intake the uploader still holds the file, so being wrong costs a retry — which // is why the budget's reasoning does not reach here. func (s *Service) parseClaimed(ctx context.Context, claim pgstore.ParseClaim, atIntake bool) error { - m, err := s.manifest(ctx, claim) + // The reserve a cut must leave itself, and it is a property of WHO is asking. At intake nothing is + // at stake in giving up — no attempt is spent and the queue finishes the book — so waiting to the + // very end of the step is free. On the queue's own path an attempt IS at stake, so a cut is not + // started, and a slot is not waited for, unless what remains is what this platform calls a cut's + // worth of time. + reserve := CutBudget + if atIntake { + reserve = 0 + } + m, err := s.manifest(ctx, claim, reserve) if err == nil { // THE FLOOR, and it is FIRST for a reason that is the whole of it: below this line a document // that could not be read correctly is indistinguishable from a book with nothing in it, and @@ -265,6 +291,17 @@ func (s *Service) parseClaimed(ctx context.Context, claim pgstore.ParseClaim, at } return nil } + if engineNotAsked(err) { + // The engine was never asked, so this pass knows NOTHING about the book and records nothing: + // no attempt spent, no reason stored, and the claim handed straight back. That is the opposite + // of the deployment faults below, where waiting is the point — the cap is the host's state at + // this instant, and the next pass may find a slot a second later. + s.log().InfoContext(ctx, "the engine was not asked, so this pass does not cut", "err", err) + if atIntake { + return notConclusive(ReasonHostAtCapacity) + } + return s.giveBack(ctx, claim, err) + } if errors.Is(err, ErrStorageGone) { // The host cannot see its own storage. Nothing about this book is known yet, so it waits with // the budget untouched — exactly like a book waiting for a configuration. @@ -305,6 +342,27 @@ func (s *Service) parseClaimed(ctx context.Context, claim pgstore.ParseClaim, at return s.defer_(ctx, claim, reason) } +// giveBack ends a pass that established NOTHING and hands the claim straight back, so the next pass +// can take the book at once instead of after the grace that exists for a process which DIED holding +// it. The attempt goes back with the claim (ReleaseParseClaim), because nothing was tried. +// +// No job is enqueued with it, unlike the intake's own release, and the reason is that the job this +// pass is running must COME BACK rather than be replaced: the error is wrapped in jobs.ErrTryAgainLater, +// and the worker turns that into a snooze, which does not spend the single attempt this queue's +// policy allows (jobs.InsertOpts, jobs.RetryDelay). Enqueueing a second job here would be one more +// job per busy moment at the same book. +// +// ⚠ The sweep is what catches the case where nothing comes back at all — a pass that is not a queue +// job (books.Sweep) simply logs and moves on, and the book is offered again once its claim goes stale. +func (s *Service) giveBack(ctx context.Context, claim pgstore.ParseClaim, cause error) error { + w, cancel := s.writeCtx(ctx) + defer cancel() + if err := s.Store.ReleaseParseClaim(w, claim.BookID, claim.At, nil); err != nil { + s.log().ErrorContext(ctx, "the parse claim could not be given back; the backstop sweep takes the book", "err", err) + } + return fmt.Errorf("%w: %w", jobs.ErrTryAgainLater, cause) +} + // defer_ spends one attempt of the budget and, when the budget is gone, ends the intake. // // ONE path for every way a parse can fail, and the reason has outlived the defect that produced it. @@ -362,45 +420,135 @@ func waitsForTheDeployment(reason string) bool { return false } -// writeCtx is the context a TERMINAL write uses: detached from the caller's deadline and bounded on -// its own. +// walkKey carries the deadline of an upload's tail to every step taken under it. // -// The engine call this follows can legitimately consume the whole budget of the pass — and then the -// write that records what happened would run on an already-expired context and be lost, leaving the -// book to be retried and the attempt to be spent again, forever. What must survive is the record. -func (s *Service) writeCtx(ctx context.Context) (context.Context, context.CancelFunc) { - budget := writeBudget - if s.writeBudget > 0 { - budget = s.writeBudget // tests shorten it; nothing outside this package can set it +// A context value rather than a parameter because the steps are not all in this function or in this +// file: the cut is shared with the queue's worker, and a bound only the callers who remember to pass +// it are subject to is the hand-written list this replaces. +type walkKey struct{} + +// walk opens the ONE budget an upload's tail is spent from, and marks it so every step taken under +// it — including steps nobody has written yet — is bounded by the same deadline. +// +// Detached from the request, because the tail must outlive it: every byte is in, and a client that +// hung up while waiting for the 201 must not cost the upload it already finished. Bounded, because +// detached is not unlimited and this is the number the boot compared against the windows an upload +// has to finish inside (internal/config). +func (s *Service) walk(ctx context.Context) (context.Context, context.CancelFunc) { + // UploadSettle is the WHOLE tail and part of it happens after this package is done: the receipt + // is written by the HTTP surface once Accept has returned (ReceiptBudget). Taking the whole of it + // here would put the walk's end exactly one receipt past the number the boot was promised, which + // is the same off-by-a-step this walk exists to make impossible. + budget := UploadSettle - ReceiptBudget + if s.uploadSettle > 0 { + budget = s.uploadSettle // tests shorten it; nothing outside this package can set it } - return context.WithTimeout(context.WithoutCancel(ctx), budget) + // The wall clock and not s.Now: this deadline is compared by the context package against its own + // clock, and a test that freezes the injected one would otherwise open a walk already over. + deadline := time.Now().Add(budget) + c, cancel := context.WithDeadline(context.WithoutCancel(ctx), deadline) + return context.WithValue(c, walkKey{}, deadline), cancel +} + +// step is the context ONE action of an intake runs on: its own budget, and never past the deadline +// of the walk it belongs to. +// +// It does two things and both are load-bearing. It DETACHES from the caller's cancellation, because +// the engine call a terminal write follows can legitimately consume everything the step before it +// had — and a write that then runs on an already-expired context is a record that happened and was +// not written down, leaving the book to be retried and the attempt spent again. And it CAPS at the +// walk's own deadline, so what a step escapes is the budget of the step before it and never the +// budget of the whole tail. +// +// That second half is what makes UploadSettle true for any number of steps: a step added below this +// line costs latency inside the tail and cannot move its end. +// +// Outside a walk — the queue's worker and the backstop sweep, whose budgets are their own — there is +// no deadline to cap against, and this is the detached, self-bounded write it has always been. +func (s *Service) step(ctx context.Context, budget time.Duration) (context.Context, context.CancelFunc) { + return s.stepLeaving(ctx, budget, 0) +} + +// stepLeaving is step for an action that must not spend what the rest of the walk still needs. +// +// ⛔ The reserve exists because the two ways a short walk can end are NOT equal, and without it the +// wrong one happens. A cut that is given less time ends as «no verdict», and the queue finishes the +// book — a degradation this intake is built around. A terminal WRITE that is given less time leaves +// the book claimed with no job, and nothing comes back for it until the backstop sweep's grace runs +// out: twenty minutes, for a book whose only misfortune was arriving late in a walk. So the slack is +// taken out of the cut and never out of the writes that record what the cut found. +// +// It is the same rule the intake sweep and the materializer already follow — do not START work the +// remaining budget cannot hold (Sweep, readmodel.Drain) — applied one level down, to the steps of +// one upload rather than to the books of one pass. +func (s *Service) stepLeaving(ctx context.Context, budget, reserve time.Duration) (context.Context, context.CancelFunc) { + deadline := time.Now().Add(budget) + if walk, ok := ctx.Value(walkKey{}).(time.Time); ok { + if keep := walk.Add(-reserve); keep.Before(deadline) { + deadline = keep + } + } + return context.WithDeadline(context.WithoutCancel(ctx), deadline) +} + +// cutTailReserve is what the walk must still hold for the steps that FOLLOW a cut: the write that +// records what it found, the release that follows a write which failed, and the re-read the response +// is built from. See stepLeaving for why the cut is the step that gives way. +// +// Derived from the write budget IN FORCE rather than from the constant, and that is not a nicety: a +// test shortens the write budget to reach cases a thirty-second one cannot, and a reserve pinned to +// the constant would then be three real writes' worth of a walk measured in milliseconds — that is, +// larger than the whole walk, so no cut would ever run and the fixture would silently model nothing. +func (s *Service) cutTailReserve() time.Duration { return 3 * s.write() } + +// write is the budget one terminal write gets: the constant, or what a test shortened it to. +func (s *Service) write() time.Duration { + if s.writeBudget > 0 { + return s.writeBudget // tests shorten it; nothing outside this package can set it + } + return writeBudget +} + +// writeCtx is the context a TERMINAL write uses. +func (s *Service) writeCtx(ctx context.Context) (context.Context, context.CancelFunc) { + return s.step(ctx, s.write()) } // writeBudget is what a terminal write gets. Short: it is one statement against a database this // process is already connected to. const writeBudget = 30 * time.Second -// UploadSettle is the worst case of everything an upload still does once its body has arrived, and -// the boot leaves room for it (internal/config): the upload's deadline bounds the BODY, and the -// windows an upload must finish inside bound all of it. +// UploadSettle is what an upload's tail gets once its body has arrived: the ONE deadline every step +// after the last byte runs under (walk), and the room the boot leaves for it (internal/config). // -// The steps, in order and by the budget each actually gets, because the sum is only as right as this -// list — and an earlier edition of it dropped the first one: +// It is an ALLOWANCE and not a sum, and that difference is the whole of this constant's history. +// Three editions of it were a hand-written list of the steps it covers, and all three were short — +// one dropped a step, one called two steps alternatives where the code runs both, and each was found +// by the overrun rather than by the list. A list has to be re-derived by whoever adds a step, and +// three times running nobody did. The walk caps every step at what is left of this deadline instead: +// the tail cannot outlive the number regardless of how many steps it grows, so what an added step +// costs is latency inside the tail and never the boundary the boot was promised. // -// 1. StartParsing, which moves the book out of `uploading` — writeBudget; -// 2. the synchronous cut — CutBudget; -// 3. whichever end the cut reaches (FinishParse, ReleaseParseClaim or DeleteRefusedIntake) — writeBudget; -// 4. the re-read that gives the response the row as it stands — writeBudget; -// 5. the idempotency receipt the HTTP surface writes (httpapi.settleCtx, 10s), which is shorter -// than a writeBudget and is covered by the fourth one here. +// The SIZE is still chosen and not arbitrary: it is every step's own budget summed, so that in the +// worst ordinary case none of them is truncated and the cap never bites. The terms are the cut, the +// four terminal writes of the walk's longest chain — StartParsing, the end the cut reaches, the +// release that follows an end which FAILED, and the re-read — and the receipt. Running out of it +// anyway is not a failed upload: a step that finds nothing left leaves the book `parsing`, which the +// backstop sweep finishes, the same degradation an overrun cut already has. // -// None of them is bounded BY another: every one takes a detached context of its own, so the worst -// case is the sum. +// ⚠ The sum is written out and it is NOT what makes this constant true; the walk is, and that +// difference is the whole lesson of PD-464. What the SUM buys is that no step is cut short in the +// ordinary case. What the WALK buys is that the tail ends here even when the sum is wrong again. // -// The reading surface is deliberately NOT in this list: materializing it runs the engine twice more +// It covers the tail END TO END, including the part that is not this package's: the receipt is a +// TERM, and the walk takes this number minus it, because the receipt is written after Accept has +// returned. The edition before this one stopped at the fourth write, and was short by exactly one +// receipt — the third miss in a row, and the last one the sum can make on its own. +// +// The reading surface is deliberately not in it: materializing runs the engine twice more // (readmodel.MaterializeBudget), and the intake leaves that debt to the materializer's sweep rather // than making an uploader wait for it (parseClaimed, the `!atIntake` guard). -const UploadSettle = CutBudget + 4*writeBudget +const UploadSettle = CutBudget + 4*writeBudget + ReceiptBudget // CutBudget bounds the cut an upload waits for. Past it the book is accepted `parsing` and the queue // finishes the job, so overrunning costs a less informative response and never a failed upload. @@ -410,9 +558,18 @@ const UploadSettle = CutBudget + 4*writeBudget // past the window in which its key can still be replayed. const CutBudget = 90 * time.Second +// ReceiptBudget is the share of UploadSettle that belongs to the HTTP surface rather than to this +// package: the idempotency receipt, written once Accept has returned (httpapi.settleCtx). +// +// Declared HERE and used there, rather than written twice. It is a term of the sum the boot compares +// against the windows an upload must finish inside, and a second copy of it in the package that +// actually spends it is a copy that can drift — which is how every earlier edition of UploadSettle +// came to be short. +const ReceiptBudget = 10 * time.Second + // manifest asks the engine to cut the book, once its configuration is there to cut it against — // rendering that configuration first, if this deployment carries a template (form Б, D39.130). -func (s *Service) manifest(ctx context.Context, claim pgstore.ParseClaim) (ingest.Manifest, error) { +func (s *Service) manifest(ctx context.Context, claim pgstore.ParseClaim, reserve time.Duration) (ingest.Manifest, error) { if s.Engine == nil { return ingest.Manifest{}, errors.New("books: no engine is configured") } @@ -437,6 +594,14 @@ func (s *Service) manifest(ctx context.Context, claim pgstore.ParseClaim) (inges }); err != nil { return ingest.Manifest{}, err } + // Under the host's cap, and taken HERE rather than in any of the three callers: this is the one + // line all of them reach the engine through, and a cap on a route leaves the other routes outside + // it (limit.go). + release, err := s.takeCutSlot(ctx, reserve) + if err != nil { + return ingest.Manifest{}, err + } + defer release() return s.Engine.Manifest(ctx, s.Cfg.EngineBinary, workdir) } @@ -523,7 +688,7 @@ func (s *Service) reject(ctx context.Context, claim pgstore.ParseClaim, reason s // One book's failure never stops the sweep: these are independent books of independent accounts. func (s *Service) Sweep(ctx context.Context) error { now := s.now() - stuck, err := s.Store.StuckIntake(ctx, now.Add(-UploadGrace), now.Add(-claimGrace)) + stuck, err := s.Store.StuckIntake(ctx, now.Add(-UploadGrace), now.Add(-ClaimGrace)) if err != nil { return err } diff --git a/platform/internal/books/render_test.go b/platform/internal/books/render_test.go index 07d75017..627aed76 100644 --- a/platform/internal/books/render_test.go +++ b/platform/internal/books/render_test.go @@ -252,7 +252,7 @@ func TestARenderedConfigurationIsNeverRewritten(t *testing.T) { } f.engine.set(wholeManifest(3, 1), nil) // Another walk of the same book — a re-parse after the claim went stale. - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } @@ -292,7 +292,7 @@ func TestABrokenTemplateStopsNoIntakeAndSpendsNoBudget(t *testing.T) { if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) } if got := f.card(t, book.ID); got.Status != "parsing" { t.Fatalf("a book on a host with a broken template is %q, want parsing", got.Status) @@ -314,7 +314,7 @@ func TestABrokenTemplateStopsNoIntakeAndSpendsNoBudget(t *testing.T) { t.Fatal(err) } f.engine.set(wholeManifest(3, 1), nil) - f.now = f.now.Add(claimGrace + time.Minute) + f.now = f.now.Add(ClaimGrace + time.Minute) if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) } diff --git a/platform/internal/books/walk_test.go b/platform/internal/books/walk_test.go new file mode 100644 index 00000000..d79eb323 --- /dev/null +++ b/platform/internal/books/walk_test.go @@ -0,0 +1,154 @@ +package books + +import ( + "context" + "testing" + "time" +) + +// The tail of an upload cannot outlive UploadSettle, whatever it does on the way there. +// +// The PROPERTY and not a sum of the steps, and that is the whole of the form. The sum was derived by +// hand three times and was short all three, each time for a different reason (register row PD-464); a +// test that re-listed the steps would be the fourth hand-derivation and would hold for exactly as +// long as somebody remembered to update it. What is asserted here survives steps nobody has written +// yet, which is why the fourth case takes fifty of them. +func TestNoStepOfAnUploadsTailOutlivesTheWalk(t *testing.T) { + // THREE different numbers on purpose: in a fixture where the walk, a step's own budget and what a + // step asks for coincide, "capped by the walk" and "given what it asked for" are the same + // observation, and the whole class of differences between them goes invisible (D39.208 §5). + const walkBudget, writeShort, asksForever = 400 * time.Millisecond, 90 * time.Millisecond, time.Hour + s := &Service{writeBudget: writeShort, uploadSettle: walkBudget} + walk, cancel := s.walk(context.Background()) + defer cancel() + end, ok := walk.Deadline() + if !ok { + t.Fatal("the walk carries no deadline, so nothing taken under it is bounded by anything") + } + + // A step that asks for more than the walk has left gets the walk's end and not what it asked for. + long, cancelLong := s.step(walk, asksForever) + defer cancelLong() + if d, _ := long.Deadline(); !d.Equal(end) { + t.Errorf("a step that asked for %s ends at %s, want the walk's own end %s: the walk is not capping it", + asksForever, d.Format(time.StampMilli), end.Format(time.StampMilli)) + } + + // A step that asks for less keeps its own budget: the cap is a ceiling, not a replacement. + short, cancelShort := s.step(walk, writeShort) + defer cancelShort() + if d, _ := short.Deadline(); !d.Before(end) { + t.Errorf("a step that asked for %s ends at %s, at or past the walk's %s: it was given the ceiling instead of its budget", + writeShort, d.Format(time.StampMilli), end.Format(time.StampMilli)) + } + + // FIFTY steps, each asking for an hour, each taken under the one before it. This is the property + // the form exists for: the number of steps in the tail is not a term of where the tail ends. + under := walk + for i := range 50 { + next, cancelNext := s.step(under, asksForever) + defer cancelNext() + if d, _ := next.Deadline(); d.After(end) { + t.Fatalf("step %d of the tail ends at %s, past the walk's %s: adding a step moves the boundary", + i, d.Format(time.StampMilli), end.Format(time.StampMilli)) + } + under = next + } + + // A terminal write taken after the step before it is SPENT is still usable — that is the whole + // reason a step detaches at all: the engine call may legitimately consume everything, and the + // record of what happened must still be written. It is capped by the walk all the same. + spent, cancelSpent := s.step(walk, time.Nanosecond) + defer cancelSpent() + <-spent.Done() + after, cancelAfter := s.step(spent, writeShort) + defer cancelAfter() + if err := after.Err(); err != nil { + t.Errorf("a write taken after an exhausted step was born cancelled (%v): what happened would go unrecorded", err) + } + if d, _ := after.Deadline(); d.After(end) { + t.Errorf("a write taken after an exhausted step ends at %s, past the walk's %s", + d.Format(time.StampMilli), end.Format(time.StampMilli)) + } +} + +// Outside a walk — the queue's worker and the backstop sweep — a step is the detached, self-bounded +// write it has always been: their budgets are their own, and there is no upload waiting on them. +func TestAStepOutsideAWalkKeepsItsOwnBudgetAndSurvivesItsCaller(t *testing.T) { + const writeShort = 90 * time.Millisecond + s := &Service{writeBudget: writeShort} + parent, cancelParent := context.WithCancel(context.Background()) + free, cancelFree := s.step(parent, writeShort) + defer cancelFree() + cancelParent() + if err := free.Err(); err != nil { + t.Errorf("a write outside a walk died with its caller (%v): a job whose deadline is spent would lose its record", err) + } + d, ok := free.Deadline() + if !ok { + t.Fatal("a write outside a walk got no deadline: detached is not the same as unlimited") + } + if left := time.Until(d); left > writeShort { + t.Errorf("a write outside a walk got %s, want no more than its own %s", left, writeShort) + } +} + +// UploadSettle covers the tail END TO END, and part of it is spent after this package is done: the +// idempotency receipt is written once Accept has returned. So the walk takes the settle budget MINUS +// the receipt's share — otherwise the tail ends exactly one receipt past the number the boot compared +// against the windows an upload has to finish inside, which is the shape of every earlier miss. +func TestTheWalkLeavesTheReceiptItsShareOfTheSettleBudget(t *testing.T) { + s := &Service{} + before := time.Now() + walk, cancel := s.walk(context.Background()) + defer cancel() + end, ok := walk.Deadline() + if !ok { + t.Fatal("the walk carries no deadline") + } + // `before` is read a hair earlier than the walk stamps its deadline, so the reading is the budget + // plus scheduling slack and never less than it. + got, want := end.Sub(before), UploadSettle-ReceiptBudget + if got < want || got > want+time.Second { + t.Errorf("the walk runs for about %s, want UploadSettle (%s) less the receipt's share (%s) = %s", + got, UploadSettle, ReceiptBudget, want) + } +} + +// The cut an upload waits for runs on the WALK's deadline, not on a budget of its own. +// +// Asserted at the real call site and by the deadline the engine is HANDED, rather than by a stopwatch +// around the request: the question is which budget bounds the cut, and a clock answers it only on a +// machine that happened to be slow enough. A cut detached from the walk — the shape this replaced — +// hands the engine CutBudget here, three orders of magnitude past the walk it is inside. +func TestTheCutOfAnUploadIsBoundedByTheWalkAndNotByItsOwnBudget(t *testing.T) { + f := newFixture(t) + templated(t, f) + const tail, write = 700 * time.Millisecond, 20 * time.Millisecond + f.svc.uploadSettle = tail + // Shortened with the walk, because the reserve a cut leaves for the writes after it is three of + // these: against the real thirty seconds a 700 ms walk holds no cut at all, and the fixture would + // then be asserting about a cut that never ran (stepLeaving). + f.svc.writeBudget = write + var granted time.Duration + var bounded bool + f.engine.onManifest = func(ctx context.Context) { + d, ok := ctx.Deadline() + bounded = ok + granted = time.Until(d) + } + f.accept(t, "蛊真人.txt", "первая глава\fвторая глава") + if f.engine.called() != 1 { + t.Fatalf("the engine was asked %d times, so there is no granted deadline to judge", f.engine.called()) + } + if !bounded { + t.Fatal("the cut ran with no deadline at all") + } + if granted <= 0 || granted > tail { + t.Errorf("the cut was granted %s inside a %s walk, want a slice of the walk", granted, tail) + } + if granted >= CutBudget { + t.Errorf("the cut was granted %s, its own CutBudget (%s): it is detached from the walk it runs inside", + granted, CutBudget) + } +} diff --git a/platform/internal/config/config.go b/platform/internal/config/config.go index 06e40628..a75579fc 100644 --- a/platform/internal/config/config.go +++ b/platform/internal/config/config.go @@ -15,6 +15,7 @@ import ( "textmachine/platform/internal/books" "textmachine/platform/internal/exports" + "textmachine/platform/internal/jobs" "textmachine/platform/internal/login" "textmachine/platform/internal/money" "textmachine/platform/internal/pgstore" @@ -257,6 +258,12 @@ type IntakeConfig struct { // against the template's. Empty means this deployment provisions books by hand, which is what // every deployment did before the form was ratified. BookTemplate string + // MaxCuts caps how many books this host lets the engine cut at once, counting every way of + // starting one. A SIZING and not a refusal, unlike MaxUploadBytes: an upload that meets the cap + // waits for a slot and is accepted `parsing` if none frees up, so raising this buys latency at + // the cost of memory. Zero takes the default (books.DefaultMaxCuts), which is the queue's own + // worker count. + MaxCuts int } // LanguagePair is one translation direction this deployment knows about. @@ -552,7 +559,7 @@ func (c *Config) loadRunner(l *loader) error { RunBudget: 60 * time.Second, ResyncEvery: 5 * time.Minute, HoldFactorPercent: pricing.DefaultHoldFactorPercent, - Workers: 4, + Workers: jobs.DefaultWorkers, } // An ABSOLUTE state directory or none. The exit marker is written by a systemd unit whose // WorkingDirectory is the BOOK's directory and read by this daemon from its own working directory, @@ -698,6 +705,7 @@ func (c *Config) loadIntake(l *loader) error { // promises — the numbers an operator raises when their users hit them. MaxUploadBytes: 64 << 20, UploadDeadline: 10 * time.Minute, + MaxCuts: books.DefaultMaxCuts, } if in.BooksDir != "" && !filepath.IsAbs(in.BooksDir) { return fmt.Errorf("config: TM_PLATFORM_BOOKS_DIR must be an absolute path, got %q", in.BooksDir) @@ -716,24 +724,51 @@ func (c *Config) loadIntake(l *loader) error { if in.UploadDeadline, err = l.duration("TM_PLATFORM_UPLOAD_DEADLINE", in.UploadDeadline); err != nil { return err } + // Zero and negatives are refused by the READER (l.number), which is where every count of this + // configuration is refused — and the refusal matters here rather than being a formality: the two + // readings of zero are opposite, and taking it as "no limit" would give a deployment that meant + // "cut nothing" an unbounded one. A second check here would be unreachable, and an unreachable + // guard is a guard no test can defend. + if in.MaxCuts, err = l.number("TM_PLATFORM_MAX_CUTS", in.MaxCuts); err != nil { + return err + } // An upload has to FINISH inside two windows, and the boot is where a deployment that breaks // either is cheap to find. The intake sweep treats a book that has been `uploading` longer than // its grace as an upload whose request is gone, and deletes the row and the directory under a // request still writing into them; past the claim window a retry takes the idempotency claim from // an upload that is still running, and the user gets the second book the key exists to prevent. // - // Against the TIGHTER of the two rather than against each: written as two checks, whichever is + // Against the TIGHTEST of them rather than against each: written as separate checks, whichever is // looser could never fire, and the pin that was supposed to cover it was passing on the other one. // And the deadline is only the BODY — what an upload still has to do afterwards is added here, // or a 29m59s deadline settled its key a moment after the claim became stealable. - if window := min(books.UploadGrace, pgstore.ClaimStale); in.UploadDeadline+books.UploadSettle >= window { - return fmt.Errorf("config: TM_PLATFORM_UPLOAD_DEADLINE (%s) plus what follows an upload (%s) must fit inside the tighter of the intake sweep's grace (%s) and the idempotency claim window (%s)", - in.UploadDeadline, books.UploadSettle, books.UploadGrace, pgstore.ClaimStale) + // + // THREE windows and not two, and the third is the parse claim's. A `parsing` book with no claim + // stamped on it is stale to the sweep by `added_at` (pgstore.StuckIntake), which is written when + // the row is created and therefore BEFORE the body has arrived — so an upload allowed to outlast + // books.ClaimGrace is one the sweep may claim while the request is still walking, and the two of + // them then race for the same book. It is the loosest-looking of the three and the tightest in + // fact: 20 minutes against 30 and 60. + if window := intakeWindow(books.UploadGrace, books.ClaimGrace, pgstore.ClaimStale); in.UploadDeadline+books.UploadSettle >= window { + return fmt.Errorf("config: TM_PLATFORM_UPLOAD_DEADLINE (%s) plus what follows an upload (%s) must fit inside the tightest of the intake sweep's grace (%s), the parse claim's grace (%s) and the idempotency claim window (%s)", + in.UploadDeadline, books.UploadSettle, books.UploadGrace, books.ClaimGrace, pgstore.ClaimStale) } c.Intake = in return nil } +// intakeWindow is the tightest of the windows an upload's whole walk has to finish inside. +// +// A named function and not an inline `min`, and the reason is not readability — it is that the risk +// here is a TERM GOING MISSING, and today's constants cannot expose one. Whichever window is tightest +// hides the other two: with the parse claim's grace at 20 minutes against 30 and 60, a gate written +// against that one alone refuses exactly the same deployments, and every case built from the real +// constants passes either way. Choosing here lets a test hand it values that make each window the +// tightest in turn — which is the only shape that catches a term nobody consulted. +func intakeWindow(sweepGrace, parseClaimGrace, idempotencyClaim time.Duration) time.Duration { + return min(sweepGrace, parseClaimGrace, idempotencyClaim) +} + // loader reads the environment and REMEMBERS what it read, so that the boot line can say where each // value came from. Every setting of this service goes through it: a print that covers most of the // configuration is worse than none, because the variable an operator is hunting is exactly the one diff --git a/platform/internal/config/effective_test.go b/platform/internal/config/effective_test.go index e03b29f2..d1dc54b8 100644 --- a/platform/internal/config/effective_test.go +++ b/platform/internal/config/effective_test.go @@ -281,32 +281,44 @@ func TestARelativeBooksDirectoryIsRefusedAtBoot(t *testing.T) { } } -// The upload deadline is bounded by two windows, and a deployment that breaks either loses data: -// past the intake sweep's grace the sweep deletes the row and the directory out from under a request -// still writing into them; past the idempotency claim window a retry takes the claim from an upload -// that is still running and the user gets a second book. +// The upload deadline is bounded by THREE windows, and a deployment that breaks any of them loses +// something: past the intake sweep's grace the sweep deletes the row and the directory out from under +// a request still writing into them; past the parse claim's grace the sweep claims the parse of a +// book whose own upload is still walking, and the two race for it; past the idempotency claim window +// a retry takes the claim from an upload that is still running and the user gets a second book. // -// ⚠ The boundary is pinned to the EXACT value, and that is the whole of this test. The window that -// binds today is the claim's, so any deadline over the sweep's grace is over it too — the earlier -// form asserted two over-long values and both were caught by the same check, so dropping the other -// one changed nothing. And "under the window" is not enough: what an upload does AFTER its body has -// to fit as well. -func TestAnUploadDeadlineIsRefusedUnlessTheWholeUploadFitsTheTighterWindow(t *testing.T) { +// ⚠ The boundary is pinned to the EXACT value. What this test does NOT do — said here because an +// earlier edition of this very comment claimed it did — is tell the three windows apart: the parse +// claim's grace is the tightest of them today (20 minutes against 30 and 60), so every case below is +// refused by that one term, and deleting either of the other two from the gate leaves this test +// green. Their coverage lives in TestEveryWindowAnUploadMustFitInsideIsActuallyConsulted, which can +// make each of them tightest in turn because it does not have to use the real constants. And "under +// the window" is not enough either: what an upload does AFTER its body has to fit as well. +func TestAnUploadDeadlineIsRefusedUnlessTheWholeUploadFitsTheTightestWindow(t *testing.T) { t.Setenv("TM_PLATFORM_LANGUAGE_PAIRS", "zh>ru") t.Setenv("TM_PLATFORM_BOOKS_DIR", "/srv/tm/books") t.Setenv("TM_PLATFORM_ENGINE_BIN", "/opt/engine/2026.08.01/tmctl") - window := min(books.UploadGrace, pgstore.ClaimStale) - for _, d := range []time.Duration{ - books.UploadGrace + time.Minute, - pgstore.ClaimStale + time.Minute, - // Inside the window and still wrong: the body ends a second before the claim is stealable and - // the receipt is written after it. - window - time.Second, - window - books.UploadSettle, + window := min(books.UploadGrace, books.ClaimGrace, pgstore.ClaimStale) + for _, c := range []struct { + window string + deadline time.Duration + }{ + {"the intake sweep's grace", books.UploadGrace + time.Minute}, + {"the idempotency claim window", pgstore.ClaimStale + time.Minute}, + // The case that matters for what this pack changed, even though the term above would also + // catch it: at 21 minutes the sweep sees a book whose `added_at` — the stamp it falls back to, + // written before the body arrived — is older than the claim's grace, while the upload that + // created it is still walking. + {"the parse claim's grace", books.ClaimGrace + time.Minute}, + // Inside the tightest window and still wrong: the body ends a second before the claim is + // stealable, and everything the upload does afterwards happens after that. + {"the tightest window, by one second", window - time.Second}, + {"the tightest window, once the tail is counted", window - books.UploadSettle}, } { - t.Setenv("TM_PLATFORM_UPLOAD_DEADLINE", d.String()) + t.Setenv("TM_PLATFORM_UPLOAD_DEADLINE", c.deadline.String()) if _, err := Load(); err == nil { - t.Errorf("an upload deadline of %s was accepted, though the whole upload does not fit %s", d, window) + t.Errorf("an upload deadline of %s was accepted, though the whole upload does not fit %s (tightest: %s)", + c.deadline, c.window, window) } } t.Setenv("TM_PLATFORM_UPLOAD_DEADLINE", (window - books.UploadSettle - time.Second).String()) @@ -315,6 +327,39 @@ func TestAnUploadDeadlineIsRefusedUnlessTheWholeUploadFitsTheTighterWindow(t *te } } +// The cap on concurrent cuts is a number an operator sets, it defaults to the figure the host was +// sized for, and zero is refused rather than taken as "no limit" — an intake that cuts nothing would +// accept every book `parsing` and look like it is working. +// +// ⚠ The refusal comes from the READER of counts (`loader.number`, which rejects every non-positive +// value), not from a check of the intake's own. An earlier edition of this pack added a second check +// beside it and this test appeared to pin it; it could not, because the first refusal fires and the +// second line is unreachable. The behaviour is what is pinned here, not the layer. +func TestTheCapOnConcurrentCutsIsConfiguredAndRefusesAnEmptyOne(t *testing.T) { + t.Setenv("TM_PLATFORM_LANGUAGE_PAIRS", "zh>ru") + t.Setenv("TM_PLATFORM_BOOKS_DIR", "/srv/tm/books") + t.Setenv("TM_PLATFORM_ENGINE_BIN", "/opt/engine/2026.08.01/tmctl") + c, err := Load() + if err != nil { + t.Fatalf("the default configuration was refused: %v", err) + } + if c.Intake.MaxCuts != books.DefaultMaxCuts { + t.Errorf("an unset cap gave %d cuts, want the package default %d", c.Intake.MaxCuts, books.DefaultMaxCuts) + } + // Not the default, and not any other number in this deployment: a value the operator chose has to + // arrive as the value the operator chose. + t.Setenv("TM_PLATFORM_MAX_CUTS", "7") + if c, err = Load(); err != nil || c.Intake.MaxCuts != 7 { + t.Fatalf("a configured cap of 7 gave %d (err %v)", c.Intake.MaxCuts, err) + } + for _, v := range []string{"0", "-1"} { + t.Setenv("TM_PLATFORM_MAX_CUTS", v) + if _, err := Load(); err == nil { + t.Errorf("a cap of %s was accepted, so this deployment would cut nothing and say nothing", v) + } + } +} + // An intake with no declared pairs makes the wire and the intake say opposite things. func TestAnIntakeWithNoDeclaredPairsIsRefusedAtBoot(t *testing.T) { t.Setenv("TM_PLATFORM_BOOKS_DIR", "/srv/tm/books") @@ -334,3 +379,26 @@ func TestAnIntakeWithNoDeclaredPairsIsRefusedAtBoot(t *testing.T) { t.Fatalf("a declared available pair was refused: %v", err) } } + +// Every window the gate is supposed to weigh is actually weighed. +// +// Built from values rather than from the real constants, and that is the whole point: with the real +// ones the tightest window hides the other two, so a term deleted from the choice changes nothing any +// boot-level case can observe (see the comment above). Here each window is made the tightest in turn, +// with the other two far enough away that only the intended one can produce the answer. +func TestEveryWindowAnUploadMustFitInsideIsActuallyConsulted(t *testing.T) { + const tight, loose, looser = 5 * time.Minute, time.Hour, 2 * time.Hour + for _, c := range []struct { + window string + sweepGrace, parseClaim, idempotencyKey time.Duration + }{ + {"the intake sweep's grace", tight, loose, looser}, + {"the parse claim's grace", loose, tight, looser}, + {"the idempotency claim window", loose, looser, tight}, + } { + if got := intakeWindow(c.sweepGrace, c.parseClaim, c.idempotencyKey); got != tight { + t.Errorf("with %s the tightest at %s, the gate would hold an upload to %s: that window is not consulted at all", + c.window, tight, got) + } + } +} diff --git a/platform/internal/httpapi/capabilities.go b/platform/internal/httpapi/capabilities.go index 96ed84f0..a633d2c2 100644 --- a/platform/internal/httpapi/capabilities.go +++ b/platform/internal/httpapi/capabilities.go @@ -23,17 +23,20 @@ import "net/http" // about members the wire already served. Prose and errata do not move a number; a served shape does. // And 0.11.0 already has its acceptance act (D39.208), which closes it. See the pack's report. // -// ⚠ THE GATE IS RED WHILE THE CANON READS 0.11.0, and that is the ratified order and its true cause: -// the CODE lands first and the canon follows in the second act. The reverse was refused deliberately -// — errata 04.09-в is the day the canon moved first and the wire spent a working day announcing a -// version it did not serve. +// ⚠ THE GATE IS RED BETWEEN THE TWO LANDINGS, and which side is ahead says which of the two orders +// is being followed. The ratified one is CODE FIRST: this constant is raised with the change that +// implements the minor, the gate goes red because the canon still reads the EARLIER version, and the +// canon's own landing clears it. The reverse — canon first, code behind, the gate red because the +// canon reads a LATER minor — is refused deliberately, because in that window the wire announces a +// version it does not serve: errata 04.09-в is the day that cost a working day, and 0.13.0 is the day +// it happened again and stood for a full day, until the next session of this zone read its battery. // // ⛔ AND WHAT THE GATE CANNOT SEE, said here because this constant is where a reader comes looking: it // compares VERSIONS, not SHAPES. Three separate untruths in the 0.11.0 text passed it in one day — // the bar described in chapters while the wire sent units, `ordered_chapters: 0` promised where the // wire sent 2, three values of a vocabulary that had four — because the NUMBER matched each time. The // bump keeps the number from lying; it does not close the blind spot, which is unified backlog row 309. -const ContractVersion = "0.12.0" +const ContractVersion = "0.13.0" // Capabilities is what this deployment can do: one flat document, the same for every account. type Capabilities struct { diff --git a/platform/internal/httpapi/idempotency.go b/platform/internal/httpapi/idempotency.go index 50cab1af..133c19bb 100644 --- a/platform/internal/httpapi/idempotency.go +++ b/platform/internal/httpapi/idempotency.go @@ -8,6 +8,7 @@ import ( "net/http" "time" + "textmachine/platform/internal/books" "textmachine/platform/internal/pgstore" ) @@ -166,8 +167,13 @@ func (i *idempotent) replayIfIdentical(w http.ResponseWriter, r *http.Request, d // settleCtx detaches a key's own write from the request: the client that will retry is precisely // the one that hung up, and on `r.Context()` the key then stays in flight for `ClaimStale` and the // retry re-does the work. Same rule as the intake's writeCtx, applied to the receipt. +// +// The budget is books.ReceiptBudget and not a number of this package's own, because it is a TERM of +// what the boot compares against the windows an upload must finish inside (books.UploadSettle): the +// receipt is the last thing an upload does, and a copy of its budget here is a copy that can drift +// from the sum that has to contain it. func settleCtx(ctx context.Context) (context.Context, context.CancelFunc) { - return context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + return context.WithTimeout(context.WithoutCancel(ctx), books.ReceiptBudget) } // complete records what this attempt answered, so a repeat is given the same thing. `content` is the diff --git a/platform/internal/httpapi/idempotency_test.go b/platform/internal/httpapi/idempotency_test.go index 77e5d507..b86acd2e 100644 --- a/platform/internal/httpapi/idempotency_test.go +++ b/platform/internal/httpapi/idempotency_test.go @@ -441,3 +441,32 @@ func TestAnOverlongKeyIsRefusedBeforeAnythingIsClaimed(t *testing.T) { t.Errorf("the store was asked about a key the surface had already refused: %+v", keys.claims) } } + +// The receipt spends the share the INTAKE's budget set aside for it, not a number of this package's +// own. +// +// The two live in different packages and the drift between them is silent. A literal here smaller +// than the declared term is a receipt cut short; one LARGER is a tail that outlives what the boot +// compared against the windows an upload must finish inside — and being short by exactly one step of +// that sum is the defect this seam was drawn to end (books.UploadSettle, register row PD-464). +func TestTheReceiptSpendsTheShareTheIntakeSetAsideForIt(t *testing.T) { + before := time.Now() + ctx, cancel := settleCtx(context.Background()) + defer cancel() + d, ok := ctx.Deadline() + if !ok { + t.Fatal("the receipt runs unbounded: a statement that hangs holds the goroutine of a request that is already over") + } + if got := d.Sub(before); got < books.ReceiptBudget || got > books.ReceiptBudget+time.Second { + t.Errorf("the receipt was given %s, want the share the intake declared for it (%s)", got, books.ReceiptBudget) + } + // And detached, which is the other half of the same rule: the client that will retry is precisely + // the one that hung up, so the receipt must outlive its request. + gone, cancelGone := context.WithCancel(context.Background()) + cancelGone() + kept, cancelKept := settleCtx(gone) + defer cancelKept() + if err := kept.Err(); err != nil { + t.Errorf("the receipt died with its request (%v): the key stays in flight and the retry re-does the work", err) + } +} diff --git a/platform/internal/jobs/jobs.go b/platform/internal/jobs/jobs.go index 83f610de..88efd5a6 100644 --- a/platform/internal/jobs/jobs.go +++ b/platform/internal/jobs/jobs.go @@ -13,6 +13,7 @@ package jobs import ( "context" + "errors" "fmt" "log/slog" "time" @@ -114,9 +115,33 @@ type parseWorker struct { } func (w *parseWorker) Work(ctx context.Context, job *river.Job[ParseArgs]) error { - return w.svc.Parse(ctx, job.Args.BookID) + err := w.svc.Parse(ctx, job.Args.BookID) + if errors.Is(err, ErrTryAgainLater) { + // The pass established NOTHING and said so. Snoozing rather than failing, and the difference + // matters because MaxAttempts is 1: a returned error consumes this job outright, and the book + // would then wait out the intake sweep's whole grace over a host that was busy for a moment. + // A snooze does not increment the attempt (river.JobSnooze), so it is the SAME recovery + // mechanism coming back — not the second one this queue's policy refuses. + return river.JobSnooze(RetryDelay) + } + return err } +// ErrTryAgainLater is a pass that did nothing and wants the job back rather than spent. +// +// Declared HERE rather than by the service, because what it selects is a QUEUE policy and this is the +// package that owns one. A service wraps its own reason in it (books.giveBack); this package decides +// what the queue does about it, and the two cannot drift into different opinions about whether the +// job is finished. +var ErrTryAgainLater = errors.New("jobs: this pass established nothing and the job should come back") + +// RetryDelay is how long a job that established nothing waits before it is offered again. +// +// Shorter than a cut, because what it usually waits out is one: the ordinary producer of +// ErrTryAgainLater is a host that has as many books under the engine as it will take, and a slot +// frees when one of them finishes. Long enough that a saturated host is not re-asked in a tight loop. +const RetryDelay = 30 * time.Second + type exportWorker struct { river.WorkerDefaults[ExportArgs] svc Exporter @@ -134,6 +159,14 @@ func (w *exportWorker) Work(ctx context.Context, job *river.Job[ExportArgs]) err // constant plus a margin. const JobTimeout = 15 * time.Minute +// DefaultWorkers is how many jobs this queue runs at once when a deployment does not say. +// +// The number a host is sized for, and therefore the ONE place it is written: the intake's cap on +// concurrent engine cuts is the same figure said for every way of starting one rather than only for +// the way that goes through here (books.DefaultMaxCuts), and two literals coupled by prose is the +// drift this package has already paid for elsewhere. +const DefaultWorkers = 4 + // Queue is the River client, wired to the services that do the work. type Queue struct { client *river.Client[pgstore.Tx] @@ -146,7 +179,7 @@ type Queue struct { // by how many workers exist. func New(pool *pgxpool.Pool, spawner Spawner, parser Parser, exporter Exporter, log *slog.Logger, workers int) (*Queue, error) { if workers <= 0 { - workers = 4 + workers = DefaultWorkers } w := river.NewWorkers() if spawner != nil { diff --git a/platform/internal/jobs/jobs_test.go b/platform/internal/jobs/jobs_test.go index 242c2e5e..f7f71678 100644 --- a/platform/internal/jobs/jobs_test.go +++ b/platform/internal/jobs/jobs_test.go @@ -1,6 +1,9 @@ package jobs import ( + "context" + "errors" + "fmt" "testing" "github.com/riverqueue/river" @@ -36,3 +39,43 @@ func TestTheQueuesKindsAndRetryPolicyAreWhatTheRestOfTheSystemAssumes(t *testing } } } + +// stubParser answers with whatever the case wants, so the worker's own decision is what is judged. +type stubParser struct{ err error } + +func (p stubParser) Parse(context.Context, string) error { return p.err } + +// A pass that established nothing gets its job BACK; anything else the pass says is the job's answer. +// +// ⛔ The distinction is the whole of it, and it exists because MaxAttempts is 1 (above). A returned +// error consumes the single attempt outright, and the book then waits out the intake sweep's whole +// grace — twenty minutes — over a host that was busy for a moment. A snooze does not increment the +// attempt, so it is the SAME recovery coming back rather than the second mechanism this queue's +// policy refuses. Nothing witnessed the mapping until this test: the service's own tests end at the +// error it returns, and the queue's end at the options it declares. +func TestAPassThatEstablishedNothingGetsItsJobBackInsteadOfSpendingIt(t *testing.T) { + failed := errors.New("the store could not be reached") + for _, c := range []struct { + what string + err error + snooze bool + }{ + {"a pass that established nothing", fmt.Errorf("%w: the host is busy", ErrTryAgainLater), true}, + {"a pass that failed for its own reasons", failed, false}, + {"a pass that finished", nil, false}, + } { + w := &parseWorker{svc: stubParser{err: c.err}} + got := w.Work(t.Context(), &river.Job[ParseArgs]{Args: ParseArgs{BookID: "bk_1"}}) + var snoozed *river.JobSnoozeError + switch { + case c.snooze && !errors.As(got, &snoozed): + t.Errorf("%s: the worker answered %v, want a snooze — this job is the book's only one, and spending it costs the sweep's whole grace", c.what, got) + case c.snooze && snoozed.Duration != RetryDelay: + t.Errorf("%s: the job comes back in %v, want %v", c.what, snoozed.Duration, RetryDelay) + case !c.snooze && errors.As(got, &snoozed): + t.Errorf("%s: the worker snoozed, so a job that is genuinely finished or genuinely broken would be offered again forever", c.what) + case !c.snooze && !errors.Is(got, c.err): + t.Errorf("%s: the worker answered %v, want the pass's own %v", c.what, got, c.err) + } + } +} diff --git a/platform/internal/metrics/metrics.go b/platform/internal/metrics/metrics.go index 6cf32dc6..38900c87 100644 --- a/platform/internal/metrics/metrics.go +++ b/platform/internal/metrics/metrics.go @@ -69,6 +69,19 @@ type Metrics struct { // backup would sit under every alert ever written for this series. backupAge prometheus.Gauge + // The host's cap on concurrent engine cuts, and how hard it is being hit. Without these a cap is + // indistinguishable from latency an operator has to guess the cause of — which is what the intake + // looked like when it had no cap at all. + cutsInFlight prometheus.Gauge + cutsWaiting prometheus.Gauge + cutSlots prometheus.Gauge + cutWaits prometheus.Counter + cutGiveUps prometheus.Counter + // What the two counters above have already been told. The service counts cumulatively and a + // Prometheus counter takes increments, so the difference is what is added; both are touched only + // from the telemetry pass, which is one goroutine (cmd/tmplatformd, observe). + countedCutWaits, countedCutGiveUps uint64 + requests *prometheus.CounterVec latency *prometheus.HistogramVec } @@ -139,9 +152,30 @@ func New() *Metrics { Help: "Time to serve a request, by route pattern and method.", Buckets: latencyBuckets, }, []string{"route", "method"}) + m.cutsInFlight = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, Name: "cuts_in_flight", + Help: "Engine cuts running right now, across the upload that cuts its own book, the queue's workers and the backstop sweep.", + }) + m.cutsWaiting = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, Name: "cuts_waiting", + Help: "Cuts queued for a slot right now. Sustained above zero means the cap, not the engine, is what shapes intake latency.", + }) + m.cutSlots = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, Name: "cut_slots", + Help: "The cap itself (TM_PLATFORM_MAX_CUTS), published so saturation can be read without knowing the deployment's configuration.", + }) + m.cutWaits = prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: namespace, Name: "cut_waits_total", + Help: "Cuts that found every slot taken and had to wait for one.", + }) + m.cutGiveUps = prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: namespace, Name: "cut_slot_timeouts_total", + Help: "Cuts whose caller ran out of budget while waiting for a slot. An upload counted here is accepted `parsing` and finished by the queue, not refused.", + }) m.registry.MustRegister(m.queueDepth, m.oldestHold, m.quarantined, m.parked, m.liveRuns, m.tailerLag, m.booksInIntake, m.sweepDuration, m.sweepUnfinished, m.stalledRuns, m.abandonedSurfaces, m.backupAge, m.requests, m.latency, + m.cutsInFlight, m.cutsWaiting, m.cutSlots, m.cutWaits, m.cutGiveUps, // The runtime and the process itself: memory, goroutines, file descriptors, CPU. They are // what answers "is this instance healthy" when none of the numbers above has moved. collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) @@ -206,6 +240,37 @@ func (m *Metrics) ObserveRunner(r Runner) { m.abandonedSurfaces.Set(float64(r.AbandonedSurfaces)) } +// Cuts is one reading of the host's cap on concurrent engine cuts. Waited and GaveUp are cumulative +// since the process started, which is what lets this publish increments to a counter. +type Cuts struct { + Limit int + InFlight int + Waiting int + Waited uint64 + GaveUp uint64 +} + +// ObserveCuts publishes where the host's cut capacity stands. +func (m *Metrics) ObserveCuts(c Cuts) { + if m == nil { + return + } + m.cutSlots.Set(float64(c.Limit)) + m.cutsInFlight.Set(float64(c.InFlight)) + m.cutsWaiting.Set(float64(c.Waiting)) + // Only ever forward. A reading below what was already counted would mean the process restarted + // under the same registry, which cannot happen — but a counter that went backwards would break + // every rate() over it, so the guard costs one comparison and removes the class. + if c.Waited > m.countedCutWaits { + m.cutWaits.Add(float64(c.Waited - m.countedCutWaits)) + m.countedCutWaits = c.Waited + } + if c.GaveUp > m.countedCutGiveUps { + m.cutGiveUps.Add(float64(c.GaveUp - m.countedCutGiveUps)) + m.countedCutGiveUps = c.GaveUp + } +} + // ObserveTailerLag publishes how far the furthest-behind live run's cursor is from the end of its // journal, in bytes. func (m *Metrics) ObserveTailerLag(bytes int64) { diff --git a/platform/internal/metrics/metrics_test.go b/platform/internal/metrics/metrics_test.go index 718a0a32..0fb6157d 100644 --- a/platform/internal/metrics/metrics_test.go +++ b/platform/internal/metrics/metrics_test.go @@ -183,3 +183,47 @@ func TestTheBackupAgeGaugeIsInfiniteWhenThereIsNoPoint(t *testing.T) { t.Errorf("age = %v seconds, want 5400 (the base unit is seconds, not minutes)", v) } } + +// The cap's five series carry the five DIFFERENT numbers they are given. +// +// Five distinct values on purpose: with a fixture where any two coincide, a pair of gauges wired to +// each other's readings is invisible, and «how saturated is the host» would be answered by «how deep +// is the line» without anybody noticing. The counters are published as INCREMENTS from cumulative +// readings, so a second observation is made and the counters have to have moved by the difference +// while the gauges simply carry the latest. +func TestTheCutCapacityIsExposedWithEveryFigureItWasGiven(t *testing.T) { + m := New() + m.ObserveCuts(Cuts{Limit: 6, InFlight: 4, Waiting: 3, Waited: 9, GaveUp: 2}) + body := scrape(t, m) + for _, want := range []string{ + "tm_platform_cut_slots 6", + "tm_platform_cuts_in_flight 4", + "tm_platform_cuts_waiting 3", + "tm_platform_cut_waits_total 9", + "tm_platform_cut_slot_timeouts_total 2", + } { + if !strings.Contains(body, want) { + t.Errorf("the exposition is missing %q", want) + } + } + // A second reading: the gauges take the new value, the counters advance by the difference rather + // than being set to it — a counter set to a cumulative reading would double-count on every scrape. + m.ObserveCuts(Cuts{Limit: 6, InFlight: 1, Waiting: 0, Waited: 11, GaveUp: 2}) + body = scrape(t, m) + for _, want := range []string{ + "tm_platform_cuts_in_flight 1", + "tm_platform_cuts_waiting 0", + "tm_platform_cut_waits_total 11", + "tm_platform_cut_slot_timeouts_total 2", + } { + if !strings.Contains(body, want) { + t.Errorf("after a second reading the exposition is missing %q", want) + } + } + // And a reading that went BACKWARDS — which cannot happen in this process and would break every + // rate() over the series if it did — leaves the counter where it stood. + m.ObserveCuts(Cuts{Limit: 6, InFlight: 0, Waiting: 0, Waited: 1, GaveUp: 0}) + if body = scrape(t, m); !strings.Contains(body, "tm_platform_cut_waits_total 11") { + t.Error("a reading below what was already counted moved the counter backwards") + } +} diff --git a/platform/internal/pgstore/books.go b/platform/internal/pgstore/books.go index e3a4d067..34368851 100644 --- a/platform/internal/pgstore/books.go +++ b/platform/internal/pgstore/books.go @@ -206,6 +206,12 @@ func (s *Store) CreateUpload(ctx context.Context, id string, in NewUpload) (Book // enqueue joins the caller's transaction for the same reason the run admission's does: a status that // says "being parsed" with no job to do it waits for the backstop sweep's grace, and a job for a // book that never became `parsing` is a worker with nothing to claim. +// +// ⚠ It is NIL on the path a deployment actually takes, and that is deliberate rather than dead code +// left behind. An intake that cuts the book inside the request must not have a job racing that cut +// for the parse claim, so it enqueues at the END of the cut instead (books.cutsItsOwnUploads, and +// ReleaseParseClaim below). What reaches this parameter is the other deployment — a queue and no +// engine — which has nothing to wait for and hands the book over at once. func (s *Store) StartParsing(ctx context.Context, id string, characters int64, enqueue func(context.Context, Tx, string) error) (Book, error) { var b Book