Stop the order form promising a start the door will refuse, and refuse a run over a book whose directory is gone before the money moves
This commit is contained in:
parent
c577611669
commit
49f74d9fe9
15 changed files with 1468 additions and 73 deletions
|
|
@ -31,7 +31,12 @@ import (
|
|||
// assigned — which is how RunBudget came to be declared, documented and left at zero.
|
||||
func runsConfig(cfg config.Config, marker []string, ceiling []string) runs.Config {
|
||||
return runs.Config{
|
||||
StateDir: cfg.Runner.StateDir,
|
||||
StateDir: cfg.Runner.StateDir,
|
||||
// From the INTAKE's section, and the one setting this mapping takes from outside the runner's:
|
||||
// the root is the intake's to write and the door's to ask about. A run whose book directory is
|
||||
// missing has to say whether the volume went with it, and that question is a path away
|
||||
// (runs.sourceThere).
|
||||
BooksDir: cfg.Intake.BooksDir,
|
||||
EngineBinary: cfg.Runner.EngineBinary,
|
||||
MarkerArgv: marker,
|
||||
Ceiling: ceiling,
|
||||
|
|
|
|||
|
|
@ -64,9 +64,22 @@ func TestTheOperatorsRunnerKnobsReachTheReconciler(t *testing.T) {
|
|||
cfg.Runner.ResyncEvery = 5 * time.Minute
|
||||
cfg.Runner.RunBudget = 90 * time.Second
|
||||
cfg.Runner.KeysFile = "/etc/tm/keys.env"
|
||||
// ⚠ The ONE setting this mapping takes from outside the runner's own section, and the reason it is
|
||||
// set here in the INTAKE's: the root belongs to the intake and the run door only asks about it.
|
||||
cfg.Intake.BooksDir = "/srv/textmachine/books"
|
||||
|
||||
got := runsConfig(cfg, []string{"/usr/bin/tmplatformctl", "exit-marker"}, []string{"--max-cost"})
|
||||
switch {
|
||||
case got.BooksDir != cfg.Intake.BooksDir:
|
||||
// ⛔ ITS OWN CASE BECAUSE ITS ABSENCE IS SILENT AND COSTS THE TRUTH AT THE DOOR. Unwired, the
|
||||
// door cannot tell "this book's directory is gone" from "the volume under every book is gone":
|
||||
// `books.Owns("", …)` is false for every book, so a book whose own directory vanished is
|
||||
// answered `503` and «this deployment cannot start runs» instead of `409 book_not_ready` with
|
||||
// `source_gone` — the PD-192 confusion the five pins of `runs.sourceThere` exist to prevent,
|
||||
// back in one dropped line. Money is untouched either way, which is exactly why nothing else
|
||||
// would notice: a mutation of this line survived the whole battery (adversarial pass, 11.09).
|
||||
t.Errorf("BooksDir is %q, want the intake's %q: the run door cannot tell whose fault a missing directory is",
|
||||
got.BooksDir, cfg.Intake.BooksDir)
|
||||
case got.RunBudget != cfg.Runner.RunBudget:
|
||||
t.Errorf("RunBudget is %v, want the operator's %v: TM_PLATFORM_RUN_BUDGET does nothing",
|
||||
got.RunBudget, cfg.Runner.RunBudget)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -478,8 +478,16 @@ sed -e 's#^pipeline: ../configs/#pipeline: <repo>/backend/configs/#' \
|
|||
<repo>/backend/example/book.yaml > $W/book-template.yaml
|
||||
```
|
||||
|
||||
⚠ Относительные `pipeline:`/`models:` — единственное отличие шаблона от репо-оригинала; без правки
|
||||
движок не находит конфиги из чужого рабочего каталога.
|
||||
⚠ Эти два пути — единственное отличие шаблона от репо-оригинала, и сторона у них такая: в ОРИГИНАЛЕ
|
||||
они относительные (`../configs/…`), а в ШАБЛОНЕ обязаны стать АБСОЛЮТНЫМИ. Относительный путь
|
||||
резолвится от рабочего каталога КНИГИ, а конфигов там нет, поэтому движок отвечает «не могу прочитать
|
||||
файл моделей» — и падает не тест, а замер. ⚠ Прежняя редакция этой строки говорила «относительные —
|
||||
единственное отличие шаблона», то есть называла сторону наоборот; спешно прочитанная, она стоила
|
||||
приёмке 11.09 восьми красных живых проб `internal/runner`, которые были дефектом шаблона, а не дерева.
|
||||
Проверка на месте, если сомневаетесь: `grep -E '^(pipeline|models):' <шаблон>` обязан показать пути,
|
||||
начинающиеся со слэша. Замерено 11.09 на рабочем шаблоне стенда: оба абсолютные, и
|
||||
`books.TestTheRenderedConfigurationIsOneTheEngineActuallyLoads` с ним зелёный против свежесобранного
|
||||
`tmctl`.
|
||||
|
||||
**Гейты батареи — их ЧЕТЫРЕ, и без них `make check` МОЛЧА скипует ~290 тестов** (вся читающая
|
||||
модель, миграции и шов). «Зелёная батарея» без них не значит ничего, поэтому `check` сам печатает
|
||||
|
|
@ -497,6 +505,23 @@ sed -e 's#^pipeline: ../configs/#pipeline: <repo>/backend/configs/#' \
|
|||
`MemoryMax` — пробой не предсказывается вовсе (`PD-423`), его знает только сам тест
|
||||
`TestARunIsBoundedByItsOwnCgroup`.
|
||||
|
||||
⚠⚠ **И ещё ДВА условия, которых в перечне выше нет, а в выдаче они есть — замер 11.09, пак «правда у
|
||||
двери».** Со всеми четырьмя гейтами поднятыми батарея дала `20 ok · 0 FAIL · 5 скипов`, и оба условия
|
||||
этих пяти — ниже. Пишутся сюда потому, что этот раздел объявлен ЕДИНСТВЕННЫМ носителем числа, а сессия,
|
||||
честно сверившаяся с ним, назвала бы «условие одно» там, где их два.
|
||||
- **Артефакт контраста движка:** пять живых движковых проб `internal/runner` скипаются со словами «this
|
||||
deployment's pipeline enables the bank contour and names `<репо>/backend/configs/mining-contrast.zh.txt`,
|
||||
which is not on this host». Файл — чужой зоны, и его отсутствие не поломка платформы; носитель —
|
||||
**строка бэклога 251** (артефакт контраста банкового контура), сверено по трекеру 11.09.
|
||||
⚠ И «пять скипов» НЕ значит «живая движковая половина недостижима»: с шаблоном, чьи
|
||||
`pipeline:`/`models:` абсолютные, пробы `internal/runner` дают **42 PASS · 0 FAIL** при тех же
|
||||
пяти скипах (замер приёмки №23, 11.09).
|
||||
- **Локальный адрес провайдера занят:** `runner.TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem`
|
||||
умеет скипнуться ВТОРЫМ условием — `listen tcp 127.0.0.1:11434: bind: address already in use` (чужой
|
||||
стенд на том же хосте). ⚠ Один тест с ДВУМЯ условиями скипа: какое он назовёт, решает состояние хоста
|
||||
в эту минуту, и два честных прогона одной смены назвали разные. Считать условия по ОДНОМУ прогону
|
||||
поэтому нельзя.
|
||||
|
||||
⚠⚠ **Движковый бинарь второго гейта обязан быть СОБРАН ИЗ ТЕКУЩЕГО `backend/`, а не переиспользован
|
||||
со стенда** (`cd <repo>/backend && go build -o $W/tmctl ./cmd/tmctl`, `PD-432`). Цена пропуска
|
||||
названа замером: стендовый `tmctl` от 24.08 против сегодняшнего
|
||||
|
|
@ -522,7 +547,12 @@ system_messages not found in type config.CapabilitiesConfig`. Диагноз с
|
|||
состояние `cgroup.subtree_control` среза `tm-runs.slice` в момент прогона — назван КАНДИДАТОМ и
|
||||
только: диагноз `PD-423` не установлен, и вносить его в рецепт как проверку нельзя.
|
||||
|
||||
Ожидание при всех четырёх: 20 пакетов (`go list ./...`), exit 0, **скипов 0**, линтер «0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD
|
||||
Ожидание при всех четырёх: 20 пакетов (`go list ./...`), exit 0, линтер «0 issues» — и **скипов 0
|
||||
ТОЛЬКО при условиях 29.08**; сегодня их ПЯТЬ, и это не поломка. ⛔ Две разные величины в одном
|
||||
разделе — ловушка, и вот развилка: число скипов держат не четыре гейта, а ещё и два условия,
|
||||
которых на дереве 29.08 не было (артефакт контраста банкового контура — строка бэклога 251 — и
|
||||
занятый локальный адрес провайдера). Оба названы выше, в блоке «И ещё ДВА условия»; читать надо
|
||||
ЕГО, а не эту строку, и сверять счёт скипов с ним. Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD
|
||||
`fbe6cf3`**, **304 на дереве пака P11** (пак добавил 17 пинов, гейченных тем же DSN). Число зависит
|
||||
от дерева, и переносить его между ними нельзя.
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,492 @@ committed_usd, which is what would let this side say "at most Y"». Движок
|
|||
чтение оценочных строк из потока. Промт ещё не написан — если у вас есть довод против любой из четырёх
|
||||
позиций, скажите ДО того, как я его напишу.
|
||||
|
||||
## ПРАВДА У ДВЕРИ — ОТЧЁТ (11.09, `textmachine-8e`)
|
||||
|
||||
> Предмет: платформа говорила человеку не то, что произойдёт. Три ряда — **`PD-455`** (форма обещает
|
||||
> старт, в котором дверь откажет) · **`PD-162`** (прогон над пропавшим каталогом берёт холд и висит
|
||||
> вечно) · **`PD-448`** (двойной клик по «продолжить»; строить было ЗАПРЕЩЕНО, заказан разбор).
|
||||
> Вход: HEAD `05f690e`, дерево зоны чистое, батарея на входе зелёная и снятая ДО первой правки —
|
||||
> `MAKE-EXIT=0 · 20 пакетов ok · 0 FAIL · линтер 0 issues · 5 скипов`. Сессия не коммитит: дерево
|
||||
> передано оркестратору №23.
|
||||
|
||||
### Комплектность против заказа — по пунктам §4 промта
|
||||
|
||||
| Пункт | Исход | Чем предъявлено |
|
||||
|---|---|---|
|
||||
| §4.1 `PD-455` — форма обязана знать про живой прогон | **сделано** | предикат с двумя привязками + `blocked.code: run_in_flight`; пины; живая проба на стенде |
|
||||
| §4.2 `PD-448` — исследуй и предложи, НЕ строй | **разбор, не строил** | замер воспроизводимости, три пути с ценой, рекомендация, расхождение канона отдельным пунктом |
|
||||
| §4.3 `PD-162` — отказ ДО денег | **сделано у ОБЕИХ денежных дверей**, ряд остаётся открытым остатком | пины с контрольным прогоном; живая проба; ряд пере-написан |
|
||||
| §4.4 интейк-гигиена | **не делал** (подписанный пропуск) | одна строка про следующий предмет `PD-175` — ниже |
|
||||
| §4.5 якоря регистра | **сделано**, и их оказалось не четыре, а пятнадцать | `counts.py --lint` до и после |
|
||||
| §5 самопроверка исполнением | **сделано** | батарея · `make vuln` · мутации на изолированной копии · живая проба · адверсариальный проход |
|
||||
|
||||
### `PD-455` — одно определение, две привязки
|
||||
|
||||
Правило допуска по фактам КНИГИ стояло инлайном в `Start` тремя проверками (статус · материализованное
|
||||
дерево глав · живой прогон), и форма заказа о них не знала: вердикт считался из остатка книги и баланса,
|
||||
а `ErrRunInFlight` жил только у двери. Человек видел `covers_all`, жал и получал 409.
|
||||
|
||||
Сделано: три проверки вынесены в **один предикат** `platform/internal/runs/runs.go:655`=`func startable`,
|
||||
у него две привязки РАЗНОЙ авторитетности — `Start` зовёт его под замком книги (решает), `Order` зовёт
|
||||
на опрашиваемом пути (`runs.go:304`, совещательно и заведомо устаревает). Мерило пака — «сколько
|
||||
ОПРЕДЕЛЕНИЙ придётся тронуть, если правило изменится» — выполнено: одно.
|
||||
|
||||
Три решения, которые я принял внутри этой свободы, и довод каждого:
|
||||
|
||||
1. **`PricedBook` НЕ расширен.** Рядом с ним стоит письменное возражение (`platform/internal/pgstore/books.go:1476`:
|
||||
«It is a second read beside ReadBookForRun rather than more columns on it, because the two answer
|
||||
different questions of different shapes»). Форма делает ВТОРОЕ чтение — `ReadBookForRun`, ту самую
|
||||
запись, которой судит дверь. Возражение этим исполнено, а не переступлено: две разной формы записи
|
||||
остаются двумя, и определение по-прежнему одно. Цена, которую я принял: +1 запрос на КАЖДЫЙ опрос
|
||||
формы (рядом уже три чтения, одно из них с пер-главным агрегатом по `units`). Это моё суждение, а не
|
||||
замер: бенчмарка я не снимал.
|
||||
2. **`verdict` не тронут.** Он отвечает про ДЕНЬГИ, и баланс книгу действительно покрывает; четвёртое
|
||||
значение закрытого enum сломало бы генерённых клиентов. Рост пошёл через `Blocked.code`, который канон
|
||||
предавторизует.
|
||||
3. **Порядок двух причин в `blocked`.** Член один, кандидатов два, и они не равны: свой живой прогон —
|
||||
это «старта нет вовсе», чужой холд — «шкала короче, чем мог бы позволить баланс». Свой прогон
|
||||
выигрывает: сказать второе, когда верно первое, значит отправить человека останавливать ЧУЖОЙ прогон,
|
||||
после чего клик всё равно откажет. Пин: `httpapi.TestARunOfThisBooksOwnOutranksAnotherBooksHold`.
|
||||
|
||||
Контракт: минор **0.14.0** ратифицирован оркестратором (`D39.244`) по моему пингу — `Blocked.code`
|
||||
получил значение `run_in_flight`, смысл схемы расширен с «что держит шкалу короче» на «почему старт не
|
||||
предлагается так, как обещал бы один баланс», `book_id` при этой причине — ЭТА ЖЕ книга. Константа
|
||||
`httpapi.ContractVersion` переведена на `0.14.0` тем же деревом; гейт `gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified`
|
||||
зелёный.
|
||||
|
||||
### `PD-162` — отказ до денег, и вторая дверь, которой ряд не называл
|
||||
|
||||
Проверка каталога книги стоит на допуске ДО холда (`runs.go:707`=`func (s *Service) sourceThere`, зовётся
|
||||
из `Start` после предиката книги и перед чтением счёта). ⚠ **Трудности, которую промт объявлял главной,
|
||||
действительно нет** (§4.3 промта): каталог создаётся на интейке ДО строки в БД, а исключение первого
|
||||
прогона принадлежит ЖУРНАЛУ внутри каталога (`journalSize` мапит ENOENT в нулевой офсет) — так что
|
||||
`os.Stat(workdir)` на допуске не отвергает ничего законного, и никакой ветви-исключения я не строил.
|
||||
|
||||
**Что я нашёл сверх заказа: резюм — вторая денежная дверь, и клин там тот же.** Измерено исполнением ДО
|
||||
правки: тест `runs.TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold` на дереве без гарда дал
|
||||
`a resume over a missing directory answered <nil>, want ErrSourceGone` — то есть резюм над пропавшим
|
||||
каталогом ВОЗВРАЩАЛ прогон и брал холд остатка бюджета. Оркестратор подтвердил, что это в границах пака
|
||||
(«предмет `PD-162` — прогон, который никогда не поедет, а не конкретная ручка»), и гард стоит теперь и там
|
||||
(`platform/internal/runs/reconcile.go:1721`, перед `reopen`).
|
||||
|
||||
**Вина книги и вина хоста разведены и отвечают РАЗНЫМИ словами** — это урок `PD-192`, который зона
|
||||
уже оплатила один раз. Итоговое правило (третья и четвёртая строки приехали дофиксом по ревью):
|
||||
|
||||
| что с каталогом | ответ | почему так |
|
||||
|---|---|---|
|
||||
| его нет, книга ПОД корнем интейка, сентинел на месте | `ErrSourceGone` → 409 `book_not_ready` + `cause: source_gone` (на резюме — 409 `run_not_resumable` + та же причина) | это и вправду конец этой книги, и ожидание не поможет — так причина и говорит |
|
||||
| его нет вместе с СЕНТИНЕЛОМ корня (форма размонтирования) | `ErrStorageUnavailable` → 503 | вина ХОСТА; сказать здесь «ваша книга непригодна» — ровно тот дефект, который в интейке отклонил все книги тома |
|
||||
| его нет, а книга лежит ВНЕ корня интейка (`tmplatformctl book add --workdir`, инстанс без `BooksDir`) | `ErrStorageUnavailable` → 503 | сентинела на том томе нет вовсе: чужой здоровый маркер уликой про этот том не является, и платформа не объявляет конец книги на основании каталога, которого не создавала |
|
||||
| путь ЕСТЬ, но это не каталог (файл, симлинк на файл) | `ErrStorageUnavailable` → 503 | `os.Stat` на файле успешен, движок открывает каталог; писала этот файл не платформа |
|
||||
| путь есть и НЕ ЧИТАЕТСЯ (права, I/O) | `ErrStorageUnavailable` → 503, в сообщении операция и errno БЕЗ пути | ошибка уходит в ERROR-строку, а норма зоны §2 запрещает id книги в логах |
|
||||
|
||||
Предикат корня не скопирован, а **вызван**: `books.storageIsThere` стал экспортируемым
|
||||
`platform/internal/books/books.go:574`=`func StorageIsThere(booksDir string) bool`, а «книга под нашим
|
||||
корнем?» — `books.go:594`=`func Owns(booksDir, dir string) bool`; методы остались обёртками в одну
|
||||
строку. Копия была бы вторым экземпляром правила, у которого первая редакция стоила зоны всех книг
|
||||
хоста — и мутация, ломающая `StorageIsThere`, красит не только мой тест, но и ДВА чужих пина `PD-192`
|
||||
в `internal/books`, что и есть доказательство, что предикат один.
|
||||
|
||||
**Ряд остаётся ОТКРЫТЫМ, и остаток пере-написан честно:** класс шире удаления каталога — прогон, УЖЕ
|
||||
живущий, который не поедет никогда (запинен к старой сборке движка, без `reserved_usd`, беспризорный
|
||||
деплой), по-прежнему висит с холдом до прихода человека. Это половина эскроу (`П-18`), автоматического
|
||||
терминального вердикта у реконсилятора нет и он не строился.
|
||||
|
||||
### `PD-448` — разбор, а не стройка
|
||||
|
||||
⚠ Ниже — ТОЛЬКО разбор; кода по этому ряду я не написал ни строки.
|
||||
|
||||
**Механика, снятая чтением (адреса пере-сняты сегодня).** `Resume` читает состояние прогона ДО замка
|
||||
книги: `platform/internal/runs/reconcile.go:1652`=`s.Store.ReadRunForResume`, затем
|
||||
`reconcile.go:1663`=`unlock, err := s.lockBook(ctx, l.BookID)`, и только потом судит по `l.Status`
|
||||
(`reconcile.go:1696`) — по значению, прочитанному ДО замка. Из этого следуют два разных проигравших:
|
||||
|
||||
* **проигравший ВНУТРИ окна** (его чтение случилось до коммита победителя) держит устаревший
|
||||
`stopped`, доходит до `reopen`, его вставка `run_attempts` падает на уникальном индексе
|
||||
`run_attempts_run_id_attempt_no_key` → `ErrNoRun` → `Resume` отвечает **прогоном**
|
||||
(`reconcile.go:1736`=`return s.Store.ReadRun(ctx, userID, runID)`), и холд не берётся: транзакция
|
||||
откатывается целиком;
|
||||
* **проигравший СНАРУЖИ окна** (его чтение случилось уже после коммита победителя) видит
|
||||
`translating`, падает в `default:` и получает `ErrNotResumable` — «the run cannot be continued: it
|
||||
is translating». **Вот это и есть дефект ряда.**
|
||||
|
||||
⇒ **Окно дефекта — НЕ гонка за индекс, а интервал между чтением состояния и взятием замка.** И отсюда
|
||||
же объяснение, почему изолированно тест зелёный, а в полном пакете под нагрузкой красный: замок книги
|
||||
(`platform/internal/runs/bank.go:400`=`func (s *Service) lockBook`) — ВНУТРИПРОЦЕССНЫЙ мьютекс, так что
|
||||
два резюма одного демона идут друг за другом, и под голоданием по процессору горутины стартуют дальше
|
||||
друг от друга — проигравший чаще успевает прочитать уже переведённое состояние. Формулировка в шапке
|
||||
теста («both calls pass the state check together and race for attempt N+1») описывает состояние ДВУХ
|
||||
экземпляров демона; в одном процессе это последовательность, а не гонка.
|
||||
|
||||
**Воспроизводимость — замер сегодня, субагентом, инструментом и с числом прогонов** (вывод в
|
||||
`scratchpad/pd448/`, 67 файлов):
|
||||
|
||||
| что запускал (всё — `go test ./internal/runs/ -race`) | раз | красных | контроль |
|
||||
|---|---|---|---|
|
||||
| изолированно `-count=10 -run '^TestTwoResumes…$'`, load 0.17 | 10 | **0** | в выводе 10 строк `=== RUN` и 10 `--- PASS` с этим именем, других `---` нет |
|
||||
| полный пакет на простаивающей машине, `-count=1` | 3 | **0** | в каждом прогоне 183 верхнеуровневых теста, целевой `--- PASS` = 1, `--- FAIL` = 0 |
|
||||
| `GOMAXPROCS=1` (`-cpu 1`) | 5 | **0** | в каждом `=== RUN` = 1, `--- PASS` = 1 |
|
||||
| **под нагрузкой** (32 CPU-жруна на 8 ядрах, load 29–50), подмножество `TestTwoResumes\|TestResumeIsRefused` | 20 | **4** | `--- PASS` 16, `--- FAIL` 4 |
|
||||
| полный пакет под нагрузкой | 4 | **не измерено** | все четыре уперлись в `-timeout 9m` на ДРУГИХ тестах; целевой в каждом успел пройти зелёным до обрыва |
|
||||
|
||||
Текст всех четырёх падений ОДИН и тот же, и он читается, а не считается цветом:
|
||||
`control_test.go:898: resume 1: runs: the run cannot be continued: it is translating, want the run back`
|
||||
— то есть падает ровно утверждение «проигравший обязан ответить прогоном», и источник ошибки — ветка
|
||||
`default` из разбора выше. ⚠ **Запись ряда «полный пакет упал 2 из 2» сегодня НЕ пере-проверена:**
|
||||
полный пакет под нагрузкой ни разу не дошёл до конца, и это названо «не измерено», а не «не
|
||||
воспроизвелось».
|
||||
⚠ Замер шёл на МОЁМ дереве (гард каталога в `Resume` уже стоял). Путь гонки он не трогает, а если и
|
||||
влияет, то в сторону РЕЖЕ: лишний `os.Stat` внутри замка отодвигает коммит победителя, то есть чаще
|
||||
оставляет проигравшего в «хорошей» ветке. Числа выше поэтому — нижняя граница, а не верхняя.
|
||||
|
||||
**Чего сегодня нет, чтобы различить два случая.** Ни `LiveRun`, ни `run_attempts` не несут ни
|
||||
заявителя, ни ключа идемпотентности: колонки таблицы — `id · run_id · attempt_no · engine_run_id ·
|
||||
started_at · ended_at · exit_code · last_seq` (`platform/internal/pgstore/migrations/00002_readmodel.sql:72-87`,
|
||||
и ни один из четырёх поздних альтеров этого не добавил). То есть у проигравшего состояние побайтово то
|
||||
же, что у законного резюма часового прогона.
|
||||
|
||||
**Три пути промта, с ценой каждого:**
|
||||
|
||||
| путь | что делает | цена | закрывает ли предмет |
|
||||
|---|---|---|---|
|
||||
| **(а)** `Idempotency-Key` на `resumeRun` | второй клик под ТЕМ ЖЕ ключом реплеит ответ первого | контрактный минор (параметр операции) + клиент обязан прислать один ключ на оба клика | **не полностью:** при ОДНОВРЕМЕННЫХ кликах второй получает `409 key_in_flight` + `Retry-After` (`platform/internal/httpapi/idempotency.go:110`), то есть снова ошибку; прогоном он ответит лишь на ПОВТОРЕ |
|
||||
| **(б)** claim-токен или колонка в транзакции рестарта | записать, КТО и чем открыл попытку, и дать проигравшему это увидеть | **миграция** + суждение по временному окну («открыто секунду назад резюмом») | да, но окно — это лотерея под другим именем |
|
||||
| **(в)** перенести чтение состояния ПОД замок | убрать интервал | одна строка | ⛔ **делает хуже ДЕТЕРМИНИРОВАННО:** проигравший тогда видит `translating` ВСЕГДА |
|
||||
|
||||
**Рекомендация — четвёртый путь, которого в промте нет: сделать резюм идемпотентным ПО СУЩЕСТВУ.**
|
||||
Ветка `case "translating"` в том же switch (`reconcile.go:1696`) отвечает **прогоном**, а не отказом —
|
||||
с единственным исключением «стоп уже запрошен» (`l.StopRequestedAt != nil`; поле есть,
|
||||
`platform/internal/pgstore/runs.go:322`), потому что такой прогон сворачивается, а не продолжается, и
|
||||
202 там был бы ложью. Тогда оба проигравших — и внутри окна, и снаружи — отвечают прогоном, и мерить
|
||||
больше нечего: гонки не остаётся, потому что исходы совпали.
|
||||
Цена: **одна строка кода и одна строка канонной таблицы.** Ни миграции, ни ключа, ни нового состояния.
|
||||
Семантика честная: `POST /resume` означает «пусть этот прогон идёт»; если он уже идёт — требование
|
||||
выполнено, это ординарная идемпотентность записи.
|
||||
**Довод ПРОТИВ, который я вижу сам:** клиент теряет различие «я продолжил» и «оно и так шло», а канон
|
||||
этим свойством дорожит строкой выше — `paused` отвечает 409 именно потому, что 202 был бы «тихим
|
||||
no-op». Разница, на которой я стою: у `paused` НИЧЕГО не переоткрывается и лекарство другое (новый
|
||||
прогон), а у `translating` состояние ровно то, которого просил пользователь.
|
||||
**Чего я НЕ знаю:** как это увидит фронт — у него сегодняшний `409 run_not_resumable` описан таблицей
|
||||
канона, а его кода я не читал (чужая зона). И не знаю, есть ли деплой с ДВУМЯ экземплярами демона: при
|
||||
одном экземпляре внутрипроцессный замок делает «оба прошли проверку вместе» невозможным, при двух —
|
||||
возможным, и тогда путь (г) закрывает и эту форму тоже, а (а) и (б) — нет.
|
||||
|
||||
**ДИСПОЗИЦИЯ ОРКЕСТРАТОРА №23, пришла в тот же день.** Четвёртый путь ПРИНЯТ как направление, строит
|
||||
его СЛЕДУЮЩИЙ платформенный пак — не этот. Довод принятия оказался не тем, с которого я начинал:
|
||||
сомнение было в том, что идемпотентный `202` ломает канонную гарантию «202 = работа реально
|
||||
пере-открыта», а проверка показала, что зона её уже НЕ держит — дерево отдаёт `202` проигравшему, у
|
||||
которого пере-открыл не он, и это закреплено шапкой самого теста. Значит выбор был не между починкой
|
||||
и обходом, а между двумя смыслами глагола. Критерий, по которому это лечение: обход оставляет ответ
|
||||
зависимым от интерливинга и молчит об этом, лечение делает его ИНВАРИАНТНЫМ К ПОРЯДКУ.
|
||||
**Три вещи поехали в ряд `PD-448`, чтобы следующий пак их не потерял** — и каждую я пере-проверил
|
||||
своим прибором, а не принял на слово: вырез «стоп уже запрошен» обязателен (`l.StopRequestedAt`,
|
||||
`pgstore/runs.go:322`; слово `stop_requested` уже на проводе, `httpapi/v0.go:242`); проверка re-pass
|
||||
стоит ДО свитча (`reconcile.go:1687` против `:1696`) и после новой ветки должна пропускаться, иначе
|
||||
двойной клик по re-pass снова получит отказ; и остаток окна, который правка НЕ закрывает —
|
||||
`stop_requested` читается в том же незамкнутом снимке, а `Stop` замка книги не берёт вовсе (первый же
|
||||
его оператор — `RequestStop`), так что стоп между снимком и ответом даст один `202` на сворачивающийся
|
||||
прогон: один кадр, денег не трогает. Канон при этом НЕ двинут осознанно: строка таблицы — минор
|
||||
`0.15.0`, и бамп сегодня покрасил бы гейт версии на сданном дереве, где константа несёт `0.14.0`.
|
||||
|
||||
### ⚠ Расхождение канона и дерева — отдельным пунктом (правит оркестратор)
|
||||
|
||||
**1. `translating` у `resumeRun`.** Канон обещает `409 run_not_resumable` («nothing; it is already
|
||||
running» — таблица §`resumeRun` в `docs/architecture/14-api-contract/openapi.yaml`), а отгруженный код
|
||||
проигравшему гонки за уникальный индекс возвращает ПРОГОН, 202 (`reconcile.go:1736`). То есть одна
|
||||
ручка отвечает на одно состояние двумя разными способами в зависимости от того, где её застали. Это
|
||||
расхождение СТАРШЕ моего пака и им не создано; рекомендация (г) как раз и сводит обе стороны к одному
|
||||
ответу.
|
||||
|
||||
**2. Отказ резюма над пропавшим каталогом — ввёл Я, называю прямо.** `Resume` теперь может ответить
|
||||
`409 book_not_ready` + `cause: source_gone` (`reconcile.go:1721`), а таблица §`resumeRun` перечисляет
|
||||
ответы по СТАТУСУ прогона и такого отказа не знает. Генерённого клиента это не ломает: операция
|
||||
объявляет общий `409 Conflict`, а `book_not_ready` — законное значение `ErrorCode`, которое эта же
|
||||
поверхность уже отдаёт на соседней ручке. Но текст канона неполон, и строка таблицы нужна.
|
||||
|
||||
### Одна строка про `PD-175` (§4.4)
|
||||
|
||||
Следующим предметом ряда я считаю **ВЫХОД ИЗ `rejected`**, а не свип сирот и не потолок аплоадов.
|
||||
Довод: у отклонённой книги сегодня выхода нет ВООБЩЕ — единственный переход в `not_started` стоит в
|
||||
`platform/internal/pgstore/books.go:360` и требует `status = 'parsing'`, то есть перепарса не существует,
|
||||
а ручки удаления нет в контракте вовсе (`PD-122`). Такая строка живёт в библиотеке человека навсегда, и
|
||||
цена этого — доверие на КАЖДОЙ неудачной загрузке, тогда как сироты и потолок диска стоят операторского
|
||||
диска, который в закрытой бете ограничен её же размером. Свип и ретеншен при этом остаются за гейтом
|
||||
открытой регистрации (`D39.176` п.1), а счётный потолок аплоадов — это квота под другим именем, и он
|
||||
запрещён.
|
||||
|
||||
### Якоря регистра (§4.5): их было не четыре, а пятнадцать
|
||||
|
||||
Промт называл три уехавших якоря в зоне плюс четвёртый, линтером не ловимый (без токена, внутри
|
||||
`PD-162`). Все четыре починены. **Остальные одиннадцать убили МОИ СОБСТВЕННЫЕ переезды строк** —
|
||||
восемь на первом круге правок и ещё три после дофикса по ревью, — и это ровно то правило зоны, по
|
||||
которому «якорь, убитый твоим переездом, чинит тот, чей переезд его убил». Прибор до и после:
|
||||
`python3 docs/scripts/counts.py --lint` из корня репозитория — **23 проблемных якоря → 12**, и группа
|
||||
«по КОРНЮ ЦЕЛИ … internal 8» из выдачи исчезла целиком.
|
||||
⚠ Двенадцатый живой ✗ — **не мой к починке**: он лежит в `docs/architecture/05-decisions-log.md:2661`
|
||||
(зона оркестратора) и указывает В мою: `platform/internal/runs/runs.go:451` → `:479`. Убил его мой
|
||||
переезд, поэтому адрес назван ему пингом, а файл я не трогаю.
|
||||
⚠ И один урок про сам прибор: якорь БЕЗ токена (`path:line` без `=`) линтер не проверяет вовсе, так
|
||||
что мои собственные такие ссылки (`v0.go:610` в ряду `PD-455`) уехали МОЛЧА и были пере-сняты рукой.
|
||||
Ставя новый якорь, ставь токен.
|
||||
|
||||
### Находка → что сделано → ЧЕМ ПРЕДЪЯВЛЕНО
|
||||
|
||||
| находка | что сделано | чем предъявлено |
|
||||
|---|---|---|
|
||||
| форма заказа не спрашивала правило допуска (`PD-455`) | один предикат `startable`, две привязки; на проводе `blocked.code: run_in_flight` | пины (ниже) + живая проба на стенде: `blocked":{"code":"run_in_flight","book_id":"bk_7JDPZD3S7J22XS3I"}` и 409 на втором клике |
|
||||
| **резюм — ВТОРАЯ денежная дверь с тем же клином** (ряд её не называл) | гард `sourceThere` и там, перед `reopen` | до правки тест дал `a resume over a missing directory answered <nil>, want ErrSourceGone`; после — зелёный 8/8 |
|
||||
| **мой собственный `fmt.Errorf` унёс бы путь книги в ERROR-лог** (класс `PD-139`) | путь выброшен, оставлены операция и errno | пин `runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath`; в логе стенда `tmstand-truth` встречается 2 раза, обе — эхо конфига на буте |
|
||||
| мои переезды строк убили 8 чужих якорей (3 были сломаны до меня) | пере-снял все 11 в своей зоне + 12-й без токена внутри `PD-162` | `counts.py --lint`: 23 → 12 проблемных, группа «internal 8» исчезла |
|
||||
| 12-й якорь моего переезда живёт в ЧУЖОЙ зоне | НЕ правлю, сообщаю адрес оркестратору | `docs/architecture/05-decisions-log.md:2661`: `platform/internal/runs/runs.go:451` → `:479` |
|
||||
| `PD-455` покинул класс тревог гейта регистра | id убран из `alarmBaseline` с причиной, ТЕМ ЖЕ деревом (инструкция самого гейта) | `ALARM PD-count: 14 … (baseline 14)`, строка `ALARM PD-455 LEFT` исчезла, тест зелёный |
|
||||
| форма молчит про ПРОПАВШИЙ каталог | НЕ чинил, завёл ряд `PD-466` с доводом, почему опрос — не место для `os.Stat` | ряд в регистре |
|
||||
| носитель числа скипов батареи (`STACK_DECISIONS` §«Гейты батареи») называет не все условия | дописал два измеренных условия | два прогона одной смены назвали РАЗНЫЕ условия для одного и того же теста — оба вывода в отчёте ниже |
|
||||
| **рецепт стенда называл сторону НАОБОРОТ**: «относительные `pipeline:`/`models:` — отличие ШАБЛОНА», хотя относительные они в ОРИГИНАЛЕ, а в шаблоне обязаны быть абсолютными | сторона названа явно, добавлена проверка на месте и цена ошибки | замерено: рабочий шаблон стенда несёт АБСОЛЮТНЫЕ пути (строки 20–21), и с ним `books.TestTheRenderedConfigurationIsOneTheEngineActuallyLoads` зелёный против свежесобранного `tmctl`; по этой строке приёмка получила восемь красных живых проб, которые были дефектом шаблона, а не дерева |
|
||||
|
||||
### Какой тест что пинит (норма зоны §3 п.4)
|
||||
|
||||
| свойство несущего пути | пин |
|
||||
|---|---|
|
||||
| форма несёт отказ двери про живой прогон, и дверь отвечает то же | `runs.TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning` |
|
||||
| на проводе это `blocked.code: run_in_flight` с `book_id` ЭТОЙ книги | `httpapi.TestTheFormCarriesTheRunInFlightThatWillRefuseTheClick` |
|
||||
| свой живой прогон важнее чужого холда в одном члене `blocked` | `httpapi.TestARunOfThisBooksOwnOutranksAnotherBooksHold` |
|
||||
| старт над пропавшим каталогом отказывает ДО холда | `runs.TestABookWhoseDirectoryIsGoneIsRefusedBeforeTheHold` |
|
||||
| резюм над пропавшим каталогом отказывает ДО холда | `runs.TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold` |
|
||||
| размонтированный том — вина деплоя, и книгу она не винит | `runs.TestAVanishedBooksVolumeIsTheDeploymentsFaultAndNotTheBooks` |
|
||||
| нечитаемый каталог — тоже деплой, и путь книги в ошибку не попадает | `runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath` |
|
||||
| два отказа выходят на провод по-разному (409+`source_gone` против 503) | `httpapi.TestAVanishedSourceAndAVanishedVolumeAnswerDifferently` |
|
||||
| объявленная версия контракта = ратифицированный канон | `gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` (существующий) |
|
||||
|
||||
### Числа, снятые ПОСЛЕ последней правки
|
||||
|
||||
* **Батарея с ЧЕТЫРЬМЯ гейтами** (`TM_PLATFORM_TEST_DSN` на стендовый Postgres · `ENGINE_BIN` собран
|
||||
из ТЕКУЩЕГО `backend/` по `PD-432` · `BOOK_TEMPLATE` стенда · достижимый пользовательский systemd):
|
||||
`MAKE-EXIT=0` · **20 пакетов `ok` · 0 `FAIL`** · линтер `0 issues` · `sqlc diff` чисто · 5 скипов.
|
||||
(Перечислены все двадцать: `cmd/tmplatformctl` `cmd/tmplatformd` `auth` `backup` `books` `config`
|
||||
`exports` `gates` `httpapi` `ingest` `jobs` `login` `metrics` `money` `pgstore` `pricing`
|
||||
`readmodel` `reqid` `runner` `runs` — «зелёная батарея» без полного списка ничего не значит.)
|
||||
* **`make vuln`** (отдельная цель, в `check` не входит, блокер лендинга по §2 стандарта):
|
||||
`No vulnerabilities found.`, `VULN-EXIT=0`.
|
||||
* ⚠ **Оба числа ПЕРЕ-СНЯТЫ ПОСЛЕ ДОФИКСА по верификатору** (дофикс тронул `internal/gates` и
|
||||
`cmd/tmplatformd`, то есть код, а не только доки): батарея снова `MAKE-EXIT=0 · 20 ok · 0 FAIL ·
|
||||
линтер 0 issues · 5 скипов`, `make vuln` снова `No vulnerabilities found.`. Числа совпали с
|
||||
до-дофиксными — но это ИЗМЕРЕНО, а не унаследовано: правка кода после снятых чисел обнуляет их
|
||||
независимо от того, насколько она мала.
|
||||
* **Скипы: 5, и условий у них ДВА, а не одно** — это отдельная находка сегодняшнего дня.
|
||||
Четыре пробы `internal/runner` скипаются словами «this deployment's pipeline enables the bank
|
||||
contour and names `<репо>/backend/configs/mining-contrast.zh.txt`, which is not on this host»
|
||||
(артефакт чужой зоны, его отсутствие — не поломка платформы). Пятая,
|
||||
`runner.TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem`, умеет скипнуться ВТОРЫМ
|
||||
условием — `listen tcp 127.0.0.1:11434: bind: address already in use`, — и два честных прогона одной
|
||||
смены назвали для неё РАЗНЫЕ условия. Носитель числа (`STACK_DECISIONS` §«Гейты батареи») знал ноль
|
||||
из двух; оба дописаны туда же вместе с предупреждением, что по одному прогону условия не считают.
|
||||
* **Денежные пины не флейковые:** четыре теста × 8 прогонов под `-race` = **32 строки `--- PASS`,
|
||||
0 `--- FAIL`** (`go test ./internal/runs/ -race -count=8 -run '<четыре имени>'`).
|
||||
* **Живая проба на стенде** — свой демон (порт 18080, свой `BOOKS_DIR` под `$HOME`, своя база
|
||||
`tmstand_truth`), книга заведена ЖИВЫМ интейком через `tmplatformctl seed`, и порт сверен с PID
|
||||
своего демона перед сидом (шаг 3а рецепта). ⛔ `TM_PLATFORM_BOOKS_DIR` ни секунды не смотрел на
|
||||
`<репо>/books`; проверка стоит в самом скрипте стенда, а не в намерении.
|
||||
- форма над книгой в покое: `"blocked":null`, `verdict":"covers_all"`;
|
||||
- первый клик: `202`, баланс 25.000000 → 22.862733 (холд взят);
|
||||
- форма над той же книгой С ЖИВЫМ ПРОГОНОМ:
|
||||
`"blocked":{"code":"run_in_flight","book_id":"bk_7JDPZD3S7J22XS3I"}` — то есть **ровно то, что
|
||||
дверь и ответила** вторым кликом: `409 run_in_flight`;
|
||||
- каталог книги удалён (руками, внутри своего стенда) → `409 book_not_ready` + `cause: source_gone`,
|
||||
**баланс 25000000 до и 25000000 после** — холд не взят;
|
||||
- сентинел `.tmplatform-books` убран (форма размонтирования) → `503 service_unavailable`, баланс тот
|
||||
же, и в логе ERROR-строка `run refused: this deployment cannot start runs`;
|
||||
- ⚠ **Живая движковая половина на этом хосте ДОСТИЖИМА, и «5 скипов» читать как «сюда не
|
||||
добраться» нельзя:** приёмка №23 прогнала пробы `internal/runner` с шаблоном, чьи
|
||||
`pipeline:`/`models:` абсолютные, и получила **42 PASS · 0 FAIL** при тех же пяти скипах;
|
||||
условие одно — артефакт контраста, строка бэклога **251**, чужая зона. Числа приёмки, не мои.
|
||||
- ⚠ $0: движок на стенде провайдерских ключей не имеет (`.env` рядом с `book.yaml` нет, ключей в
|
||||
окружении 0), прогон честно умер `exit-code`, и холд вернулся сам.
|
||||
|
||||
### Мутационные посадки — 18 штук, выживших НЕТ
|
||||
|
||||
Изоляция по зонному стандарту §3 п.3: копия взята ВМЕСТЕ с каноном
|
||||
(`cp -a --parents platform docs/architecture/14-api-contract <куда>/`), перед каждой правкой
|
||||
скрипт утверждает `test -f go.mod` и что путь — копия, а не дерево. **База копии зелёная**
|
||||
(`internal/runs` ok 57 с · `httpapi` ok 2.8 с · `gates` ok 0.46 с · `books` ok 25 с, exit 0), и
|
||||
вердикты судятся по ДЕЛЬТЕ против неё.
|
||||
⚠ Первый прогон базы упёрся в `-timeout 20m` на `internal/books` — под ЧУЖОЙ нагрузкой (load 46,
|
||||
соседний замер), — и скрипт ОТКАЗАЛСЯ судить мутации против красной базы. Числа ниже со второго
|
||||
прогона. Та же ловушка поймала и адверсариальный проход: его первая батарея была красной по
|
||||
таймауту трёх пакетов из-за параллельной батареи, а не из-за дерева.
|
||||
|
||||
| # | что сломано | падает | ТЕКСТ падения (по нему и засчитано) |
|
||||
|---|---|---|---|
|
||||
| 1 | дверь перестаёт спрашивать про живой прогон | форма | `the form says <nil> over a book that is being translated, want the door's run_in_flight` |
|
||||
| 2 | форма перестаёт НЕСТИ ответ двери | форма | то же сообщение с другой стороны |
|
||||
| 3 | провод предпочитает чужой холд своему прогону | `httpapi` ×1 | `blocked = map[book_id:bk_other code:credit_held], want this book's own run` |
|
||||
| 4 | `blocked` называет чужую книгу | `httpapi` ×2 | `blocked.book_id = , want the book being asked about` |
|
||||
| 5 | допуск перестаёт смотреть на каталог | `runs` ×5 | `a start over a missing directory answered <nil>, want ErrSourceGone` |
|
||||
| **6** | **каталог смотрится ПОСЛЕ холда, а не до** | `runs` ×5 | **`a refused start moved money: {Balance:10000000 Reserved:0} -> {Balance:6180172 Reserved:3819828}`** — денежный пин не вакуумен: на кону были настоящие $3.819828 |
|
||||
| 7 | резюм перестаёт смотреть на каталог | `runs` ×1 | `a resume over a missing directory answered <nil>, want ErrSourceGone` |
|
||||
| 8 | пропавший ТОМ свалить на книгу | `runs` ×2 | `answered … its source directory is not on this deployment, want ErrStorageUnavailable` |
|
||||
| 9 | пропавший КАТАЛОГ свалить на деплой | `runs` ×2 | `answered … the book storage is not mounted on this deployment, want ErrSourceGone` |
|
||||
| **10** | **маркер СВОЕГО корня читается как улика про ЛЮБОЙ том** | `runs` ×1 | `a start over a book outside the root answered … not on this deployment, want the deployment's` — пин находки ревью |
|
||||
| 11 | сентинел хранилища перестаёт спрашиваться | `runs` ×1 **плюс два ЧУЖИХ пина `PD-192`** в `internal/books` | `the host is at fault, not the book` |
|
||||
| 12b | путь, который не каталог, проходит дверь | `runs` ×1 | `a start over a FILE in the book's place answered runs: stat journal: …` |
|
||||
| 13 | пропавший источник перестаёт быть видом `book_not_ready` | `runs` ×1 | `the refusal is not a book_not_ready: runs: its source directory is not on this deployment` |
|
||||
| 14 | причина `source_gone` замолкает у СТАРТА | `httpapi` ×1 | `cause = <nil>, want "source_gone" — without it the client offers waiting` |
|
||||
| 15 | РЕЗЮМ отвечает словами старта | `httpapi` ×1 | `code = book_not_ready, want "run_not_resumable"` |
|
||||
| **16** | **оба новых значения провода переименованы** | `httpapi` ×4 **и гейт** | `this build serves blocked.code: runInFlight and the canon's enum does not carry it: [credit_held run_in_flight]` — до дофикса эта посадка ВЫЖИВАЛА |
|
||||
| 17 | объявленная версия контракта уезжает от канона | `gates` ×1 | `this build announces contract 0.13.1 and the ratified canon is 0.14.0` |
|
||||
|
||||
**Итог: 18 посадок (17 + одна пере-посаженная), 17 пойманы, 0 выживших**, и каждая засчитана по
|
||||
ТЕКСТУ падения, а не по цвету.
|
||||
⚠ **Четыре посадки в ПЕРВОЙ редакции не собрались** — снятие блока оставляло неиспользованными `facts`,
|
||||
импорт `books`, переменную `st`, — и «ни один тест не упал» там означало «пакет не собрался», то есть
|
||||
НЕ ИЗМЕРЕНО, а вовсе не «дыра». Пере-посажены так, чтобы менялось РЕШЕНИЕ, а идентификаторы
|
||||
оставались в деле, и только тогда засчитаны. Это тот самый случай, когда вердикт по неправой причине
|
||||
отличается от поимки лишь тем, прочёл ли кто-нибудь текст.
|
||||
⚠ **Посадка 11 — улика «предикат ВЫЗВАН, а не скопирован»:** ломая `books.StorageIsThere`, она красит
|
||||
и мой тест, и ДВА чужих пина `PD-192`. Копия так бы не покраснела.
|
||||
⚠ **Посадка 1 показала границу:** снятие `HasLiveRun` из предиката красит только ФОРМУ — дверь
|
||||
по-прежнему отвергает вторую покупку, потому что за ней стоит частичный уникальный индекс
|
||||
`runs_one_live_per_book` (`pgstore.StartRun`, ветка `isUnique`). У двери есть второй рубеж, у формы
|
||||
его не было — это и есть предмет `PD-455` одной фразой.
|
||||
|
||||
### Греп ОТКРЫТЫХ рядов регистра по СВОИМ ПОЛНЫМ путям (норма зоны §3 п.8)
|
||||
|
||||
Прибор: греп по полным путям семи тронутых файлов с разбором таблицы регистра — **34 открытых ряда**
|
||||
цитируют мои файлы. Диспозиции:
|
||||
|
||||
* **тронуты механизмом — четыре:** `PD-455` закрыт · `PD-162` сужен, остаток пере-написан · `PD-448`
|
||||
разобран и остаётся открытым · `PD-466` заведён этим паком.
|
||||
* **назван, но не закрыт — один:** `PD-139` (путь каталога книги утекает в ERROR-логи внутри обёрнутых
|
||||
ошибок). Моя новая ветка МОГЛА стать его третьим носителем и не стала — путь выброшен из ошибки, пин
|
||||
`runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath`. Сам ряд я не лечил: два его
|
||||
существующих носителя — тейлер и обёртки спавна — мой дифф не трогает.
|
||||
* **`PD-175`** — §4.4, одна строка выше.
|
||||
* **остальные 28 — совпадение по ИМЕНИ ФАЙЛА, а не по предмету** (реконсилятор, свип, телеметрия,
|
||||
интейк, читающая модель). Проверяемо: в `reconcile.go` мой дифф — ОДИН хунк на 8 строк
|
||||
(`git diff -- platform/internal/runs/reconcile.go | grep -E '^@@'` даёт ровно одну строку,
|
||||
`@@ -1713,6 +1713,14 @@`), в `books.go` — экспорт одной функции, в `runner.go` — одно поле
|
||||
конфигурации. Оставлены открытыми с этой причиной.
|
||||
|
||||
### Что сказал адверсариальный проход (author≠reviewer, исполнением)
|
||||
|
||||
Направленный второй читатель по пяти осям §5 промта, со своей копией дерева и своими посадками.
|
||||
**Девять находок, четыре существенные, и три из них я сам не видел.** Что сделано с каждой:
|
||||
|
||||
| находка прохода | вердикт | что сделано |
|
||||
|---|---|---|
|
||||
| **F3.** `sourceThere` спрашивал сентинел КОРНЯ ИНТЕЙКА про любую книгу — а книга от `tmplatformctl book add --workdir` живёт на томе, которого платформа не писала. Проход показал пробой: том такой книги пропал, корень интейка здоров ⇒ ответ был «конец КНИГИ». **Это `PD-192` заново, моими руками** | принята целиком | сентинел спрашивается только под своим корнем (`books.Owns`); где спрашивать нечего — ответ деплоя. Пин `runs.TestABookOutsideTheIntakesRootIsNotDeclaredDeadByAnotherVolumesMarker` |
|
||||
| **F2.** Оба новых значения провода (`run_in_flight`, `source_gone`) не были запинены НИЧЕМ: все мои ассерты сравнивали провод с той же Go-константой. Проход переименовал обе — **вся батарея осталась зелёной** (класс `PD-1`) | принята целиком | ассерты переписаны на ЛИТЕРАЛЫ + гейт со ВТОРЫМ независимым источником: `gates.TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates` читает enum `Blocked.code` из канона и сверяет в обе стороны |
|
||||
| **F1.** Отказ резюма выходил словом СТАРТА (`book_not_ready` на `runId`), которого таблица §`resumeRun` не знает, и не был покрыт ни одним тестом | принята | резюм отвечает своим словарём — `409 run_not_resumable` + `cause: source_gone`; это форма, какой канон уже пользуется для двух осей, перекрывающих таблицу. Пин `httpapi.TestAResumeOverAGoneSourceIsRefusedInTheResumeHandlesOwnWords`. Строка канонной таблицы всё равно нужна — пункт оркестратору |
|
||||
| **F5.** «Одно определение» верно для предиката, но отображение «ответ предиката → член провода» — вторая рукописная вещь с МОЛЧАЛИВЫМ хвостом: новый отказ завтра оставит форму с `covers_all` | принята | хвост стал громким: незнакомый отказ пишет ERROR-строку оператору. Компайл-тайм это не ловит (предикат отвечает `error`), и я это говорю, а не прячу |
|
||||
| **F8.** `os.Stat` успешен на ФАЙЛЕ: путь, который есть, но не каталог, проходил дверь | принята | `IsDir()`, ответ деплоя; пин `runs.TestAFileWhereTheBooksDirectoryShouldBeIsRefusedToo` |
|
||||
| **F9.** Фраза «the wire this build serves is the 0.13.1 shape» осталась в настоящем времени над константой `0.14.0` | принята | время исправлено |
|
||||
| **F4.** Свойство «путь книги не течёт в ERROR» запинено у `sourceThere` и сломано строкой позже на том же вызове: `journalSize` (`internal/runs/spawn.go:301`) отдаёт `*fs.PathError` целиком, и он уходит в `default:` → 500 | **не лечил, назвал** | это носитель ряда `PD-139`, у которого СВОЯ диспозиция («решение, а не побочный эффект»); я записал в ряд и своё решение по своей ветке, и второй носитель адресом. Класс закрыт на ОДИН носитель из двух, и в отчёте это сказано так |
|
||||
| **F6.** `bank.go` несёт посимвольную копию первой ветки предиката | **не лечил, назвал** | копируется не ПРАВИЛО, а предложение: сам предикат `readyToTranslate` там и вызывается, а живой-прогон половина у двери банка законно ДРУГАЯ (исключение `awaiting_bank`, ратифицированное находкой P9). Сводить их значило бы либо сломать исключение, либо втащить внутрь флаг «я дверь банка» |
|
||||
| **F7.** Реконсилятор при своём респавне каталог не спрашивает, и окно между проверкой и спавном остаётся | **граница, а не дефект** | ровно то, что абзац «ОТКРЫТЫМ остаётся» ряда `PD-162` и говорит; половина живого прогона — эскроу (`П-18`) |
|
||||
|
||||
⚠ **Проход также подтвердил три моих утверждения исполнением, а не чтением:** автоматического
|
||||
терминального вердикта живому прогону в дельте нет (грепы по `+367` добавленным строкам
|
||||
`reconcile.go`); `PricedBook` не тронут (`pgstore/books.go` вообще отсутствует в `git status`);
|
||||
контракт-первичность соблюдена — канон уехал на 0.14.0 РАНЬШЕ кода, коммитом `ba8542a`.
|
||||
⚠ И назвал ловушку прибора, которую я знал по чужому опыту, а он встретил сам: его ПЕРВАЯ батарея была
|
||||
красной по таймауту трёх пакетов — из-за параллельной батареи на той же машине, а не из-за дерева;
|
||||
на чистом перепрогоне те же пакеты дали 48 с / 148 с / 123 с.
|
||||
|
||||
|
||||
### Чего НЕ удалось / не измерено (§10)
|
||||
|
||||
* **Полный пакет `internal/runs` под нагрузкой ни разу не дошёл до конца** — четыре попытки уперлись в
|
||||
`-timeout 9m` на ДРУГИХ тестах (`TestALiftedQuarantineMaterializesTheJournalAgainFromTheCursor`,
|
||||
`TestARestartHoldsWhatTheRunWasSoldForWhenTheRateHasMovedSince`, `TestADeadlockDoesNotStopTheProjection`).
|
||||
Значит запись ряда `PD-448` «упал 2 из 2 полным пакетом» сегодня **не пере-проверена**; гонка
|
||||
воспроизведена подмножеством. Это «не измерено», а не «не воспроизвелось».
|
||||
* **Цена второго чтения на пути формы не замерена.** Я утверждаю, что +1 индексный запрос на опрос
|
||||
приемлем рядом с уже стоящим там пер-главным агрегатом, но бенчмарка не снимал — это суждение.
|
||||
* **Ветка «стат каталога упал не-ENOENT» проверена только правами** (`chmod 000` на корне): EIO,
|
||||
залипшее сетевое монтирование и симлинк-в-никуда я не воспроизводил.
|
||||
* **Гонка «каталог удалён МЕЖДУ проверкой и спавном» остаётся** — проверка сужает окно, а не закрывает
|
||||
его; это уже половина живого прогона, то есть эскроу (`П-18`), и её пак не брал.
|
||||
* **Фронт я не читал** (чужая зона), поэтому не знаю, как он сегодня рисует `blocked` и что сделает с
|
||||
новым значением кода; канон обязывает клиента терпеть незнакомое значение, но проверить это я не мог.
|
||||
* **Число скипов батареи снято дважды и дало РАЗНЫЕ условия** для одного и того же теста (см. ниже) —
|
||||
считать условия по одному прогону нельзя, и я не знаю, сколько их всего.
|
||||
|
||||
### Что я сам считаю слабым местом сделанного
|
||||
|
||||
1. **Форма по-прежнему может обещать старт над пропавшим каталогом** (`PD-466`). Я разрезал правило на
|
||||
«факты БД спрашивают обе площадки» и «диск спрашивает только дверь», и довод у разреза есть
|
||||
(опрос — не место для `os.Stat` зависшего монтирования), но это именно РАЗРЕЗ: половина обещания
|
||||
формы осталась неправдой, и я назвал её, а не закрыл.
|
||||
2. **`Options.Refusal` — поле типа `error` в структуре-значении.** Оно даёт слою провода решать
|
||||
словарь (что правильно: коды живут в `httpapi`), но это необычная форма, и следующий читатель может
|
||||
принять её за «ошибку чтения формы», а не за «ответ двери». Комментарий это говорит; форма всё равно
|
||||
на любителя.
|
||||
3. **Обе главные находки прохода — мои СЛЕПЫЕ ПЯТНА, и они одного рода.** Я построил фикстуру
|
||||
`onTheVolume` под свою же картину мира (книга лежит под корнем интейка) и посадил двенадцать
|
||||
мутаций — но ни фикстура, ни каталог мутаций не могли найти случай, которого в моей картине не
|
||||
было: книгу ВНЕ этого корня. И мутировал я ЛОГИКУ, ни разу не тронув СЛОВАРЬ, поэтому «переименуй
|
||||
оба новых значения провода» в мой каталог не попало — а именно эта посадка и выживала. Вывод, к
|
||||
которому я пришёл не сам: свои мутации проверяют то, что автор считает важным, и ровно поэтому
|
||||
второй читатель не роскошь.
|
||||
4. **Мой предикат не покрывает третью площадку, которая судит те же факты** — дверь правок банка
|
||||
(`internal/runs/bank.go:124-142`) читает `readyToTranslate` и `HasLiveRun` своим кодом, потому что у
|
||||
неё СВОЁ правило (исключение для `awaiting_bank`, ратифицированное находкой P9). Я сознательно её не
|
||||
тронул: свести их в один предикат значило бы либо сломать исключение, либо втащить в предикат флаг
|
||||
«я — дверь банка», после которого «одно определение» становится лозунгом. Но факт остаётся: слово
|
||||
«одно определение» верно для ДВУХ площадок из трёх, и я предпочитаю сказать это прямо.
|
||||
|
||||
### Дофикс по верификатору приёмки (11.09, тот же день) — один ВЫЖИВШИЙ мутант и одно тихо-зелёное
|
||||
|
||||
Проход верификатора по замороженной копии: девять посадок, семь чистых поимок, **один выживший и одно
|
||||
тихо-зелёное**. Оба — вне моей карты находок, оба однострочные, и оба ломали ровно тот механизм, что
|
||||
пак объявил главным. Обе находки я пере-проверил исполнением, прежде чем чинить.
|
||||
|
||||
1. **ВЫЖИВШИЙ: единственная проводка `BooksDir` в дверь не была утверждена ничем.**
|
||||
`cmd/tmplatformd/runner.go:39` — единственное производственное присваивание, а соседний тест-свидетель
|
||||
несёт комментарий «Mutation caught: dropping any assignment in runsConfig» и **`BooksDir` не
|
||||
утверждал**. Посадка «снять строку» пережила полную батарею. Цена: с непроведённым корнем
|
||||
`books.Owns("", …)` ложен для КАЖДОЙ книги, и книга, чей собственный каталог пропал, отвечает `503`
|
||||
и операторским «this deployment cannot start runs» вместо `409 book_not_ready`+`source_gone` — то
|
||||
есть путаница `PD-192` возвращается одной выпавшей строкой, молча, при целых деньгах. Запинено
|
||||
своим случаем в `TestTheOperatorsRunnerKnobsReachTheReconciler`; посадка теперь краснеет текстом
|
||||
«BooksDir is "", want the intake's …: the run door cannot tell whose fault a missing directory is».
|
||||
⚠ И класс тут тот же, что мы записали нормой сегодня: **комментарий теста утверждал ШИРЕ, чем тест
|
||||
делал.** Теперь утверждение и комментарий сошлись — покрыты все одиннадцать полей.
|
||||
2. **ТИХО-ЗЕЛЁНОЕ В МОЁМ ЖЕ НОВОМ ГЕЙТЕ.** Регулярка `(?s)\n Blocked:\n.*?\n +enum: \[…\]`
|
||||
телом схемы не ограничена: с удалённым из канона enum она лениво дотягивалась до СЛЕДУЮЩЕГО
|
||||
`enum:` и зачитывала `Usage.state` семьюдесятью строками ниже — красное по неправой причине; а если
|
||||
те же два слова положить под соседнюю схему, гейт отвечал **`--- PASS` над каноном, который их
|
||||
больше не ратифицирует**. Чтение теперь ограничено телом схемы (`enumOfSchema`), и у самого
|
||||
читателя есть пин на трёх синтетических документах — `TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody`.
|
||||
Проверено посадкой верификатора на копии: удаление enum из канона даёт теперь МОЙ текст
|
||||
«schema "Blocked" carries no enum», а не чужие значения.
|
||||
⚠ Горькая деталь: этот гейт я построил ровно как противоядие от своего слепого пятна по сигналу
|
||||
прохода — и построил его с собственным слепым пятном того же рода. Прибор, проверяющий словарь,
|
||||
сам обязан иметь пин; теперь имеет.
|
||||
3. **Носитель числа скипов нёс ДВА разных числа.** Мой новый блок говорил «5 скипов», а нетронутая
|
||||
строка того же раздела — «ожидание при всех четырёх: … **скипов 0**» (замер 29.08). Читатель,
|
||||
попавший на вторую, объявил бы приёмку при пяти скипах или счёл бы пять поломкой. Разведено по
|
||||
УСЛОВИЯМ: ноль относится к дереву 29.08, где не было ни артефакта контраста (строка бэклога 251),
|
||||
ни занятого локального адреса провайдера; счёт скипов сверяется с блоком, который эти условия
|
||||
называет.
|
||||
4. **Якорь `PD-448` на сам тест уехал** (`control_test.go:846` → `:881`) — уехал ДО пака и промтом не
|
||||
назывался, починен заодно. Плюс мой собственный `contract_test.go:16` → `:17`, который сдвинул
|
||||
импорт, добавленный этим же дофиксом.
|
||||
|
||||
⚠ **Чего в этом списке НЕТ и почему.** Верификатор назвал пятым пунктом отсутствие фразы «работа
|
||||
завершена, править не планирую» в зонном журнале — **фраза там была и есть**, в конце моей
|
||||
секции (на момент проверки — `platform-PROGRESS.md:481`, после этой вставки она уехала ниже; греп по
|
||||
файлу даёт ТРИ хита: сама фраза, её упоминание в этом абзаце и пак 08.09). Проверил прибором, прежде
|
||||
чем «чинить»: приёмку своей работы тоже надо проверять, иначе в журнал уедет вторая копия той же
|
||||
фразы и следующая смена будет гадать, какая из них настоящая.
|
||||
|
||||
**Работа завершена, править не планирую.** Дерево — 14 путей, все в `platform/` (13 изменённых плюс
|
||||
новый `internal/runs/admission_test.go`), вне зоны не тронуто ничего; `docs/PROGRESS.md`,
|
||||
`docs/experiments/`, `backend/docs/` в дереве — работа соседних смен. Сессия не коммитит: передано
|
||||
оркестратору №23 вместе с тремя пунктами, которые правит он (якорь в журнале решений · строка
|
||||
канонной таблицы §`resumeRun` · расхождение по `translating`, старше этого пака).
|
||||
|
||||
|
||||
## ДОРАБОТКА ПО ИНВАРИАНТУ `D39.240` — ОТЧЁТ (11.09, `textmachine-5c`)
|
||||
|
||||
> Три предмета: строки **401** (вторая половина), **394**, **392**. Вход HEAD `d61469f`, дерево зоны
|
||||
|
|
@ -927,7 +1413,7 @@ ratified canon is 0.13.0». Улика: файл-носитель (`internal/htt
|
|||
| `docs/PROGRESS.md`, `docs/architecture/05-decisions-log.md` | **4** | ЧУЖАЯ зона — ушли пингом с готовыми адресами и токенами |
|
||||
|
||||
⭐ **Находка из этого же хода:** экранирование `\|` в ячейке регистра (§4.5) **ломает якорь**, если черта
|
||||
попала в его токен. `PD-422` держал `internal/runs/runs.go:408`=`resnapshot := book.BankMoved || book.HasPriorRun`;
|
||||
попала в его токен. `PD-422` держал `internal/runs/runs.go:436`=`resnapshot := book.BankMoved || book.HasPriorRun`;
|
||||
после экранирования токен перестал совпадать с кодом. Вылечено укорочением токена до
|
||||
`resnapshot := book.BankMoved` (единственный хит в файле). Счёт колонок и сверка токена тянут ячейку в
|
||||
разные стороны — следующий, кто пойдёт экранировать черты, наступит на то же. Ушло пингом.
|
||||
|
|
|
|||
|
|
@ -552,7 +552,8 @@ func (s *Service) markStorage() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// storageIsThere reports whether the books storage is the one this platform wrote into.
|
||||
// StorageIsThere reports whether the books storage under booksDir is the one this platform wrote
|
||||
// into.
|
||||
//
|
||||
// ⚠ It asks about the MARKER and not about the directory, and the difference is the whole point. A
|
||||
// volume mounted AT BooksDir leaves an empty mountpoint behind when it is unmounted, so the directory
|
||||
|
|
@ -564,19 +565,37 @@ func (s *Service) markStorage() error {
|
|||
//
|
||||
// A host that has never taken an upload has no marker either, and answers "not there" — which is the
|
||||
// safe direction: it has no books to reject.
|
||||
func (s *Service) storageIsThere() bool {
|
||||
if s.Cfg.BooksDir == "" {
|
||||
//
|
||||
// ⚠ Exported and taking the root as an argument because the RUN DOOR asks the same question of the
|
||||
// same storage (runs.sourceThere): a book admitted over a vanished directory holds the account's
|
||||
// credit for good, and telling that apart from an unmounted volume is this predicate, not a second
|
||||
// edition of it. A copy would be the same defect twice — the first edition of THIS one cost the
|
||||
// intake every book on the host.
|
||||
func StorageIsThere(booksDir string) bool {
|
||||
if booksDir == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(filepath.Join(s.Cfg.BooksDir, StorageMarker))
|
||||
_, err := os.Stat(filepath.Join(booksDir, StorageMarker))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Service) owns(dir string) bool {
|
||||
if s.Cfg.BooksDir == "" || dir == "" {
|
||||
func (s *Service) storageIsThere() bool { return StorageIsThere(s.Cfg.BooksDir) }
|
||||
|
||||
func (s *Service) owns(dir string) bool { return Owns(s.Cfg.BooksDir, dir) }
|
||||
|
||||
// Owns reports whether dir is a path this platform's intake created, i.e. one UNDER booksDir.
|
||||
//
|
||||
// ⚠ Exported for the run door (runs.sourceThere), and the question it answers there is which fault a
|
||||
// missing directory is. The storage marker only says something about booksDir, so it may only be
|
||||
// consulted about books that live under it: a book placed by hand somewhere else — `tmplatformctl
|
||||
// book add --workdir` — sits on a volume this platform never wrote and has no sentinel on, and
|
||||
// reading the intake's marker as evidence about THAT volume is the PD-192 mistake with a longer
|
||||
// path.
|
||||
func Owns(booksDir, dir string) bool {
|
||||
if booksDir == "" || dir == "" {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(s.Cfg.BooksDir, dir)
|
||||
rel, err := filepath.Rel(booksDir, dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package gates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"textmachine/platform/internal/httpapi"
|
||||
|
|
@ -40,3 +43,148 @@ func TestTheAnnouncedContractVersionIsTheOneTheCanonRatified(t *testing.T) {
|
|||
t.Errorf("this build announces contract %s and the ratified canon is %s; the constant is raised in the same change as the code that implements a minor, never afterwards", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The `blocked` vocabulary this deployment serves must be the one the canon enumerates.
|
||||
//
|
||||
// ⛔ IT IS THE SECOND CHECK IN THIS ZONE WITH AN INDEPENDENT SOURCE, and it exists because the first
|
||||
// one — the version number above — cannot see a SHAPE. Every other test of these values compares the
|
||||
// wire to the same Go constant it was rendered from, which is self-consistent and passes at any
|
||||
// value: rename both of them and the whole module stays green (measured by the pack's own adversarial
|
||||
// pass, 11.09). That is the class PD-1 names, and `Blocked.code` is the one place it costs a
|
||||
// contract: the canon declares a CLOSED enum there, so a value this build serves and the canon does
|
||||
// not is a client generated against the canon meeting a word its stubs do not carry.
|
||||
//
|
||||
// It reads the canon rather than a copy of it, like its neighbour, and it asserts BOTH directions:
|
||||
// a value served and not ratified is a wire ahead of the contract, a value ratified and not served
|
||||
// is a contract ahead of the wire — and which of the two is a defect depends on the day, so the gate
|
||||
// names what it saw instead of guessing.
|
||||
func TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates(t *testing.T) {
|
||||
src, err := os.ReadFile(filepath.Join(canonPath))
|
||||
if err != nil {
|
||||
t.Fatalf("the ratified canon could not be read, so the served `blocked` vocabulary has nothing to be checked against: %v", err)
|
||||
}
|
||||
ratified, err := enumOfSchema(string(src), "Blocked")
|
||||
if err != nil {
|
||||
t.Fatalf("the canon's Blocked schema: %v — this gate reads the wrong document or the wrong shape", err)
|
||||
}
|
||||
// What this build can put in that member, written out rather than derived: a list a test walks is
|
||||
// a list somebody has to extend deliberately, which is the whole point of a closed vocabulary.
|
||||
served := map[string]bool{
|
||||
httpapi.CauseCreditHeld: true,
|
||||
httpapi.CauseRunInFlight: true,
|
||||
}
|
||||
for v := range served {
|
||||
if !ratified[v] {
|
||||
t.Errorf("this build serves `blocked.code: %s` and the canon's enum does not carry it: %v", v, keys(ratified))
|
||||
}
|
||||
}
|
||||
for v := range ratified {
|
||||
if !served[v] {
|
||||
t.Errorf("the canon ratifies `blocked.code: %s` and this build never serves it: the wire is behind the contract", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// enumOfSchema returns the values of the one `enum:` inside the named schema of the canon.
|
||||
//
|
||||
// ⛔ THE SEARCH IS BOUNDED BY THE SCHEMA'S OWN BODY, and the bound is the whole of this function. The
|
||||
// first edition was one regexp — `\n Blocked:\n.*?\n +enum: \[…\]` — whose `.*?` was stopped by
|
||||
// nothing: with the enum deleted from the canon it walked on and read the NEXT schema's, so a missing
|
||||
// closed vocabulary came back as `[exhausted low ok]` from `Usage.state` seventy lines below. Red, and
|
||||
// red for the wrong reason; move those two words under the following schema instead and the gate goes
|
||||
// GREEN over a canon that no longer ratifies them at all. A gate that can read a neighbour's answer is
|
||||
// not a second source, it is a coin.
|
||||
//
|
||||
// The body is every line indented deeper than the schema's own name, which is what YAML means by
|
||||
// nesting and what the canon does everywhere. Pinned by TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody,
|
||||
// whose fixtures are exactly the two shapes above.
|
||||
func enumOfSchema(canon, schema string) (map[string]bool, error) {
|
||||
head := "\n " + schema + ":\n"
|
||||
i := strings.Index(canon, head)
|
||||
if i < 0 {
|
||||
return nil, fmt.Errorf("no schema %q in the document", schema)
|
||||
}
|
||||
body := canon[i+len(head):]
|
||||
// …up to the next line that is indented no deeper than the schema's name: the next schema, or the
|
||||
// end of the section.
|
||||
if end := regexp.MustCompile(`(?m)^ {0,4}\S`).FindStringIndex(body); end != nil {
|
||||
body = body[:end[0]]
|
||||
}
|
||||
m := regexp.MustCompile(`(?m)^ +enum: \[([^\]]*)\]`).FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("schema %q carries no enum", schema)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, v := range strings.Split(m[1], ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
out[v] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// The reader stops at the schema's own body — asserted on documents built for the question, because
|
||||
// the live canon cannot show the failure this exists to prevent.
|
||||
//
|
||||
// Both fixtures are the adversarial pass's own posting: delete the enum and see whether a neighbour's
|
||||
// is read instead. The first says the reader must REFUSE; the second says it must refuse even when the
|
||||
// missing words are sitting a few lines below under another name, which is the shape that came back
|
||||
// GREEN from the first edition.
|
||||
func TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody(t *testing.T) {
|
||||
const withIt = `
|
||||
Blocked:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [credit_held, run_in_flight]
|
||||
Usage:
|
||||
properties:
|
||||
state:
|
||||
enum: [exhausted, low, ok]
|
||||
`
|
||||
got, err := enumOfSchema(withIt, "Blocked")
|
||||
if err != nil || !got["credit_held"] || !got["run_in_flight"] || len(got) != 2 {
|
||||
t.Fatalf("the reader did not read the schema's own enum: %v (%v)", keys(got), err)
|
||||
}
|
||||
// The enum is GONE from Blocked and a neighbour still has one.
|
||||
const withoutIt = `
|
||||
Blocked:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
Usage:
|
||||
properties:
|
||||
state:
|
||||
enum: [exhausted, low, ok]
|
||||
`
|
||||
if got, err := enumOfSchema(withoutIt, "Blocked"); err == nil {
|
||||
t.Errorf("a schema with no enum answered %v: the reader walked into the next schema", keys(got))
|
||||
}
|
||||
// And the same, with the missing words present further down under another name — the shape that
|
||||
// makes a lenient reader answer RIGHT for the wrong document.
|
||||
const movedAway = `
|
||||
Blocked:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
Somewhere:
|
||||
properties:
|
||||
code:
|
||||
enum: [credit_held, run_in_flight]
|
||||
`
|
||||
if got, err := enumOfSchema(movedAway, "Blocked"); err == nil {
|
||||
t.Errorf("the words were read from the wrong schema: %v", keys(got))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,11 +73,16 @@ var alarmBaseline = []string{
|
|||
// ровно один) и о том, что отказ приходит МОЛЧА для пользователя, дважды нажавшего кнопку.
|
||||
// Ниже major он стоит потому, что деньги не теряются: отказ случается ДО взятия холда.
|
||||
"PD-448",
|
||||
// PD-455 — вошёл в класс 06.09 вторым кругом пака «форма заказа». Маркеры законны: ряд про то, что
|
||||
// форма МОЛЧИТ о живом прогоне, и про ХОЛД, потому что отказ случается ровно до его взятия. Ниже
|
||||
// major он стоит по той же причине, по которой его цена терпима: денег не теряется и работа не
|
||||
// портится — ломается обещание «вердикт ДО клика», то есть продукт, а не деньги.
|
||||
"PD-455",
|
||||
// ⚠ `PD-455` LEFT the class on 11.09 and its id is dropped here in the same change, by the same
|
||||
// instruction and the same precedent as PD-168 above. It was `open` because the order form was
|
||||
// SILENT about a live run and said `covers_all` in front of a click the door would refuse; the
|
||||
// form now carries that refusal as `blocked.code: run_in_flight` from the door's own predicate
|
||||
// (`runs.startable`, contract 0.14.0), and the row is `fixed`. Pinned by
|
||||
// `runs.TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning` and
|
||||
// `httpapi.TestTheFormCarriesTheRunInFlightThatWillRefuseTheClick`.
|
||||
// · The silence about a VANISHED DIRECTORY is a different row (`PD-466`, info) and it does not
|
||||
//
|
||||
// enter this class: it carries one marker, the hold it never reaches.
|
||||
// PD-456 — вошёл в класс 06.09 вторым кругом пака «форма заказа». Маркеры несёт ЗАКОННО: ряд про
|
||||
// деньги и про холд, потому что дефект — завышение оценки остатка (юнит, у которого сделан только
|
||||
// черновик, входит в цену обеими волнами). Ниже major он стоит потому, что **ошибка направлена
|
||||
|
|
|
|||
|
|
@ -40,8 +40,14 @@ import "net/http"
|
|||
// ⚠ 0.13.1 is the canon's CORRECTING minor (D39.235 §6) and this constant did not follow it for a day:
|
||||
// the canon moved first and the gate stood red the whole time, which is the reverse of the order the
|
||||
// paragraph above ratifies. The number is brought to the canon here rather than the other way round,
|
||||
// because the canon is the one that is right — the wire this build serves is the 0.13.1 shape.
|
||||
const ContractVersion = "0.13.1"
|
||||
// because the canon was the one that was right — the wire this build served then was the 0.13.1 shape.
|
||||
//
|
||||
// ⚠ 0.14.0 grows `Blocked.code` by `run_in_flight` (D39.244): the order form now says that a book
|
||||
// with a run of its own is not startable, instead of quoting `covers_all` over a click admission was
|
||||
// going to refuse (PD-455). The value went into the canon's enum and the form serves it, which is a
|
||||
// served SHAPE and therefore a minor — the vocabulary of second-level `cause` codes beside it stays
|
||||
// open and moves no number.
|
||||
const ContractVersion = "0.14.0"
|
||||
|
||||
// Capabilities is what this deployment can do: one flat document, the same for every account.
|
||||
type Capabilities struct {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,20 @@ const (
|
|||
CauseChapterOrdersUnavailable = "chapter_orders_unavailable"
|
||||
// CauseCreditHeld — another book of this account holds the credit; `blocked` names it.
|
||||
CauseCreditHeld = "credit_held"
|
||||
// CauseRunInFlight — this book has a run of its own going, so there is no start to offer at all,
|
||||
// whatever the balance covers. It fills `RunOptions.blocked` and names THIS book: the place the
|
||||
// user can act is the book's own card, where that run is.
|
||||
//
|
||||
// ⚠ THE ONE VALUE HERE WHOSE VOCABULARY IS NOT THIS OPEN LIST. As a `cause` the rule above holds;
|
||||
// as a `Blocked.code` it is the canon's own enum beside `credit_held`, which a client may be
|
||||
// generated from — so it travels with the contract minor that adds it there, and never ahead of
|
||||
// it.
|
||||
CauseRunInFlight = "run_in_flight"
|
||||
// CauseSourceGone — the book's project directory is not on this deployment any more, so a run
|
||||
// over it could never move. Its own cause under `book_not_ready` because the remedy is opposite
|
||||
// to that code's ordinary one: waiting clears an intake that is still running and never brings
|
||||
// back a directory that is gone.
|
||||
CauseSourceGone = "source_gone"
|
||||
// CauseKeyReused / CauseKeyInFlight — the two halves of `Idempotency-Key`.
|
||||
CauseKeyReused = "key_reused"
|
||||
CauseKeyInFlight = "key_in_flight"
|
||||
|
|
|
|||
|
|
@ -561,7 +561,8 @@ func (h *v0) runOptions(w http.ResponseWriter, r *http.Request) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
opts, err := h.runs.Order(r.Context(), user, r.PathValue("bookId"))
|
||||
bookID := r.PathValue("bookId")
|
||||
opts, err := h.runs.Order(r.Context(), user, bookID)
|
||||
if err != nil {
|
||||
h.fail(w, r, err)
|
||||
return
|
||||
|
|
@ -589,10 +590,35 @@ func (h *v0) runOptions(w http.ResponseWriter, r *http.Request) {
|
|||
structure := opts.Structure
|
||||
out.Order.Structure = &structure
|
||||
}
|
||||
// Why the scale is smaller than the account could otherwise afford. Without it a second book
|
||||
// shows a shrunken scale — or none — with no way to learn that the account's FIRST book is the
|
||||
// reason (canon §RunOptions.blocked).
|
||||
if opts.BlockedBy != "" {
|
||||
// Why a start is not on offer as the balance alone would suggest — and the two reasons are
|
||||
// ordered, because only one of them can be carried.
|
||||
//
|
||||
// A run of this book's OWN comes first: it is not a shorter scale but no start at all, and it is
|
||||
// the answer the door will give whatever the money says. Without it the form put `covers_all` in
|
||||
// front of a buyer whose click the door was already going to refuse (PD-455). Another book's hold
|
||||
// is the second: there a start IS on offer, only shorter than the account could otherwise afford,
|
||||
// and naming that book is what lets a client offer the user somewhere to go (canon
|
||||
// §RunOptions.blocked).
|
||||
//
|
||||
// ⛔ AND A REFUSAL THIS SURFACE HAS NO WORD FOR IS SAID OUT LOUD RATHER THAN DROPPED. The predicate
|
||||
// is one definition with two bindings, but the mapping from its answer to a wire member is a
|
||||
// SECOND, hand-kept thing: a refusal added to it tomorrow would reach the door and leave this form
|
||||
// saying `covers_all`, which is PD-455 all over again and silent. It cannot be a compile error —
|
||||
// the predicate answers an `error` — so it is an operator's line instead.
|
||||
if opts.Refusal != nil && !errors.Is(opts.Refusal, pgstore.ErrRunInFlight) {
|
||||
h.log.ErrorContext(r.Context(), "the order form has no word for a refusal admission would give",
|
||||
"err", opts.Refusal)
|
||||
}
|
||||
// ⚠ The door's other refusals have no line here because this path cannot reach them: an unpriced
|
||||
// book is answered `not_priced` above, and a book that is not ready to be translated — still
|
||||
// arriving, still being cut, rejected, or owing its chapter tree — is not priced either, since one
|
||||
// transaction of the materializer writes the price and the tree together (pgstore/readmodel.go,
|
||||
// `update books set … expected_micro_usd …` beside writeChapters). A word invented for them here
|
||||
// would be a wire value nothing can produce.
|
||||
switch {
|
||||
case errors.Is(opts.Refusal, pgstore.ErrRunInFlight):
|
||||
out.Blocked = &Blocked{Code: CauseRunInFlight, BookID: bookID}
|
||||
case opts.BlockedBy != "":
|
||||
out.Blocked = &Blocked{Code: CauseCreditHeld, BookID: opts.BlockedBy}
|
||||
}
|
||||
h.writeJSON(w, r, http.StatusOK, out)
|
||||
|
|
@ -713,6 +739,17 @@ func (h *v0) resumeRun(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
run, err := h.runs.Resume(r.Context(), user, r.PathValue("runId"))
|
||||
if errors.Is(err, runs.ErrSourceGone) {
|
||||
// The same fact as at admission, in THIS operation's own vocabulary. `book_not_ready` is the
|
||||
// word for a book that is still arriving or was rejected, and it is what a start answers; here
|
||||
// the caller is asking about a RUN, and what it is being told is that this run cannot be
|
||||
// continued — with the cause that says why waiting will not help. It is the shape the canon
|
||||
// already uses for the two axes that override its status table (`ceiling_reached`,
|
||||
// `credit_unavailable`): the same code, a narrower cause.
|
||||
h.log.ErrorContext(r.Context(), "resume refused: the book's source directory is gone", "err", err)
|
||||
FailCause(w, r, CodeRunNotResumable, CauseSourceGone)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.fail(w, r, err)
|
||||
return
|
||||
|
|
@ -1126,6 +1163,12 @@ func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) {
|
|||
Invalid(w, r)
|
||||
case errors.Is(err, pgstore.ErrRunInFlight):
|
||||
Fail(w, r, CodeRunInFlight)
|
||||
case errors.Is(err, runs.ErrSourceGone):
|
||||
// The same 409 as below — the book is there and cannot be translated — with the cause that
|
||||
// says waiting will not help. Logged at ERROR because it is also the operator's business: a
|
||||
// directory under the books root went missing while its row lives on.
|
||||
h.log.ErrorContext(r.Context(), "run refused: the book's source directory is gone", "err", err)
|
||||
FailCause(w, r, CodeBookNotReady, CauseSourceGone)
|
||||
case errors.Is(err, runs.ErrBookNotReady):
|
||||
// The book is still being received or was rejected. 409 and not 404: the book exists and the
|
||||
// client can see it — what it cannot do is start a translation of it yet.
|
||||
|
|
@ -1167,9 +1210,13 @@ func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) {
|
|||
// Its own cause since 0.7.0: nothing to re-pass — no correction since the last run, or a
|
||||
// resnapshot run already walked it in (canon §RunRequest.re_pass).
|
||||
FailCause(w, r, CodeCeilingUnavailable, CauseRePassUnavailable)
|
||||
case errors.Is(err, runner.ErrCeilingNotWired), errors.Is(err, runs.ErrRunnerIncomplete):
|
||||
// A DEPLOYMENT that cannot start runs: it has no way to tell the engine its ceiling (row 145)
|
||||
// or no way to record how a unit ended.
|
||||
case errors.Is(err, runner.ErrCeilingNotWired), errors.Is(err, runs.ErrRunnerIncomplete),
|
||||
errors.Is(err, runs.ErrStorageUnavailable):
|
||||
// A DEPLOYMENT that cannot start runs: it has no way to tell the engine its ceiling (row 145),
|
||||
// no way to record how a unit ended, or no book storage under it. The last one is a book whose
|
||||
// directory is missing together with the marker of the volume it lived on, and it is the
|
||||
// host's fault rather than the book's — answering it as the book's is what PD-192 cost the
|
||||
// intake, and there the answer had no way back.
|
||||
h.log.ErrorContext(r.Context(), "run refused: this deployment cannot start runs", "err", err)
|
||||
Fail(w, r, CodeServiceUnavailable)
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -376,6 +376,124 @@ func TestTheOrderFormCarriesTheVerdictTheEstimateAndTheSlidersBounds(t *testing.
|
|||
}
|
||||
}
|
||||
|
||||
// The form carries the refusal the DOOR will give — and carries it as `blocked`, because the
|
||||
// verdict has no room for it and must not grow any.
|
||||
//
|
||||
// PD-455: a book with a run of its own going was quoted `covers_all`, since the verdict is computed
|
||||
// from what is left and what the balance covers and nothing asked whether the book was busy. The
|
||||
// answer is not a fourth verdict value — `covers_all`/`covers_part`/`covers_none` is a closed enum
|
||||
// and a fourth member breaks every generated client — but the member the canon already pre-authorised
|
||||
// for growth, whose own instruction to a client is to tolerate a code it does not know.
|
||||
//
|
||||
// ⚠ The money half stays exactly as it was: the balance really does cover this book, and `verdict`
|
||||
// answers about money.
|
||||
func TestTheFormCarriesTheRunInFlightThatWillRefuseTheClick(t *testing.T) {
|
||||
rn := &fakeRuns{bounds: runs.Options{
|
||||
Options: pricing.Options{ChaptersLeft: 3, AffordableChapters: 3, Verdict: pricing.VerdictCoversAll},
|
||||
Structure: "detected", ChapterOrders: true,
|
||||
Refusal: pgstore.ErrRunInFlight,
|
||||
}}
|
||||
got := decode(t, call(t, v0Server(t, &fakeLibrary{}, rn), "GET", "/v0/books/bk_1/run-options", ""))
|
||||
blocked, ok := got["blocked"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("the form says nothing about the run that will refuse the click: %v", got["blocked"])
|
||||
}
|
||||
// ⚠ THE LITERAL AND NOT THE CONSTANT. Compared to `CauseRunInFlight` this assertion is
|
||||
// self-consistent and passes at any value — rename the constant and the whole module stays green,
|
||||
// which the pack's adversarial pass demonstrated by doing it. The word is the canon's (the enum of
|
||||
// `Blocked.code`), so it is written out here as a word; the second, independent source for it is
|
||||
// `gates.TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates`, which reads the canon itself.
|
||||
if blocked["code"] != "run_in_flight" {
|
||||
t.Errorf("blocked.code = %v, want %q", blocked["code"], "run_in_flight")
|
||||
}
|
||||
// THIS book, not another one: the place the user can act is its own card, where that run is.
|
||||
if blocked["book_id"] != "bk_1" {
|
||||
t.Errorf("blocked.book_id = %v, want the book being asked about", blocked["book_id"])
|
||||
}
|
||||
if order, _ := got["order"].(map[string]any); order["verdict"] != "covers_all" {
|
||||
t.Errorf("the verdict moved to %v because a run is going; it answers about money", order["verdict"])
|
||||
}
|
||||
}
|
||||
|
||||
// `blocked` carries ONE fact and the two candidates are not equal: a run of this book's own means
|
||||
// there is no start to offer at all, while another book's hold only makes the scale shorter than the
|
||||
// account could afford. Told the second while the first is true, a client sends the user off to stop
|
||||
// somebody else's run and the click still fails.
|
||||
func TestARunOfThisBooksOwnOutranksAnotherBooksHold(t *testing.T) {
|
||||
rn := &fakeRuns{bounds: runs.Options{
|
||||
Options: pricing.Options{ChaptersLeft: 3, AffordableChapters: 1, Verdict: pricing.VerdictCoversPart},
|
||||
Structure: "detected", ChapterOrders: true,
|
||||
BlockedBy: "bk_other", Refusal: pgstore.ErrRunInFlight,
|
||||
}}
|
||||
got := decode(t, call(t, v0Server(t, &fakeLibrary{}, rn), "GET", "/v0/books/bk_1/run-options", ""))
|
||||
blocked, _ := got["blocked"].(map[string]any)
|
||||
if blocked["code"] != "run_in_flight" || blocked["book_id"] != "bk_1" {
|
||||
t.Errorf("blocked = %v, want this book's own run to be what is named", blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// The two refusals a vanished book directory produces, on the wire.
|
||||
//
|
||||
// They are different answers because the FAULT is different, and the difference is the one PD-192
|
||||
// cost the intake: a book whose own directory is gone is that book's end and says so with a cause
|
||||
// that tells the client waiting will not help, while a book whose whole volume is gone is the host's
|
||||
// business and must not reach the user as a verdict about their book.
|
||||
func TestAVanishedSourceAndAVanishedVolumeAnswerDifferently(t *testing.T) {
|
||||
h := v0Server(t, &fakeLibrary{}, &fakeRuns{err: runs.ErrSourceGone})
|
||||
w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":false,"chapters":10}`)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("a book whose directory is gone: %d, want 409", w.Code)
|
||||
}
|
||||
got := decode(t, w)
|
||||
if got["code"] != string(CodeBookNotReady) {
|
||||
t.Errorf("code = %v, want %q", got["code"], CodeBookNotReady)
|
||||
}
|
||||
// The literal again, for the reason given above: a cause compared to its own constant is a
|
||||
// tautology, and this one is what tells a client that waiting is not the remedy.
|
||||
if cause, _ := got["cause"].(map[string]any); cause["code"] != "source_gone" {
|
||||
t.Errorf("cause = %v, want %q — without it the client offers waiting, which never helps here",
|
||||
got["cause"], "source_gone")
|
||||
}
|
||||
|
||||
h = v0Server(t, &fakeLibrary{}, &fakeRuns{err: runs.ErrStorageUnavailable})
|
||||
w = call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":false,"chapters":10}`)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("an unmounted books volume: %d, want 503 — it is the deployment's fault, not the book's", w.Code)
|
||||
}
|
||||
if got := decode(t, w); got["code"] == string(CodeBookNotReady) {
|
||||
t.Errorf("the host's fault reached the client as a verdict about the book: %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
// The SAME absence answered on the resume handle, and answered in that handle's own vocabulary.
|
||||
//
|
||||
// A caller of `POST /runs/{runId}/resume` is asking about a RUN, so what it is told is that this run
|
||||
// cannot be continued — `run_not_resumable` — with the cause that says why waiting will not help.
|
||||
// `book_not_ready`, which the start handle answers for the same fact, is the word for a book still
|
||||
// arriving or rejected, and it would send a client to wait for an intake that finished long ago.
|
||||
//
|
||||
// ⚠ The pair is asserted together on purpose: one fact, two operations, two words, and the thing
|
||||
// that makes them right is that each belongs to its own operation's table in the canon.
|
||||
func TestAResumeOverAGoneSourceIsRefusedInTheResumeHandlesOwnWords(t *testing.T) {
|
||||
h := v0Server(t, &fakeLibrary{}, &fakeRuns{err: runs.ErrSourceGone})
|
||||
w := call(t, h, "POST", "/v0/runs/run_1/resume", "")
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("a resume over a gone source: %d, want 409", w.Code)
|
||||
}
|
||||
got := decode(t, w)
|
||||
if got["code"] != "run_not_resumable" {
|
||||
t.Errorf("code = %v, want %q — the caller asked about a run, not about an intake", got["code"], "run_not_resumable")
|
||||
}
|
||||
if cause, _ := got["cause"].(map[string]any); cause["code"] != "source_gone" {
|
||||
t.Errorf("cause = %v, want %q", got["cause"], "source_gone")
|
||||
}
|
||||
// The deployment's half of the same absence keeps the deployment's own answer here too.
|
||||
h = v0Server(t, &fakeLibrary{}, &fakeRuns{err: runs.ErrStorageUnavailable})
|
||||
if w := call(t, h, "POST", "/v0/runs/run_1/resume", ""); w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("a resume over an unmounted volume: %d, want 503", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A book whose chapter cut cannot be sold against says so, and says WHY, instead of offering a
|
||||
// chapter slider over boundaries that do not name chapters. ⚠ `declared` is deliberately among them:
|
||||
// for an EPUB the engine cuts by spine DOCUMENTS, and «document» is not «chapter» (ratified 05.09).
|
||||
|
|
|
|||
362
platform/internal/runs/admission_test.go
Normal file
362
platform/internal/runs/admission_test.go
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
package runs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"textmachine/platform/internal/books"
|
||||
"textmachine/platform/internal/money"
|
||||
"textmachine/platform/internal/pgstore"
|
||||
"textmachine/platform/internal/pricing"
|
||||
"textmachine/platform/internal/runner"
|
||||
)
|
||||
|
||||
// onTheVolume moves the fixture's book onto a books ROOT shaped like a deployment's — a marked
|
||||
// storage directory with the book's own directory under it — and answers both paths.
|
||||
//
|
||||
// The shape matters to what is being tested: the door tells "this book's directory is gone" from
|
||||
// "the volume under every book is gone" by the storage MARKER, so a fixture whose book sits in a
|
||||
// bare temporary directory could only ever produce the second answer.
|
||||
func onTheVolume(t *testing.T, f *fixture, bookID string) (root, dir string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, books.StorageMarker), []byte("test"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir = filepath.Join(root, bookID)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.store.Pool().Exec(f.ctx, `update books set workdir = $2 where id = $1`, bookID, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.svc.Cfg.BooksDir = root
|
||||
return root, dir
|
||||
}
|
||||
|
||||
// A run over a book whose directory is not there is refused AT THE DOOR, before the hold.
|
||||
//
|
||||
// What it used to do instead is the whole of PD-162: the offset the spawn reads maps a missing
|
||||
// journal onto "zero, no error" — right for a first run — so nothing on the way in looked at the
|
||||
// directory at all. The run was admitted, the money was held, every spawn failed and no path ever
|
||||
// reached a terminal state: the user saw "translating" for good and the credit stayed frozen until
|
||||
// a person came with `tmplatformctl run abandon`.
|
||||
//
|
||||
// The test ends by putting the directory back and starting the same run: that is what says the
|
||||
// money was genuinely at stake here and the refusal is the directory's doing, not the fixture's.
|
||||
func TestABookWhoseDirectoryIsGoneIsRefusedBeforeTheHold(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
book := f.bookID(t)
|
||||
_, dir := onTheVolume(t, f, book)
|
||||
before := f.account(t)
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)})
|
||||
if !errors.Is(err, ErrSourceGone) {
|
||||
t.Fatalf("a start over a missing directory answered %v, want ErrSourceGone", err)
|
||||
}
|
||||
// It travels as a kind of `book_not_ready`, which is how it reaches the wire at all: the client
|
||||
// is told the book cannot be translated, and the cause beside it says waiting will not help.
|
||||
if !errors.Is(err, ErrBookNotReady) {
|
||||
t.Errorf("the refusal is not a book_not_ready: %v", err)
|
||||
}
|
||||
acct := f.account(t)
|
||||
if acct.Reserved != before.Reserved || acct.Balance != before.Balance {
|
||||
t.Fatalf("a refused start moved money: %+v -> %+v", before, acct)
|
||||
}
|
||||
if acct.Balance != acct.LedgerSum {
|
||||
t.Fatalf("the cached balance and the ledger disagree: %s vs %s", acct.Balance.USD(), acct.LedgerSum.USD())
|
||||
}
|
||||
var rows int
|
||||
if err := f.store.Pool().QueryRow(f.ctx,
|
||||
`select count(*) from runs where book_id = $1`, book).Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 0 {
|
||||
t.Fatalf("%d run rows after a refused start, want none", rows)
|
||||
}
|
||||
|
||||
// The control: the same call over the same book with its directory back takes the hold. Without
|
||||
// it "no money moved" would be satisfied by a fixture that could not have spent any.
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}); err != nil {
|
||||
t.Fatalf("the same start with the directory in place: %v", err)
|
||||
}
|
||||
if got := f.account(t); got.Reserved != fixtureHold(100) {
|
||||
t.Fatalf("the control start reserved %s, want %s — this fixture never had money at stake",
|
||||
got.Reserved.USD(), fixtureHold(100).USD())
|
||||
}
|
||||
}
|
||||
|
||||
// The same absence with the storage MARKER gone with it is the deployment's fault, and it must not
|
||||
// be answered as the book's.
|
||||
//
|
||||
// ⛔ THE ASYMMETRY IS THE POINT, and the intake paid for it once already (PD-192): an unmounted
|
||||
// volume leaves an empty mountpoint, so every book under it reads exactly like a book whose own
|
||||
// directory was removed. Read as the book's own end there, one sweep rejected every book on the host
|
||||
// with a reason that blames the user's file and has no way back. Here the price is smaller and the
|
||||
// same in kind — a user told their book cannot be translated while the truth is that an operator has
|
||||
// a volume to mount — so the answer is the deployment's, and `errors.Is(err, ErrBookNotReady)` must
|
||||
// stay FALSE.
|
||||
func TestAVanishedBooksVolumeIsTheDeploymentsFaultAndNotTheBooks(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
book := f.bookID(t)
|
||||
root, _ := onTheVolume(t, f, book)
|
||||
before := f.account(t)
|
||||
// The whole root, marker included — an unmount, not a deletion under it.
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)})
|
||||
if !errors.Is(err, ErrStorageUnavailable) {
|
||||
t.Fatalf("a start over an unmounted volume answered %v, want ErrStorageUnavailable", err)
|
||||
}
|
||||
if errors.Is(err, ErrBookNotReady) {
|
||||
t.Errorf("the host's fault reached the user as the book's own: %v", err)
|
||||
}
|
||||
if acct := f.account(t); acct.Reserved != before.Reserved || acct.Balance != before.Balance {
|
||||
t.Fatalf("a refused start moved money: %+v -> %+v", before, acct)
|
||||
}
|
||||
|
||||
// And the mount coming back is all it takes: nothing about the book was written down, so the
|
||||
// same call succeeds without anybody repairing anything.
|
||||
if err := os.MkdirAll(filepath.Join(root, book), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, books.StorageMarker), []byte("test"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}); err != nil {
|
||||
t.Fatalf("the same start after the volume came back: %v", err)
|
||||
}
|
||||
if got := f.account(t); got.Reserved != fixtureHold(100) {
|
||||
t.Fatalf("the control start reserved %s, want %s — this fixture never had money at stake",
|
||||
got.Reserved.USD(), fixtureHold(100).USD())
|
||||
}
|
||||
}
|
||||
|
||||
// The RESUME door is the second one money goes through, and it asks the same question.
|
||||
//
|
||||
// PD-162 is about a run that can never move, not about one handle: a resume re-opens the run, takes
|
||||
// a hold of what is left of its budget and hands it to a spawn that will fail for exactly the same
|
||||
// reason a first spawn would. Measured before the guard reached this path: the resume returned the
|
||||
// run and the account's reserved figure went from nothing to the remainder of the budget, with no
|
||||
// way back except an operator's `run abandon`.
|
||||
func TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
book := f.bookID(t)
|
||||
_, dir := onTheVolume(t, f, book)
|
||||
runID := f.stopped(t, 100, money.MicroUSD(500_000), runner.Marker{Result: "exit-code",
|
||||
Code: "exited", Status: "1", At: f.now.Add(time.Second)})
|
||||
before := f.account(t)
|
||||
if before.Reserved != 0 {
|
||||
t.Fatalf("the stopped run still holds %s: this test cannot tell a new hold from an old one",
|
||||
before.Reserved.USD())
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := f.svc.Resume(f.ctx, "u1", runID); !errors.Is(err, ErrSourceGone) {
|
||||
t.Fatalf("a resume over a missing directory answered %v, want ErrSourceGone", err)
|
||||
}
|
||||
acct := f.account(t)
|
||||
if acct.Reserved != before.Reserved || acct.Balance != before.Balance {
|
||||
t.Fatalf("a refused resume moved money: %+v -> %+v", before, acct)
|
||||
}
|
||||
if acct.Balance != acct.LedgerSum {
|
||||
t.Fatalf("the cached balance and the ledger disagree: %s vs %s", acct.Balance.USD(), acct.LedgerSum.USD())
|
||||
}
|
||||
|
||||
// The control, as at the other door: with the directory back the same call re-opens the run and
|
||||
// takes the hold, so "no money moved" above is a fact about the guard and not about the fixture.
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil {
|
||||
t.Fatalf("the same resume with the directory in place: %v", err)
|
||||
}
|
||||
if got := f.account(t); got.Reserved != fixtureHold(100)-money.MicroUSD(500_000) {
|
||||
t.Fatalf("the control resume reserved %s, want the remainder %s — this fixture never had money at stake",
|
||||
got.Reserved.USD(), (fixtureHold(100) - money.MicroUSD(500_000)).USD())
|
||||
}
|
||||
}
|
||||
|
||||
// A book that does not live under the intake's root is never declared finished on the strength of a
|
||||
// marker about somebody else's volume.
|
||||
//
|
||||
// ⛔ THE MARKER IS EVIDENCE ABOUT ONE ROOT AND NOTHING ELSE. `tmplatformctl book add --workdir` puts a
|
||||
// book on any absolute path, and an instance with no `BooksDir` at all holds every book that way; for
|
||||
// those, the intake's healthy marker says nothing whatsoever about the volume the book is on. Read as
|
||||
// evidence anyway, a vanished mount under such a book would be announced as that book's own end —
|
||||
// PD-192 with a longer path, and exactly the mistake this pair of errors exists to avoid. So where
|
||||
// there is no sentinel to ask, the answer is the deployment's.
|
||||
//
|
||||
// Found by the pack's own adversarial pass, which probed the shape the tests did not have.
|
||||
func TestABookOutsideTheIntakesRootIsNotDeclaredDeadByAnotherVolumesMarker(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
book := f.bookID(t)
|
||||
// A marked, healthy intake root — and a book that is NOT under it.
|
||||
onTheVolume(t, f, book)
|
||||
elsewhere := filepath.Join(t.TempDir(), "hand-placed")
|
||||
if err := os.MkdirAll(elsewhere, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.store.Pool().Exec(f.ctx,
|
||||
`update books set workdir = $2 where id = $1`, book, elsewhere); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !books.StorageIsThere(f.svc.Cfg.BooksDir) {
|
||||
t.Fatal("the intake root lost its marker: this test would then measure the ordinary unmount")
|
||||
}
|
||||
if err := os.RemoveAll(elsewhere); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)})
|
||||
if !errors.Is(err, ErrStorageUnavailable) {
|
||||
t.Fatalf("a start over a book outside the root answered %v, want the deployment's ErrStorageUnavailable", err)
|
||||
}
|
||||
if errors.Is(err, ErrBookNotReady) {
|
||||
t.Errorf("a marker about ANOTHER volume was read as proof that this book is finished: %v", err)
|
||||
}
|
||||
if acct := f.account(t); acct.Reserved != 0 {
|
||||
t.Errorf("a refused start reserved %s", acct.Reserved.USD())
|
||||
}
|
||||
}
|
||||
|
||||
// A FILE where the book's project directory should be is not a project directory, and `os.Stat`
|
||||
// alone cannot tell the difference.
|
||||
//
|
||||
// The engine opens a directory; a path that answers `Stat` without being one would pass the door,
|
||||
// take the money and die in the transient unit — the shape this whole guard exists to prevent. It is
|
||||
// the deployment's word and not the book's: this platform did not write that file, and it is not
|
||||
// something to announce as the end of somebody's book.
|
||||
func TestAFileWhereTheBooksDirectoryShouldBeIsRefusedToo(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
book := f.bookID(t)
|
||||
_, dir := onTheVolume(t, f, book)
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dir, []byte("not a project"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)})
|
||||
if !errors.Is(err, ErrStorageUnavailable) {
|
||||
t.Fatalf("a start over a FILE in the book's place answered %v, want ErrStorageUnavailable", err)
|
||||
}
|
||||
if acct := f.account(t); acct.Reserved != 0 {
|
||||
t.Errorf("a refused start reserved %s", acct.Reserved.USD())
|
||||
}
|
||||
}
|
||||
|
||||
// A directory that is THERE and cannot be read is the host's business too — and the refusal carries
|
||||
// no path.
|
||||
//
|
||||
// Two properties in one test because they are one decision. The error class: an unreadable directory
|
||||
// is not the book's end (nothing about the book is wrong) and not an internal error (nothing is
|
||||
// broken here), so it travels as the deployment's, like an unmounted volume. And the TEXT: `os.Stat`
|
||||
// answers a *fs.PathError whose message carries the book's own directory, this error reaches an ERROR
|
||||
// log through the handler, and a user's book has no business being in one (PD-139, PD-99). The errno
|
||||
// is what an operator needs; the path is what the run id already resolves for anyone allowed to ask.
|
||||
func TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath(t *testing.T) {
|
||||
f := newFixture(t, "10", 500)
|
||||
book := f.bookID(t)
|
||||
root, dir := onTheVolume(t, f, book)
|
||||
// The book's directory stays; what goes is the right to look into the root.
|
||||
if err := os.Chmod(root, 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(root, 0o700) })
|
||||
if _, err := os.Stat(dir); !errors.Is(err, fs.ErrPermission) {
|
||||
// A host where this cannot be arranged — running as root, or a filesystem that ignores the
|
||||
// mode — would otherwise measure the ENOENT branch and report it as this one.
|
||||
t.Skipf("this host still reads a 0000 directory (%v): the unreadable-directory branch is not exercised", err)
|
||||
}
|
||||
|
||||
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)})
|
||||
if !errors.Is(err, ErrStorageUnavailable) {
|
||||
t.Fatalf("a start over an unreadable directory answered %v, want ErrStorageUnavailable", err)
|
||||
}
|
||||
if errors.Is(err, ErrBookNotReady) {
|
||||
t.Errorf("the host's fault reached the user as the book's own: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), dir) || strings.Contains(err.Error(), book) {
|
||||
t.Errorf("the refusal carries the book's directory into a line that is logged at ERROR: %q", err)
|
||||
}
|
||||
// It still says WHAT happened: the operation and the errno, which is what an operator acts on.
|
||||
if !strings.Contains(err.Error(), "stat") || !errors.Is(err, fs.ErrPermission) {
|
||||
t.Errorf("the refusal says nothing an operator can act on: %q", err)
|
||||
}
|
||||
if acct := f.account(t); acct.Reserved != 0 {
|
||||
t.Errorf("a refused start reserved %s", acct.Reserved.USD())
|
||||
}
|
||||
}
|
||||
|
||||
// The order form asks the DOOR'S OWN question and says so before the click.
|
||||
//
|
||||
// PD-455: the verdict was computed from what is left of the book and what the balance covers, and
|
||||
// nothing on that path asked whether the book already had a run. A second purchase is refused by the
|
||||
// door, so the form answered `covers_all` over a book whose click could only produce an error — and
|
||||
// the facts to say so were a query away.
|
||||
//
|
||||
// The pin is the PAIR: the form's answer and the door's are taken from one predicate, so it asserts
|
||||
// both, and the door's refusal is the one the form predicted. Money stays what it is — the balance
|
||||
// really does cover this book — because the verdict answers about money and this is not about money.
|
||||
func TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning(t *testing.T) {
|
||||
f := newFixture(t, "10", 4)
|
||||
book := f.bookID(t)
|
||||
// Before anything runs the form promises a start, and nothing stands in its way.
|
||||
clear, err := f.svc.Order(f.ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if clear.Refusal != nil {
|
||||
t.Fatalf("the form refuses a book at rest: %v", clear.Refusal)
|
||||
}
|
||||
if clear.Verdict != pricing.VerdictCoversAll {
|
||||
t.Fatalf("the fixture's balance does not cover its own book (%q): this test cannot say what it claims",
|
||||
clear.Verdict)
|
||||
}
|
||||
|
||||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(1)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
opts, err := f.svc.Order(f.ctx, "u1", book)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !errors.Is(opts.Refusal, pgstore.ErrRunInFlight) {
|
||||
t.Fatalf("the form says %v over a book that is being translated, want the door's run_in_flight", opts.Refusal)
|
||||
}
|
||||
// The money half is untouched, and that is deliberate: `verdict` answers "does the balance cover
|
||||
// it", the balance does, and a fourth verdict value would break every generated client.
|
||||
if opts.Verdict != pricing.VerdictCoversAll {
|
||||
t.Errorf("the verdict moved to %q because a run is going; it answers about MONEY", opts.Verdict)
|
||||
}
|
||||
// …and the door answers exactly what the form predicted.
|
||||
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(1)}); !errors.Is(err, pgstore.ErrRunInFlight) {
|
||||
t.Fatalf("the door answered %v where the form predicted run_in_flight", err)
|
||||
}
|
||||
|
||||
// A SECOND book of the same account is not blocked by the first one's run: the predicate is about
|
||||
// this book, and a form that answered otherwise would send the user to stop a run for nothing.
|
||||
if second, err := f.svc.Order(f.ctx, "u1", f.secondBook(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if second.Refusal != nil {
|
||||
t.Errorf("another book's run refuses this book's form: %v", second.Refusal)
|
||||
}
|
||||
}
|
||||
|
|
@ -1713,6 +1713,14 @@ func (s *Service) Resume(ctx context.Context, userID, runID string) (pgstore.Run
|
|||
default:
|
||||
return pgstore.Run{}, fmt.Errorf("%w: it is %s", ErrNotResumable, l.Status)
|
||||
}
|
||||
// The disk, asked HERE for the same reason admission asks it: what follows takes a hold of what
|
||||
// is left of this run's budget, and a book whose directory is not there hands that money to a
|
||||
// spawn that cannot succeed — one more run "translating" for good with the credit inside it
|
||||
// (PD-162). The two doors money goes through are Start and this one, and neither is the place to
|
||||
// discover the absence afterwards.
|
||||
if err := s.sourceThere(l.Workdir); err != nil {
|
||||
return pgstore.Run{}, err
|
||||
}
|
||||
next, v, err := s.reopen(ctx, l, fromAFinishedRun)
|
||||
if errors.Is(err, pgstore.ErrStopRequested) {
|
||||
// Cannot happen through this path today — a resume works on a run that has ENDED and the guard
|
||||
|
|
|
|||
|
|
@ -12,10 +12,13 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"textmachine/platform/internal/books"
|
||||
"textmachine/platform/internal/ingest"
|
||||
"textmachine/platform/internal/money"
|
||||
"textmachine/platform/internal/pgstore"
|
||||
|
|
@ -48,6 +51,13 @@ type Config struct {
|
|||
// StateDir is where exit markers live. Platform state, deliberately NOT the book's directory:
|
||||
// the engine owns that one and the platform does not write into it (D39.110).
|
||||
StateDir string
|
||||
// BooksDir is the root the intake creates book directories under — the same path that package
|
||||
// holds, and READ ONLY: this package never writes under it. It is here for one question, asked
|
||||
// at the door when a book's directory is missing (sourceThere): is this book's own directory
|
||||
// gone, or the whole volume? Empty answers "the storage is not there", which refuses the start
|
||||
// as a deployment fault — the safe direction on an instance that was never configured to hold
|
||||
// books, since it cannot tell the two apart and must not blame a book for it.
|
||||
BooksDir string
|
||||
// EngineBinary is the VERSIONED path of tmctl (unified backlog row 139). An attempt is pinned to
|
||||
// the path it was started with, so a deploy of the engine mid-run cannot change what a resumed
|
||||
// attempt executes.
|
||||
|
|
@ -161,6 +171,27 @@ var ErrRunnerIncomplete = errors.New("runs: this deployment cannot record how a
|
|||
// what cannot happen yet is a translation of it.
|
||||
var ErrBookNotReady = errors.New("runs: the book is not ready to be translated")
|
||||
|
||||
// ErrSourceGone is a book whose project directory is not on this deployment any more.
|
||||
//
|
||||
// ⚠ IT IS A KIND OF ErrBookNotReady rather than a code of its own, and the wrapping is what a client
|
||||
// reads: the contract's `book_not_ready` already means "the book is there and a translation of it
|
||||
// cannot happen", which is exactly this, and the second-level cause is where the difference belongs —
|
||||
// waiting clears the ordinary one and never clears this one. What it must not become is silence
|
||||
// after the money moved: a run over a directory that is not there spawns, fails, respawns and holds
|
||||
// the account's credit until a person comes (PD-162).
|
||||
var ErrSourceGone = fmt.Errorf("%w: its source directory is not on this deployment", ErrBookNotReady)
|
||||
|
||||
// ErrStorageUnavailable is the book STORAGE ROOT missing under a book whose directory is missing
|
||||
// with it — an unmounted volume, or an instance pointed at a path that is not there yet.
|
||||
//
|
||||
// ⛔ TOLD APART FROM ErrSourceGone BECAUSE THE FAULT IS THE HOST'S, and reading the host's fault as
|
||||
// the book's is a defect this repository has already paid for once: in the intake it rejected every
|
||||
// book on the volume with a reason that blames the user's file and has no way back (PD-192). Here the
|
||||
// price would be smaller and the same in kind — a user told their book is unusable while the truth is
|
||||
// that an operator has a volume to mount — so it travels as the deployment's own 503, beside the
|
||||
// refusals of a runner that was never wired.
|
||||
var ErrStorageUnavailable = errors.New("runs: the book storage is not mounted on this deployment")
|
||||
|
||||
// ErrNotStoppable is a stop asked for on a run that is already over.
|
||||
var ErrNotStoppable = errors.New("runs: the run is not live")
|
||||
|
||||
|
|
@ -211,6 +242,16 @@ type Options struct {
|
|||
pricing.Options
|
||||
// BlockedBy is the book whose hold is holding this one's order down, or "".
|
||||
BlockedBy string
|
||||
// Refusal is what the door would answer a start on this book right now, from the book's own
|
||||
// facts — nil when nothing about the book stands in the way. ADVISORY by construction: it is read
|
||||
// outside the book lock, so a run can begin or end between this read and the click, and the start
|
||||
// remains the only authority on whether a start may happen (canon §RunOptions).
|
||||
//
|
||||
// ⚠ What it is FOR is the systematic case rather than the one-second race: a run that has been
|
||||
// going for minutes was invisible to this form, so the form said `covers_all` over a book the
|
||||
// door would refuse and the user learned it by clicking (PD-455). Racing the door is not what
|
||||
// this closes and not what a client should expect of it.
|
||||
Refusal error
|
||||
// Structure is where the chapter cut came from, verbatim from the engine. ChapterOrders says
|
||||
// whether an order may be phrased in CHAPTERS against it at all — see ingest.ChapterOrdersOffered,
|
||||
// which trusts `detected` alone.
|
||||
|
|
@ -240,6 +281,15 @@ func (s *Service) Order(ctx context.Context, userID, bookID string) (Options, er
|
|||
if !priced.Priced {
|
||||
return Options{}, ErrNotPriced
|
||||
}
|
||||
// The door's own read of the book, and a SECOND read beside the one above rather than more
|
||||
// columns on it. The two answer differently shaped questions — one row of facts about the book
|
||||
// against one row per chapter — which is the reason PricedBook gives for being separate, and it
|
||||
// holds in this direction too: the form asks the door's question with the door's own query, so
|
||||
// there is one definition of "may a run start here" and not a copy of it phrased for the form.
|
||||
facts, err := s.Store.ReadBookForRun(ctx, userID, bookID)
|
||||
if err != nil {
|
||||
return Options{}, err
|
||||
}
|
||||
held, heldAmount, err := s.Store.CreditHeldBy(ctx, userID, bookID)
|
||||
if err != nil {
|
||||
return Options{}, err
|
||||
|
|
@ -251,6 +301,7 @@ func (s *Service) Order(ctx context.Context, userID, bookID string) (Options, er
|
|||
Options: s.Pricing.Order(book, acct.Balance),
|
||||
Structure: priced.Structure,
|
||||
ChapterOrders: ingest.Manifest{Structure: priced.Structure}.ChapterOrdersOffered(),
|
||||
Refusal: startable(facts),
|
||||
}
|
||||
for _, c := range book.Remaining {
|
||||
out.SourceChars += c.SourceChars
|
||||
|
|
@ -340,39 +391,16 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro
|
|||
if err != nil {
|
||||
return pgstore.Run{}, err
|
||||
}
|
||||
if !readyToTranslate(book.Status) {
|
||||
// A book whose intake has not finished has no chapter tree to translate and, while it is
|
||||
// `uploading`, half a file on disk. Refused here rather than discovered by the engine inside a
|
||||
// transient unit, where the only trace would be a marker reading "exit-code 1" — and after the
|
||||
// account's money had already been held for it.
|
||||
return pgstore.Run{}, fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status)
|
||||
// The book's own half of admission, AUTHORITATIVE here because it is read under the lock. The
|
||||
// order form asks the same predicate before the click, where the answer is advisory (Order).
|
||||
if err := startable(book); err != nil {
|
||||
return pgstore.Run{}, err
|
||||
}
|
||||
if book.ChapterCount > 0 && !book.HasTree {
|
||||
// The book DECLARES chapters and its tree has not been materialised: the intake committed
|
||||
// `not_started` and the materialisation that follows it — in a separate transaction, outside
|
||||
// the intake's — failed or has not run. Refused HERE, before the hold, because there is no
|
||||
// honest bar for such a run and no way out of it either (PD-405). Every counter the screen
|
||||
// shows is a count over `chapters`, so the run would read 0/total for its entire life while
|
||||
// spending; and the tree's own debt is FROZEN while it runs, because the sweep that pays that
|
||||
// debt skips a book with a live run. The window is the intake's normal one — measured at
|
||||
// 0.018 s on the healthy path — and unbounded exactly when the materialisation broke, which is
|
||||
// the only shape worth refusing.
|
||||
//
|
||||
// `book_not_ready` is the word for it, not a new one: the canon's own gloss is "still
|
||||
// arriving, still being cut, or was rejected", and a book that owes its tree is still being
|
||||
// cut. The remedy is to wait for the intake, which is what the status says.
|
||||
//
|
||||
// ⚠ ONE population does not clear by waiting, and the operator's handle for it exists: a book
|
||||
// whose reading-surface debt was WRITTEN OFF after its attempts (AbandonReadModelDebt) owes
|
||||
// nothing, so no sweep will build its tree and this refusal stands until somebody asks again —
|
||||
// `tmplatformctl book refresh --book <id>`, which is exactly what that command is for, and the
|
||||
// books behind it are listed by `tmplatformctl books --abandoned`. Named here because this
|
||||
// guard is what turns a book that merely showed a frozen bar into one that cannot be started:
|
||||
// the refusal is the honest half, and being able to find the remedy is the other.
|
||||
return pgstore.Run{}, fmt.Errorf("%w: its chapters are still being materialised", ErrBookNotReady)
|
||||
}
|
||||
if book.HasLiveRun {
|
||||
return pgstore.Run{}, pgstore.ErrRunInFlight
|
||||
// And the half only the disk can answer, asked BEFORE the money: a run over a book whose
|
||||
// directory is not there can never move, and until it was asked here the platform took the hold
|
||||
// first and discovered the absence afterwards, in a place with no way back (PD-162).
|
||||
if err := s.sourceThere(book.Workdir); err != nil {
|
||||
return pgstore.Run{}, err
|
||||
}
|
||||
acct, err := s.Store.ReadAccount(ctx, in.UserID)
|
||||
if err != nil {
|
||||
|
|
@ -613,6 +641,111 @@ func readyToTranslate(status string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// startable is what THIS BOOK cannot do, beside runnable's what this DEPLOYMENT cannot do: the whole
|
||||
// rule a start is judged by before money or disk is looked at, and the one definition of it.
|
||||
//
|
||||
// ⚠ IT HAS TWO BINDINGS OF DIFFERENT AUTHORITY, and that asymmetry is the design rather than a
|
||||
// shortcut. `Start` binds it under the book lock, where its answer decides whether the run happens;
|
||||
// `Order` binds it on the path a client POLLS, where the same answer is advisory and can be stale
|
||||
// before the user clicks — the canon says as much in its own words, «the only authority on whether
|
||||
// THIS start may happen; every read below can be stale» (§RunOptions). What one definition buys is
|
||||
// that the two cannot drift apart: while the live run was known to the door alone, the form answered
|
||||
// `covers_all` over a book the door was about to refuse, and the click met a refusal the form had
|
||||
// the facts to predict (PD-455). A rule changed here changes both.
|
||||
func startable(book pgstore.BookRunContext) error {
|
||||
if !readyToTranslate(book.Status) {
|
||||
// A book whose intake has not finished has no chapter tree to translate and, while it is
|
||||
// `uploading`, half a file on disk. Refused here rather than discovered by the engine inside a
|
||||
// transient unit, where the only trace would be a marker reading "exit-code 1" — and after the
|
||||
// account's money had already been held for it.
|
||||
return fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status)
|
||||
}
|
||||
if book.ChapterCount > 0 && !book.HasTree {
|
||||
// The book DECLARES chapters and its tree has not been materialised: the intake committed
|
||||
// `not_started` and the materialisation that follows it — in a separate transaction, outside
|
||||
// the intake's — failed or has not run. Refused HERE, before the hold, because there is no
|
||||
// honest bar for such a run and no way out of it either (PD-405). Every counter the screen
|
||||
// shows is a count over `chapters`, so the run would read 0/total for its entire life while
|
||||
// spending; and the tree's own debt is FROZEN while it runs, because the sweep that pays that
|
||||
// debt skips a book with a live run. The window is the intake's normal one — measured at
|
||||
// 0.018 s on the healthy path — and unbounded exactly when the materialisation broke, which is
|
||||
// the only shape worth refusing.
|
||||
//
|
||||
// `book_not_ready` is the word for it, not a new one: the canon's own gloss is "still
|
||||
// arriving, still being cut, or was rejected", and a book that owes its tree is still being
|
||||
// cut. The remedy is to wait for the intake, which is what the status says.
|
||||
//
|
||||
// ⚠ ONE population does not clear by waiting, and the operator's handle for it exists: a book
|
||||
// whose reading-surface debt was WRITTEN OFF after its attempts (AbandonReadModelDebt) owes
|
||||
// nothing, so no sweep will build its tree and this refusal stands until somebody asks again —
|
||||
// `tmplatformctl book refresh --book <id>`, which is exactly what that command is for, and the
|
||||
// books behind it are listed by `tmplatformctl books --abandoned`. Named here because this
|
||||
// guard is what turns a book that merely showed a frozen bar into one that cannot be started:
|
||||
// the refusal is the honest half, and being able to find the remedy is the other.
|
||||
return fmt.Errorf("%w: its chapters are still being materialised", ErrBookNotReady)
|
||||
}
|
||||
if book.HasLiveRun {
|
||||
return pgstore.ErrRunInFlight
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sourceThere is the DISK half of admission: the book's project directory, asked for before any
|
||||
// money moves.
|
||||
//
|
||||
// ⛔ IT IS THE DOOR'S ALONE AND DELIBERATELY NOT THE FORM'S, unlike startable above. The form is
|
||||
// polled, and a stat of a hung mount would take the poll down with it; the door is one call the user
|
||||
// is already waiting on. The price of the asymmetry is named rather than hidden: the form stays
|
||||
// silent about a vanished directory and says `covers_all` over it — what it no longer does is stay
|
||||
// silent while the money is TAKEN, which is the half PD-162 is about.
|
||||
//
|
||||
// ⚠ There is no "the journal has not been written yet" case to tell apart here, and looking for one
|
||||
// sends a reader to the wrong file: the directory is created ON INTAKE, before the row that points
|
||||
// at it (books.Accept, MkdirAll ahead of the insert), so a row always names a directory that existed.
|
||||
// The first-run exception belongs to the JOURNAL inside it — journalSize maps ENOENT to a zero
|
||||
// offset, and that is the line this check stands beside rather than duplicates.
|
||||
func (s *Service) sourceThere(workdir string) error {
|
||||
if st, err := os.Stat(workdir); err == nil {
|
||||
if st.IsDir() {
|
||||
return nil
|
||||
}
|
||||
// Something is at the path and it is not a project directory. The platform never wrote that,
|
||||
// so it is not this book's own end to announce — it is an anomaly an operator is owed.
|
||||
return fmt.Errorf("%w: the book's directory is not a directory", ErrStorageUnavailable)
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
// A directory that is there and cannot be read — a permission, an I/O error — is the host's
|
||||
// business exactly as an absent volume is, and the run is refused before the money either way.
|
||||
//
|
||||
// ⚠ The PATH is dropped and only the operation and the errno are kept. `os.Stat` answers a
|
||||
// *fs.PathError, whose text carries the book's own directory, and this error reaches an ERROR
|
||||
// log through the handler: a user's book has no business being in one (PD-139, PD-99). The run
|
||||
// id the log line already carries says which book to an operator who is allowed to ask.
|
||||
var pe *fs.PathError
|
||||
if errors.As(err, &pe) {
|
||||
return fmt.Errorf("%w: %s: %w", ErrStorageUnavailable, pe.Op, pe.Err)
|
||||
}
|
||||
return fmt.Errorf("%w: %w", ErrStorageUnavailable, err)
|
||||
}
|
||||
// WHICH absence this is decides who is at fault, and the two are identical from one book: an
|
||||
// unmounted volume leaves an empty mountpoint behind, so every book under it reads as "gone".
|
||||
// Blaming the book there is the defect PD-192 cost the intake — a host-wide fault, answered as
|
||||
// this book's own end — so the question is put to the storage marker the first upload wrote, not
|
||||
// to the root's existence, and the two answers travel under different words: a deployment's 503
|
||||
// and the book's own 409.
|
||||
//
|
||||
// ⛔ AND THE MARKER IS ONLY EVIDENCE ABOUT THE ROOT IT LIVES IN. A book whose directory is NOT
|
||||
// under the intake's root — `tmplatformctl book add --workdir` puts one anywhere, and so does an
|
||||
// instance with no `BooksDir` at all — sits on a volume this platform never wrote and has no
|
||||
// sentinel on. Reading the intake's healthy marker as evidence about THAT volume would announce a
|
||||
// vanished mount as the book's own end, which is PD-192 again with a longer path. Where there is
|
||||
// nothing to ask, the answer is the deployment's: this platform does not tell anyone their book is
|
||||
// finished on the strength of a directory it never created.
|
||||
if !books.Owns(s.Cfg.BooksDir, workdir) || !books.StorageIsThere(s.Cfg.BooksDir) {
|
||||
return ErrStorageUnavailable
|
||||
}
|
||||
return ErrSourceGone
|
||||
}
|
||||
|
||||
func (s *Service) enqueue(ctx context.Context, tx pgstore.Tx, runID string) error {
|
||||
if s.Queue == nil {
|
||||
return nil // no queue configured: the reconciler still picks the run up on its next sweep
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue