diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 72286e12..3f30905b 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -310,7 +310,7 @@ | 386 | ⭐ **НЕСУЩЕЕ СВОЙСТВО, НЕ ЗАПИСАННОЕ НИГДЕ: контекст стора НЕЗАВИСИМ от контекста прогона, поэтому записи ПОСЛЕ отмены доходят.** `backend/internal/store/store.go`, греп `opContext` — каждая операция стора идёт на своём `context.WithTimeout(context.Background(), 10s)`; ни один метод стора к контексту прогона не привязан (`LogRequest` берёт `ctx` только ради trace-id и лога). ⇒ **корректность жёсткой остановки зависит от ПОРЯДКА записей, а не от того, переживут ли они отмену**: пометки, сеттлы и терминальный кадр доезжают. Не дефект — свойство, на которое обязан опереться любой пак про корректное завершение (`D39.240`). ⚠ Заведено строкой именно потому, что не записано: без него следующий пак начнёт с ложной тревоги «а доедут ли записи», потратит круг и придёт к тому же ответу. Найдено движковой сессией ЧТЕНИЕМ | бэкенд | скоро | свойство записано носителем, на который можно сослаться — комментарием в `store.go` или нотой, и порядок записей при остановке проверен ПРОТИВ него | улов движковой сессии перед откатом пака «две остановки», 10.09 (`D39.240` п.6) | | 387 | ⛔ **ИНВЕРСИЯ БЛОКИРОВОК «ЭМИТТЕР ↔ СТОР» — МИНА, КОТОРАЯ СНАРУЖИ НЕ ПОХОЖА НА ДЕДЛОК.** Замерено ИСПОЛНЕНИЕМ движковой сессией: эмиттер держит свой мьютекс ПОПЕРЁК записей в стор (`project()` → журнал + `MarkAnnounced`; `enqueue` → `EnqueueEvent`), а замыкание строки расхода, отдаваемое в `SettleWithCheckpoint`, исполняется ВНУТРИ единственной пишущей транзакции стора. ⇒ всякий код, берущий мьютекс эмиттера из этого замыкания, замыкает цикл: сеттл держит пишущее соединение и ждёт мьютекс, эмиттер держит мьютекс и ждёт соединение. **Снаружи это не дедлок:** его разрывает 10-секундный таймаут стора, и симптом — `pipeline: ensure job …: context deadline exceeded` в ТРИНАДЦАТИ тестах, к правке отношения не имеющих. Сессия наступила, продиагностировала и вылечила отдельным мьютексом, который не держится поперёк ввода-вывода, — **но лечение откатывается вместе с паком, и мина остаётся заряженной**. ⚠ Правило, которое надо записать в код: не брать мьютекс эмиттера из-под сеттла; всё, что зовут из обработчика сигналов, — тем более | бэкенд | скоро | правило названо в коде рядом с обоими замками, и есть проба, которая ловит цикл раньше, чем он вылезет чужим таймаутом | улов движковой сессии, замер исполнением, 10.09 | | 388 | **ДЕНЬГИ БАНКОВОГО ПАСА ИСЧЕЗАЮТ ИЗ ИТОГА ПРОГОНА, ЕСЛИ ПАС ОБОРВАЛИ.** `backend/internal/pipeline/mining.go`, греп `tres, err := r.runTerminologist`: на ЛЮБОЙ ошибке, включая отмену, идёт `return false, err` ДО присваивания `r.lastTerminology`; а единственный сумматор (`backend/internal/pipeline/waverun.go`, греп `t.CostUSD + t.ClassifyCostUSD`) прибавляет только когда поле не пусто. Партии при этом КУПЛЕНЫ и чекпойнтнуты. ⇒ прогон, оборванный внутри паса, недосчитывает свой расход ровно на этот пас — на той единственной строке, которую читает оператор. Найдено ЧТЕНИЕМ, обе половины | бэкенд | скоро | итог прогона включает купленное банковым пасом независимо от того, чем пас кончился | улов движковой сессии, 10.09 | -| 389 | **ОСТАНОВЛЕННЫЙ ПРОГОН НЕ ПЕЧАТАЕТ НИЧЕГО О ТОМ, ЧТО ПРОИЗВЁЛ, и ПОЗИЦИЯ ЧИСЛИТСЯ `failed`.** Две половины одной беды — человек после остановки не понимает, в каком состоянии книга. **(а)** `backend/cmd/tmctl/main.go`, `translate()`: частичный результат рендерится только для подписного стопа и потолка; на отмене результата нет вовсе, видна одна строка ошибки. **(б)** `backend/internal/pipeline/stagerun.go`: ветвь прерванного ожидания и охранник после впуска резервации обе пишут `jobs.status = failed` за позицию, которую остановил ЧЕЛОВЕК. Ничто на `jobs.status` не гейтится (файл это и говорит) ⇒ это телеметрия, но слово читает человек. ⚠ Под инвариантом `D39.240` («правильно возобновить») это поверхность, по которой понимают, откуда продолжать ⛔ **ЗНАМЕНАТЕЛЬ ПОСЧИТАН 11.09, И МОЯ СТРОКА НАЗЫВАЛА ДВА НОСИТЕЛЯ ИЗ ПЯТИ.** Движковая сессия посчитала все места, пишущие `failed` в пайплайне, и разобрала каждое по достижимости ОТМЕНОЙ. Отменой достижимы **пять**: путь оборванного вызова (`backend/internal/pipeline/cutcall.go`, греп `setJobStatus`) — несущий, остановка ходит именно через него · ветвь прерванного ожидания и охранник после впуска резервации (`backend/internal/pipeline/stagerun.go`) — те два, что называла строка · **рейт-гард** (возвращает ошибку контекста на отмене) · **транспортный отказ «запрос не ушёл»** — сюда падает отмена, случившаяся ДО записи запроса, то есть самый обычный Ctrl-C в начале вызова. Последние два не назывались ни строкой, ни отчётом сессии. Остальные — настоящие поломки, слово остаётся. ⚠ **Счёт самого знаменателя разошёлся у двух приборов: у сессии 7 сайтов, у меня 8** (`git show HEAD:…/stagerun.go` — 7 плюс 1 в `cutcall.go`; контроль: всего вызовов сеттера в `stagerun.go` — 12). Расхождение инструментальное и предмета не меняет: два против пяти. ⭐ **Починка сделана ОДНИМ контрактом, а не пятью правками** — сужающая обёртка рядом с сеттером, с доводом в коде: пять сайтов это один вопрос («позиция сломалась или прогон кончился, пока она ждала?»), и пять `if`-ов закрыли бы три, оставив счёт неверным опять. ⛔ **Покрытие УЖЕ починки, и это назвала сама сессия:** пины ловят класс через путь оборванного вызова, отдельных пинов на рейт-гард и транспортный отказ НЕТ | бэкенд | скоро | после остановки человек видит, что куплено и что доделано, и остановленная позиция не названа упавшей | улов движковой сессии, 10.09 | +| 389 | **ОСТАНОВЛЕННЫЙ ПРОГОН НЕ ПЕЧАТАЕТ НИЧЕГО О ТОМ, ЧТО ПРОИЗВЁЛ, и ПОЗИЦИЯ ЧИСЛИТСЯ `failed`.** Две половины одной беды — человек после остановки не понимает, в каком состоянии книга. **(а)** `backend/cmd/tmctl/main.go`, `translate()`: частичный результат рендерится только для подписного стопа и потолка; на отмене результата нет вовсе, видна одна строка ошибки. **(б)** `backend/internal/pipeline/stagerun.go`: ветвь прерванного ожидания и охранник после впуска резервации обе пишут `jobs.status = failed` за позицию, которую остановил ЧЕЛОВЕК. Ничто на `jobs.status` не гейтится (файл это и говорит) ⇒ это телеметрия, но слово читает человек. ⚠ Под инвариантом `D39.240` («правильно возобновить») это поверхность, по которой понимают, откуда продолжать ⛔ **ЗНАМЕНАТЕЛЬ ПОСЧИТАН 11.09, И МОЯ СТРОКА НАЗЫВАЛА ДВА НОСИТЕЛЯ ИЗ ПЯТИ.** Движковая сессия посчитала все места, пишущие `failed` в пайплайне, и разобрала каждое по достижимости ОТМЕНОЙ. Отменой достижимы **пять**: путь оборванного вызова (`backend/internal/pipeline/cutcall.go`, греп `setJobStatus`) — несущий, остановка ходит именно через него · ветвь прерванного ожидания и охранник после впуска резервации (`backend/internal/pipeline/stagerun.go`) — те два, что называла строка · **рейт-гард** (возвращает ошибку контекста на отмене) · **транспортный отказ «запрос не ушёл»** — сюда падает отмена, случившаяся ДО записи запроса, то есть самый обычный Ctrl-C в начале вызова. Последние два не назывались ни строкой, ни отчётом сессии. Остальные — настоящие поломки, слово остаётся. ⚠ **Расхождение счёта РАЗРЕШЕНО 11.09 одним прибором: сайтов ВОСЕМЬ.** `git show HEAD:…/stagerun.go | grep -n 'setJobStatus.*failed'` → 7 строк (557 · 574 · 625 · 644 · 680 · 759 · 797) плюс `cutcall.go:151` = 8. Прежние «семь» сессия сняла по РАБОЧЕМУ дереву, где уже успела снять одну ветвь, — число было честно снято прибором, но не с того предмета, и она назвала это сама. Достижимы отменой ПЯТЬ: `cutcall:151` · `stagerun:625` (прерванное ожидание) · `:644` (охранник после впуска) · `:680` (рейт-гард) · `:759` (транспортный отказ); остальные три — настоящие поломки. Арифметика после починки закрывается на живом дереве: через сужающий контракт 5, прямых записей 3, было 8. ⭐ **Починка сделана ОДНИМ контрактом, а не пятью правками** — сужающая обёртка рядом с сеттером, с доводом в коде: пять сайтов это один вопрос («позиция сломалась или прогон кончился, пока она ждала?»), и пять `if`-ов закрыли бы три, оставив счёт неверным опять. ⛔ **Покрытие УЖЕ починки, и это назвала сама сессия:** пины ловят класс через путь оборванного вызова, отдельных пинов на рейт-гард и транспортный отказ НЕТ | бэкенд | скоро | после остановки человек видит, что куплено и что доделано, и остановленная позиция не названа упавшей | улов движковой сессии, 10.09 | | 390 | **ПРОВАЛИВШИЙСЯ СЕТТЛ НАМЕРЕННО ОСТАВЛЯЕТ РЕЗЕРВАЦИЮ — И СЛЕДУЮЩИЙ ПОТОЛОЧНЫЙ СТОП ПРОСИТ ПОПОЛНИТЬ БОЛЬШЕ, ЧЕМ НУЖНО.** `backend/internal/pipeline/stagerun.go`, греп `THE RESERVATION IS DELIBERATELY NOT RELEASED HERE`: подметается только следующим пишущим `store.Open`. В окне между остановкой и этим открытием `reserved_usd` завышен, а нехватка для сообщения о потолке считается ИМЕННО от `reserved` ⇒ пользователю называют завышенную сумму пополнения. Решение оставить резервацию — осознанное и объявленное в коде; незаявлено ПОСЛЕДСТВИЕ | бэкенд | когда-нибудь | сумма пополнения, называемая пользователю, не зависит от того, была ли остановка между сеттлом и следующим открытием стора | улов движковой сессии, 10.09 | | 391 | ⛔ **ИЗ ЗАВИСШЕГО СВОРАЧИВАНИЯ НЕТ ВЫХОДА, ОСТАВЛЯЮЩЕГО ТЕРМИНАЛЬНЫЙ КАДР.** `backend/cmd/tmctl/main.go`, греп `signal.NotifyContext`: горутина выходит после ПЕРВОГО сигнала, регистрация снимается только отложенным `stop()`, канал ёмкостью один ⇒ второй сигнал падает в переполненный буфер и дефолтная диспозиция НЕ восстанавливается. Складывается с `D39.238`: два сигнала в одном кванте процесс видит как ОДИН, значит «нажать ещё раз» не выход даже теоретически. Единственный способ прекратить зависшее сворачивание — убийство извне, после которого платформа не получает ни исхода, ни расчёта. ⚠ **ИСПР. 11.09 — ПРЕЖНЯЯ РЕДАКЦИЯ ЭТОЙ СТРОКИ ПРОТИВОРЕЧИЛА САМА СЕБЕ, и нашла это движковая сессия, а не я.** Она обещала лечение «около двадцати строк» (вернуть дефолтную диспозицию) при критерии приёмки «выход, ОСТАВЛЯЮЩИЙ терминальный кадр». Дешёвое лечение даёт РОВНО ОБРАТНОЕ: второй сигнал убивает процесс диспозицией по умолчанию — тот же SIGKILL, только вызванный вежливее, и кадра по-прежнему нет. ⇒ **дешёвого решения у этой строки НЕТ**: выход С кадром означает «на второй сигнал сами пишем терминальную строку и выходим», и это отдельный предмет с ценой, вплотную подходящий к отменённой лестнице (`D39.240`). Состояние стора после убийства при этом ЗАПИНЕНО и корректно (`TestKillMinus9LosesAtMostOneCall`), то есть строка про ВИДИМОСТЬ исхода для платформы, а не про консистентность | бэкенд | когда-нибудь | у зависшего сворачивания есть выход, после которого платформа получает исход и расчёт — либо признано, что его цена не стоит предмета | улов движковой сессии 10.09; противоречие критерия найдено ею же 11.09 | | 392 | ⛔ **ОБРАБОТЧИК `/stop` ШЛЁТ СИГНАЛ ПО ИМЕНИ ЮНИТА ИЗ СВОЕЙ ЖЕ ВЫПИСКИ — окно в один тик, и всё это время пользователь видит `202`, а прогон работает.** `platform/internal/runs/reconcile.go`, греп `func (s \*Service) Stop`: имя юнита приходит из `RequestStop` и уходит в `Runner.Stop`. Если между коммитом намерения и вызовом systemd свип успел РЕСТАРТОВАТЬ попытку, сигнал уходит СТАРОМУ юниту, а живёт новый. Самолечение есть — следующий проход увидит живой юнит с намерением и пере-выдаст стоп, — но **починка приходит от свипа, а не от обработчика**. ⚠ И самолечение слабее, чем кажется: пере-выдача ИНЕРТНА на юните в `deactivating` (замер `D39.237` п.2), то есть если первый сигнал не дошёл, свип этого не чинит вовсе — юнит стоит до убийства по грейсу, до десяти минут прогона, который ничего не делает, с зарезервированным холдом ✅ **ПОЧИНЕНО 11.09 (`00d590e`), и МЕХАНИЗМ ДОСТАТОЧНОСТИ надо записать верно — моё первое объяснение было неверным.** Лечение: `select … for update of r` в одной транзакции с записью намерения (`platform/internal/pgstore/runs.go`, греп `for update of r`, единственная строка этого коммита — прежние `for update of r, a` принадлежат другим функциям и стояли до неё). Гонка ВОСПРОИЗВЕДЕНА на живой БД в обе стороны: до правки перезапуск коммитил новое имя юнита, а ответ приходил со старым; после — с новым. ⛔ **Почему замка на строке ПРОГОНА достаточно, и почему это НЕ «заперта и строка попытки»:** опасность со стороны попытки — не изменение существующей строки, а ПОЯВЛЕНИЕ новой (имя юнита ставит заявка на спавн), а строку, которой ещё нет, замком не удержать. Существующую держит УСЛОВИЕ, а не замок: `RecordSpawn` несёт в своём `where` `and exists (… r.finished_at is null and r.stop_requested_at is null)` — заявка отказывается сама, как только намерение зафиксировано. ⇒ следующий, кто пойдёт двигать `RecordSpawn`, обязан споткнуться об это, а не о ложное «там замок». ⚠ **Остаточное окно есть и оно ДРУГОЕ:** транзакция взяла замок и прочла имя юнита ПУСТЫМ → спавн коммитится (его гард ещё видит намерение незаписанным, обычный `SELECT` замка не ждёт) → намерение коммитится. Итог: имя ПУСТОЕ, а юнит живой — это не устаревшее имя из прежней формулировки. Самолечится: `finishStopped` при пустом имени и непустой базе расхода не закрывает прогон, а спрашивает systemd и останавливает юнит — ветка написана заранее под «claim was given back but its unit exists» | платформа | скоро | остановка доходит до ЖИВОГО юнита в том же действии, которым записано намерение | улов платформенной сессии, 10.09 |