Land platform P5 after three acceptance rounds: streaming book intake with storage sentinel, stop and resume with recorded intent, Prometheus observability, enforced Go 1.26.6 floor
This commit is contained in:
parent
d1f42c5470
commit
69d485a16f
48 changed files with 8090 additions and 364 deletions
|
|
@ -18,6 +18,8 @@
|
||||||
| П-7 | **Кредитный баланс и фри-тир** (решения владельца 05.08; ПРОДАЖИ НЕТ — бета на предоплаченных ключах владельца): append-only леджер в целых микро-долларах, грант фри-тира из админки (дефолт $5, настраиваемый), холд ДО спавна прогона + пер-книжный потолок движку (жёсткий стоп исполняет движок), расчёт на границе попытки. ⚠ Окон с обнулением НЕТ — баланс, а не подписка: черновик `usage_windows` в подписочной форме заменяется. Платёжный провайдер не проектируется до решения продавать. Разбор — `docs/PLATFORM_DIRECTION.md` §2 | P1+, после П-6 | владелец 04–05.08 |
|
| П-7 | **Кредитный баланс и фри-тир** (решения владельца 05.08; ПРОДАЖИ НЕТ — бета на предоплаченных ключах владельца): append-only леджер в целых микро-долларах, грант фри-тира из админки (дефолт $5, настраиваемый), холд ДО спавна прогона + пер-книжный потолок движку (жёсткий стоп исполняет движок), расчёт на границе попытки. ⚠ Окон с обнулением НЕТ — баланс, а не подписка: черновик `usage_windows` в подписочной форме заменяется. Платёжный провайдер не проектируется до решения продавать. Разбор — `docs/PLATFORM_DIRECTION.md` §2 | P1+, после П-6 | владелец 04–05.08 |
|
||||||
| П-8 | **Минимальная админ-поверхность**: начислить/списать кредиты аккаунту (одна запись леджера), посмотреть баланс и состояние прогонов. Защищённая ручка либо CLI — форму предлагает сессия. Без неё фри-тир неуправляем | вместе с П-7 | владелец 05.08 |
|
| П-8 | **Минимальная админ-поверхность**: начислить/списать кредиты аккаунту (одна запись леджера), посмотреть баланс и состояние прогонов. Защищённая ручка либо CLI — форму предлагает сессия. Без неё фри-тир неуправляем | вместе с П-7 | владелец 05.08 |
|
||||||
| П-5 | **API лимитов/использования + оповещение стопа по потолку** (решение владельца 04.08, D39.100/ПТ-35, механизм «как Claude Code»): страница лимитов в настройках читает СТАТУС использования (не суммы — деньги на провод не идут, D39.84); стоп по потолку → статус `paused` + оповещение «перевод остановлен: лимиты исчерпаны»; сырьё у движка есть (`request_log`/`ledger`, потолки конфига), форму API предлагает P0 | Ф3, вместе с П-1 | D39.100, ПТ-35, контракт К-8 |
|
| П-5 | **API лимитов/использования + оповещение стопа по потолку** (решение владельца 04.08, D39.100/ПТ-35, механизм «как Claude Code»): страница лимитов в настройках читает СТАТУС использования (не суммы — деньги на провод не идут, D39.84); стоп по потолку → статус `paused` + оповещение «перевод остановлен: лимиты исчерпаны»; сырьё у движка есть (`request_log`/`ledger`, потолки конфига), форму API предлагает P0 | Ф3, вместе с П-1 | D39.100, ПТ-35, контракт К-8 |
|
||||||
| П-9 | **Загрузка книги `POST /books` (multipart)** — единственная ручка контракта 0.2.0, которую пак раннера НЕ взял. Причина названа, а не умолчана: это не «ещё один хендлер», а хранилище (куда лёг файл, кто его чистит при отказе), разбор (статусы `uploading`/`parsing`/`rejected` существуют в схеме и ни одним писателем не заполняются), каталог проекта движка (кто пишет `book.yaml` — платформа его НЕ правит, D39.110) и пер-маршрутный потолок тела вместе с тестом, которого ждёт PD-72. Пока её нет, книги заводятся дев-инструментом `tmplatformctl book add`, и библиотека читает их так же, как читала бы загруженные: read-model один. ⚠ PD-72 закрывается ВМЕСТЕ с этой ручкой, не раньше | Ф3, следующий пак | сессия P4 (границей промта D39.119 §5а) |
|
| П-9 | **Загрузка книги `POST /books` (multipart)** — единственная ручка контракта 0.2.0, которую пак раннера НЕ взял. Причина названа, а не умолчана: это не «ещё один хендлер», а хранилище (куда лёг файл, кто его чистит при отказе), разбор (статусы `uploading`/`parsing`/`rejected` существуют в схеме и ни одним писателем не заполняются), каталог проекта движка (кто пишет `book.yaml` — платформа его НЕ правит, D39.110) и пер-маршрутный потолок тела вместе с тестом, которого ждёт PD-72. Пока её нет, книги заводятся дев-инструментом `tmplatformctl book add`, и библиотека читает их так же, как читала бы загруженные: read-model один. ⚠ PD-72 закрывается ВМЕСТЕ с этой ручкой, не раньше — **ИСПОЛНЕНО P5 (11.08), с одной НАЗВАННОЙ половиной на ратификации:** ручка построена потоково (`r.MultipartReader`, пер-маршрутный потолок тела, свой дедлайн чтения), хранилище — каталог книги под `TM_PLATFORM_BOOKS_DIR`, статусы `uploading → parsing → not_started \| rejected` получили писателей, разбор зовёт `tmctl manifest`, PD-72 закрыт своим тестом. НЕ построено и вынесено вопросом в журнал: кто пишет стартовый `book.yaml` при интейке (D39.110 §2b) — шов `books.ErrNotProvisioned` назван, без конфигурации книга отказывает разбором | Ф3, следующий пак | сессия P4 (границей промта D39.119 §5а), исполнено P5 |
|
||||||
| П-10 | **Честная оценка «$/глава» от ДВИЖКА.** Сейчас ставка — константа платформы ($0.03, провенанс exp08 v2 через D30.4, `STACK_DECISIONS` §20), и это осознанная бета-мера: движковой поверхности оценки не существует, а выдумывать её запрещено. Ставка решает только ДЛИНУ шкалы (деньги защищены холдом и потолком движка), но на книге, которая заметно дороже или дешевле средней, шкала врёт пользователю о том, сколько глав он покупает. Нужна оценка от движка по конкретной книге — запрос уходит строкой ЕДИНОГО бэклога через оркестратора, не сюда | когда-нибудь (до первого платящего) | сессия P4 |
|
| П-10 | **Честная оценка «$/глава» от ДВИЖКА.** Сейчас ставка — константа платформы ($0.03, провенанс exp08 v2 через D30.4, `STACK_DECISIONS` §20), и это осознанная бета-мера: движковой поверхности оценки не существует, а выдумывать её запрещено. Ставка решает только ДЛИНУ шкалы (деньги защищены холдом и потолком движка), но на книге, которая заметно дороже или дешевле средней, шкала врёт пользователю о том, сколько глав он покупает. Нужна оценка от движка по конкретной книге — запрос уходит строкой ЕДИНОГО бэклога через оркестратора, не сюда | когда-нибудь (до первого платящего) | сессия P4 |
|
||||||
| П-11 | **Наблюдаемость раннера.** Метрик и трейсинга в зоне нет вовсе (грепнуто: ни prometheus, ни otel, ни expvar, ни pprof), а у раннера появились величины, которые без них не видны: глубина очереди, возраст незакрытых холдов, число прогонов в карантине, отставание тейлера, длительность свипа. Сегодня всё это читается только глазами по логам и SQL. Связано с PD-115 (у зоны нет внешнего эталона ни по одной оси, кроме безопасности) | Ф3 | сессия P4 |
|
| П-11 | **Наблюдаемость раннера.** Метрик и трейсинга в зоне нет вовсе (грепнуто: ни prometheus, ни otel, ни expvar, ни pprof), а у раннера появились величины, которые без них не видны: глубина очереди, возраст незакрытых холдов, число прогонов в карантине, отставание тейлера, длительность свипа. Сегодня всё это читается только глазами по логам и SQL. Связано с PD-115 (у зоны нет внешнего эталона ни по одной оси, кроме безопасности) — **ИСПОЛНЕНО P5 (11.08):** `prometheus/client_golang` v1.24.1 на отдельном слушателе `TM_PLATFORM_METRICS_ADDR`; глубина очереди · возраст самого старого открытого холда · карантины · отставание тейлера · книги в интейке · длительность свипа и счётчик недоведённых проходов · запросы и задержки по паттерну маршрута. Ось наблюдаемости получила внешний эталон (практики именования Prometheus + золотые сигналы, `ENGINEERING_STANDARDS` §2) — половина PD-115 | Ф3 | сессия P4, исполнено P5 |
|
||||||
|
| П-12 | **Квота интейка и ретеншен отклонённых книг.** `POST /books` даёт аутентифицированному аккаунту писать на диск оператора: один аплоад ограничен (64 МиБ), число аплоадов — ничем. Отклонённая по вине источника книга каталог теряет, отклонённая по вине деплоя — сохраняет намеренно, и не чистит их никто. Нужны лимит книг на аккаунт (сколько книг входит во фри-тир — продуктовая политика, не инженерная) и свип ретеншена. Строка регистра — PD-175 | до первого чужого пользователя | сессия P5 |
|
||||||
|
| П-13 | **Оценка размера книги в символах — у ДВИЖКА.** `character_count` контракта платформа считает потоково на приёме (байты, не являющиеся продолжением UTF-8), что точно для UTF-8 и приблизительно для GB18030/UTF-16, которые движок принимает и декодирует сам. Манифест несёт `source_bytes` и `encoding`, но не число символов. Запрос уходит строкой ЕДИНОГО бэклога через оркестратора; строка регистра — PD-177 | когда-нибудь | сессия P5 |
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,24 @@
|
||||||
# under you on someone else's machine is not a gate. Shape mirrors backend/Makefile deliberately.
|
# under you on someone else's machine is not a gate. Shape mirrors backend/Makefile deliberately.
|
||||||
|
|
||||||
GO ?= go
|
GO ?= go
|
||||||
# go.mod's floor is 1.26.4 (the engine's), but the BUILD toolchain floor here is 1.26.5: it carries
|
# go.mod's floor is 1.26.4 (the engine's), but the BUILD toolchain floor here is HIGHER, and for one
|
||||||
# the crypto/tls and os security fixes, and this module is the one exposed to the network.
|
# reason: this module is the one exposed to the network, so it takes the standard library's security
|
||||||
GO_MIN_VERSION := 1.26.5
|
# fixes as soon as they are released.
|
||||||
|
#
|
||||||
|
# ⚠ Raised 1.26.5 → 1.26.6 on 13.08 by `make vuln`, not by choice: the advisory database published
|
||||||
|
# five standard-library vulnerabilities against 1.26.5 — net/http, crypto/tls, net/url, encoding/xml,
|
||||||
|
# encoding/asn1 (GO-2026-6218/6090/6089/6088/5972) — all fixed in 1.26.6, and govulncheck traces two
|
||||||
|
# of them into paths this service calls. The same battery is green on 1.26.6 and the scan is clean.
|
||||||
|
# HOW the floor is actually enforced, because the first version of this raise enforced nothing: the
|
||||||
|
# check below COMPARES versions (its predecessor was a regex that matched the very 1.26.5 it was
|
||||||
|
# written to refuse), and go.mod carries `toolchain go1.26.6`, which every build reads whether or not
|
||||||
|
# it calls make — with GOTOOLCHAIN=auto such a host fetches this toolchain, with =local it stops.
|
||||||
|
# The comparison itself is pinned in internal/gates.
|
||||||
|
GO_MIN_VERSION := 1.26.6
|
||||||
GOLANGCI_LINT ?= golangci-lint
|
GOLANGCI_LINT ?= golangci-lint
|
||||||
GOLANGCI_VERSION := 2.12.2
|
GOLANGCI_VERSION := 2.12.2
|
||||||
|
|
||||||
.PHONY: build vet fmt lint test check tools-check vuln fuzz
|
.PHONY: build vet fmt lint test check tools-check version-check vuln fuzz
|
||||||
|
|
||||||
build: tools-check
|
build: tools-check
|
||||||
$(GO) build ./...
|
$(GO) build ./...
|
||||||
|
|
@ -21,9 +32,26 @@ vet:
|
||||||
fmt:
|
fmt:
|
||||||
@test -z "$$(gofmt -l .)" || { echo "gofmt: not formatted:"; gofmt -l .; exit 1; }
|
@test -z "$$(gofmt -l .)" || { echo "gofmt: not formatted:"; gofmt -l .; exit 1; }
|
||||||
|
|
||||||
tools-check:
|
# GO_VERSION is what the gate JUDGES, and it is a variable so the judgement can be tested with
|
||||||
@$(GO) version | grep -qE 'go1\.26\.([5-9]|[0-9]{2,})|go1\.(2[7-9]|[3-9][0-9])' || { \
|
# versions this host does not have: `make version-check GO_VERSION=go1.26.5` must fail.
|
||||||
echo "Go $(GO_MIN_VERSION)+ required (security fixes in a network-facing module); got: $$($(GO) version)"; exit 1; }
|
GO_VERSION ?= $$($(GO) env GOVERSION)
|
||||||
|
|
||||||
|
# version-check COMPARES rather than matches. The predecessor of this rule was a regex over
|
||||||
|
# `go version`, and it accepted the very release it was written to refuse — 1.26.5 matched
|
||||||
|
# `go1\.26\.([5-9]|…)` — while GO_MIN_VERSION lived only in the failure message. A comparison has no
|
||||||
|
# such gap: `sort -V` orders 1.26.10 above 1.26.9, which a regex over digits gets wrong too, and it
|
||||||
|
# is the same tool the release engineering of this repo already relies on.
|
||||||
|
version-check:
|
||||||
|
@have="$$(printf '%s' "$(GO_VERSION)" | sed 's/^go//')"; \
|
||||||
|
case "$$have" in *[!0-9.]*) \
|
||||||
|
echo "Go $(GO_MIN_VERSION)+ required, and a release: a prerelease or development toolchain does not carry the fixes its number promises; got: go$$have"; \
|
||||||
|
exit 1;; esac; \
|
||||||
|
lowest="$$(printf '%s\n%s\n' "$$have" "$(GO_MIN_VERSION)" | sort -V | head -1)"; \
|
||||||
|
test "$$lowest" = "$(GO_MIN_VERSION)" || { \
|
||||||
|
echo "Go $(GO_MIN_VERSION)+ required (standard-library security fixes in a network-facing module); got: go$$have"; \
|
||||||
|
exit 1; }
|
||||||
|
|
||||||
|
tools-check: version-check
|
||||||
@$(GOLANGCI_LINT) --version 2>/dev/null | grep -q " $(GOLANGCI_VERSION) " || { \
|
@$(GOLANGCI_LINT) --version 2>/dev/null | grep -q " $(GOLANGCI_VERSION) " || { \
|
||||||
echo "golangci-lint $(GOLANGCI_VERSION) required (findings are version-dependent)."; \
|
echo "golangci-lint $(GOLANGCI_VERSION) required (findings are version-dependent)."; \
|
||||||
echo "install: https://github.com/golangci/golangci-lint/releases/tag/v$(GOLANGCI_VERSION)"; exit 1; }
|
echo "install: https://github.com/golangci/golangci-lint/releases/tag/v$(GOLANGCI_VERSION)"; exit 1; }
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@
|
||||||
Бинари: `cmd/tmplatformd` (сервис) и `cmd/tmplatformctl` (админ: гранты, КОРРЕКТИРОВКИ (`adjust`), баланс, журнал входов,
|
Бинари: `cmd/tmplatformd` (сервис) и `cmd/tmplatformctl` (админ: гранты, КОРРЕКТИРОВКИ (`adjust`), баланс, журнал входов,
|
||||||
отзыв сессий, дев-интейк `book add`, и `exit-marker` — его зовёт systemd на конце прогона). Деплой — `deploy/`.
|
отзыв сессий, дев-интейк `book add`, и `exit-marker` — его зовёт systemd на конце прогона). Деплой — `deploy/`.
|
||||||
|
|
||||||
|
Метрики — отдельным слушателем (`TM_PLATFORM_METRICS_ADDR`, дефолт `127.0.0.1:9464`), формат
|
||||||
|
Prometheus; на контрактную поверхность они не выходят и наружу не привязываются (`docs/STACK_DECISIONS.md` §24).
|
||||||
|
Эффективная конфигурация печатается на старте с источником каждой настройки; секреты и денежные
|
||||||
|
суммы — фактом наличия, без значения (PD-114).
|
||||||
|
|
||||||
⚠ **Прогон — транзиентный systemd-юнит, а не ребёнок сервиса** (D39.106). Установка требует
|
⚠ **Прогон — транзиентный systemd-юнит, а не ребёнок сервиса** (D39.106). Установка требует
|
||||||
`loginctl enable-linger`, иначе не стартует ни один прогон; почему именно так и что было
|
`loginctl enable-linger`, иначе не стартует ни один прогон; почему именно так и что было
|
||||||
измерено — `docs/STACK_DECISIONS.md` §15–20.
|
измерено — `docs/STACK_DECISIONS.md` §15–20.
|
||||||
|
|
@ -35,12 +40,21 @@
|
||||||
- аутентификация и аккаунты — **есть (P1)**: вход через OIDC даёт только СОБЫТИЕ входа, сессия
|
- аутентификация и аккаунты — **есть (P1)**: вход через OIDC даёт только СОБЫТИЕ входа, сессия
|
||||||
своя; ключ личности `(provider, subject)`, почта не ключ. Оплаты нет и в бете не будет
|
своя; ключ личности `(provider, subject)`, почта не ключ. Оплаты нет и в бете не будет
|
||||||
(владелец 05.08): аккаунты живут на кредитном балансе, фри-тир — запись `grant` в леджер;
|
(владелец 05.08): аккаунты живут на кредитном балансе, фри-тир — запись `grant` в леджер;
|
||||||
- библиотека книг: чья книга, права доступа, хранение исходников и экспортов;
|
- библиотека книг: чья книга, права доступа, хранение исходников и экспортов — **приём есть (P5)**:
|
||||||
|
`POST /v0/books` принимает multipart потоково со своим потолком тела и своим дедлайном чтения,
|
||||||
|
кладёт исходник в каталог книги под `TM_PLATFORM_BOOKS_DIR` и ведёт книгу по статусам
|
||||||
|
`uploading → parsing → not_started | rejected`; разбор — $0-команда движка `tmctl manifest`.
|
||||||
|
⚠ Кто кладёт стартовый `book.yaml` в каталог новой книги — открытый вопрос на ратификации
|
||||||
|
(D39.110 §2b): без него разбор честно отказывает, шов назван `books.ErrNotProvisioned`;
|
||||||
- учёт денег **на пользователя** — **схема и операции есть (P1)**: append-only леджер в целых
|
- учёт денег **на пользователя** — **схема и операции есть (P1)**: append-only леджер в целых
|
||||||
микро-долларах, резервации, кэш баланса с инвариантом `balance == SUM(ledger)`. Защита прогона —
|
микро-долларах, резервации, кэш баланса с инвариантом `balance == SUM(ledger)`. Защита прогона —
|
||||||
холд ДО спавна плюс книжный потолок движку (жёсткий стоп исполняет движок). ⚠ Потолок, который
|
холд ДО спавна плюс книжный потолок движку (жёсткий стоп исполняет движок). ⚠ Потолок, который
|
||||||
получает движок, — это НАКОПЛЕННЫЙ потолок КНИГИ (`committed + reserved` за всю её историю плюс
|
получает движок, — это НАКОПЛЕННЫЙ потолок КНИГИ (`committed + reserved` за всю её историю плюс
|
||||||
купленный прирост), а не бюджет прогона: платформа переводит одно в другое сама (D39.122);
|
купленный прирост), а не бюджет прогона: платформа переводит одно в другое сама (D39.122);
|
||||||
|
- остановка и продолжение перевода — **есть (P5)**: `POST /v0/runs/{id}/stop` пишет НАМЕРЕНИЕ стопа
|
||||||
|
в Postgres до сигнала (иначе стоп и авария — один и тот же выход движка, PD-152) и просит systemd;
|
||||||
|
`/resume` открывает новую попытку с ОСТАТКОМ бюджета прогона, переиспользуя механику
|
||||||
|
перезапуска реконсилятора;
|
||||||
- очередь задач и запуск воркеров, статусы прогонов, ретраи — **есть (P4)**: River на том же
|
- очередь задач и запуск воркеров, статусы прогонов, ретраи — **есть (P4)**: River на том же
|
||||||
Postgres; задание очереди выдаёт только РАЗРЕШЕНИЕ стартовать, а жизнь прогона ведёт
|
Postgres; задание очереди выдаёт только РАЗРЕШЕНИЕ стартовать, а жизнь прогона ведёт
|
||||||
реконсилятор, который читает мир (Postgres · журнал книги · маркер выхода) и не ждёт процесса;
|
реконсилятор, который читает мир (Postgres · журнал книги · маркер выхода) и не ждёт процесса;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
|
@ -17,6 +18,7 @@ import (
|
||||||
"textmachine/platform/internal/config"
|
"textmachine/platform/internal/config"
|
||||||
"textmachine/platform/internal/httpapi"
|
"textmachine/platform/internal/httpapi"
|
||||||
"textmachine/platform/internal/login"
|
"textmachine/platform/internal/login"
|
||||||
|
"textmachine/platform/internal/metrics"
|
||||||
"textmachine/platform/internal/pgstore"
|
"textmachine/platform/internal/pgstore"
|
||||||
"textmachine/platform/internal/reqid"
|
"textmachine/platform/internal/reqid"
|
||||||
)
|
)
|
||||||
|
|
@ -40,10 +42,20 @@ func run(log *slog.Logger) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Before anything else is decided: an environment-only configuration leaves no artifact to read
|
||||||
|
// back, so a deployment whose EnvironmentFile= never loaded looks exactly like one whose values
|
||||||
|
// were chosen (register row PD-114, ratified 09.08). Secrets and money amounts are named, never
|
||||||
|
// printed.
|
||||||
|
cfg.LogEffective(log)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// One registry for the process, whether or not anything scrapes it: the instruments are wired
|
||||||
|
// into the request path and the sweep either way, and a nil registry would make the telemetry a
|
||||||
|
// second code path that only runs in production.
|
||||||
|
telemetry := metrics.New()
|
||||||
|
|
||||||
var db *pgstore.Store
|
var db *pgstore.Store
|
||||||
if cfg.DSN == "" {
|
if cfg.DSN == "" {
|
||||||
// Deliberate: the process still serves liveness so a supervisor can start it before the
|
// Deliberate: the process still serves liveness so a supervisor can start it before the
|
||||||
|
|
@ -72,7 +84,8 @@ func run(log *slog.Logger) error {
|
||||||
Log: log,
|
Log: log,
|
||||||
Deny: httpapi.ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"),
|
Deny: httpapi.ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"),
|
||||||
}
|
}
|
||||||
deps := httpapi.Deps{Log: log, Auth: authn, TrustedOrigins: cfg.TrustedOrigins, HSTS: !cfg.InsecureCookies}
|
deps := httpapi.Deps{Log: log, Auth: authn, TrustedOrigins: cfg.TrustedOrigins,
|
||||||
|
HSTS: !cfg.InsecureCookies, Observe: telemetry.Middleware()}
|
||||||
if db != nil {
|
if db != nil {
|
||||||
deps.DB = db
|
deps.DB = db
|
||||||
authn.Sessions = db
|
authn.Sessions = db
|
||||||
|
|
@ -107,7 +120,7 @@ func run(log *slog.Logger) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if db != nil {
|
if db != nil {
|
||||||
stopRunner, err := startRunner(ctx, cfg, db, log, &deps)
|
stopRunner, err := startRunner(ctx, cfg, db, log, telemetry, &deps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +132,9 @@ func run(log *slog.Logger) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopMetrics := serveMetrics(ctx, cfg.MetricsAddr, telemetry, log)
|
||||||
|
defer stopMetrics()
|
||||||
|
|
||||||
// A second signal must kill rather than wait: once the drain starts, the handler is
|
// A second signal must kill rather than wait: once the drain starts, the handler is
|
||||||
// unregistered and the next SIGTERM goes back to being fatal.
|
// unregistered and the next SIGTERM goes back to being fatal.
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -135,6 +151,57 @@ func run(log *slog.Logger) error {
|
||||||
return srv.Run(ctx, ln)
|
return srv.Run(ctx, ln)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveMetrics exposes the exposition endpoint on a listener of its OWN, and returns the function
|
||||||
|
// that stops it.
|
||||||
|
//
|
||||||
|
// Separate from the API on purpose, and the reason is that this surface has no session behind it.
|
||||||
|
// The contract's surface answers 401 before 404 so that an anonymous caller cannot map it (STACK §4),
|
||||||
|
// and metrics carry operational shape — queue depth, run counts, the size of this deployment —
|
||||||
|
// which is the same class of fact. The two ways out of that are inventing a second authorization
|
||||||
|
// model for scrapers or binding the endpoint where only the host can reach it; the second is what
|
||||||
|
// every control plane does, and it is one line of deployment rather than one more thing to get
|
||||||
|
// right. Default 127.0.0.1; empty turns it off.
|
||||||
|
//
|
||||||
|
// A listener that cannot bind is a WARN and not a fatal: telemetry is how a service is watched, not
|
||||||
|
// how it serves, and taking the control plane down because a port is busy would make the watching
|
||||||
|
// more dangerous than the not-watching.
|
||||||
|
func serveMetrics(ctx context.Context, addr string, m *metrics.Metrics, log *slog.Logger) func() {
|
||||||
|
if addr == "" {
|
||||||
|
log.Warn("no TM_PLATFORM_METRICS_ADDR: this instance exposes no metrics")
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("GET /metrics", m.Handler())
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
// The same deadlines the API carries, and for once a WriteTimeout too: nothing here streams,
|
||||||
|
// and a scrape that hangs is a connection held for nothing.
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
BaseContext: func(net.Listener) context.Context { return context.WithoutCancel(ctx) },
|
||||||
|
}
|
||||||
|
var lc net.ListenConfig
|
||||||
|
ln, err := lc.Listen(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("metrics endpoint could not be served", "addr", addr, "err", err)
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
log.Info("serving metrics", "addr", ln.Addr().String())
|
||||||
|
go func() {
|
||||||
|
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Error("metrics endpoint stopped", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() {
|
||||||
|
stop, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = srv.Shutdown(stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// sweepSessions deletes rows past their absolute expiry (PD-7). A failed sweep is logged and
|
// sweepSessions deletes rows past their absolute expiry (PD-7). A failed sweep is logged and
|
||||||
// retried on the next tick: it is housekeeping, and it must never take the service down.
|
// retried on the next tick: it is housekeeping, and it must never take the service down.
|
||||||
func sweepSessions(ctx context.Context, db *pgstore.Store, log *slog.Logger) {
|
func sweepSessions(ctx context.Context, db *pgstore.Store, log *slog.Logger) {
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"textmachine/platform/internal/books"
|
||||||
"textmachine/platform/internal/config"
|
"textmachine/platform/internal/config"
|
||||||
"textmachine/platform/internal/httpapi"
|
"textmachine/platform/internal/httpapi"
|
||||||
|
"textmachine/platform/internal/jobs"
|
||||||
|
"textmachine/platform/internal/metrics"
|
||||||
"textmachine/platform/internal/money"
|
"textmachine/platform/internal/money"
|
||||||
"textmachine/platform/internal/pgstore"
|
"textmachine/platform/internal/pgstore"
|
||||||
"textmachine/platform/internal/pricing"
|
"textmachine/platform/internal/pricing"
|
||||||
|
|
@ -18,16 +21,17 @@ import (
|
||||||
"textmachine/platform/internal/runs"
|
"textmachine/platform/internal/runs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// startRunner wires the run lifecycle and returns the function that stops it.
|
// startRunner wires the run lifecycle and the book intake, and returns the function that stops them.
|
||||||
//
|
//
|
||||||
// The reads are mounted whether or not runs can be spawned: a library and a book card are not run
|
// The reads are mounted whether or not runs can be spawned: a library and a book card are not run
|
||||||
// machinery, and an instance without an engine binary is a perfectly useful read replica. What an
|
// machinery, and an instance without an engine binary is a perfectly useful read replica. What an
|
||||||
// unconfigured runner refuses is starting a run, and it says so at boot rather than at the first
|
// unconfigured runner refuses is starting a run, and it says so at boot rather than at the first
|
||||||
// click.
|
// click.
|
||||||
func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log *slog.Logger, deps *httpapi.Deps) (func(), error) {
|
func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log *slog.Logger,
|
||||||
|
m *metrics.Metrics, deps *httpapi.Deps) (func(), error) {
|
||||||
deps.Library = db
|
deps.Library = db
|
||||||
if !cfg.RunsEnabled() {
|
if !cfg.RunsEnabled() {
|
||||||
log.Warn("no TM_PLATFORM_ENGINE_BIN: the library is served read-only and no run can be started")
|
log.Warn("no TM_PLATFORM_ENGINE_BIN: the library is served read-only, no run can be started and no book can be uploaded")
|
||||||
return func() {}, nil
|
return func() {}, nil
|
||||||
}
|
}
|
||||||
model, err := pricing.New(money.MicroUSD(cfg.Runner.PerChapterMicroUSD))
|
model, err := pricing.New(money.MicroUSD(cfg.Runner.PerChapterMicroUSD))
|
||||||
|
|
@ -73,16 +77,31 @@ func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log
|
||||||
},
|
},
|
||||||
Log: log,
|
Log: log,
|
||||||
}
|
}
|
||||||
queue, err := runs.NewQueue(db.Pool(), svc, log, cfg.Runner.Workers)
|
intake, err := startIntake(cfg, db, rn, log, deps)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A typed nil is not nil once it is inside an interface, and the queue decides which workers to
|
||||||
|
// register by exactly that check. Spelled out so an instance without intake registers no parse
|
||||||
|
// worker rather than one that calls a nil service.
|
||||||
|
var parser jobs.Parser
|
||||||
|
if intake != nil {
|
||||||
|
parser = intake
|
||||||
|
}
|
||||||
|
queue, err := jobs.New(db.Pool(), svc, parser, log, cfg.Runner.Workers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
svc.Queue = queue
|
svc.Queue = queue
|
||||||
deps.Runs = svc
|
deps.Runs = svc
|
||||||
|
if intake != nil {
|
||||||
|
intake.Queue = queue
|
||||||
|
}
|
||||||
if err := queue.Start(ctx); err != nil {
|
if err := queue.Start(ctx); err != nil {
|
||||||
return nil, fmt.Errorf("start queue: %w", err)
|
return nil, fmt.Errorf("start queue: %w", err)
|
||||||
}
|
}
|
||||||
go sweepRuns(ctx, svc, cfg.Runner.SweepEvery, log)
|
go sweep(ctx, sweeps{runs: svc, books: intake, db: db, metrics: m},
|
||||||
|
cfg.Runner.SweepEvery, log)
|
||||||
return func() {
|
return func() {
|
||||||
// The queue is drained; the RUNS are not touched. They are transient units, not children,
|
// The queue is drained; the RUNS are not touched. They are transient units, not children,
|
||||||
// and outliving this process is the whole point of the seam (D39.106).
|
// and outliving this process is the whole point of the seam (D39.106).
|
||||||
|
|
@ -94,19 +113,95 @@ func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepRuns runs the reconciler at boot and then on a ticker.
|
// startIntake wires the book upload, or explains at boot why this deployment takes none.
|
||||||
|
//
|
||||||
|
// A nil service leaves POST /books unmounted, which is the same shape every unbuilt contract route
|
||||||
|
// has: an instance that accepted a file it had nowhere to put would take the whole upload before it
|
||||||
|
// could say so.
|
||||||
|
func startIntake(cfg config.Config, db *pgstore.Store, engine books.Manifester, log *slog.Logger,
|
||||||
|
deps *httpapi.Deps) (*books.Service, error) {
|
||||||
|
if !cfg.IntakeEnabled() {
|
||||||
|
log.Warn("no TM_PLATFORM_BOOKS_DIR: this instance accepts no book uploads")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// Created at boot, where a permission problem is an operator's to see, rather than on the first
|
||||||
|
// upload, where it would be one user's mysterious failure.
|
||||||
|
if err := os.MkdirAll(cfg.Intake.BooksDir, 0o750); err != nil {
|
||||||
|
return nil, fmt.Errorf("books directory %s: %w", cfg.Intake.BooksDir, err)
|
||||||
|
}
|
||||||
|
svc := &books.Service{
|
||||||
|
Store: db,
|
||||||
|
Engine: engine,
|
||||||
|
Cfg: books.Config{
|
||||||
|
BooksDir: cfg.Intake.BooksDir,
|
||||||
|
EngineBinary: cfg.Runner.EngineBinary,
|
||||||
|
},
|
||||||
|
Log: log,
|
||||||
|
}
|
||||||
|
deps.Intake = svc
|
||||||
|
deps.Upload = httpapi.UploadLimits{
|
||||||
|
MaxBytes: cfg.Intake.MaxUploadBytes,
|
||||||
|
Deadline: cfg.Intake.UploadDeadline,
|
||||||
|
}
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepBudget is what ONE pass of the RUN sweep may take. Per pass rather than per tick: see the
|
||||||
|
// note inside sweep().
|
||||||
|
const sweepBudget = 2 * time.Minute
|
||||||
|
|
||||||
|
// intakeSweepBudget is the same for the intake, and it is larger because the work inside it is:
|
||||||
|
// re-driving one book's parse is an engine call the queue itself bounds at jobs.JobTimeout, and a
|
||||||
|
// pass shorter than that turns "this book is large" into "this host cannot run the engine".
|
||||||
|
const intakeSweepBudget = jobs.JobTimeout + time.Minute
|
||||||
|
|
||||||
|
// sweeps is everything one tick of the reconciler covers.
|
||||||
|
type sweeps struct {
|
||||||
|
runs *runs.Service
|
||||||
|
books *books.Service
|
||||||
|
db *pgstore.Store
|
||||||
|
metrics *metrics.Metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweep runs the reconcilers at boot and then on a ticker.
|
||||||
//
|
//
|
||||||
// The boot pass is not a special case and is not skipped: after a reboot every transient unit is
|
// The boot pass is not a special case and is not skipped: after a reboot every transient unit is
|
||||||
// gone, and this is what notices and restarts the runs they were carrying (unified backlog row 138).
|
// gone, and this is what notices and restarts the runs they were carrying (unified backlog row 138).
|
||||||
func sweepRuns(ctx context.Context, svc *runs.Service, every time.Duration, log *slog.Logger) {
|
// The intake's own pass rides the same ticker — it answers the same question about a different
|
||||||
sweep := func() {
|
// object, "whose walk stopped and nobody is coming back for it".
|
||||||
c, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
func sweep(ctx context.Context, s sweeps, every time.Duration, log *slog.Logger) {
|
||||||
|
// EACH pass gets its OWN budget, and that is not tidiness: sharing one deadline let the runs pass
|
||||||
|
// spend all of it — two runs whose engine hangs are two minutes — and hand an already-expired
|
||||||
|
// context to the intake sweep and to the telemetry, every tick, for as long as those runs sat
|
||||||
|
// there. The starvation the per-run budget bounds inside one pass would simply have moved up a
|
||||||
|
// level, and the metric that shows it would have stopped updating with it.
|
||||||
|
pass := func(name string, budget time.Duration, fn func(context.Context) error) {
|
||||||
|
c, cancel := context.WithTimeout(ctx, budget)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := svc.Sweep(c); err != nil && !errors.Is(err, context.Canceled) {
|
start := time.Now()
|
||||||
log.Error("run sweep failed", "err", err)
|
err := fn(c)
|
||||||
|
// A pass that ran out of its budget left work untouched, and the list is ordered the same way
|
||||||
|
// every time, so the tail starves. Counted rather than only logged: this is the number that
|
||||||
|
// says whether it is happening (register row PD-169).
|
||||||
|
s.metrics.ObserveSweep(name, time.Since(start), errors.Is(err, context.DeadlineExceeded))
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
log.Error(name+" sweep failed", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sweep()
|
one := func() {
|
||||||
|
pass("runs", sweepBudget, s.runs.Sweep)
|
||||||
|
if s.books != nil {
|
||||||
|
// The intake's pass is allowed MORE than the runs' pass, and the number is not a taste: one
|
||||||
|
// book's parse is the same engine call the queue gives `jobs.JobTimeout`, and a pass that
|
||||||
|
// cannot contain one kills it — which the intake then counts as a host that cannot run the
|
||||||
|
// engine and spends an attempt on. The pass has to be able to hold at least one book.
|
||||||
|
pass("intake", intakeSweepBudget, s.books.Sweep)
|
||||||
|
}
|
||||||
|
c, cancel := context.WithTimeout(ctx, sweepBudget)
|
||||||
|
defer cancel()
|
||||||
|
observe(c, s, log)
|
||||||
|
}
|
||||||
|
one()
|
||||||
t := time.NewTicker(every)
|
t := time.NewTicker(every)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
|
|
@ -114,11 +209,35 @@ func sweepRuns(ctx context.Context, svc *runs.Service, every time.Duration, log
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
sweep()
|
one()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// observe publishes the state of the control plane. A failure to measure never fails the sweep: it
|
||||||
|
// is one WARN and the next tick tries again.
|
||||||
|
func observe(ctx context.Context, s sweeps, log *slog.Logger) {
|
||||||
|
o, err := s.db.Observe(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("the control plane's own state could not be read", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.metrics.ObserveRunner(metrics.Runner{
|
||||||
|
QueueDepth: o.QueueDepth,
|
||||||
|
OldestHoldSeconds: o.OldestHoldSeconds,
|
||||||
|
QuarantinedAttempts: o.QuarantinedAttempts,
|
||||||
|
LiveRuns: o.LiveRuns,
|
||||||
|
BooksUploading: o.BooksUploading,
|
||||||
|
BooksParsing: o.BooksParsing,
|
||||||
|
})
|
||||||
|
lag, err := s.runs.Lag(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("the tailer's lag could not be read", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.metrics.ObserveTailerLag(lag)
|
||||||
|
}
|
||||||
|
|
||||||
// markerArgv is the command systemd runs when a unit ends. It defaults to the admin CLI next to the
|
// markerArgv is the command systemd runs when a unit ends. It defaults to the admin CLI next to the
|
||||||
// running daemon, because that is the one binary guaranteed to be the same build as this process.
|
// running daemon, because that is the one binary guaranteed to be the same build as this process.
|
||||||
func markerArgv(configured string) ([]string, error) {
|
func markerArgv(configured string) ([]string, error) {
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,31 @@ TM_PLATFORM_OIDC_CLIENT_ID=...
|
||||||
TM_PLATFORM_OIDC_REDIRECT_URL=https://app.example.org/auth/callback
|
TM_PLATFORM_OIDC_REDIRECT_URL=https://app.example.org/auth/callback
|
||||||
TM_PLATFORM_AFTER_LOGIN=/library
|
TM_PLATFORM_AFTER_LOGIN=/library
|
||||||
TM_PLATFORM_SIGNUP_GRANT_USD=5
|
TM_PLATFORM_SIGNUP_GRANT_USD=5
|
||||||
|
TM_PLATFORM_BOOKS_DIR=/srv/textmachine/books
|
||||||
|
TM_PLATFORM_METRICS_ADDR=127.0.0.1:9464
|
||||||
```
|
```
|
||||||
|
|
||||||
|
⚠ **`TM_PLATFORM_BOOKS_DIR` — каталог, который сервис ПИШЕТ и, на отклонённом интейке, УДАЛЯЕТ.**
|
||||||
|
Всё под ним создано загрузкой книг; книга, заведённая дев-инструментом с чужим `--workdir`, туда не
|
||||||
|
попадает и не удаляется никогда (гард `books.owns`). Каталог должен существовать и принадлежать
|
||||||
|
`tmplatform`, и он обязан быть в `ReadWritePaths=` юнита — при `ProtectSystem=strict` отсутствующий
|
||||||
|
путь валит сборку mount-namespace целиком (PD-91, та же грабля, что и с `/srv/textmachine`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine/books
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ **`/metrics` не аутентифицирован** — его защищает только адрес привязки. Дефолт `127.0.0.1`, то
|
||||||
|
есть снаружи он недостижим; поднять его на внешний адрес значит открыть операционную форму деплоя
|
||||||
|
(глубина очереди, число прогонов, возраст холдов) всем, кто до него дотянется. Скрейпер живёт на том
|
||||||
|
же хосте либо ходит через тот же edge, что и API. Разбор — `docs/STACK_DECISIONS.md` §24, строка
|
||||||
|
риска — PD-179.
|
||||||
|
|
||||||
|
⚠ **Загрузка книги идёт минуты, и это касается edge-прокси.** Маршрут `POST /v0/books` принимает до
|
||||||
|
`TM_PLATFORM_MAX_UPLOAD_BYTES` (64 МиБ) и держит соединение до `TM_PLATFORM_UPLOAD_DEADLINE`
|
||||||
|
(10 минут). Прокси перед сервисом обязан разрешать столько же: у него свои `client_max_body_size` и
|
||||||
|
свои таймауты чтения, и молчаливо режет их он, а не мы.
|
||||||
|
|
||||||
⚠ **`TM_PLATFORM_STATE_DIR` меняется только когда живых прогонов нет.** Exit-маркер пишется по пути,
|
⚠ **`TM_PLATFORM_STATE_DIR` меняется только когда живых прогонов нет.** Exit-маркер пишется по пути,
|
||||||
вычисленному при СПАВНЕ, а читается по пути из текущей конфигурации: после смены каталога конец
|
вычисленному при СПАВНЕ, а читается по пути из текущей конфигурации: после смены каталога конец
|
||||||
идущего прогона становится невидим, и реконсилятор перезапускает его как потерянный (PD-155). Путь
|
идущего прогона становится невидим, и реконсилятор перезапускает его как потерянный (PD-155). Путь
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -40,9 +40,19 @@
|
||||||
- Отказ зависимости — деградация с диагностикой, не тишина: проглоченная ошибка без лога и без
|
- Отказ зависимости — деградация с диагностикой, не тишина: проглоченная ошибка без лога и без
|
||||||
комментария-обоснования = дефект (класс PD-5/PD-16).
|
комментария-обоснования = дефект (класс PD-5/PD-16).
|
||||||
|
|
||||||
**Наблюдаемость:** структурные логи (route-pattern, не сырой путь — id пользователя/книги в логи не
|
**Наблюдаемость** — базовая линия **практики именования Prometheus** (базовые единицы: секунды и
|
||||||
текут), `X-Request-Id` на каждом ответе, ошибки видимы на своём уровне. Money-числа — только в
|
байты; `_total` у счётчиков; единица в имени, не в лейбле) + **четыре золотых сигнала** SRE на
|
||||||
приватных таблицах, ни в логах, ни в ответах (кроме P-5-статуса процентами).
|
вопрос «что мерить» (внесено P5 по ратифицированному направлению PD-115: у оси появился внешний
|
||||||
|
эталон, а не только собственная проза зоны). Конкретика:
|
||||||
|
- структурные логи (route-pattern, не сырой путь — id пользователя/книги в логи не текут),
|
||||||
|
`X-Request-Id` на каждом ответе, ошибки видимы на своём уровне;
|
||||||
|
- метрики отдаются в формате Prometheus на ОТДЕЛЬНОМ слушателе (`STACK_DECISIONS` §24); лейбл несёт
|
||||||
|
паттерн маршрута, никогда путь — иначе это и неограниченная кардинальность, и библиотека
|
||||||
|
пользователя в индексе оператора;
|
||||||
|
- Money-числа — только в приватных таблицах, ни в логах, ни в ответах, ни в метриках (кроме
|
||||||
|
P-5-статуса процентами);
|
||||||
|
- ⚠ оси **ops** (релиз/откат, ёмкость, восстановление) и **конфигурация** внешнего эталона
|
||||||
|
по-прежнему не имеют — PD-115 открыт ими.
|
||||||
|
|
||||||
**Контракт-первичность:** поверхность = `docs/architecture/14-api-contract/openapi.yaml`; расхождение
|
**Контракт-первичность:** поверхность = `docs/architecture/14-api-contract/openapi.yaml`; расхождение
|
||||||
кода со спекой = дефект чей-то один: либо спека правится через ратификацию, либо код. Генерация
|
кода со спекой = дефект чей-то один: либо спека правится через ратификацию, либо код. Генерация
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,15 @@
|
||||||
>
|
>
|
||||||
> Общая записка по обоим новым сервисам — `frontend/docs/STACK_DECISIONS.md` §5 (02.08). Здесь —
|
> Общая записка по обоим новым сервисам — `frontend/docs/STACK_DECISIONS.md` §5 (02.08). Здесь —
|
||||||
> платформенная часть с датами релизов и сверкой. Что изменилось за два дня: три библиотечных пина
|
> платформенная часть с датами релизов и сверкой. Что изменилось за два дня: три библиотечных пина
|
||||||
> §5 (pgx · goose · River) на 04.08 всё ещё последние; по Go последним патчем стенда идёт 1.26.5
|
> §5 (pgx · goose · River) на 04.08 всё ещё последние; по Go последним патчем стенда идёт **1.26.6**
|
||||||
> (07.07) — floor `go.mod` оставлен общим с движком, а тулчейн сборки поднят до 1.26.5 (см. ниже).
|
> (13.08, поднят security-адвизори — см. таблицу) — floor `go.mod` оставлен общим с движком.
|
||||||
|
|
||||||
## Пины
|
## Пины
|
||||||
|
|
||||||
| Что | Пин | Релиз пина | Зачем нам |
|
| Что | Пин | Релиз пина | Зачем нам |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Go (язык, `go.mod`) | **1.26.4** | 02.06.2026 | Тот же floor, что у движка (`backend/go.mod`) — общий стенд собирает оба модуля одним тулчейном |
|
| Go (язык, `go.mod`) | **1.26.4** | 02.06.2026 | Тот же floor, что у движка (`backend/go.mod`) — общий стенд собирает оба модуля одним тулчейном |
|
||||||
| Go (тулчейн сборки, `make tools-check`) | **≥1.26.5** | 07.07.2026 | 1.26.5 несёт security-фиксы `crypto/tls` и `os`; сетевой модуль собирается ими, а не «чем-нибудь 1.26» |
|
| Go (тулчейн сборки, `make tools-check`) | **≥1.26.6** | 13.08.2026 | Поднят с 1.26.5 не по вкусу, а по `make vuln`: база адвизори опубликовала пять уязвимостей stdlib против 1.26.5 — `net/http`, `crypto/tls`, `net/url`, `encoding/xml`, `encoding/asn1` (GO-2026-6218/6090/6089/6088/5972), все закрыты в 1.26.6, и две трассируются в пути, которые эта служба зовёт (`pgstore.Open → pgx.ParseConfig → asn1.Unmarshal`). На 1.26.6 батарея зелёная и скан чист. ⚠ Как floor РЕАЛЬНО держится (первая редакция этого подъёма не держала ничего — регекс `go1\.26\.([5-9]…)` принимал ту самую 1.26.5, а `GO_MIN_VERSION` жил только в echo): `make version-check` СРАВНИВАЕТ версии (`sort -V`, префиксы `rc`/`devel` отвергаются), и `go.mod` несёт `toolchain go1.26.6` — его читает всякая сборка, даже мимо make: при `GOTOOLCHAIN=auto` хост скачает нужный тулчейн, при `=local` остановится с ошибкой. Само сравнение запинено (`internal/gates`) |
|
||||||
| PostgreSQL | **18.x** (проверено на 18.4), floor **16** | 18.4 — май 2026 | 18 — текущая мажорная (19 в бете, в прод не берём); floor 16, потому что River тестируется на трёх последних мажорных |
|
| PostgreSQL | **18.x** (проверено на 18.4), floor **16** | 18.4 — май 2026 | 18 — текущая мажорная (19 в бете, в прод не берём); floor 16, потому что River тестируется на трёх последних мажорных |
|
||||||
| HTTP | stdlib `net/http` + `ServeMux` | — | Роутер-библиотека не нужна: `ServeMux` с 1.22 умеет метод+wildcards, а `Request.Pattern` даёт лог по маршруту, не по пути |
|
| HTTP | stdlib `net/http` + `ServeMux` | — | Роутер-библиотека не нужна: `ServeMux` с 1.22 умеет метод+wildcards, а `Request.Pattern` даёт лог по маршруту, не по пути |
|
||||||
| CSRF | stdlib `http.CrossOriginProtection` | Go 1.25 | Ровно тот механизм, что описан в §5 (Sec-Fetch-Site → Origin), теперь в тулчейне — свой велосипед не пишем |
|
| CSRF | stdlib `http.CrossOriginProtection` | Go 1.25 | Ровно тот механизм, что описан в §5 (Sec-Fetch-Site → Origin), теперь в тулчейне — свой велосипед не пишем |
|
||||||
|
|
@ -221,6 +221,28 @@
|
||||||
|
|
||||||
23. **«Ошибка материализации» — два разных факта, и различает их `runs.quarantines`.** Транзиентное — повтор следующим свипом; сюда входят не только дедлок и сериализация (`40P01`/`40001`), но и всё, во что превращается ШТАТНЫЙ рестарт управляемого Postgres: класс 08, `57P01`/`57P02`/`57P03`, `pgconn.SafeToRetry`, любой `net.Error`. Первая редакция знала только первые два, и рестарт базы карантинил проекцию живого платного прогона навсегда — снятия карантина в дереве нет (найдено ре-чеком V2, исполнением). Граница держится на типах: ошибка чтения ФАЙЛА — `*fs.PathError`, а он `net.Error` не удовлетворяет; пропасть, конфликт payload, битая строка — карантин ПОПЫТКИ (её проекции), прогон при этом продолжается и продолжает платить. До различения дедлок, который разрешился сам, ослеплял проекцию живого платного прогона навсегда.
|
23. **«Ошибка материализации» — два разных факта, и различает их `runs.quarantines`.** Транзиентное — повтор следующим свипом; сюда входят не только дедлок и сериализация (`40P01`/`40001`), но и всё, во что превращается ШТАТНЫЙ рестарт управляемого Postgres: класс 08, `57P01`/`57P02`/`57P03`, `pgconn.SafeToRetry`, любой `net.Error`. Первая редакция знала только первые два, и рестарт базы карантинил проекцию живого платного прогона навсегда — снятия карантина в дереве нет (найдено ре-чеком V2, исполнением). Граница держится на типах: ошибка чтения ФАЙЛА — `*fs.PathError`, а он `net.Error` не удовлетворяет; пропасть, конфликт payload, битая строка — карантин ПОПЫТКИ (её проекции), прогон при этом продолжается и продолжает платить. До различения дедлок, который разрешился сам, ослеплял проекцию живого платного прогона навсегда.
|
||||||
|
|
||||||
|
## Что решено сессией P5 (11.08) — загрузка книги, стоп/резюм, наблюдаемость
|
||||||
|
|
||||||
|
24. **Метрики — `prometheus/client_golang` v1.24.1, на ОТДЕЛЬНОМ слушателе, и это выбор против stdlib.** Норма зоны требует stdlib прежде библиотеки (`ENGINEERING_STANDARDS` §1), поэтому первым рассмотрен `expvar` — и он этой работы не несёт: нет лейблов (⇒ «запросы по маршруту и коду» не выражаются вовсе), нет гистограмм (⇒ на вопрос о задержке остаётся среднее — единственная статистика, которая прячет хвост), а его JSON не читает ни один скрейпер без переводчика. Сэкономил бы он зависимость, а стоил бы написания недостающих трёх руками — то есть ровно того самописного пути, который та же норма и запрещает. OpenTelemetry для этого деплоя тяжелее: коллектор процессом, протокол экспорта настройкой, и всё равно scrape-эндпоинт на конце. Пин сверен живьём 11.08 (`proxy.golang.org/@latest`), релиз 24.07.2026.
|
||||||
|
|
||||||
|
**Внешний эталон оси (половина PD-115):** практики именования Prometheus (базовые единицы — секунды и байты; `_total` у счётчиков; единица не в лейбле) плюс «четыре золотых сигнала» на вопрос «что мерить». Пин на форму — `metrics.TestTheRunnersStateIsExposedWithItsUnits`, он же ловит единицу в имени.
|
||||||
|
|
||||||
|
**Кардинальность:** лейбл несёт ПАТТЕРН маршрута, никогда путь — сырой путь это библиотека пользователя в индексе оператора (PD-3) и неограниченное число рядов; неразобранный запрос сворачивается в один ряд `(unmatched)`, потому что там лейбл выбирает не сервер. Пин — `TestRequestsAreCountedByRoutePatternAndNeverByPath`.
|
||||||
|
|
||||||
|
**Отдельный слушатель (`TM_PLATFORM_METRICS_ADDR`, дефолт `127.0.0.1:9464`), а не маршрут под `/v0`.** Контрактная поверхность отвечает 401 раньше 404, чтобы аноним не картографировал её (§4), а экспозиция несёт ту же породу фактов — глубину очереди, число прогонов, размер деплоя. Выходов два: вторая модель авторизации для скрейпера или привязка туда, где достаёт только хост; второе — то, что делает каждый контрол-плейн, и это строка деплоя вместо ещё одной вещи, которую надо не сломать (та же логика, по которой админ-поверхность стала CLI, §10). Не поднявшийся слушатель — WARN, не фатал: телеметрия это как за сервисом смотрят, а не как он служит.
|
||||||
|
|
||||||
|
**Значения снимает СВИП, а не скрейп.** Гейджи ставятся раз в проход реконсилятора, который и так читает базу по таймеру; коллектор, ходящий в Postgres на каждый скрейп, отдал бы нагрузку на контрол-плейн тому, у кого есть доступ к эндпоинту.
|
||||||
|
|
||||||
|
25. **Дедлайн чтения маршрута загрузки РАСШИРЯЕТСЯ, но никогда не снимается.** `ReadTimeout` сервера (30 с) покрывает ВЕСЬ запрос вместе с телом — это и есть закрытие PD-2, — а книга это десятки мегабайт и минуты бытового аплинка. Маршрут `POST /books` ставит собственный конечный дедлайн через `http.ResponseController` (`TM_PLATFORM_UPLOAD_DEADLINE`, дефолт 10 минут); §12 запрещает СНЯТИЕ дедлайна, и запрет в силе — снятый дедлайн возвращает полу-кормленный запрос к неограниченному удержанию, а расширенный оставляет его ограниченным. Первоисточник тот же, что и у самого запрета: доккоммент `net/http.Server.ReadTimeout` называет пер-запросный дедлайн именно для этого случая («Because ReadTimeout does not let Handlers make per-request decisions on each request body's acceptable deadline or upload rate…»). Пин — `TestTheUploadRouteExtendsItsOwnReadDeadlineAndOnlyItsOwn`, и он двусторонний: тот же медленный запрос на ОБЫЧНОМ маршруте обязан быть отрезан. ⚠ Следствие для обёрток: любая обёртка `ResponseWriter` обязана нести `Unwrap`, иначе расширение молча становится no-op (посадка «убрать `Unwrap` из метрик» падает).
|
||||||
|
|
||||||
|
26. **Приём книги: строка ПЕРЕД байтами, файл в каталоге книги, разбор — отдельным шагом.** Порядок «строка, потом байты» делает `uploading` состоянием, которое кто-то может наблюдать (вторая вкладка видит книгу, пока файл ещё идёт), и — что важнее операционно — делает брошенную загрузку НАХОДИМОЙ: строка единственное, что говорит, чей каталог под `BooksDir`. Цена порядка названа: языки обязаны прийти ДО файла, потому что потоковый читатель отдаёт части в порядке провода; тем же правилом специфицирована браузерная загрузка S3, а в спеке нашего контракта его нет — заведено вопросом владельцу контракта (PD-172).
|
||||||
|
|
||||||
|
Тело читается `r.MultipartReader` и льётся в файл `source.<расширение>`; расширение сохраняется, потому что движок диспетчеризует читатель по нему (`.epub` → epub, иначе текст), а не потому, что платформа знает форматы — списка форматов у неё нет и не должно быть. Разбор = `tmctl manifest --json`, $0-команда, которая и создаёт БД проекта; она даёт число глав и идентичность разреза (`chunker_version`, `source_sha256`).
|
||||||
|
|
||||||
|
**Два разных отказа и две разных судьбы файла.** Движок ОТВЕТИЛ «не разобрать» (`*exec.ExitError`) — это про книгу, терминально с первого ответа, каталог удаляется: перепарсить нечем, скачать нечем, а аутентифицированный маршрут, который пишет на диск оператора и ничего не убирает, был бы дырой, которую этот пак открыл бы сам. Движок НЕ ЗАПУСТИЛСЯ — это про хост: заявка возвращается, следующий свип пробует снова, и только после бюджета попыток книга становится `rejected` с файлом НА МЕСТЕ (удалять чужую загрузку из-за своей поломки — не наше решение).
|
||||||
|
|
||||||
|
27. **Стоп различается НАМЕРЕНИЕМ, записанным до сигнала, а не догадкой по коду выхода.** Движок ловит SIGTERM и выходит кодом 1, поэтому маркер пользовательского стопа и маркер аварии — одни и те же байты (PD-152). Платформа пишет `runs.stop_requested_at` в Postgres и только потом просит systemd; на гонку «стоп против самостоятельного финиша» стоит сверка времени маркера, а чистые исходы движка (0/2/3) намерение перебивают — завершённый перевод не должен показываться отменённым. Следствия, каждое запинено: прогон с намерением НЕ перезапускается реконсилятором (иначе деньги уходят на работу, которую владелец только что отменил), живой юнит с намерением получает повторный стоп (закрывает «платформа умерла между записью и вызовом»), а стоп до спавна заканчивает прогон и возвращает холд целиком. ⚠ Что этим НЕ закрыто и названо: штатная перезагрузка хоста по-прежнему выглядит как авария — там намерения нет ни у кого, и различить это может только сам движок различимым кодом выхода (строка 165 единого бэклога).
|
||||||
|
|
||||||
## Как поднять локально
|
## Как поднять локально
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -236,6 +258,11 @@ curl -s localhost:8080/readyz # ready
|
||||||
`TM_PLATFORM_SESSION_MAX_AGE` · `TM_PLATFORM_INSECURE_COOKIES` (dev, по HTTP) ·
|
`TM_PLATFORM_SESSION_MAX_AGE` · `TM_PLATFORM_INSECURE_COOKIES` (dev, по HTTP) ·
|
||||||
`TM_PLATFORM_OIDC_ISSUER` · `_OIDC_CLIENT_ID` · `_OIDC_CLIENT_SECRET` (или `_FILE`) ·
|
`TM_PLATFORM_OIDC_ISSUER` · `_OIDC_CLIENT_ID` · `_OIDC_CLIENT_SECRET` (или `_FILE`) ·
|
||||||
`_OIDC_REDIRECT_URL` · `TM_PLATFORM_AFTER_LOGIN` · `TM_PLATFORM_SIGNUP_GRANT_USD`.
|
`_OIDC_REDIRECT_URL` · `TM_PLATFORM_AFTER_LOGIN` · `TM_PLATFORM_SIGNUP_GRANT_USD`.
|
||||||
|
Приём книги: `TM_PLATFORM_BOOKS_DIR` (только АБСОЛЮТНЫЙ путь, корень каталогов книг; пусто =
|
||||||
|
инстанс загрузок не принимает и `POST /books` не смонтирован) · `TM_PLATFORM_MAX_UPLOAD_BYTES`
|
||||||
|
(дефолт 64 МиБ — потолок ОДНОЙ загрузки, §25) · `TM_PLATFORM_UPLOAD_DEADLINE` (дефолт 10 минут —
|
||||||
|
сколько тело одной загрузки вправе идти).
|
||||||
|
Наблюдаемость: `TM_PLATFORM_METRICS_ADDR` (дефолт `127.0.0.1:9464`; пусто = метрики не отдаются).
|
||||||
Раннер: `TM_PLATFORM_ENGINE_BIN` (версионированный путь tmctl; пусто = инстанс только читает) ·
|
Раннер: `TM_PLATFORM_ENGINE_BIN` (версионированный путь tmctl; пусто = инстанс только читает) ·
|
||||||
`TM_PLATFORM_STATE_DIR` (только АБСОЛЮТНЫЙ путь: маркер пишет юнит из каталога книги, читает демон
|
`TM_PLATFORM_STATE_DIR` (только АБСОЛЮТНЫЙ путь: маркер пишет юнит из каталога книги, читает демон
|
||||||
из своего — относительный назвал бы два разных файла; отказ на буте) · `TM_PLATFORM_CTL_BIN` ·
|
из своего — относительный назвал бы два разных файла; отказ на буте) · `TM_PLATFORM_CTL_BIN` ·
|
||||||
|
|
@ -246,20 +273,50 @@ curl -s localhost:8080/readyz # ready
|
||||||
на той сборке движка, с которой прогон начался — строка 139).
|
на той сборке движка, с которой прогон начался — строка 139).
|
||||||
Вход монтируется, только если задана ВСЯ четвёрка OIDC; половина конфигурации — отказ на старте.
|
Вход монтируется, только если задана ВСЯ четвёрка OIDC; половина конфигурации — отказ на старте.
|
||||||
|
|
||||||
|
⚠ Все они печатаются на старте с источником (`default`/`environment`/`file`) — PD-114; секреты и
|
||||||
|
денежные суммы печатаются фактом наличия, без значения.
|
||||||
|
|
||||||
Админ-команды: `tmplatformctl grant --user <id> --usd 5 [--note ...] [--key ...]` ·
|
Админ-команды: `tmplatformctl grant --user <id> --usd 5 [--note ...] [--key ...]` ·
|
||||||
`balance --user <id>` · `logins --user <id>` · `revoke --user <id>`.
|
`balance --user <id>` · `logins --user <id>` · `revoke --user <id>`.
|
||||||
|
|
||||||
### Postgres на стенде без root
|
### Postgres на стенде без root
|
||||||
|
|
||||||
|
**Основной способ (13.08): ПОЛНЫЙ дистрибутив в домашнем каталоге, тоже без sudo.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# micromamba — один статический бинарь с conda-forge; прокси стенда пускает conda-forge
|
||||||
|
curl -sL -o mm.tar.bz2 https://conda.anaconda.org/conda-forge/linux-64/micromamba-2.9.0-0.tar.bz2
|
||||||
|
tar -xjf mm.tar.bz2 bin/micromamba && install -m755 bin/micromamba ~/.local/bin/micromamba
|
||||||
|
|
||||||
|
MAMBA_ROOT_PREFIX=$HOME/.local/share/mamba \
|
||||||
|
~/.local/bin/micromamba create -y -p ~/.local/pgsql -c conda-forge 'postgresql=18.4'
|
||||||
|
|
||||||
|
~/.local/pgsql/bin/initdb -D ~/.local/share/tmstand/pgdata -U postgres -A trust \
|
||||||
|
--no-locale --encoding=UTF8
|
||||||
|
~/.local/pgsql/bin/pg_ctl -D ~/.local/share/tmstand/pgdata \
|
||||||
|
-l ~/.local/share/tmstand/pg.log -o "-k /tmp -p 55433 -c listen_addresses=''" start
|
||||||
|
export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55433&sslmode=disable'
|
||||||
|
```
|
||||||
|
|
||||||
|
164 МБ, ставится в `~/.local` (та же схема, что у Go и Node на этой машине), sudo не нужен, и в
|
||||||
|
пакете есть `psql`, `pg_dump`, `pg_basebackup`, contrib — то есть БД можно смотреть руками. Порт
|
||||||
|
55433 выбран, чтобы не спорить со стендом-скрэтчпадом на 55432, если тот поднят.
|
||||||
|
⚠ Проверено исполнением 13.08: батарея зоны `make check` под `-race` на этом стенде — EXIT=0,
|
||||||
|
скипов 0, все пакеты зелёные. Стенд переживает уборку скрэтчпадов сессий; после рестарта машины
|
||||||
|
поднимается тем же `pg_ctl … start`.
|
||||||
|
|
||||||
|
**Запасной способ — бинарники `io.zonky.test.postgres` с Maven Central в скрэтчпад:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# бинарники io.zonky.test.postgres с Maven Central, распакованные в скрэтчпад
|
|
||||||
pg/bin/initdb -D pgdata -U postgres -A trust --no-locale --encoding=UTF8
|
pg/bin/initdb -D pgdata -U postgres -A trust --no-locale --encoding=UTF8
|
||||||
pg/bin/pg_ctl -D pgdata -l pg.log -o "-k /tmp -p 55432 -c listen_addresses=" start
|
pg/bin/pg_ctl -D pgdata -l pg.log -o "-k /tmp -p 55432 -c listen_addresses=" start
|
||||||
export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55432&sslmode=disable'
|
export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55432&sslmode=disable'
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠ Сокет кладём в `/tmp` (`-k`): полный путь скрэтчпада длиннее лимита Unix-сокета.
|
⚠ Сокет кладём в `/tmp` (`-k`): полный путь скрэтчпада длиннее лимита Unix-сокета.
|
||||||
⚠ `psql` в пакете zonky НЕТ — только `initdb`/`pg_ctl`/`postgres`; проверять из Go.
|
⚠ В пакете zonky ТОЛЬКО `initdb`/`pg_ctl`/`postgres` — проверено содержимым архива
|
||||||
|
(1071 файл, в `bin/` три штуки); `psql` нет, смотреть БД приходится из Go. Живёт в скрэтчпаде
|
||||||
|
сессии, поэтому умирает вместе с ним — для долгой работы предпочтителен способ выше.
|
||||||
|
|
||||||
⚠ Postgres на стенде отсутствует как системный пакет и sudo нет. Схема и запросы этой сессии
|
⚠ Postgres на стенде отсутствует как системный пакет и sudo нет. Схема и запросы этой сессии
|
||||||
проверены на ЖИВОМ PostgreSQL **18.4**, поднятом без root из бинарников zonky
|
проверены на ЖИВОМ PostgreSQL **18.4**, поднятом без root из бинарников zonky
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,32 @@
|
||||||
|
|
||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
|
|
||||||
|
- **P5 ПОСТРОЕН и ДОФИКШЕН по приёмке — дерево передаётся на лендинг (11.08).** `POST /v0/books`
|
||||||
|
потоково с пер-маршрутным потолком тела и своим дедлайном чтения · статусы
|
||||||
|
`uploading → parsing → not_started | rejected` получили писателей, разбор — $0-команда движка
|
||||||
|
`tmctl manifest` · `POST /v0/runs/{id}/stop|resume` с намерением стопа в Postgres ДО сигнала ·
|
||||||
|
метрики Prometheus на отдельном слушателе · печать эффективной конфигурации.
|
||||||
|
- **Приёмка 11.08 (8 находок) → дофикс → кросс-семейное ревью дофикса (5 находок) → ре-чек
|
||||||
|
оркестратора (3 находки, 2 high) → ТРЕТИЙ РАУНД ЗАКРЫТ (14.08)**: разделы «Третий раунд»,
|
||||||
|
«Дофикс-2» и «Дофикс P5» ниже, движение — записками-планами. Батарея на стенде `~/.local/pgsql`
|
||||||
|
(порт 55433) под `-race`: EXIT=0, 16 пакетов, линтер 0 issues, скипов 0, `make vuln` чист, тестов
|
||||||
|
**261 → 359**. Посадки: 10/10 · 6/6 · 6/6 (две пережившие за все раунды — оба раза виноват был
|
||||||
|
слепой пин, переписан пин, не мутация); списки — в журнале.
|
||||||
|
⚠ **Тулчейн 1.26.6 НА РАТИФИКАЦИЮ**, и вчерашний клейм про него был ЛОЖЕН: гейт принимал 1.26.5
|
||||||
|
(регекс вместо сравнения). Теперь floor держат два механизма — `make version-check` сравнивает
|
||||||
|
версии, `go.mod` несёт `toolchain go1.26.6`, который читает всякая сборка; при `GOTOOLCHAIN=auto`
|
||||||
|
хост скачает нужный тулчейн, при `=local` остановится. Само сравнение запинено (`internal/gates`).
|
||||||
|
**ВОПРОС на ратификацию — кто пишет `book.yaml` при интейке**: половина П-9 стоит на нём, раздел
|
||||||
|
«Развилка» ниже. Дерево не коммичено.
|
||||||
- **P4 «раннер» + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ — D39.123, код `d29e30c`** (ревью-шапка ниже):
|
- **P4 «раннер» + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ — D39.123, код `d29e30c`** (ревью-шапка ниже):
|
||||||
транзиентный systemd-юнит на прогон · очередь River с холдом-до-спавна в одной транзакции ·
|
транзиентный systemd-юнит на прогон · очередь River с холдом-до-спавна в одной транзакции ·
|
||||||
реконсилятор с базовой линией расчёта · тейлер `events.jsonl` с карантином проекции · пять
|
реконсилятор с базовой линией расчёта · тейлер `events.jsonl` с карантином проекции · пять
|
||||||
ручек `/v0` контракта **0.2.1** · дев-интейк. Батарея с живым PG 18.4 под `-race` зелёная,
|
ручек `/v0` контракта **0.2.1** · дев-интейк. Батарея с живым PG 18.4 под `-race` зелёная,
|
||||||
скипов 0, тестов зоны **261**. Формула аргумента потолка — `committed + прирост` (PD-158,
|
скипов 0, тестов зоны **261**. Формула аргумента потолка — `committed + прирост` (PD-158,
|
||||||
ратифицирована ПОПРАВКОЙ к пингу оркестратора).
|
ратифицирована ПОПРАВКОЙ к пингу оркестратора).
|
||||||
- **Регистр — 171 строка** (`python3 docs/scripts/counts.py`): 124 закрыто · 1 закрыт ратификацией (PD-59) · 3 риском · 43 открыто
|
- **Регистр — 198 строк** (`python3 docs/scripts/counts.py`): 145 закрыто · 1 закрыт ратификацией
|
||||||
(**1 major — PD-113**: стоп по потолку отдаётся `failed` до эмиттера; после PD-158 его цена
|
(PD-59) · 4 риском · 48 открыто (**1 major — PD-113**: стоп по потолку отдаётся `failed` до
|
||||||
выросла — потолок срабатывает ровно на исчерпании холда).
|
эмиттера). Форма — 13 секций (пинг оркестратора №16).
|
||||||
- **Промт P5 ВЫДАН оркестратором 10.08** (`PLATFORM_P5_SESSION_PROMPT.md`, D39.128): П-9
|
- **Промт P5 ВЫДАН оркестратором 10.08** (`PLATFORM_P5_SESSION_PROMPT.md`, D39.128): П-9
|
||||||
`POST /books` (+PD-72 потолок тела; развилка «кто пишет book.yaml при интейке» — вопросом в этот
|
`POST /books` (+PD-72 потолок тела; развилка «кто пишет book.yaml при интейке» — вопросом в этот
|
||||||
журнал ДО стройки той половины) · стоп/резюм-ручки PD-140 (различение «потолок vs авария» —
|
журнал ДО стройки той половины) · стоп/резюм-ручки PD-140 (различение «потолок vs авария» —
|
||||||
|
|
@ -25,6 +42,812 @@
|
||||||
(D39.107/109/112/114); разделы — в [archive/platform-PROGRESS-P0-P3.md](archive/platform-PROGRESS-P0-P3.md).
|
(D39.107/109/112/114); разделы — в [archive/platform-PROGRESS-P0-P3.md](archive/platform-PROGRESS-P0-P3.md).
|
||||||
Стек и рецепт стенда — `STACK_DECISIONS.md`; критерии приёмки — `ENGINEERING_STANDARDS.md`.
|
Стек и рецепт стенда — `STACK_DECISIONS.md`; критерии приёмки — `ENGINEERING_STANDARDS.md`.
|
||||||
|
|
||||||
|
## Третий раунд (14.08): ре-чек оркестратора FP5-9…FP5-11 и хвосты
|
||||||
|
|
||||||
|
Все три находки подтвердились на коде до правки; возражений нет. Две из трёх — мои вчерашние фиксы,
|
||||||
|
которые ЗАЯВЛЕНЫ шире, чем сделаны, и это ровно тот класс, за который приёмка уже била.
|
||||||
|
|
||||||
|
### FP5-9 [HIGH] Гейт тулчейна не отказывал 1.26.5 — и три дока это утверждали
|
||||||
|
|
||||||
|
Проверено исполнением: `make version-check GO_VERSION=go1.26.5` на прежнем правиле проходил.
|
||||||
|
Регекс `go1\.26\.([5-9]|[0-9]{2,})` принимал ровно ту версию, ради отказа от которой floor и
|
||||||
|
поднимали, а `GO_MIN_VERSION` жил только в тексте сообщения. Тот же регекс ставил 1.26.10 НИЖЕ
|
||||||
|
1.26.9. Клейм «хост на 1.26.5 получит отказ» был ложен в трёх местах (журнал ×2, `STACK_DECISIONS`,
|
||||||
|
коммент Makefile) — гейт «поднят» был только на словах.
|
||||||
|
|
||||||
|
Сделано, двумя независимыми механизмами:
|
||||||
|
|
||||||
|
1. `make version-check` СРАВНИВАЕТ версии (`sort -V`), а не матчит; префиксы `rc`/`devel` отвергаются
|
||||||
|
отдельной веткой — пререлиз не несёт фиксов, которые обещает его номер. Цель отделена от
|
||||||
|
`tools-check` и берёт версию из переменной `GO_VERSION`, чтобы её можно было судить версиями,
|
||||||
|
которых на хосте нет.
|
||||||
|
2. `go.mod` получил `toolchain go1.26.6`. **Решение и обоснование** (оркестратор просил решить): floor
|
||||||
|
ЯЗЫКА (`go 1.26.4`) остаётся общим с движком — один стенд собирает оба модуля; директива
|
||||||
|
`toolchain` поднимает только тулчейн, то есть stdlib, которым линкуется сетевой модуль. Её читает
|
||||||
|
ВСЯКАЯ сборка, включая ту, что не зовёт make: при `GOTOOLCHAIN=auto` (дефолт) хост скачает 1.26.6
|
||||||
|
вместо того, чтобы собрать уязвимый бинарь, при `=local` — остановится с ошибкой, называющей
|
||||||
|
версию. Обе ветки — тот ответ, который нужен; ни одна не зависит от того, прочитал ли кто-то
|
||||||
|
Makefile. Цена названа: офлайн-хост с `=local` и только 1.26.5 теперь не соберёт зону — это и есть
|
||||||
|
гейт.
|
||||||
|
|
||||||
|
Пин — на СРАВНЕНИЕ, а не на прогон: новый пакет `internal/gates` гоняет `version-check` таблицей
|
||||||
|
версий, которых на этой машине нет (1.26.4/1.26.5/1.25.9/1.9.9/rc/devel — отказ;
|
||||||
|
1.26.6/1.26.7/1.26.10/1.27.0/2.0.0 — приём), и вторым тестом требует, чтобы `go.mod` и
|
||||||
|
`GO_MIN_VERSION` называли одну версию. Две посадки падают. Три ложных клейма переписаны на описание
|
||||||
|
механизма. Строка PD-197.
|
||||||
|
|
||||||
|
### FP5-10 [HIGH] Гард корня не переживал unmount и маскировался бутовым `MkdirAll`
|
||||||
|
|
||||||
|
Тоже подтверждено: `Stat(BooksDir)` отвечает «есть» про пустой mountpoint, оставшийся после
|
||||||
|
размонтирования тома, смонтированного РОВНО в `BooksDir`, — а бут безусловным `MkdirAll` пересоздаёт
|
||||||
|
корень после рестарта. То есть мой вчерашний фикс закрывал только форму «каталог удалён вместе с
|
||||||
|
родителем» и не закрывал ту, ради которой писался.
|
||||||
|
|
||||||
|
Сделано по кандидату оркестратора — **сентинел провижининга**: файл `.tmplatform-books` в корне,
|
||||||
|
который пишет ПЕРВАЯ загрузка (`markStorage`, `O_EXCL`, поэтому две одновременные загрузки обе правы)
|
||||||
|
и не пишет больше никто — в частности, не пишет бут. Решение «хранилище на месте» принимается по
|
||||||
|
СЕНТИНЕЛУ (`storageIsThere`), а не по существованию каталога: ни unmount, ни `MkdirAll` его не
|
||||||
|
подделывают. Хост, который никогда не принимал загрузок, отвечает «нет» — безопасная сторона: у него
|
||||||
|
нет книг, которые можно отклонить.
|
||||||
|
|
||||||
|
Пин — `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`: удаляем всё под
|
||||||
|
корнем и пересоздаём сам корень (ровно то, что оставляют unmount и бут), десять циклов ожидания —
|
||||||
|
книга жива, бюджет цел, движок не спрошен, сентинел не воскрес. **Первая редакция этого пина посадку
|
||||||
|
ПЕРЕЖИЛА** (мутация «не писать сентинел вовсе»): без сентинела всё ждёт, и тест этого не отличал —
|
||||||
|
добавлено утверждение, что принятая загрузка сентинел ПИШЕТ, иначе терминальность крэш-окна (FP5-3)
|
||||||
|
теряется. Обе посадки падают. PD-192 дополнена.
|
||||||
|
|
||||||
|
### FP5-11 Правило ревизии применено не ко всем писателям
|
||||||
|
|
||||||
|
Пять писателей жизненного цикла ПРОГОНА остались на `revision + 1` против правила, которое дофикс
|
||||||
|
ввёл для трёх интейковых: старт, реоткрытие, пауза (`runs.go`) и два закрытия (`sink.go`). Значит
|
||||||
|
staleness библиотеки, которую вчера закрыли на статусах интейка, оставалась на статусах прогона:
|
||||||
|
книга не-максимума аккаунта уходила `translating` — библиотека не двигалась.
|
||||||
|
|
||||||
|
Сделано: все пять берут `nextRevisionOfThisBooksLibrary`. Пин —
|
||||||
|
`pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary` (вторая книга держит максимум 41,
|
||||||
|
прогон стартует на первой; проверяются обе половины жизненного цикла — старт и пауза). Посадка
|
||||||
|
падает.
|
||||||
|
|
||||||
|
⚠ **Мотивированное исключение, названное вслух:** два писателя `books.revision` НЕ переведены —
|
||||||
|
прогресс-путь синка (`bumpBook` на `unitDone`/`progress`). Причина не вкусовая: `unitDone` вычисляет
|
||||||
|
`chapters.revision` из `books.revision + 1` ДО инкремента, и прыжок книги через пол развязал бы
|
||||||
|
книжную и главную шкалы, а это отдельная scope-семантика контракта. Она и не нужна: прогресс
|
||||||
|
существует только у книги, у которой ИДЁТ прогон, а старт прогона теперь сам поднимает книгу на
|
||||||
|
уровень библиотеки — дальше `+1` монотонен и виден. Если правило когда-нибудь понадобится и там,
|
||||||
|
оно приедет вместе с проверкой главной шкалы, а не молча.
|
||||||
|
|
||||||
|
### Хвосты ре-чека
|
||||||
|
|
||||||
|
- **(а)** Коммент `PauseRun` обещал проверку «в том же стейтменте», а стоит отдельный
|
||||||
|
`select … for update` в той же транзакции. Текст приведён к коду (транзакция и её замки).
|
||||||
|
- **(б)** Комменты `parse.go` («terminal on the first answer») описывали до-дофиксное поведение на
|
||||||
|
пути, который УДАЛЯЕТ файл пользователя, — самое опасное место для устаревшего текста. Переписаны:
|
||||||
|
через бюджет попыток идёт КАЖДЫЙ ответ движка, удаляет только последний.
|
||||||
|
- **(в)** Остаток FP5-5 закрыт кодом, а не строкой: проход, которому осталось меньше, чем нужно
|
||||||
|
разбору, книгу НЕ НАЧИНАЕТ (клейм берётся внутри `Parse`, поэтому отложенная книга не тратит
|
||||||
|
ничего) и говорит об этом одной строкой лога вместо ошибки на книгу. Пин —
|
||||||
|
`books.TestAPassTooShortForAParseStartsNoneAtAll`, посадка падает.
|
||||||
|
- **(г)** Мета-пин systemd-гейта перечислителен — принято как названный остаток: он ловит возврат к
|
||||||
|
сверке сообщений по словам systemd и требует, чтобы гейт вообще спрашивал менеджер, но исчерпать
|
||||||
|
все будущие формулировки перечислением нельзя.
|
||||||
|
|
||||||
|
### Посадки третьего раунда
|
||||||
|
|
||||||
|
| Посадка (что откатывается) | Пин, который обязан упасть | Итог |
|
||||||
|
|---|---|---|
|
||||||
|
| гейт снова матчит регексом (принимает 1.26.5) | `gates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem` | поймана |
|
||||||
|
| `go.mod` снова без `toolchain` | `gates.TestGoModPinsTheSameToolchainTheBatteryDemands` | поймана |
|
||||||
|
| гард снова смотрит на существование корня | `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault` | поймана |
|
||||||
|
| сентинел не пишется загрузкой | тот же | ПЕРЕЖИЛА → пин усилен → поймана |
|
||||||
|
| старт прогона снова `revision + 1` | `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary` | поймана |
|
||||||
|
| проход снова начинает книгу, на которую нет времени | `books.TestAPassTooShortForAParseStartsNoneAtAll` | поймана |
|
||||||
|
|
||||||
|
### Батарея третьего раунда
|
||||||
|
|
||||||
|
- `make check` с живым PG 18.4 (стенд `~/.local/pgsql`, порт 55433) под `-race`: **EXIT=0, 16
|
||||||
|
пакетов, скипов 0**, линтер **0 issues**, `make vuln` — чисто.
|
||||||
|
- Тесты `^func Test` исполнением: **354 → 359** (+5, удалённых 0).
|
||||||
|
- Посадки: **6 из 6** (одна со второго захода, см. таблицу).
|
||||||
|
|
||||||
|
### Записка-план третьего раунда: ID → статус → улика
|
||||||
|
|
||||||
|
| ID | Статус | Улика |
|
||||||
|
|---|---|---|
|
||||||
|
| FP5-9 | ЗАКРЫТО | `version-check` сравнивает (`sort -V`) + `toolchain go1.26.6` в `go.mod`; `internal/gates` ×2 пина, две посадки падают; три клейма переписаны. PD-197 |
|
||||||
|
| FP5-10 | ЗАКРЫТО | сентинел `.tmplatform-books`, пишет только загрузка; `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`; две посадки падают. PD-192 дополнена |
|
||||||
|
| FP5-11 | ЗАКРЫТО с названным исключением | пять писателей прогона на `nextRevisionOfThisBooksLibrary`; `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary`; посадка падает. Прогресс-путь синка НЕ тронут — обоснование выше. PD-122 дополнена |
|
||||||
|
| хвост (а) | ЗАКРЫТО | текст `PauseRun` приведён к коду |
|
||||||
|
| хвост (б) | ЗАКРЫТО | текст `parse.go` про терминальность приведён к коду. PD-198 |
|
||||||
|
| хвост (в) | ЗАКРЫТО кодом | проход не начинает книгу без полного бюджета; `books.TestAPassTooShortForAParseStartsNoneAtAll`; посадка падает. PD-189 дополнена |
|
||||||
|
| хвост (г) | ПРИНЯТО как остаток | перечислительность мета-пина названа, не «закрыта» |
|
||||||
|
|
||||||
|
|
||||||
|
## Дофикс-2 (13.08): кросс-семейное ревью САМОГО дофикса
|
||||||
|
|
||||||
|
Пропуск, который я закрыл только по вопросу владельца: дофикс приёмки был проверен исполнением
|
||||||
|
(пины · посадки · батарея · живая проба), но НЕ был отревьюен независимо — author≠reviewer для
|
||||||
|
дофикс-диффа не соблюдался. Запущены два ревьюера другой семьи (Fable 5, я Opus), read-only по
|
||||||
|
дереву, каждый со своей линзой — «деньги и гонки» и «интейк, потеря данных, правдивость чисел».
|
||||||
|
Вернули **пять дефектов с воспроизведением, одну гипотезу и два расхождения отчётности**. Все
|
||||||
|
подтверждены мной на коде до правки; возражений нет.
|
||||||
|
|
||||||
|
### Н1 [medium-high, интейк] Пропавший КОРЕНЬ хранилища читался как вина каждой книги
|
||||||
|
|
||||||
|
`os.Stat(workdir)` даёт ENOENT и когда пропал каталог одной книги, и когда не смонтирован сам
|
||||||
|
`BooksDir`. Первое — терминальный конец этой книги (и лечение крэш-окна отказа, FP5-3), второе —
|
||||||
|
беда хоста. Читая второе как первое, ОДИН проход свипа терминально отклонял ВСЕ книги в интейке с
|
||||||
|
`source_unreadable` — причиной, которая винит файл пользователя, терминальна по устройству и не
|
||||||
|
имеет обратного хода (ни перепарса, ни удаления, PD-175). То есть мой же фикс FP5-3 создал путь
|
||||||
|
массового уничтожения загрузок из-за размонтированного тома.
|
||||||
|
|
||||||
|
Сделано: `ErrStorageGone` отличён от `ErrDirectoryGone` — корень спрашивается ПЕРЕД тем, как винить
|
||||||
|
книгу; причина `storage_unavailable` никогда не терминальна, бюджет не тратит (движка не звали) и на
|
||||||
|
книге не хранится. Предикат `waitsForTheDeployment` собрал оба таких случая в одном месте.
|
||||||
|
Пин — `books.TestAVanishedStorageRootIsNotEveryBooksFault` (две книги, снесённый корень, десять
|
||||||
|
циклов ожидания: обе живы, бюджет цел, движок не спрошен ни разу, том вернулся — следующий проход
|
||||||
|
разбирает). Посадка падает. Строка PD-192.
|
||||||
|
|
||||||
|
### Н2 [minor, ревизия] Пол ревизии глотал весь статусный проход книги
|
||||||
|
|
||||||
|
Продолжение FP5-1 на соседнем пути: вставка научилась брать `greatest(max, пол) + 1`, а смены
|
||||||
|
статуса остались на `revision + 1`. Книга, загруженная ДО чужой отменённой загрузки, идёт
|
||||||
|
`uploading → parsing → not_started` тремя инкрементами СВОЕГО счётчика и всё ещё под полом —
|
||||||
|
`greatest(max, пол)` не двигается вовсе, а клиент по контракту отбрасывает чтение, которое не выше
|
||||||
|
применённого. Экран, только что загрузивший книгу, показывает её «приходящей», пока библиотеку не
|
||||||
|
сдвинет постороннее событие. Монотонность при этом не нарушена — это staleness, и именно поэтому
|
||||||
|
прошлый круг её не заметил.
|
||||||
|
|
||||||
|
Сделано: три писателя статуса (`StartParsing`, `FinishParse`, `RejectBook`) берут следующий номер
|
||||||
|
БИБЛИОТЕКИ, а не свой + 1 (`nextRevisionOfThisBooksLibrary`, владелец читается из обновляемой
|
||||||
|
строки). Пин — `books.TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor`, посадка падает. PD-122
|
||||||
|
переформулирована: остаток — собственный счётчик области (двое ОДНОВРЕМЕННЫХ писателей могут
|
||||||
|
вычислить одно и то же число; значение при этом растёт, поэтому клиент перезапрашивает).
|
||||||
|
|
||||||
|
### М1 [medium, деньги] `PauseRun` — третий закрывающий путь без гарда живой попытки
|
||||||
|
|
||||||
|
Тот же класс, что PD-181 и FP5-2, на пути, который прошлый круг не проверил: пауза закрывала прогон,
|
||||||
|
не спрашивая, жива ли ещё закрываемая попытка И этого ли она прогона (апдейт попытки шёл даже без
|
||||||
|
`run_id`). Сценарий ревьюера: два поколения реконсилятора при перекрывающемся деплое, у старого ниже
|
||||||
|
`PerChapter` в конфиге → его проход считает остаток исчерпанным и паузит прогон, который уже
|
||||||
|
рестартован в живую попытку 2. Итог — прогон `paused/credit_exhausted` под живым тратящим движком, а
|
||||||
|
открытый холд попытки 2 не виден НИ в `ListLiveRuns` (прогон завершён), НИ в `UnsettledRuns` (попытка
|
||||||
|
не завершена).
|
||||||
|
|
||||||
|
Сделано: тот же `exists (… a.id = $4 and a.run_id = runs.id and a.ended_at is null)`, что у соседей,
|
||||||
|
плюс `run_id` в апдейте попытки. Пин — `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt`
|
||||||
|
(проверяет и то, что прогон жив, и то, что холд живой попытки виден в списке). Посадка падает. PD-193.
|
||||||
|
|
||||||
|
### М2 [low, проекция] Синк законченной попытки усыновлял чужой хендшейк
|
||||||
|
|
||||||
|
Форма, которой до P5 не существовало: стоп ДО спавна оставляет попытку закрытой и без
|
||||||
|
`engine_run_id`. Устаревший материализатор с такой попыткой принимал hello попытки, которая её
|
||||||
|
заменила, биндил чужой `engine_run_id` на себя и материализовал тот же журнал второй раз — счётчики
|
||||||
|
глав и юнитов удваивались. Деньги не двигались.
|
||||||
|
|
||||||
|
Сделано: бинд отказан для законченной попытки (`ended_at is null` в CAS). Пин —
|
||||||
|
`pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`, посадка падает. PD-194.
|
||||||
|
|
||||||
|
### М3 [low, деньги] Возврат холда «прогона, который не запускался», ждал ответа движка
|
||||||
|
|
||||||
|
Ревьюер дал это гипотезой; я перепроверил и подтвердил. `settle` сперва требовал успешного
|
||||||
|
`tmctl status`, и только потом ветка «юнита не было — вернуть холод целиком». Но хост, который
|
||||||
|
производит эту ситуацию, — ровно тот, где движок не запускается: `Status` падает на каждом проходе,
|
||||||
|
и деньги прогона, который не начинался, остаются зарезервированными навсегда (видимыми, но
|
||||||
|
запертыми).
|
||||||
|
|
||||||
|
Сделано: ветка «нет юнита и нет базовой линии» идёт ДО вызова движка; `ReleaseUnspawned`
|
||||||
|
перепроверяет строку под замком, поэтому снапшот, который успели заспавнить, отвергается там и
|
||||||
|
settle идёт обычным путём. Пин —
|
||||||
|
`runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer` (движок отвечает
|
||||||
|
«no such file» на любой запрос; холд обязан вернуться, движок — не быть спрошенным). Посадка падает.
|
||||||
|
PD-195.
|
||||||
|
|
||||||
|
### Хвосты того же круга
|
||||||
|
|
||||||
|
- **Н3.** `StartParsing` и оба `abandon` в `Accept` шли на `context.WithoutCancel` без своего
|
||||||
|
дедлайна: отсоединённый — не значит бесконечный, повисший запрос держал бы горутину запроса,
|
||||||
|
которого уже нет. Переведены на `writeCtx` (30 с). **Пина нет и не заявляется**: это граница, а не
|
||||||
|
наблюдаемое поведение — чтобы его пинить, нужен стор, умеющий висеть.
|
||||||
|
- **Н4.** Опечатка оператора в рукописном `book.yaml` по-прежнему стоит файла пользователя: движок
|
||||||
|
отвечает exit 1 на всё, через пять циклов книга отклоняется как `source_unreadable` и исходник
|
||||||
|
удаляется. FP5-4 закрыл только «конфигурации нет вовсе». Заведено отдельной строкой риска PD-196,
|
||||||
|
а не спрятано в комментарий: настоящее лечение — различающий код выхода или `--dry-run` на стороне
|
||||||
|
движка, это не правка платформы.
|
||||||
|
- **Н5.** Пин FP5-7 сверял ДВЕ точные формулировки старого стринг-матча — третье написание прошло бы
|
||||||
|
мимо. Теперь тест разбирает файл гейта парсером и смотрит только КОД (комментарии там цитируют ту
|
||||||
|
самую строку — текстовый скан принял бы историю за дефект), запрещает в коде слова systemd и
|
||||||
|
требует, чтобы гейт вообще спрашивал менеджер. Посадка «вернуть матч по сообщению» падает.
|
||||||
|
- **Отчётность.** Ревьюер не смог проверить клейм «посадки 10 из 10»: списки лежали в скрэтчпаде
|
||||||
|
сессии, а не в дереве. Исправлено — списки посадок теперь ниже, в самом журнале.
|
||||||
|
- Ловушка счёта, в которую ревьюер едва не попал и которую стоит знать: `git grep '^func Test' HEAD --
|
||||||
|
platform` даёт 262 — лишняя строка из код-блока в архивном доке; счёт по `*_test.go` даёт 261.
|
||||||
|
|
||||||
|
### Списки посадок (то, чего не хватало отчёту)
|
||||||
|
|
||||||
|
| Раунд | Посадка (что откатывается) | Пин, который обязан упасть | Итог |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Дофикс | вставка снова читает только `max(revision)` | `TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision` | поймана |
|
||||||
|
| Дофикс | `FinishUnspawnedStop` без гарда живой попытки | `TestAStaleUnspawnedStopDoesNotCloseAResumedRun` | поймана |
|
||||||
|
| Дофикс | пропавший каталог снова = «нет конфигурации» | `TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting` | поймана |
|
||||||
|
| Дофикс | ожидание конфигурации снова жжёт бюджет | `TestWaitingForAConfigurationDoesNotBringDeletionCloser` | поймана |
|
||||||
|
| Дофикс | проход интейка снова под бюджетом прогонов | `TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` | поймана |
|
||||||
|
| Дофикс | просроченный дедлайн снова 500 | `TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError` | поймана |
|
||||||
|
| Дофикс | гейт systemd снова по тексту сообщения | `TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage` | поймана |
|
||||||
|
| Дофикс | пауза перебивает стоп | `TestAStopDuringSettlementOutranksThePause` | поймана |
|
||||||
|
| Дофикс | потолок частей формы снова `n > cap` | `TestAFormWithTooManyPartsIsRefusedAtTheCap` | ПЕРЕЖИЛА → пин переписан → поймана |
|
||||||
|
| Дофикс | wire-тест ревизии без различающей силы | `TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone` | поймана |
|
||||||
|
| Дофикс-2 | пропавший корень снова читается как вина книги | `TestAVanishedStorageRootIsNotEveryBooksFault` | поймана |
|
||||||
|
| Дофикс-2 | смена статуса снова `revision + 1` | `TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor` | поймана |
|
||||||
|
| Дофикс-2 | пауза снова без гарда живой попытки | `TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` | поймана |
|
||||||
|
| Дофикс-2 | синк снова биндит законченную попытку | `TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine` | поймана |
|
||||||
|
| Дофикс-2 | возврат холда снова ждёт ответа движка | `TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer` | поймана |
|
||||||
|
| Дофикс-2 | гейт systemd снова сверяет сообщение (усиленный пин) | тот же | поймана |
|
||||||
|
|
||||||
|
### Батарея дофикса-2
|
||||||
|
|
||||||
|
- `make check` с живым PG 18.4 под `-race`: **EXIT=0, 15 пакетов, скипов 0**, линтер **0 issues**.
|
||||||
|
- Тесты `^func Test` исполнением: **349 → 354** (+5 пинов, удалённых 0).
|
||||||
|
- Посадки: **6 из 6** (таблица выше, нижние шесть строк).
|
||||||
|
- ⚠ **`make vuln` покраснел между двумя прогонами ОДНОГО дня, и не из-за дерева.** База адвизори
|
||||||
|
опубликовала пять уязвимостей stdlib против Go **1.26.5** — `net/http`, `crypto/tls`, `net/url`,
|
||||||
|
`encoding/xml`, `encoding/asn1` (GO-2026-6218 / 6090 / 6089 / 6088 / 5972), все закрыты в
|
||||||
|
**1.26.6**, и govulncheck трассирует две из них в пути, которые эта служба зовёт (например
|
||||||
|
`pgstore.Open → pgx.ParseConfig → asn1.Unmarshal`). Поставил 1.26.6 в `~/.local` (sha256 сверен с
|
||||||
|
go.dev), поднял `GO_MIN_VERSION` в Makefile 1.26.5 → 1.26.6 с обоснованием на месте, обновил пин в
|
||||||
|
`STACK_DECISIONS.md`. На 1.26.6 батарея зелёная и скан чист.
|
||||||
|
**НА РАТИФИКАЦИЮ:** хост сборки, оставшийся на 1.26.5, должен получать отказ.
|
||||||
|
⚠ **Правка 14.08: в этой редакции он его НЕ получал** — гейт сравнивал регексом и принимал 1.26.5
|
||||||
|
(ре-чек, FP5-9). Как floor держится теперь — раздел «Третий раунд» выше.
|
||||||
|
|
||||||
|
### Записка-план дофикса-2: ID → статус → улика
|
||||||
|
|
||||||
|
| ID | Статус | Улика |
|
||||||
|
|---|---|---|
|
||||||
|
| Н1 | ЗАКРЫТО | `ErrStorageGone` + `waitsForTheDeployment`; `books.TestAVanishedStorageRootIsNotEveryBooksFault`; посадка падает. PD-192 |
|
||||||
|
| Н2 | ЗАКРЫТО | `nextRevisionOfThisBooksLibrary` у трёх писателей статуса; `books.TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor`; посадка падает. PD-122 переформулирована |
|
||||||
|
| М1 | ЗАКРЫТО | гард живой попытки в `PauseRun` + `run_id` в апдейте попытки; `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt`; посадка падает. PD-193 |
|
||||||
|
| М2 | ЗАКРЫТО | `ended_at is null` в бинде; `pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`; посадка падает. PD-194 |
|
||||||
|
| М3 | ЗАКРЫТО (гипотеза ревью подтверждена мной) | ветка «не спавнился» ДО вызова движка; `runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer`; посадка падает. PD-195 |
|
||||||
|
| Н3 | ЗАКРЫТО БЕЗ ПИНА (честно) | `writeCtx` на `StartParsing` и обоих `abandon`; граница, а не поведение — пин не заявляется |
|
||||||
|
| Н4 | СТРОКОЙ | PD-196: exit 1 движка на опечатку в `book.yaml` стоит файла; лечение — на стороне движка |
|
||||||
|
| Н5 | ЗАКРЫТО | пин FP5-7 разбирает КОД гейта парсером, а не текст файла; посадка падает |
|
||||||
|
| Отчётность | ЗАКРЫТО | списки посадок перенесены из скрэтчпада в журнал (таблица выше) |
|
||||||
|
| Тулчейн | НА РАТИФИКАЦИЮ | Go 1.26.5 → 1.26.6 по пяти адвизори stdlib; `make vuln` чист, батарея зелёная |
|
||||||
|
|
||||||
|
|
||||||
|
## Дофикс P5 (11.08): фикс-лист приёмки FP5-1…FP5-8
|
||||||
|
|
||||||
|
Пак принят УСЛОВНО с восемью находками (одна high). Ниже — что сделано по каждой, чем проверено и
|
||||||
|
что осталось. Каждое число снято исполнением, команда рядом. Возражений НЕТ: все восемь
|
||||||
|
подтвердились на коде до правки — три из них были прямыми последствиями МОИХ же решений, принятых в
|
||||||
|
разных файлах и не сверенных между собой.
|
||||||
|
|
||||||
|
### FP5-1 [HIGH] Пол ревизии библиотеки игнорировался на вставке
|
||||||
|
|
||||||
|
Приёмка права, и посылка перепроверена мной на дереве, не по памяти: `DeleteUpload` поднимал
|
||||||
|
`users.library_revision`, `ListBooks` читал `greatest(max, пол)`, а `nextLibraryRevision` — ТОЛЬКО
|
||||||
|
`max(books.revision)`. Книга, загруженная после отменённой загрузки, входила на или ниже пола, и
|
||||||
|
одна ревизия отвечала за три разных состояния библиотеки: до отменённой загрузки, во время неё и
|
||||||
|
после прихода следующей книги. Клейм «PD-122 закрыта в половине добавления» был ЛОЖЕН — тесты
|
||||||
|
покрывали только свежий аккаунт, где пол ещё ноль.
|
||||||
|
|
||||||
|
Сделано: вставка берёт `greatest(max(revision), users.library_revision) + 1`. Пин —
|
||||||
|
`books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision`, и он гоняет именно ту
|
||||||
|
последовательность, которой не было: книга → отмена → книга; проверяет и ревизию БИБЛИОТЕКИ, и
|
||||||
|
ревизию самой новой книги (иначе её карточка устарела бы в момент появления). PD-122 пере-формулирована:
|
||||||
|
открытым остаётся один случай — смена статуса книги, которая не самая новая, не двигает ни одну из
|
||||||
|
половин.
|
||||||
|
|
||||||
|
### FP5-2 [деньги] `FinishUnspawnedStop` без гарда живой попытки
|
||||||
|
|
||||||
|
Тот же гард, что `FinishRun` получил кругом раньше (PD-181), на соседнем пути отсутствовал: путь
|
||||||
|
закрытия «стопа до спавна» проверял только `unit_name is null`. Устаревший проход мог закрыть уже
|
||||||
|
резюмированный прогон, и холд второй попытки выпадал из обоих списков; приёмка добавила к этому
|
||||||
|
следствие, которого я не назвал: каждый следующий резюм отвечал бы 409 навсегда, потому что
|
||||||
|
продолжать пришлось бы уже завершённый прогон.
|
||||||
|
|
||||||
|
Сделано: попытка обязана быть живой и принадлежать этому прогону (`ended_at is null and run_id = $`).
|
||||||
|
Пин — `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`; посадка «снять гард» падает.
|
||||||
|
Строка PD-186.
|
||||||
|
|
||||||
|
### FP5-3 Крэш-окно «снос каталога → запись строки»
|
||||||
|
|
||||||
|
Клейм «порядок самоизлечивающийся» был ложен, и ложным его сделала МОЯ ЖЕ вторая правка того же
|
||||||
|
круга: снесённый каталог читается как «нет конфигурации», а эту причину я тем же паком сделал
|
||||||
|
НЕтерминальной — книга оставалась в `parsing` навсегда, ожидая конфигурацию, которую положить некуда.
|
||||||
|
Диспозиция F11 («порядок выбран самоизлечивающийся») переписана: она стояла на посылке, которая к
|
||||||
|
моменту записи уже не была верна.
|
||||||
|
|
||||||
|
Сделано: пропавший КАТАЛОГ отличён от отсутствующей конфигурации (`ErrDirectoryGone`) и терминален
|
||||||
|
сразу — движка при этом не зовём вовсе. Пин —
|
||||||
|
`books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting`. Строка PD-187.
|
||||||
|
|
||||||
|
### FP5-4 Ожидание конфигурации жгло бюджет разбора
|
||||||
|
|
||||||
|
`ClaimParse` считает КАЖДУЮ заявку, а книга без конфигурации заявляется раз в грацию бесконечно.
|
||||||
|
После пяти циклов ожидания бюджет исчерпан — и первый же ответ движка становится терминальным
|
||||||
|
мгновенно, вместе с удалением исходника. Движок отдаёт exit 1 и на опечатку в `book.yaml`, так что
|
||||||
|
цена ошибки оператора — файл пользователя.
|
||||||
|
|
||||||
|
Сделано: попытка возвращается (`RefundParseAttempt`), когда движок не был спрошен вовсе; возврат
|
||||||
|
условен на клейме, как и все прочие записи, заканчивающие проход. Пин —
|
||||||
|
`books.TestWaitingForAConfigurationDoesNotBringDeletionCloser`: после пятнадцати циклов ожидания
|
||||||
|
бюджет цел, и первый отказ движка после этого НЕ терминален и ничего не удаляет. Строка PD-188.
|
||||||
|
|
||||||
|
### FP5-5 Бэкстоп гнал разбор под бюджетом прохода
|
||||||
|
|
||||||
|
Проход интейка шёл под теми же двумя минутами, что и проход прогонов, а разбор внутри — это тот же
|
||||||
|
вызов движка, которому очередь даёт пятнадцать минут. Большая книга убивалась дедлайном свипа,
|
||||||
|
убийство читалось как «хост не может запустить движок», попытка списывалась — и так каждый проход,
|
||||||
|
пока книга не отклонялась ЗА СВОЙ РАЗМЕР. Вторую половину приёмка назвала точно: запись отказа шла
|
||||||
|
на уже просроченном контексте и терялась, то есть цикл был бесконечным.
|
||||||
|
|
||||||
|
Сделано: у прохода интейка свой бюджет (`jobs.JobTimeout + 1m`), у каждой книги внутри прохода —
|
||||||
|
свой (`jobs.JobTimeout`), а терминальные записи (`FinishParse`/`RejectBook`/возврат попытки) идут на
|
||||||
|
контексте, переживающем дедлайн вызова. Пин — `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn`
|
||||||
|
(фейк движка читает дедлайн, который ему выдали). Строка PD-189.
|
||||||
|
|
||||||
|
### FP5-6 Просроченный дедлайн загрузки уходил 500
|
||||||
|
|
||||||
|
Сделано: 408 problem+json. Выбор кода назван вслух: медленный клиент — не сломанный сервис, а 408 по
|
||||||
|
RFC 9110 §15.5.9 означает ровно «полный запрос не пришёл за время, которое сервер готов был ждать», и
|
||||||
|
сообщает клиенту, что лечение — повтор. Кода 408 в перечне операции нет, поэтому он внесён в тот же
|
||||||
|
пакет вопросов владельцу контракта, что PD-172/PD-174/PD-180. Пин —
|
||||||
|
`httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError`. Строка PD-190.
|
||||||
|
|
||||||
|
### FP5-7 Скип-гард systemd-тестов пинил СООБЩЕНИЕ
|
||||||
|
|
||||||
|
Приёмка права дважды, и вторая половина — исправление МОЕЙ ошибки в фактах. Гейт существовал и
|
||||||
|
скипал по подстроке «Failed to connect to bus», а systemd 259 отвечает «Failed to connect to user
|
||||||
|
scope bus» — гейт перестал гейтить от чужой правки строки. Моя строка PD-178 при этом утверждала, что
|
||||||
|
формы скипа для systemd в зоне НЕТ ВОВСЕ: это было неверно, и я записал предположение как факт.
|
||||||
|
|
||||||
|
⚠ И вторая посылка PD-178 устарела прямо в ходе дофикса: **на этом стенде пользовательский менеджер
|
||||||
|
systemd теперь ЖИВ** (`systemctl --user show` → `Version=259.5`), и все три теста проходят — тогда
|
||||||
|
как несколькими часами раньше в этой же сессии его не было (`Linger=no`, sudo нет, ручной запуск
|
||||||
|
выходил кодом 1). То есть состояние менеджера ПЛАВАЕТ между сессиями стенда, и строка переписана
|
||||||
|
именно так.
|
||||||
|
|
||||||
|
Сделано: гейт спрашивает СПОСОБНОСТЬ — доходит ли процесс до своего менеджера, — и скипает громко и
|
||||||
|
поимённо, симметрично форме БД-тестов. Пин —
|
||||||
|
`runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage`: он падает, если гейт снова начнёт
|
||||||
|
сверять текст. PD-178 закрыта.
|
||||||
|
|
||||||
|
### FP5-8 Хвосты
|
||||||
|
|
||||||
|
- **(а) деньги.** Стоп, пришедший в окно расчёта, отвечал `paused/credit_exhausted` — «кончились
|
||||||
|
деньги» вместо «владелец остановил». Пауза теперь отказывает при висящем интенте
|
||||||
|
(`ErrStopRequested`), реконсилятор заканчивает прогон стопом. Пин
|
||||||
|
`runs.TestAStopDuringSettlementOutranksThePause`. Строка PD-191.
|
||||||
|
- **(б)** PD-153 дополнена: у СТОП-пути окно закрыто с обеих сторон (заявка не выдаётся прогону с
|
||||||
|
интентом; закрытие «стопа до спавна» спрашивает systemd, если у попытки есть базовая линия). Само
|
||||||
|
окно PD-153 — грация от `started_at` — не тронуто.
|
||||||
|
- **(в)** Потолок частей формы пропускал N+1 (`n > cap` вместо `n >= cap`). Пин
|
||||||
|
`httpapi.TestAFormWithTooManyPartsIsRefusedAtTheCap` — и ПЕРВАЯ его редакция посадку ПЕРЕЖИЛА:
|
||||||
|
она сверяла только код 400, а форма, у которой просто кончились части, тоже 400, так что обе
|
||||||
|
версии потолка проходили. Переписан на наблюдаемое следствие: файл стоит ровно на части `cap+1`,
|
||||||
|
и тест требует, чтобы интейку его НЕ отдали (`in.read == 0`) — при потолке на единицу больше файл
|
||||||
|
дочитывается и книга принимается. Посадка теперь падает.
|
||||||
|
- **(г)** Выключатель metrics-листенера был недостижим через окружение: пустое значение переменной
|
||||||
|
неотличимо от неустановленного и берёт дефолт. Теперь выключает СЛОВО (`off`/`none`), и это
|
||||||
|
записано там же, где документирован сам выключатель.
|
||||||
|
- **(д)** Wire-тест ревизии вернул различающую силу: фикстура снова несёт РАЗНЫЕ числа (книга 41,
|
||||||
|
прогон 12), и тест утверждает то, за что отвечает этот слой — он отдаёт число стора и не выдумывает
|
||||||
|
своё. Правило, что число берётся из книги, пинится в сторе.
|
||||||
|
- **(е)** `help` метрики `queue_depth` называет все состояния, которые она считает, включая `pending`.
|
||||||
|
- **(ж)** Счёт тестов в таблице задач пересчитан исполнением.
|
||||||
|
|
||||||
|
### Батарея дофикса
|
||||||
|
|
||||||
|
- `make check` с живым PostgreSQL 18.4 под `-race`: **все пакеты зелёные, включая `internal/runner`**
|
||||||
|
(менеджер systemd на стенде жив — см. FP5-7), линтер **0 issues**.
|
||||||
|
- Скипы: 0 при живом менеджере; при мёртвом — три ИМЕНОВАННЫХ скипа вместо трёх падений.
|
||||||
|
- Тесты `^func Test` исполнением: **340 → 349**, добавленных **9**, удалённых **0**, переименован
|
||||||
|
**1** (`TestTheCardsRevisionIsTheBooksAndNotTheRuns` → `TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone`,
|
||||||
|
FP5-8д — свойство то же, имя стало отвечать за то, что тест на самом деле утверждает). Против HEAD:
|
||||||
|
**261 → 349**. Команда: `comm` двух списков `^func Test` (HEAD и дерево) — единственная строка
|
||||||
|
расхождения и есть это переименование.
|
||||||
|
- Посадки дофикса: **10, поймано 10** — но честно: первый круг дал **9 из 10**, пережившая посадка
|
||||||
|
(в) показала, что пин сверял не то следствие; переписан пин, не мутация. Списки — `mut9.json`.
|
||||||
|
- `make vuln`: чисто.
|
||||||
|
|
||||||
|
**Живая проба дофикса на собранном бинаре** (`go build ./cmd/tmplatformd`, боевой PostgreSQL 18.4,
|
||||||
|
сессия заведена прямой вставкой в стенд и убрана после пробы):
|
||||||
|
|
||||||
|
- FP5-6: сырым сокетом отправлена шапка `POST /v0/books` с `Content-Length: 100000` и одной частью,
|
||||||
|
дальше клиент «думает» 4 секунды при `TM_PLATFORM_UPLOAD_DEADLINE=2s`. Ответ —
|
||||||
|
`HTTP/1.1 408 Request Timeout`, `Content-Type: application/problem+json`,
|
||||||
|
тело `{"type":"about:blank","title":"The upload did not finish in time","status":408}`, соединение
|
||||||
|
закрыто. Юнит-тест это же место видит через фейковый ридер: живой прогон подтверждает, что дедлайн
|
||||||
|
РЕАЛЬНО срабатывает на соединении, а не только маппинг ошибки.
|
||||||
|
- FP5-8г: с `TM_PLATFORM_METRICS_ADDR=off` в печати эффективной конфигурации стоит
|
||||||
|
`value=off source=environment`, в логе — `no TM_PLATFORM_METRICS_ADDR: this instance exposes no
|
||||||
|
metrics`, слушателя на порту нет (`ss -ltn` пуст). Со значением-адресом скрейп отдаёт 200.
|
||||||
|
- FP5-8е: `# HELP tm_platform_queue_depth Jobs in the queue that have not finished (pending,
|
||||||
|
available, running, scheduled or retryable)` — из живого скрейпа. Там же видно 9 семейств зоны из
|
||||||
|
десяти: `sweep_unfinished_total` — счётчик с лейблами и серии до первого инкремента не имеет,
|
||||||
|
это нормальная семантика prometheus, а не пропавшая метрика.
|
||||||
|
- Заодно: `tm_platform_http_requests_total{code="200",method="GET",route="GET /v0/books"}` — лейбл
|
||||||
|
маршрута это ПАТТЕРН (F9), и `GET /v0/books` живым Bearer'ом отвечает 200.
|
||||||
|
|
||||||
|
### Записка-план дофикса: ID → статус → улика
|
||||||
|
|
||||||
|
| ID | Статус | Улика |
|
||||||
|
|---|---|---|
|
||||||
|
| FP5-1 | ЗАКРЫТО | `nextLibraryRevision` = `greatest(max(revision), users.library_revision)+1`; `books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision`; посадка «снова только максимум» падает |
|
||||||
|
| FP5-2 | ЗАКРЫТО | гард живой попытки в `FinishUnspawnedStop` (`ended_at is null and run_id = $2`); `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`; посадка падает. PD-186 |
|
||||||
|
| FP5-3 | ЗАКРЫТО | `ErrDirectoryGone` проверяется ПЕРЕД `ErrNotProvisioned` и терминален; `books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting`; посадка падает. PD-187 |
|
||||||
|
| FP5-4 | ЗАКРЫТО | `RefundParseAttempt` условно на клейме; `books.TestWaitingForAConfigurationDoesNotBringDeletionCloser`; посадка падает. PD-188 |
|
||||||
|
| FP5-5 | ЗАКРЫТО | свой бюджет у прохода интейка и у каждой книги, терминальные записи на пережившем контексте; `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn`; посадка падает. PD-189 |
|
||||||
|
| FP5-6 | ЗАКРЫТО кодом, вопрос владельцу контракта СТРОКОЙ | `os.ErrDeadlineExceeded` → 408; `httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError`; посадка падает. PD-190 |
|
||||||
|
| FP5-7 | ЗАКРЫТО, PD-178 переписана и закрыта | гейт спрашивает способность соединиться с менеджером; `runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage`; посадка падает. Менеджер на стенде сейчас ЖИВ (259.5) — три теста прошли |
|
||||||
|
| FP5-8а | ЗАКРЫТО | `PauseRun` отказывает при висящем интенте (`ErrStopRequested`); `runs.TestAStopDuringSettlementOutranksThePause`; посадка падает. PD-191 |
|
||||||
|
| FP5-8б | СТРОКОЙ | PD-153 дополнена: стоп-путь закрыт с обеих сторон; само окно грации не тронуто |
|
||||||
|
| FP5-8в | ЗАКРЫТО со второго захода | `n >= cap`; пин переписан на «файл за потолком не отдан интейку»; посадка падает |
|
||||||
|
| FP5-8г | ЗАКРЫТО | выключатель metrics-листенера — СЛОВО (`off`/`none`), пустая переменная неотличима от неустановленной; `config/effective_test.go` |
|
||||||
|
| FP5-8д | ЗАКРЫТО | фикстура снова несёт разные числа (книга 41, прогон 12); `httpapi.TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone`; посадка падает |
|
||||||
|
| FP5-8е | ЗАКРЫТО | `help` у `queue_depth` называет все состояния, включая `pending` |
|
||||||
|
| FP5-8ж | ЗАКРЫТО | числа в таблице задач пересчитаны исполнением (см. батарею выше) |
|
||||||
|
|
||||||
|
|
||||||
|
## Приёмка P5 оркестратором (11.08): ПРИНЯТ УСЛОВНО — дофикс-раунд ДО лендинга (прецедент P4)
|
||||||
|
|
||||||
|
**Что подтверждено исполнением.** Батарея пере-прогнана мной на СВЕЖЕПОДНЯТОМ стенде (zonky PG
|
||||||
|
18.4, рецепт §«Postgres без root»): **все пакеты зелёные, ВКЛЮЧАЯ `internal/runner`** — на текущем
|
||||||
|
стенде пользовательский менеджер systemd ЖИВ (`user@1000.service` active, systemd 259), и три
|
||||||
|
теста PD-178 прошли; линтер 0 issues; тестов **340**, удалённых против HEAD **0** (comm пуст).
|
||||||
|
Живые числа проб и мутационных прогонов read-only не пере-снимаются — приняты «со слов» с
|
||||||
|
пометкой. Ревью 10 агентами (6 линз + скептики): **8 находок подтверждено (1 high), 0
|
||||||
|
опровергнуто, 9 минорных.** Задеты деньги и данные пользователей ⇒ по прецеденту P4 пак
|
||||||
|
принимается УСЛОВНО, лендинг после дофикса и ре-чека.
|
||||||
|
|
||||||
|
**Фикс-лист (движение — запиской-планом ID → статус → улика, D39.121):**
|
||||||
|
|
||||||
|
| ID | Находка | Лечение |
|
||||||
|
|---|---|---|
|
||||||
|
| FP5-1 **HIGH** | **Пол ревизии библиотеки игнорируется на вставке** (`pgstore/books.go:99`): `nextLibraryRevision` читает только `max(books.revision)`, а `DeleteUpload` поднимает `users.library_revision`; книга, загруженная после отменённой загрузки, входит НА/НИЖЕ текущей ревизии — три разных состояния библиотеки под одной ревизией, revision-keyed refetch слеп. Клейм закрытия PD-122 ложен; тесты покрывают только свежий аккаунт | вставка учитывает пол (`greatest`); тест со сценарием «отмена → повторная загрузка»; PD-122 переформулировать |
|
||||||
|
| FP5-2 деньги | **`FinishUnspawnedStop` без гарда живой попытки** (`pgstore/sink.go:362`): закрытие по устаревшему снапшоту РЕЗЮМИРОВАННОГО прогона — холд попытки-2 вне `ListLiveRuns` И вне `UnsettledRuns`, движок без надзора, последующий резюм вечно 409. Ровно та гонка, от которой `FinishRun` уже получил гард (PD-181) | тот же гард «закрываемая попытка ещё живая»; пин + посадка |
|
||||||
|
| FP5-3 | **Крашокно `removeDir → RejectBook`** (`books/parse.go:188`): смерть между ними = книга навечно в `parsing` (ENOENT читается как `not_configured`, терминального пути нет) — клейм «порядок самоизлечивающийся» ложен, диспозиция F11 стояла на ложной посылке | терминальность переживает краш (запись/маркер до сноса либо «каталога нет вовсе» = терминал); диспозицию F11 переписать |
|
||||||
|
| FP5-4 | **`not_configured`-проходы жгут бюджет разбора** (`parse.go:138`): `ClaimParse` инкрементит `parse_attempts` безусловно ⇒ после ~5 циклов ожидания конфигурации ПЕРВЫЙ отказ движка терминален мгновенно — включая удаление исходника при опечатке `book.yaml` (exit 1 = «вина файла») | `not_configured` не тратит бюджет; пин «ожидание не приближает удаление» |
|
||||||
|
| FP5-5 | **Свип-бэкстоп гонит разбор под общим бюджетом прохода 2 мин против 15 мин очереди** (`cmd/tmplatformd/runner.go:151`): восстановление большой книги убивается дедлайном свипа, каждый цикл жжёт попытку, а reject на просроченном контексте не записывается — kill-loop | у бэкстоп-разбора свой бюджет ≥ `jobs.JobTimeout` (пара тестом, как `claimGrace`); reject на свежем контексте |
|
||||||
|
| FP5-6 | **Просроченный дедлайн загрузки уходит 500** (`httpapi/v0.go:404`) — код вне спеки и вне декларации PD-180 | замаппить в честный код problem+json; внести в пакет вопросов PD-180 |
|
||||||
|
| FP5-7 | **Скип-гард systemd-тестов пинит сообщение, которого systemd 259 не даёт** (`runner/systemd_test.go:25`: ждёт «Failed to connect to bus», стенд говорит «…user scope bus…») — на стенде без менеджера тесты ПАДАЮТ, а не скипаются; PD-178 не знает об этом гарде и устарела фактами (менеджер сейчас ЖИВ, батарея зелёная) | гейт по СПОСОБНОСТИ (проба соединения), скип поимённо — симметрия DSN-формы; PD-178 переписать: «состояние менеджера плавает между сессиями стенда» |
|
||||||
|
| FP5-8 хвосты | (а) стоп в окне settle отвечает `paused/credit_exhausted` вместо `stopped` (`reconcile.go:543`) · (б) PD-153-окно: полный возврат холда под стартующим движком через новый стоп-путь — дополнить строку · (в) потолок частей формы пропускает N+1 · (г) выключатель metrics-листенера недостижим через env · (д) wire-тест ревизии потерял различающую силу (фикстура 12→41 сравняла прогон с книгой) · (е) help `queue_depth` не называет `pending` · (ж) счёт тестов в таблице задач устарел | по весу: (а)(б)(д) — фиксы с пинами; (в)(г)(е)(ж) — мелкие правки/честные строки |
|
||||||
|
|
||||||
|
Развилка `book.yaml` (вопрос зоны) и четыре контракт-вопроса — на мне, ратифицирую при лендинге
|
||||||
|
дофикса. Дерево остаётся незакоммиченным до ре-чека.
|
||||||
|
|
||||||
|
**Ре-чек дофикса-2 (11.08, оркестратор): батарея воспроизведена зелёной (354 теста, Go 1.26.6,
|
||||||
|
стенд ~/.local/pgsql:55433), НО 3 находки подтверждены (2 high) — третий раунд по списку ниже,
|
||||||
|
лендинг после него:**
|
||||||
|
|
||||||
|
| ID | Находка | Лечение |
|
||||||
|
|---|---|---|
|
||||||
|
| FP5-9 **HIGH** | **Гейт тулчейна НЕ отказывает 1.26.5**: `Makefile:33` — регекс `go1\.26\.([5-9]\|…)` принимает 1.26.5, `GO_MIN_VERSION=1.26.6` живёт только в echo; `go.mod` — `go 1.26.4` БЕЗ `toolchain`; клейм «хост на 1.26.5 получит отказ» ложен в трёх доках (журнал ×2, STACK_DECISIONS §, коммент Makefile) | регекс/сравнение версий чинится по-настоящему (+ `toolchain go1.26.6` в go.mod — реши и обоснуй); пин на функцию сравнения, не на прогон; три клейма поправить |
|
||||||
|
| FP5-10 **HIGH** | **PD-192 не закрывает заявленный сценарий**: гард — только `Stat(BooksDir)` (`parse.go:235`); unmount тома, смонтированного РОВНО в BooksDir, оставляет пустой mountpoint → Stat успешен → терминальный reject ВСЕХ книг с удалением; безусловный `MkdirAll` на буте (`runner.go:129`) маскирует пропажу после рестарта | различать «хранилище пропало» от «книги нет» устойчиво к обеим формам (кандидат: сентинел-файл провижининга в BooksDir, который бут НЕ пересоздаёт; нет сентинела → ErrStorageGone, не терминалить, бюджет не жечь); пин на unmount-форму |
|
||||||
|
| FP5-11 | **Н2 закрыт не для всех писателей статуса**: пять писателей жизненного цикла прогона остались на `revision+1` (`pgstore/runs.go:108,517,620` · `sink.go:318,396`) против собственного правила дофикса (`books.go:111`) — staleness библиотеки на статусах прогона | те же «следующий номер библиотеки», что у трёх интейковых; пин на прогонный переход у книги не-максимума аккаунта |
|
||||||
|
| хвосты | (а) коммент `PauseRun` «в том же стейтменте» против фактического SELECT FOR UPDATE (`runs.go:580`) — текст к коду; (б) комменты `parse.go:41-52` «terminal on the first answer» описывают до-дофиксное поведение на пути удаления файла; (в) остаток FP5-5: в одном проходе бэкстопа полный бюджет получает только первая книга — вторая жжёт попытки на обрезанном контексте (узкий триггер — честная строка либо бюджет на КНИГУ); (г) мета-пин systemd-гейта перечислителен — остаток названный, не дефект | (а)(б) — правки текста; (в) — строка или фикс; (г) — знать |
|
||||||
|
|
||||||
|
## Сессия P5 (11.08): загрузка книги · стоп и резюм · наблюдаемость · форма регистра
|
||||||
|
|
||||||
|
**Каждое число ниже снято исполнением, команда стоит рядом.** Дерево не коммичено, в индексе ничего
|
||||||
|
не держу; зона записи — только `platform/`.
|
||||||
|
|
||||||
|
### Таблица задач
|
||||||
|
|
||||||
|
| Задача промта | Что сделано | Чем проверено |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. `POST /books` (П-9, PD-72) | Потоковый multipart (`r.MultipartReader`), пер-маршрутный потолок тела, свой дедлайн чтения, каталог книги под `TM_PLATFORM_BOOKS_DIR`, статусы `uploading → parsing → not_started \| rejected` с писателями, разбор = `tmctl manifest --json`, свип-бэкстоп интейка | **44 теста** (пак + пины дофикса): 22 `internal/books` + 11 `httpapi/intake_test.go` + 6 `pgstore/intake_test.go` + 5 на шов манифеста (`runner/manifest_test.go`). 21 посадка пака, живая проба на боевом бинаре |
|
||||||
|
| ⚠ половина 1: кто пишет `book.yaml` | **НЕ построена — вопрос ниже.** Построен ШОВ: `books.ErrNotProvisioned`, книга без конфигурации отказывает разбором с причиной `not_configured` | `TestABookWithNoEngineConfigurationWaitsRatherThanDies` |
|
||||||
|
| 2. Стоп и резюм (PD-140) | `POST /v0/runs/{id}/stop` пишет намерение (`runs.stop_requested_at`, миграция 00014) ДО сигнала; `/resume` переиспользует `reopen` реконсилятора и ставит спавн в очередь, а не в запрос; реконсилятор классифицирует маркер по намерению, не рестартит стопнутый прогон и повторяет стоп живому юниту | **32 теста** (пак + пины дофикса): 28 `runs/control_test.go` + 4 `httpapi/control_test.go`. 25 посадок пака, живая проба |
|
||||||
|
| 3. Наблюдаемость (П-11) + PD-114 | `prometheus/client_golang` v1.24.1 на отдельном слушателе; 10 семейств метрик зоны, включая запросы и задержки по паттерну маршрута; бюджет свипа НА ПРОГОН (PD-169); печать эффективной конфигурации с источником и редакцией секретов и денег | **12 тестов** (пак + пин дофикса): 5 `internal/metrics` + 6 `config/effective_test.go` + 1 `internal/jobs`. 8 посадок пака, живая проба |
|
||||||
|
| 4. Регистр секциями (пинг №16) | Таблица разложена на 11 секций (открытые по весу · принятый риск · закрытые ратификацией · закрытые по эрам P1–P5), построчная форма сохранена | `python3 docs/scripts/counts.py --check` — зелено; список ID до и после перекладки совпадает посимвольно |
|
||||||
|
|
||||||
|
### ⚠ ВОПРОС НА РАТИФИКАЦИЮ: кто пишет `book.yaml` при интейке
|
||||||
|
|
||||||
|
Промт гейтит эту половину явно, и она НЕ построена. Что известно и чего стоит каждый вариант:
|
||||||
|
|
||||||
|
**Факт.** Каталог новой книги создаёт платформа (иначе `POST /books` некуда писать), а разбор —
|
||||||
|
`tmctl manifest --config <каталог>/book.yaml`; движок требует в конфиге `book_id`, пару языков,
|
||||||
|
пути `pipeline`/`models`/`source_file` и хотя бы один потолок (`backend/internal/config/book.go`
|
||||||
|
проверяет всё это на загрузке). D39.110 §2b говорит «платформа `book.yaml` не правит» — в контексте
|
||||||
|
того решения речь шла о ПОТОЛКАХ, которые платформа передаёт аргументом прогона, а не о создании
|
||||||
|
файла для книги, которой ещё не существует.
|
||||||
|
|
||||||
|
| Вариант | Что делает платформа | Цена |
|
||||||
|
|---|---|---|
|
||||||
|
| **А. Оператор кладёт файл** (то, что построено) | Ничего: каталог и исходник создаются, конфигурации ждём; без неё разбор отказывает и после бюджета попыток книга становится `rejected` с причиной `not_configured` | Загрузка через UI не доходит до конца без ручной работы оператора НА КАЖДУЮ книгу; бета с чужими пользователями на этом не живёт. Зато D39.110 §2b соблюдён буквально |
|
||||||
|
| **Б. Рендер из деплой-шаблона при интейке** (кандидат промта) | Один раз, при создании каталога, рендерит `book.yaml` из шаблона деплоя (`TM_PLATFORM_BOOK_TEMPLATE`): языки и жанр из `BookIntake`, `source_file` — имя, которое платформа же и записала, потолки и пути пайплайна — из шаблона оператора. Дальше файл принадлежит оператору: платформа его не читает и не трогает | Платформа становится писателем файла движка ОДИН раз. Нужен шаблон в деплое (ещё одна переменная и ещё один файл, который можно забыть). Если оператор потом файл поправил — ничего не происходит, платформа туда не возвращается |
|
||||||
|
| **В. Движок сам заводит книгу** | Платформа зовёт новую $0-команду движка (`tmctl init --source-lang zh --target-lang ru …`), движок пишет свой конфиг сам | Архитектурно чище всех: файл движка пишет движок, и требования к полям живут там же, где проверяются. Но это строка ЕДИНОГО бэклога и релиз движка, то есть П-9 стоит до неё |
|
||||||
|
|
||||||
|
**Что предлагает зона:** Б как бета-меру и В как правильную форму, строкой единого бэклога. При Б
|
||||||
|
шов уже готов — `books.ErrNotProvisioned` заменяется рендером в ОДНОМ месте
|
||||||
|
(`books.Service.manifest`), остальной интейк не меняется. **Ратифицирует оркестратор; тихо
|
||||||
|
интерпретировать эту развилку сессия не стала.**
|
||||||
|
|
||||||
|
Вторым вопросом того же корня: **название книги.** `BookIntake` поля `title` не несёт, манифест
|
||||||
|
движка названия не даёт вовсе (он даёт id, счёты и идентичность разреза). Сегодня заголовок берётся
|
||||||
|
из ИМЕНИ ЗАГРУЖЕННОГО ФАЙЛА (`книга.epub` → «книга»), пустое остаётся пустым — синтезировать
|
||||||
|
«Книга 1» значит показать читателю ярлык, которого никто не писал. Если ратифицируется Б, имя
|
||||||
|
логично брать оттуда же; если владелец хочет поле `title` в форме — это правка спеки, не зоны.
|
||||||
|
|
||||||
|
### Дизайн стопа: намерение, а не догадка по коду выхода
|
||||||
|
|
||||||
|
Кандидат-дизайн промта взят целиком; вот его границы.
|
||||||
|
|
||||||
|
- **Что записывается.** `runs.stop_requested_at` пишется ОДНИМ оператором с проверкой владения и
|
||||||
|
живости (`pgstore.RequestStop`: `update … from books where owner_id = $2 and finished_at is null
|
||||||
|
returning …` плюс имя юнита живой попытки). Ноль строк — два разных ответа, и они разделены
|
||||||
|
вторым запросом ТОЛЬКО на этом пути: видит владелец прогон → `ErrRunNotLive` (409), не видит →
|
||||||
|
`ErrNoRun` (404). Идемпотентно через `coalesce`: второй стоп не сдвигает ПЕРВУЮ отметку, потому
|
||||||
|
что она — улика о том, что случилось раньше.
|
||||||
|
- **Порядок.** Запись коммитится ДО обращения к systemd. Запинено не косвенно: фейк раннера зовёт
|
||||||
|
хук ВНУТРИ своего `Stop`, хук читает `stop_requested_at` из БД и требует, чтобы она уже была
|
||||||
|
(`TestTheStopIsRecordedBeforeSystemdIsAsked`). Посадка «спросить systemd раньше» падает.
|
||||||
|
- **Гонка «стоп против самостоятельного финиша».** Намерение перекрашивает исход, только если оно
|
||||||
|
РАНЬШЕ маркера (`stoppedOnRequest`: `!l.StopRequestedAt.After(m.At)`), а чистые коды движка
|
||||||
|
(0 · 2 · 3) намерение перебивают всегда — завершённый перевод не должен показываться отменённым.
|
||||||
|
Три пина: стоп → `stopped`, маркер раньше стопа → `failed`, чистый выход → `ready`.
|
||||||
|
- **Три следствия, каждое запинено.** Прогон с намерением НЕ перезапускается реконсилятором (иначе
|
||||||
|
деньги уходят на работу, которую владелец отменил) · живой юнит с намерением получает стоп
|
||||||
|
ПОВТОРНО (закрывает «платформа умерла между записью и вызовом») · стоп до спавна заканчивает
|
||||||
|
прогон и возвращает холд ЦЕЛИКОМ (проверено балансом, не статусом).
|
||||||
|
- **Чего дизайн НЕ закрывает, и это названо:** штатная перезагрузка хоста. Там намерения нет ни у
|
||||||
|
кого, маркер честно скажет `exited/1`, и прогон закроется как `failed` вместо перезапуска.
|
||||||
|
Различить это может только движок различимым кодом выхода graceful-stop (строка 165 единого
|
||||||
|
бэклога) — платформенной догадки здесь быть не должно. PD-152 остаётся открытой этой половиной.
|
||||||
|
|
||||||
|
Резюм по контракту: `stopped`/`paused` → новая попытка с ОСТАТКОМ бюджета (`потолок − RunSpent`);
|
||||||
|
`awaiting_bank` → 409 «подпись неполна», и она неполна у любого деплоя, потому что банк сегодня не
|
||||||
|
материализуется вовсе (компаньон §3); нечего продолжать → 202 с прогоном КАК ЕСТЬ, без переписывания
|
||||||
|
его состояния (прогон, который остановил пользователь, остаётся `stopped`, а не становится `paused`
|
||||||
|
из-за того, что резюм не нашёл денег). Механика перезапуска переиспользована: `restart`
|
||||||
|
реконсилятора и `Resume` — один `reopen`; решение «паузить» вынесено к вызывающим, потому что
|
||||||
|
реконсилятор паузит ЖИВОЙ прогон, а резюм работает с уже завершённым и переписывать ему исход не
|
||||||
|
вправе.
|
||||||
|
|
||||||
|
### Наблюдаемость: что выбрано и почему не stdlib
|
||||||
|
|
||||||
|
`expvar` рассмотрен первым (норма §1 «stdlib прежде библиотеки») и этой работы не несёт: нет
|
||||||
|
лейблов — «запросы по маршруту и коду» не выражаются вовсе; нет гистограмм — на вопрос о задержке
|
||||||
|
остаётся среднее, единственная статистика, которая прячет хвост; его JSON не читает ни один скрейпер.
|
||||||
|
Сэкономил бы он зависимость, а стоил бы написания недостающих трёх руками — ровно того самописного
|
||||||
|
пути, который та же норма и запрещает. Взят `prometheus/client_golang` **v1.24.1** (релиз
|
||||||
|
24.07.2026, пин сверен живьём через `proxy.golang.org`), OpenTelemetry отклонён как тяжёлый для
|
||||||
|
одной VM: коллектор процессом, протокол экспорта настройкой и всё равно scrape-эндпоинт на конце.
|
||||||
|
Эталон оси (половина PD-115) — практики именования Prometheus плюс четыре золотых сигнала, внесены в
|
||||||
|
`ENGINEERING_STANDARDS` §2. Разбор — `STACK_DECISIONS` §24.
|
||||||
|
|
||||||
|
Отдаётся ОТДЕЛЬНЫМ слушателем (`TM_PLATFORM_METRICS_ADDR`, дефолт `127.0.0.1:9464`), не маршрутом
|
||||||
|
под `/v0`: экспозиция несёт операционную форму деплоя, а второй модели авторизации ради скрейпера
|
||||||
|
зона не заводит — тот же довод, что сделал админ-поверхность CLI (§10). Риск принят строкой PD-179 и
|
||||||
|
записан в `deploy/README.md`. Величины снимает СВИП, а не скрейп: коллектор, ходящий в Postgres на
|
||||||
|
каждый запрос, отдал бы нагрузку на контрол-плейн тому, у кого есть доступ к порту.
|
||||||
|
|
||||||
|
Метрики зоны: глубина очереди · возраст самого старого открытого холда · попытки в карантине ·
|
||||||
|
живые прогоны · отставание тейлера в байтах · книги в интейке по статусу · длительность свипа и
|
||||||
|
счётчик проходов, не уложившихся в бюджет (PD-169) · запросы и задержки по паттерну маршрута.
|
||||||
|
|
||||||
|
### Что нашла ЖИВАЯ ПРОБА, чего не нашли тесты
|
||||||
|
|
||||||
|
Проба гоняет боевые `tmplatformd`/`tmplatformctl` против живого Postgres реальными HTTP-вызовами;
|
||||||
|
подменены две чужие стороны и обе процессом — движок (как в P4) и systemd (стенда с пользовательским
|
||||||
|
менеджером нет, PD-178). Нашла две вещи, и обе исправлены:
|
||||||
|
|
||||||
|
1. **Резюм спавнил движок ВНУТРИ запроса** — запрос висел, пока прогон не кончится. На фейке это
|
||||||
|
видно как таймаут 120 с; на настоящем systemd висело бы меньше, но по-прежнему секундами:
|
||||||
|
`spawnAttempt` читает отсчёт книги (`tmctl status` — секунды CPU движка) и ходит к systemd.
|
||||||
|
Исправлено: резюм ставит спавн в ОЧЕРЕДЬ (`EnqueueRunNow`), как это делает допуск прогона;
|
||||||
|
реконсилятор — бэкстоп, если запись потерялась. Пин переписан на новое свойство, посадка
|
||||||
|
«спавнить в запросе» падает.
|
||||||
|
2. **Идентификатор книги тёк в INFO-логи** (`book accepted`, `book parsed`, `book rejected`) —
|
||||||
|
против нормы `ENGINEERING_STANDARDS` §Наблюдаемость («id пользователя/книги в логи не текут») и
|
||||||
|
против собственной дисциплины раннера, который в INFO называет ПРОГОН и никогда книгу. Снято;
|
||||||
|
на ERROR путь книги остаётся в двух местах намеренно (терминальный отказ разбора и неудавшееся
|
||||||
|
удаление каталога) — это открытый класс PD-139, строка дополнена. Пин —
|
||||||
|
`TestNoBookIdentifierReachesAnInfoLine`.
|
||||||
|
|
||||||
|
Прогон пробы после исправлений (числа с экрана):
|
||||||
|
|
||||||
|
| Шаг | Наблюдение |
|
||||||
|
|---|---|
|
||||||
|
| печать конфигурации | 29 настроек с источником; DSN — `(set)/environment`, обе денежные — `(an amount; not logged)`; утечек значений в лог **0** |
|
||||||
|
| `POST /v0/books` (38 символов) | **201**, `status: parsing`, `title: 蛊真人` из имени файла, `character_count: 38` (руны, не байты) |
|
||||||
|
| статусы | `parsing → not_started`, `chapter_count: 2` из манифеста, каталог книги: `book.yaml`, `source.txt` |
|
||||||
|
| файл больше 64 КиБ | **413** problem+json (PD-72) |
|
||||||
|
| источник, который движок не разобрал | **`rejected`**, каталог книги удалён |
|
||||||
|
| `POST .../runs` (2 главы) | **202**, юнит создан, движок запущен, холд **$0.06** |
|
||||||
|
| **стоп живого прогона** | **202**, и в САМОМ ответе прогон ещё `translating` — стоп ПРИНЯТ, а не завершён (движок дописывает чекпойнт). Через свип: юнит погашен, маркер `run_…-1.exit` записан, книга и прогон → **`stopped`**, `finished_at` проставлен, расчёт: баланс **9.990000** (списано $0.01 — то, что отчитал движок), холд закрыт |
|
||||||
|
| **резюм** | **202**; прогон → `translating`, попытка **2** с НОВЫМ юнитом, холд **$0.05** = потолок $0.06 минус потраченное |
|
||||||
|
| резюм живого прогона | **409** · стоп несуществующего прогона — **404** |
|
||||||
|
| метрики | `queue_depth 0` · `live_runs 1` · `oldest_open_hold_seconds 2.63` · `quarantined_attempts 0` · `tailer_lag_bytes 0` · `books_in_intake{uploading,parsing} 0/0` · `sweep_duration_seconds_count{runs} 8` · `http_requests_total{code="201",route="POST /v0/books"} 2` |
|
||||||
|
| гигиена логов | сумм, argv движка и id книг — **0**; ERROR-строк — **0** |
|
||||||
|
|
||||||
|
⚠ В пробе `book.yaml` кладёт СТЕНД, играя оператора, — это и есть та половина, что стоит на
|
||||||
|
ратификации. Заодно этим прогоняется путь «поломка деплоя ретраится, а не отвергает книгу»: первая
|
||||||
|
попытка разбора отказывает как `not_configured`, следующий свип разбирает книгу уже с конфигурацией.
|
||||||
|
|
||||||
|
### Батарея, посадки и счёт
|
||||||
|
|
||||||
|
- `make check` с живым PostgreSQL 18.4 под `-race`: **все пакеты зелёные, кроме `internal/runner`**
|
||||||
|
(три systemd-теста, PD-178 — разбор ниже), линтер **0 issues**.
|
||||||
|
- Скипы: `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**.
|
||||||
|
- `make vuln`: **No vulnerabilities found**.
|
||||||
|
- Тесты `^func Test` исполнением: **261 → 340**, добавленных **79**, удалённых **0**
|
||||||
|
(`comm -23` списка HEAD и списка дерева — пусто).
|
||||||
|
- **Посадки: 74 прогона в КОПИИ зоны вне репозитория** (скрипт восстанавливает копию после каждой;
|
||||||
|
списки — `mut1…mut6.json` рабочего каталога сессии). Итог: **65 пойманы своим кругом, 7 пережили,
|
||||||
|
2 не применились по образцу — и все девять разобраны, а не подчищены**:
|
||||||
|
- ДВЕ пережили по-настоящему, и обе были дефектами ТЕСТА, а не кода: «брошенная загрузка чистится
|
||||||
|
на отменённом контексте» (тест не отменял контекст, то есть проверял не то свойство) и «отмена
|
||||||
|
загрузки роняет ревизию» (тест сравнивал с числом ДО загрузки вместо того, которое клиент мог
|
||||||
|
увидеть ВО ВРЕМЯ неё). Оба теста усилены — первый отменяет контекст ровно так, как ушедший
|
||||||
|
клиент, второй читает библиотеку из середины аплоада, — и пере-посадки ПОЙМАНЫ;
|
||||||
|
- три пережили как СЛАБЫЕ мутации: `_ = unit` не меняет порядка вызовов; переименование ветки
|
||||||
|
`awaiting_bank` эквивалентно, потому что обе ветки отвечают отказом; «заявка разбора отдаётся
|
||||||
|
назад» не моделировала снятия заявки. Все три переписаны в настоящие (перестановка вызовов,
|
||||||
|
«пустить `awaiting_bank` в резюмируемые», снятие условия свежести) и ПОЙМАНЫ;
|
||||||
|
- одна пережила на УСТАРЕВШЕЙ копии (посадка на `Unwrap` метрик гонялась до того, как телеметрия
|
||||||
|
была вписана в цепочку теста дедлайна) — пере-посадка ПОЙМАНА;
|
||||||
|
- две не применились: образец встречался дважды либо расходился пробелами; переписаны и ПОЙМАНЫ.
|
||||||
|
⚠ Чего эта цифра НЕ значит (урок PD-142/PD-151): поимённого соответствия «каждый пин — своя
|
||||||
|
посадка» нет и не заявляется. 62 посадки против 73 новых тестов покрывают несущие свойства —
|
||||||
|
деньги, гонки снапшота, переходы статусов, потолки, редакцию логов, гарды файловой системы; не
|
||||||
|
покрыты вспомогательные, где тест утверждает форму ответа или таблицу значений.
|
||||||
|
Несущие посадки: потолок тела возвращается к дефолту · дедлайн загрузки не расширяется · `Unwrap`
|
||||||
|
снят · стоп классифицируется по коду выхода · гард гонки «стоп vs финиш» снят · стопнутый прогон
|
||||||
|
рестартится · рестарт стирает свежий интент · закрытие неспавненного стопа не перепроверяет юнит ·
|
||||||
|
право на спавн выдаётся завершённому прогону · спавн игнорирует интент · резюм берёт второй холд ·
|
||||||
|
резюм даёт полный потолок · резюм спавнит в запросе · резюм судит деплой раньше владения · конфликт
|
||||||
|
второго живого прогона снова 500 · прогон стартует на книге вне интейка · книга, отклонённая
|
||||||
|
движком, сохраняет файл · поломка деплоя считается виной книги · бюджет попыток снят · заявка
|
||||||
|
разбора не compare-and-set и не спейсит ретраи · свип сносит каталог живой книги · новая книга
|
||||||
|
входит с ревизией 0 · отмена загрузки роняет ревизию · пол ревизии игнорируется · ревизия прогона
|
||||||
|
берётся из своей колонки (два места) · корневой гард каталога принимает любой путь · символы
|
||||||
|
считаются байтами · строка книги пишется после тела · сумма и секрет печатаются · лейбл метрики —
|
||||||
|
путь вместо маршрута · настройка читается без записи · свип без бюджета на прогон · вердикт
|
||||||
|
остановленной попытки переписывается резюмом.
|
||||||
|
|
||||||
|
### ⚠ Оспаривание посылки: батарея зоны на этом стенде не может быть зелёной (PD-178)
|
||||||
|
|
||||||
|
Промт требует «0 FAIL». Три теста `internal/runner` падают, и это свойство СТЕНДА, а не пака: на
|
||||||
|
хосте нет пользовательского менеджера systemd (`/run/systemd/system` есть, PID 1 — systemd **259**,
|
||||||
|
но `user@1000.service` не поднят, `Linger=no`, sudo нет; попытка поднять менеджер руками выходит
|
||||||
|
кодом 1 без вывода). Проверено, что это не регрессия: `git archive HEAD platform`, распакованный в
|
||||||
|
копию вне репозитория, даёт те же три падения с тем же текстом «Failed to connect to user scope bus».
|
||||||
|
Форма зоны для внешнего предусловия существует и она другая — БД-тесты СКИПАЮТСЯ вслух по
|
||||||
|
`TM_PLATFORM_TEST_DSN`, и `make check` называет скипы поимённо. Приводить одно к другому сессия не
|
||||||
|
стала: правка теста ради зелени запрещена (D39.121), решение — оркестратора.
|
||||||
|
|
||||||
|
⚠ Отсюда же ограничение живой пробы, названное прямо: **systemd-клеймы P4 этим паком НЕ пере-мерены**
|
||||||
|
(cgroup прогона, `--collect`, поведение маркера на трёх исходах). Проба подменяет systemd процессом,
|
||||||
|
моделируя ровно ту семантику, от которой зависит стоп: юнит переживает создателя, `ExecStopPost`
|
||||||
|
отрабатывает с `SERVICE_RESULT`/`EXIT_CODE`/`EXIT_STATUS`, `stop` = SIGTERM главному процессу.
|
||||||
|
⚠ Стенд с P4 при этом изменился: там был systemd 255, здесь 259.
|
||||||
|
|
||||||
|
### Регистр: закрыто, сужено, заведено
|
||||||
|
|
||||||
|
- **Закрыто (4):** PD-72 (потолок тела приехал с маршрутом и своим тестом) · PD-140 (ручки стопа и
|
||||||
|
резюма) · PD-114 (печать эффективной конфигурации) · PD-169 (бюджет свипа на прогон плюс метрика).
|
||||||
|
- **Сужено, но открыто (4 + 1):** PD-152 (пользовательский стоп закрыт, ребут — за движком) ·
|
||||||
|
PD-115 (ось наблюдаемости получила эталон; ops и конфигурация — нет) · PD-122 (добавление книги
|
||||||
|
двигает ревизию области; удаление — по-прежнему нет) · PD-162 (терминальное состояние появилось у
|
||||||
|
ИНТЕЙКА; клин живого прогона на удалённом каталоге не тронут) · PD-139 дополнена вторым местом.
|
||||||
|
- **Заведено (14, из них 4 закрыты тем же паком):** PD-172 «файл последним» не записано в контракте · PD-173 у `rejected` нет
|
||||||
|
причины на проводе · PD-174 `POST /books` отвечает 404 там, где спека кода не даёт (и резюм —
|
||||||
|
503) · PD-175 квоты интейка нет · PD-176 `requestTooLarge` не доходит через обёртки ·
|
||||||
|
PD-177 `character_count` для не-UTF-8 — оценка · PD-178 systemd-тесты падают, а не скипаются ·
|
||||||
|
PD-179 `/metrics` без аутентификации (принятый риск) · PD-180 201 несёт `parsing`, а не
|
||||||
|
`uploading`, плюс отказы интейка вне спеки · PD-181…PD-184 (кросс-семейное ревью, закрыты
|
||||||
|
здесь же) · PD-185 `finalizing` без писателя.
|
||||||
|
- Регистр: **185 строк** (`python3 docs/scripts/counts.py`): 132 закрыто · 1 ратификацией · 4 риском ·
|
||||||
|
48 открыто (1 major — PD-113, прежний).
|
||||||
|
- Форма файла по пингу №16: 11 секций, построчная форма `| PD-N | … |` сохранена, `--check` зелёный.
|
||||||
|
Проверено, что ни одна строка не потеряна: список ID до и после перекладки сортированно совпадает
|
||||||
|
(`diff <(grep -o '^| PD-[0-9]* |' до) <(… после)` — пусто).
|
||||||
|
|
||||||
|
### Вопросы владельцу контракта (через оркестратора)
|
||||||
|
|
||||||
|
1. **Порядок частей формы.** `POST /books` требует, чтобы `file` шёл ПОСЛЕДНИМ (PD-172). Это
|
||||||
|
свойство потокового приёма, а не прихоть: строка книги пишется до тела, а языки в ней
|
||||||
|
обязательны. Тем же правилом специфицирована браузерная загрузка S3. Спека молчит — вносить?
|
||||||
|
2. **Причина отказа.** `rejected` без причины на проводе (PD-173) не даёт экрану различить «файл не
|
||||||
|
тот» и «наш движок был недоступен», а это разные советы пользователю. Поле заводить не право
|
||||||
|
зоны; в БД причина есть.
|
||||||
|
3. **Инстанс без интейка** отвечает на `POST /books` охраняемым 404 (PD-174) — по форме зоны для
|
||||||
|
несмонтированных маршрутов, но кода этого спека не перечисляет (класс PD-112). Там же: резюм на
|
||||||
|
деплое без маркер-команды отвечает 503, а 0.2.1 ратифицировала 503 только для СТАРТА прогона.
|
||||||
|
4. **«Responds immediately; the book enters `uploading`»** недостижимо как класс (PD-180): по HTTP
|
||||||
|
ответ не может уйти раньше, чем прочитано тело, поэтому 201 несёт `parsing`. Плюс отказы интейка,
|
||||||
|
которых спека не описывает вовсе: число частей формы, длина текстового поля, порог 413 и обрыв
|
||||||
|
соединения по дедлайну маршрута.
|
||||||
|
|
||||||
|
### Кросс-семейное ревью (D39.120 п.1а): что нашла ДРУГАЯ модель после трёх своих
|
||||||
|
|
||||||
|
⚠ **Сначала о разрыве, который это закрывает.** Три ревьюера выше запускались без указания модели и
|
||||||
|
унаследовали модель сессии — то есть все четверо (автор и трое) были одной семьи. D39.120 п.1(а)
|
||||||
|
просит в панели ≥1 опровергателя ДРУГОЙ моделью на оценочных линзах; механические линзы (сверка
|
||||||
|
провода, посадки, батарея) от смены модели не выигрывают, а «деньги и гонки» — линза именно
|
||||||
|
оценочная. Пробел назван владельцем, не найден сессией. Запущены два ревьюера **Fable 5** (слепой
|
||||||
|
поиск по диффу и атака на денежные инварианты), обоим предписано воспроизводить исполнением.
|
||||||
|
⚠ Оговорка из той же ноты, п.1(в): Fable — та же семья Anthropic, поэтому семейный слепой участок
|
||||||
|
этим НЕ снимается; первичны пере-ран и исполнение, а внешняя калибровка — не право сессии.
|
||||||
|
|
||||||
|
**Результат: другая модель нашла 5 дефектов, которые три однофамильца пропустили, и два из них —
|
||||||
|
уничтожение пользовательских данных.** Все воспроизведены исполнением, все закрыты, каждый с пином и
|
||||||
|
посадкой.
|
||||||
|
|
||||||
|
| Находка | Чем это было | Как закрыто |
|
||||||
|
|---|---|---|
|
||||||
|
| **Грация клейма (10 мин) КОРОЧЕ таймаута задания очереди (15 мин)** — обе константы поставил этот же пак, в разных файлах, не сверив | В окне 10–15 минут свип отдаёт клейм второму парсеру: два `tmctl manifest` на одной директории, проигравший умирает на эксклюзивном локе движка с exit 1 — а exit 1 у движка означает «источник не разобрать». Книга отклонялась ТЕРМИНАЛЬНО, исходник пользователя удалялся. Воспроизведено тестом ревьюера | Грация написана как `jobs.JobTimeout + 5m`, пара утверждается тестом; терминальная запись возможна только пока клейм ЕЩЁ НАШ (`parse_started_at = <наш>`). PD-183 |
|
||||||
|
| **Один exit 1 движка = удаление загрузки** | Движок маппит на exit 1 ВСЕ свои отказы (его собственный комментарий): полный диск, лок от ручного `tmctl` оператора, украденный клейм. Первый же такой отказ уничтожал файл безвозвратно — ре-парса и удаления в контракте нет | Отказ ИСТОЧНИКА идёт через тот же бюджет попыток, что и поломка деплоя; удаление — только на терминальном шаге. Пин расширен: первый отказ обязан оставить файл на месте |
|
||||||
|
| **`not_configured` был терминальным** — а конфигурацию не пишет никто, пока развилка не ратифицирована | Значит на СЕГОДНЯШНЕМ деплое любая загрузка умирала бы в течение часа, с удалением, из-за пробела, которого пользователь не видит, а оператор закрывает одним файлом | `not_configured` НЕ становится терминальным никогда: книга ждёт в `parsing` с целым исходником, и это видно метрикой интейка. ⚠ Тест на этом свойстве ПЕРЕВЁРНУТ намеренно — он пинил «пять попыток и отказ», и это и было ошибкой |
|
||||||
|
| **Устаревший снапшот до-финиширует РЕЗЮМИРОВАННЫЙ прогон** (деньги) | Маркер завершённой попытки остаётся на диске; проход со старым снапшотом закрывал вернувшийся к жизни прогон, и холд попытки 2 выпадал из ОБОИХ списков — ни живых прогонов, ни несведённых. Достижимо стало ровно с появлением резюма | `FinishRun` пишет, только если закрываемая попытка ещё живая, и возвращает признак «закрыл». PD-181 |
|
||||||
|
| **Стоп закрывал прогон под ЖИВЫМ движком** (деньги) | «Заявка отдана назад» ≠ «процесса нет»: `systemd-run`, убитый после запроса, оставляет движок работать — зона это уже знает и ради этого хранит базовую линию, но стоп-путь читал пустое имя юнита как «его не было» | Ненулевая базовая линия при пустом имени = надгробие спавна; имя юнита детерминировано, платформа спрашивает systemd `Alive` и живому юниту шлёт стоп. PD-182 |
|
||||||
|
| Заявка на спавн игнорировала висящий интент стопа (мой комментарий утверждал обратное) | Стоп, попавший в окно, пока воркер читает отсчёт книги, не мешал старту движка | `stop_requested_at is null` — в том же CAS; комментарий приведён к коду |
|
||||||
|
| `TM_PLATFORM_UPLOAD_DEADLINE` мог пережить грацию свипа | Оператор, поднявший дедлайн выше часа, получал удаление строки и каталога из-под ещё идущей загрузки | Пара проверяется на буте |
|
||||||
|
| Полностью принятая загрузка выбрасывалась, если клиент ушёл до 201 | Телефон уснул — файл и минуты линии в корзину | `StartParsing` идёт на контексте, переживающем запрос |
|
||||||
|
| Лейбл `method` — токен вызывающего | Для маршрута кардинальность закрыта, для метода — нет | Закрытый список, прочее — `(other)`. PD-184 |
|
||||||
|
| `runner.Manifest` подвисал/убивал движок на превышении потолка | Правку я собирался внести раньше, и она НЕ доехала (скрипт упал на другой ассерции) — поймано только здесь | Остаток дренится, превышение — честная ошибка |
|
||||||
|
| Статус `finalizing` без писателя | Слово контракта, которого не пишет ни один путь — тот же класс, что интейк-статусы до этого пака | Строкой PD-185, не кодом: писателя выдумывать не право зоны |
|
||||||
|
|
||||||
|
**Посадок этого круга: 12, поймано 11.** Пережила одна и она названа, а не подчищена: «отказ сносит
|
||||||
|
каталог ПОСЛЕ строки, а не до» — свойство про ПОРЯДОК двух побочных эффектов, наблюдаемое только
|
||||||
|
через смерть процесса между ними; конечное состояние в обоих порядках одинаково, и юнит-тестом это
|
||||||
|
не ловится. Порядок выбран самоизлечивающийся (сначала каталог, потом строка) и обоснован в коде.
|
||||||
|
|
||||||
|
⚠ **Что этот круг говорит о предыдущем.** Три однофамильных ревьюера прошли по тем же файлам и не
|
||||||
|
увидели ни рассинхрона двух констант, поставленных в одном паке, ни того, что «exit 1 = вина файла»
|
||||||
|
уничтожает данные на первом же транзиенте. Это ровно та коррелированная слепота, ради которой
|
||||||
|
D39.120 просит другую модель, — и здесь она подтверждена эмпирически, а не принята на веру.
|
||||||
|
|
||||||
|
### Записка-план после ревью: ID → статус → улика (D39.121)
|
||||||
|
|
||||||
|
| ID | Что | Статус | Улика |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R1 | Право на спавн выдаётся законченному прогону (деньги, критично) | ЗАКРЫТО | гард в `RecordSpawn`; `TestTheQueueWorkerDoesNotStartARunThatWasAlreadyStopped`; посадка «снять гард» падает |
|
||||||
|
| R2 | Рестарт стирает свежий интент стопа (деньги) | ЗАКРЫТО | `RestartRun` берёт строку прогона `for update` и отдаёт `ErrStopRequested`; `TestAStopPressedWhileTheReconcilerRestartsIsNotLost`; посадка падает |
|
||||||
|
| R3 | Закрытие неспавненного стопа роняет только что стартовавший движок (деньги) | ЗАКРЫТО | `FinishUnspawnedStop` с перепроверкой под замком; `TestARunSpawnedWhileTheSweepWasClosingItIsNotAbandoned`; посадка падает |
|
||||||
|
| R4 | Спавн не читает интент стопа (деньги) | ЗАКРЫТО | отказ в `spawnAttempt`; тот же пин, что у R1; посадка падает |
|
||||||
|
| R5 | Интейк и телеметрия голодают на общем дедлайне | ЗАКРЫТО | у каждого прохода свой бюджет (`cmd/tmplatformd/runner.go`); прогон пробы: `sweep_duration_seconds_count{intake}` растёт вместе с `{runs}` |
|
||||||
|
| R6 | Свип сносит каталог книги, которую не удалил | ЗАКРЫТО | `abandon` возвращается на ошибке удаления; `TestTheSweepNeverRemovesTheSourceOfABookItCouldNotDelete`; посадка падает |
|
||||||
|
| R7 | Отмена загрузки роняет ревизию библиотеки назад | ЗАКРЫТО | пол `users.library_revision`; `TestCancellingAnUploadDoesNotWindTheLibraryBack`; две посадки падают |
|
||||||
|
| R8 | Бюджет разбора считает тики, а не время | ЗАКРЫТО | заявка держится; пин расширен проверкой «до грации не делается ничего»; посадка падает |
|
||||||
|
| R9 | Таймаут задания очереди — молчаливая минута River | ЗАКРЫТО | `JobTimeout` выбран явно; `jobs_test.go` пинит вид и политику заданий |
|
||||||
|
| R10 | `runner.Manifest` подвисает на превышении потолка | ЗАКРЫТО | остаток дренится, превышение — ошибка; `TestAManifestThatIsNotJSONIsAnError` и соседние гоняют реальный процесс |
|
||||||
|
| R11 | Ревизия прогона на проводе — из его колонки | ЗАКРЫТО | стор отдаёт книжную на всех путях; `pgstore.TestEveryRunTheStoreHandsOutCarriesItsBooksRevision`, `runs.TestEveryRunCarryingAnswerUsesTheBooksRevision`; посадка падает |
|
||||||
|
| R12 | Резюм судит деплой раньше владения | ЗАКРЫТО | порядок переставлен; `TestOwnershipIsJudgedBeforeTheDeploymentsHealth` |
|
||||||
|
| R13 | Резюм второго живого прогона книги → 500 | ЗАКРЫТО | `ErrRunInFlight` → 409; `TestResumeIsRefusedWhenTheBookHasAnotherLiveRun` |
|
||||||
|
| R14 | Два резюма подряд → 404 проигравшему | ЗАКРЫТО | проигравший перечитывает прогон; `TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` |
|
||||||
|
| R15 | Каталог-сирота, если строка исчезла между телом и `parsing` | ЗАКРЫТО | `Accept` убирает за собой и на этой ветке |
|
||||||
|
| R16 | 201 несёт `parsing`, а не `uploading` | СТРОКОЙ, не кодом | PD-180: по HTTP ответ не может уйти раньше тела; `uploading` наблюдаем параллельным чтением, пин есть |
|
||||||
|
| R17 | Отказы интейка, которых спека не описывает (части формы, длина поля, порог 413, обрыв по дедлайну) | СТРОКОЙ | PD-180, вопросом владельцу контракта одним пакетом с PD-172/PD-174 |
|
||||||
|
| R18 | 503 на резюме у несконфигурированного деплоя | СТРОКОЙ | PD-174 расширен: класс тот же, что у ратифицированного 503 на старте |
|
||||||
|
| R19 | Любая загрузка сегодня приходит к `rejected`, и выхода из `rejected` нет | СТРОКОЙ | PD-175 расширен; корень — развилка `book.yaml`, вопрос выше |
|
||||||
|
| R20 | Пути книг в ERROR-логах интейка | СТРОКОЙ | PD-139 дополнена; на INFO/WARN идентификаторов нет, и это запинено |
|
||||||
|
| R21 | `requestTooLarge` не доходит через обёртки | СТРОКОЙ | PD-176: 413 отдаётся штатно, соединение закрывается обычным путём под `ReadTimeout` |
|
||||||
|
| R22 | Порядок блокировок в `RestartRun` (строка прогона бралась последней) | ЗАКРЫТО попутно | R2 берёт её сразу после книги — документированный порядок восстановлен |
|
||||||
|
| F1 | Грация клейма короче таймаута задания: свип крадёт парс, книга терминально отклоняется с удалением файла | ЗАКРЫТО | `claimGrace = jobs.JobTimeout + 5m`; `books.TestTheClaimGraceOutlivesTheQueuesJobTimeout`; посадка падает |
|
||||||
|
| F2 | Один exit 1 движка уничтожал загрузку | ЗАКРЫТО | отказ источника идёт через бюджет; удаление только на терминальном шаге; посадка «отклонять с первого раза» падает |
|
||||||
|
| F3 | `not_configured` терминален — то есть каждая сегодняшняя загрузка обречена | ЗАКРЫТО (тест перевёрнут намеренно) | `TestABookWithNoEngineConfigurationWaitsRatherThanDies`; посадка «отклонять после бюджета» падает |
|
||||||
|
| F4 | Устаревший снапшот до-финиширует резюмированный прогон, холд вне списков | ЗАКРЫТО | гард живой попытки в `FinishRun`; `TestAStaleSweepDoesNotReFinishAResumedRunFromTheOldAttemptsMarker`; посадка падает |
|
||||||
|
| F5 | Стоп закрывает прогон под живым движком после отданной заявки | ЗАКРЫТО | `Alive` по детерминированному имени юнита; `TestAStopDoesNotCloseARunWhoseGivenBackClaimLeftAnEngineRunning`; посадка падает |
|
||||||
|
| F6 | Заявка на спавн игнорирует интент стопа | ЗАКРЫТО | условие в CAS; `TestAStopCommittedWhileTheWorkerReadsTheMeterStopsTheSpawn`; посадка падает |
|
||||||
|
| F7 | Дедлайн загрузки мог пережить грацию свипа | ЗАКРЫТО | проверка пары на буте; `config.TestAnUploadDeadlineLongerThanTheSweepsGraceIsRefused`; посадка падает |
|
||||||
|
| F8 | Принятая загрузка выбрасывалась, если клиент ушёл до 201 | ЗАКРЫТО | контекст переживает запрос; `TestAnUploadSurvivesAClientThatHangsUpAfterTheLastByte`; посадка падает |
|
||||||
|
| F9 | Лейбл `method` — токен вызывающего | ЗАКРЫТО | закрытый список; посадка падает |
|
||||||
|
| F10 | `runner.Manifest` подвисает на превышении потолка | ЗАКРЫТО | дренаж остатка + ошибка; правка была задумана раньше и НЕ доехала — поймана только кросс-семейным кругом |
|
||||||
|
| F11 | Порядок «каталог до строки» в отказе | ⚠ ПЕРЕСМОТРЕНО дофиксом → FP5-3 | диспозиция «порядок самоизлечивающийся» стояла на посылке, которую тот же пак отменил (`not_configured` стал НЕтерминальным), поэтому крэш-окно оставляло книгу в `parsing` навсегда; закрыто отдельной причиной `ErrDirectoryGone` (PD-187), пин и посадка есть |
|
||||||
|
| F12 | `finalizing` — статус без писателя | СТРОКОЙ | PD-185 |
|
||||||
|
|
||||||
|
### Что НЕ делалось (по промту)
|
||||||
|
|
||||||
|
Эскроу/`uncertain`/`closing` (строка 136) · формула потолка PD-158 и SpendBound PD-159 · ставка
|
||||||
|
$0.03 · эмиттер-сторона движка и словарь `events.go` · П-2 · платёжный провайдер · удаление аккаунта
|
||||||
|
(PD-107) · счётчик ревизии ОБЛАСТИ целиком (PD-122 сужена, не закрыта) · правка контракта (четыре
|
||||||
|
вопроса выше вместо неё).
|
||||||
|
|
||||||
## Ратификация приёмкой P4 (оркестратор №15, 09.08)
|
## Ратификация приёмкой P4 (оркестратор №15, 09.08)
|
||||||
|
|
||||||
**Вердикт: P4 + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ — D39.123, код `d29e30c` (53 файла, +9867/−170,
|
**Вердикт: P4 + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ — D39.123, код `d29e30c` (53 файла, +9867/−170,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,21 @@ module textmachine/platform
|
||||||
|
|
||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
|
// The LANGUAGE floor stays the engine's (one stand builds both modules). The TOOLCHAIN is pinned
|
||||||
|
// higher and separately, because it is what supplies the standard library this network-facing module
|
||||||
|
// links: 1.26.5 carries five advisories govulncheck traces into paths this service calls
|
||||||
|
// (GO-2026-6218/6090/6089/6088/5972), all fixed in 1.26.6. With the default GOTOOLCHAIN=auto a host
|
||||||
|
// on an older toolchain fetches this one instead of building a vulnerable binary; with
|
||||||
|
// GOTOOLCHAIN=local it stops with an error naming the version. Both are the answer we want, and
|
||||||
|
// neither depends on anybody reading a Makefile. `make version-check` is the same floor for the
|
||||||
|
// battery, which runs where GOTOOLCHAIN may be pinned to local.
|
||||||
|
toolchain go1.26.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0
|
github.com/coreos/go-oidc/v3 v3.20.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/pressly/goose/v3 v3.27.3
|
github.com/pressly/goose/v3 v3.27.3
|
||||||
|
github.com/prometheus/client_golang v1.24.1
|
||||||
github.com/riverqueue/river v0.42.0
|
github.com/riverqueue/river v0.42.0
|
||||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.0
|
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
|
|
@ -13,13 +24,19 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
github.com/riverqueue/river/riverdriver v0.42.0 // indirect
|
github.com/riverqueue/river/riverdriver v0.42.0 // indirect
|
||||||
github.com/riverqueue/river/rivershared v0.42.0 // indirect
|
github.com/riverqueue/river/rivershared v0.42.0 // indirect
|
||||||
github.com/riverqueue/river/rivertype v0.42.0 // indirect
|
github.com/riverqueue/river/rivertype v0.42.0 // indirect
|
||||||
|
|
@ -32,6 +49,8 @@ require (
|
||||||
go.uber.org/goleak v1.3.0 // indirect
|
go.uber.org/goleak v1.3.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|
@ -7,6 +11,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
||||||
|
|
@ -19,20 +25,34 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
|
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
|
||||||
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
|
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
|
||||||
|
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||||
|
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||||
|
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||||
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/riverqueue/river v0.42.0 h1:WDx0F0loJg41XvkHVyQVr9PUN2p9SPLr2Tx7zs5ONlY=
|
github.com/riverqueue/river v0.42.0 h1:WDx0F0loJg41XvkHVyQVr9PUN2p9SPLr2Tx7zs5ONlY=
|
||||||
|
|
@ -71,6 +91,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
|
@ -81,6 +103,8 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|
|
||||||
386
platform/internal/books/books.go
Normal file
386
platform/internal/books/books.go
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
// Package books owns a book's INTAKE: receiving the file the user uploads, putting it where the
|
||||||
|
// engine will look for it, and turning it into a chapter tree.
|
||||||
|
//
|
||||||
|
// The shape mirrors the run lifecycle deliberately (package runs): the request only gets the book as
|
||||||
|
// far as "the bytes are here", and everything after that is a step some later sweep can finish. A
|
||||||
|
// parse is one $0 call of the engine that takes seconds on a large book, and holding a request open
|
||||||
|
// for it — or losing the book when the process doing it is restarted — are the two failures this
|
||||||
|
// split exists to avoid.
|
||||||
|
package books
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"textmachine/platform/internal/ingest"
|
||||||
|
"textmachine/platform/internal/pgstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifester is the engine's $0 producer of a chapter tree: `tmctl manifest`. An interface so the
|
||||||
|
// intake's decisions can be pinned without an engine binary.
|
||||||
|
type Manifester interface {
|
||||||
|
Manifest(ctx context.Context, binary, workdir string) (ingest.Manifest, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueuer hands a book to the queue inside the caller's transaction.
|
||||||
|
type Enqueuer interface {
|
||||||
|
EnqueueParse(ctx context.Context, tx pgstore.Tx, bookID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is what an operator chooses about intake.
|
||||||
|
type Config struct {
|
||||||
|
// BooksDir is the root the platform creates book directories under. Absolute, and NOT the place
|
||||||
|
// an operator keeps hand-made books: everything below it is written and, on a rejected intake,
|
||||||
|
// removed by this service.
|
||||||
|
BooksDir string
|
||||||
|
// EngineBinary is the versioned tmctl path used to parse. Intake is not mounted without it: a
|
||||||
|
// deployment that cannot parse would take uploads it can only reject.
|
||||||
|
EngineBinary string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service is the intake.
|
||||||
|
type Service struct {
|
||||||
|
Store *pgstore.Store
|
||||||
|
Engine Manifester
|
||||||
|
Queue Enqueuer
|
||||||
|
Cfg Config
|
||||||
|
Log *slog.Logger
|
||||||
|
// Now is injectable so the graces below are testable without sleeping.
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) now() time.Time {
|
||||||
|
if s.Now != nil {
|
||||||
|
return s.Now()
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) log() *slog.Logger {
|
||||||
|
if s.Log == nil {
|
||||||
|
return slog.New(slog.DiscardHandler)
|
||||||
|
}
|
||||||
|
return s.Log
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrBadIntake is a request this route cannot make a book out of. It is the contract's 400.
|
||||||
|
var ErrBadIntake = errors.New("books: the intake form is not usable")
|
||||||
|
|
||||||
|
// SourceName is the file name the intake writes a book's source under, extension aside. The name is
|
||||||
|
// FIXED and predictable because the engine finds the source through `source_file:` in the book's own
|
||||||
|
// config, which somebody else writes (see the provisioning seam in parse.go).
|
||||||
|
const SourceName = "source"
|
||||||
|
|
||||||
|
// maxTitle bounds the title taken from the uploaded file's name. The library lists it, and the
|
||||||
|
// client-supplied name is otherwise the one unbounded string on that screen.
|
||||||
|
const maxTitle = 200
|
||||||
|
|
||||||
|
// langCode is the contract's LangCode: a code, never a name (§LangCode). Validated here as well as
|
||||||
|
// in the read model because this is where a value from a browser enters.
|
||||||
|
var langCode = regexp.MustCompile(`^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$`)
|
||||||
|
|
||||||
|
// Intake is one accepted call of POST /books.
|
||||||
|
type Intake struct {
|
||||||
|
UserID string
|
||||||
|
SourceLang string
|
||||||
|
TargetLang string
|
||||||
|
Genre string
|
||||||
|
// Filename is the name the client gave the part. It is used for two things and trusted for
|
||||||
|
// neither: a title for the library and the extension that tells the engine which reader to use.
|
||||||
|
Filename string
|
||||||
|
// File is the body of the upload, already bounded by the route's own limit. It is streamed to
|
||||||
|
// disk and never held in memory.
|
||||||
|
File io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept receives one book.
|
||||||
|
//
|
||||||
|
// The order — row, then bytes — is what makes `uploading` a state anything can observe: a second tab
|
||||||
|
// listing the library while a 60 MB file is still on the wire sees the book with its languages and
|
||||||
|
// without its size. It also makes an abandoned upload FINDABLE, which is the half that matters
|
||||||
|
// operationally: the row is the only record that a directory under BooksDir belongs to anyone.
|
||||||
|
func (s *Service) Accept(ctx context.Context, in Intake) (pgstore.Book, error) {
|
||||||
|
if !langCode.MatchString(in.SourceLang) || !langCode.MatchString(in.TargetLang) {
|
||||||
|
return pgstore.Book{}, fmt.Errorf("%w: the languages must be codes", ErrBadIntake)
|
||||||
|
}
|
||||||
|
if s.Cfg.BooksDir == "" {
|
||||||
|
return pgstore.Book{}, errors.New("books: no books directory is configured")
|
||||||
|
}
|
||||||
|
id := pgstore.NewBookID()
|
||||||
|
dir := filepath.Join(s.Cfg.BooksDir, id)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return pgstore.Book{}, fmt.Errorf("books: create book directory: %w", err)
|
||||||
|
}
|
||||||
|
// The first upload is what marks this storage as ours — see storageIsThere. Written here and
|
||||||
|
// nowhere else, and deliberately NOT at boot: a marker the boot recreates says "the storage is
|
||||||
|
// here" about a directory the boot itself just made.
|
||||||
|
if err := s.markStorage(); err != nil {
|
||||||
|
s.removeDir(dir)
|
||||||
|
return pgstore.Book{}, err
|
||||||
|
}
|
||||||
|
// The created row is not kept: what the caller gets back is the row as it stands AFTER the file
|
||||||
|
// landed, and between the two the status has moved from `uploading` to `parsing`.
|
||||||
|
_, err := s.Store.CreateUpload(ctx, id, pgstore.NewUpload{
|
||||||
|
OwnerID: in.UserID,
|
||||||
|
Title: titleFrom(in.Filename),
|
||||||
|
SourceLang: in.SourceLang,
|
||||||
|
TargetLang: in.TargetLang,
|
||||||
|
Genre: in.Genre,
|
||||||
|
Workdir: dir,
|
||||||
|
Now: s.now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.removeDir(dir)
|
||||||
|
return pgstore.Book{}, err
|
||||||
|
}
|
||||||
|
characters, err := s.receive(filepath.Join(dir, SourceName+extensionOf(in.Filename)), in.File)
|
||||||
|
if err != nil {
|
||||||
|
// The upload did not finish, so there is nothing to parse and nothing to keep. The row is
|
||||||
|
// DELETED rather than rejected: `rejected` means the file could not be parsed (contract
|
||||||
|
// §BookStatus), and there is no delete handle in the contract for the user to clear a row an
|
||||||
|
// abandoned upload would otherwise leave in their library forever.
|
||||||
|
//
|
||||||
|
// On its own context: the ordinary cause of getting here is the client going away, and the
|
||||||
|
// request's context is already cancelled by then.
|
||||||
|
c, cancel := writeCtx(ctx)
|
||||||
|
defer cancel()
|
||||||
|
s.abandon(c, id, dir)
|
||||||
|
return pgstore.Book{}, err
|
||||||
|
}
|
||||||
|
// On a context that outlives the request: every byte is in, and a client that hung up while
|
||||||
|
// waiting for the 201 must not cost the upload it already finished. The ROW is what makes the
|
||||||
|
// book findable, so it is written even when nobody is left to read the answer. Bounded on its own
|
||||||
|
// (writeCtx): detached is not the same as unlimited, and a hung statement here would hold the
|
||||||
|
// goroutine of a request that is already over.
|
||||||
|
start, cancelStart := writeCtx(ctx)
|
||||||
|
defer cancelStart()
|
||||||
|
book, err := s.Store.StartParsing(start, id, characters, s.enqueue)
|
||||||
|
if err != nil {
|
||||||
|
// The row is gone or unreachable, and the directory holds a file nothing points at — which is
|
||||||
|
// the one thing the row-first order exists to prevent, so it is undone here too. The ordinary
|
||||||
|
// cause is the sweep having abandoned this upload while it was still arriving.
|
||||||
|
c, cancel := writeCtx(ctx)
|
||||||
|
defer cancel()
|
||||||
|
s.abandon(c, id, dir)
|
||||||
|
return pgstore.Book{}, err
|
||||||
|
}
|
||||||
|
// No book id: an INFO line must not identify a user's library (ENGINEERING_STANDARDS
|
||||||
|
// §Наблюдаемость, the same rule that keeps raw paths out of the access log — PD-3). The request
|
||||||
|
// id the handler carries is what ties this line to the response the user got.
|
||||||
|
s.log().InfoContext(ctx, "book accepted", "characters", characters)
|
||||||
|
return book, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// receive streams the upload to disk and counts what went past.
|
||||||
|
//
|
||||||
|
// Streamed, never buffered: the route's limit is tens of megabytes and reading that into memory
|
||||||
|
// would make one upload per concurrent request the platform's memory profile. The file is created
|
||||||
|
// with O_EXCL so a book directory can never be written twice by two requests that somehow minted the
|
||||||
|
// same id.
|
||||||
|
func (s *Service) receive(path string, body io.Reader) (int64, error) {
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("books: create source file: %w", err)
|
||||||
|
}
|
||||||
|
c := &counter{w: f}
|
||||||
|
if _, err := io.Copy(c, body); err != nil {
|
||||||
|
f.Close()
|
||||||
|
// The error is returned as it came: the handler tells a body that outgrew the route's limit
|
||||||
|
// from a client that went away, and both are the caller's to name (http.MaxBytesError).
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return 0, fmt.Errorf("books: close source file: %w", err)
|
||||||
|
}
|
||||||
|
return c.characters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// counter writes through and counts CHARACTERS, which is what the contract's Book carries — a size
|
||||||
|
// in characters, known to the platform from intake and to nobody else (§Book.character_count).
|
||||||
|
//
|
||||||
|
// It counts every byte that is not a UTF-8 continuation byte, which is exactly the rune count of
|
||||||
|
// valid UTF-8 and needs no state across the chunk boundaries io.Copy hands it. ⚠ For a source in
|
||||||
|
// another encoding — the engine accepts GB18030 and UTF-16 and decodes them itself — the figure is
|
||||||
|
// an approximation of the character count, not the count. Naming a size for a file whose encoding is
|
||||||
|
// not yet known is what intake can do; the exact one would need the engine's decode, which happens a
|
||||||
|
// step later and reports bytes.
|
||||||
|
type counter struct {
|
||||||
|
w io.Writer
|
||||||
|
characters int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *counter) Write(p []byte) (int, error) {
|
||||||
|
n, err := c.w.Write(p)
|
||||||
|
for _, b := range p[:n] {
|
||||||
|
if b&0xC0 != 0x80 {
|
||||||
|
c.characters++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) enqueue(ctx context.Context, tx pgstore.Tx, bookID string) error {
|
||||||
|
if s.Queue == nil {
|
||||||
|
return nil // no queue configured: the sweep picks the book up on its next pass
|
||||||
|
}
|
||||||
|
return s.Queue.EnqueueParse(ctx, tx, bookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// abandon undoes an upload that did not finish. Both halves are best effort and both are logged:
|
||||||
|
// what must not happen is a silent leak of either the row or the directory.
|
||||||
|
func (s *Service) abandon(ctx context.Context, id, dir string) {
|
||||||
|
// The ROW is deleted first here, and unlike the reject path that order is forced: the directory
|
||||||
|
// may only go once it is certain nobody owns it. The sweep decides from a snapshot, and a request
|
||||||
|
// that finished in the meantime has moved the book to `parsing` — DeleteUpload's status guard then
|
||||||
|
// refuses, and removing the directory anyway would delete the source of a live book under it.
|
||||||
|
//
|
||||||
|
// The crash window that leaves is a directory with no row, and it is named rather than closed:
|
||||||
|
// nothing walks BooksDir looking for orphans (register row PD-175, where the retention sweep that
|
||||||
|
// would is filed).
|
||||||
|
if err := s.Store.DeleteUpload(ctx, id); err != nil {
|
||||||
|
if errors.Is(err, pgstore.ErrNoBook) {
|
||||||
|
// The ordinary race: another instance's sweep got there first, or the upload finished. Not
|
||||||
|
// an error — an ERROR line on a routine race is noise that teaches operators to skim.
|
||||||
|
s.log().InfoContext(ctx, "the abandoned upload was already gone; its directory is left alone")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log().ErrorContext(ctx, "an abandoned upload was not removed; its directory is left alone", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.removeDir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDir deletes a directory this service created, and NOTHING else.
|
||||||
|
//
|
||||||
|
// The guard is not ceremony: a book registered by the dev CLI carries a workdir the operator chose —
|
||||||
|
// their own project directory, with their own source and their own project database — and no path
|
||||||
|
// here may ever remove one. Everything under BooksDir was created by this service and holds nothing
|
||||||
|
// the platform did not put there.
|
||||||
|
func (s *Service) removeDir(dir string) {
|
||||||
|
if !s.owns(dir) {
|
||||||
|
s.log().Error("refusing to remove a directory this service did not create", "dir", dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
s.log().Error("book directory could not be removed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageMarker is the file that says "this is the storage this platform has been writing books
|
||||||
|
// into". Exported so an operator provisioning a volume by hand can put one there.
|
||||||
|
const StorageMarker = ".tmplatform-books"
|
||||||
|
|
||||||
|
const storageMarkerText = `This directory holds book sources written by tmplatformd.
|
||||||
|
Its presence is what tells the intake that the storage is mounted: without it a book whose directory
|
||||||
|
is missing is treated as "the storage is gone" and WAITS, instead of being rejected with its source
|
||||||
|
deleted. Do not remove it while books live here.
|
||||||
|
`
|
||||||
|
|
||||||
|
// markStorage writes the marker if it is not there. Idempotent and race-free by O_EXCL: two uploads
|
||||||
|
// arriving together are both correct, and the loser's EEXIST is the state it wanted.
|
||||||
|
func (s *Service) markStorage() error {
|
||||||
|
f, err := os.OpenFile(filepath.Join(s.Cfg.BooksDir, StorageMarker),
|
||||||
|
os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
|
||||||
|
if errors.Is(err, fs.ErrExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("books: mark the storage: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.WriteString(storageMarkerText); err != nil {
|
||||||
|
return fmt.Errorf("books: mark the storage: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageIsThere reports whether the books storage 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
|
||||||
|
// still exists and `Stat` still succeeds — which is how the first version of this guard (PD-192) let
|
||||||
|
// an unmount reject every book in intake, with their sources deleted, exactly as if each book's own
|
||||||
|
// directory had been removed. The boot's MkdirAll made it worse by recreating the root after a
|
||||||
|
// restart. The marker is written by the FIRST upload and by nothing else, so neither an unmount nor a
|
||||||
|
// fresh MkdirAll can forge it (re-check of the dofix, FP5-10).
|
||||||
|
//
|
||||||
|
// 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 == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := os.Stat(filepath.Join(s.Cfg.BooksDir, StorageMarker))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) owns(dir string) bool {
|
||||||
|
if s.Cfg.BooksDir == "" || dir == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(s.Cfg.BooksDir, dir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
|
// titleFrom is the book's name until something better exists.
|
||||||
|
//
|
||||||
|
// ⚠ Named limitation, not a design: `BookIntake` carries no title field (contract §BookIntake) and
|
||||||
|
// inventing one is not this zone's right, while the engine's manifest reports counts and identity
|
||||||
|
// and no title either. What is left is the name the user gave the file, which is a real thing they
|
||||||
|
// chose. An empty result stays empty: a synthesized "Book 1" would be a label nobody wrote.
|
||||||
|
func titleFrom(filename string) string {
|
||||||
|
name := filepath.Base(filepath.FromSlash(filename))
|
||||||
|
if i := strings.LastIndexByte(name, '.'); i > 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
name = strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, name)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "." || name == ".." || name == string(filepath.Separator) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(name) > maxTitle {
|
||||||
|
name = string([]rune(name)[:maxTitle])
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// extensionOf is the one thing about the FORMAT the platform is allowed to know: the engine
|
||||||
|
// dispatches its reader by extension (`.epub` → the epub reader, anything else → plain text,
|
||||||
|
// backend/internal/chunk/ingest.go), so the extension has to survive intake or an EPUB is read as
|
||||||
|
// text.
|
||||||
|
//
|
||||||
|
// It is not a format allowlist, and that is deliberate: which formats exist is the engine's
|
||||||
|
// question, and a platform that refused an extension the engine had just learned would be a second
|
||||||
|
// place to teach. What it does refuse is a name that is not an extension — the value goes into a
|
||||||
|
// path, so anything but lowercase alphanumerics is dropped and the source becomes plain text.
|
||||||
|
func extensionOf(filename string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filepath.Base(filepath.FromSlash(filename))))
|
||||||
|
if len(ext) < 2 || len(ext) > 9 {
|
||||||
|
return ".txt"
|
||||||
|
}
|
||||||
|
for _, r := range ext[1:] {
|
||||||
|
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
|
||||||
|
return ".txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ext
|
||||||
|
}
|
||||||
1099
platform/internal/books/books_test.go
Normal file
1099
platform/internal/books/books_test.go
Normal file
File diff suppressed because it is too large
Load diff
342
platform/internal/books/parse.go
Normal file
342
platform/internal/books/parse.go
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
package books
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"textmachine/platform/internal/ingest"
|
||||||
|
"textmachine/platform/internal/jobs"
|
||||||
|
"textmachine/platform/internal/pgstore"
|
||||||
|
"textmachine/platform/internal/runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Graces and budgets of the intake walk. Constants rather than settings: they are properties of the
|
||||||
|
// walk itself, and every one of them is a number an operator would have to reason about the
|
||||||
|
// reconciler to choose.
|
||||||
|
const (
|
||||||
|
// claimGrace is how long a parse may be somebody's business before another pass may take it. It
|
||||||
|
// covers the ordinary case — the queue job is claimed in milliseconds — and the failure it exists
|
||||||
|
// for: the process holding the claim was restarted mid-parse.
|
||||||
|
//
|
||||||
|
// ⚠ It MUST outlive the queue's own job timeout, and it is written as that constant plus a margin
|
||||||
|
// so the two cannot drift apart. Shorter, the sweep steals the claim from a parse that is still
|
||||||
|
// legitimately running: thief and holder meet on one project directory, the loser dies on the
|
||||||
|
// engine's exclusive lock with exit 1 — and exit 1 is how the engine says "I cannot cut this
|
||||||
|
// source", so a book that was parsing perfectly well would be rejected for it.
|
||||||
|
claimGrace = jobs.JobTimeout + 5*time.Minute
|
||||||
|
// UploadGrace is how long a book may stay `uploading`. A request that is still arriving holds the
|
||||||
|
// row, so anything older than this is an upload whose request is gone — and that reasoning holds
|
||||||
|
// only while the route's own read deadline is SHORTER. Exported so the boot can refuse a
|
||||||
|
// configuration where it is not: an operator who raises TM_PLATFORM_UPLOAD_DEADLINE past this
|
||||||
|
// would have the sweep delete a book's row and directory out from under a request still writing
|
||||||
|
// into it.
|
||||||
|
UploadGrace = time.Hour
|
||||||
|
// parseAttempts is how many times a book may be handed to the engine before intake gives up. EVERY
|
||||||
|
// answer of the engine goes through it — a refusal of the source as much as a host that cannot run
|
||||||
|
// the binary — because the engine gives both the same exit code, so what this really bounds is how
|
||||||
|
// long a broken host re-runs the engine over every book uploaded to it, and how many chances a
|
||||||
|
// genuinely unreadable file gets before its source is removed.
|
||||||
|
parseAttempts = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reasons a book is rejected. The platform's OWN closed vocabulary: the engine's text reads like
|
||||||
|
// pipeline internals and never crosses this seam (contract §Problem, PT-33), and these are stored
|
||||||
|
// for an operator rather than projected — contract v0 gives a rejected book no reason field.
|
||||||
|
const (
|
||||||
|
// ReasonSourceUnreadable — the engine ran and refused the file. Terminal, but NOT on the first
|
||||||
|
// answer: the engine maps every failure it has onto exit 1, so a refusal goes through the attempt
|
||||||
|
// budget like everything else and only the last one deletes the source (see defer_).
|
||||||
|
ReasonSourceUnreadable = "source_unreadable"
|
||||||
|
// ReasonNotConfigured — the book has no project configuration, so there is nothing to parse
|
||||||
|
// against. See ErrNotProvisioned: today that is a deployment's state, not a user's mistake.
|
||||||
|
ReasonNotConfigured = "not_configured"
|
||||||
|
// ReasonParserUnavailable — the engine could not be RUN, repeatedly, until the attempt budget was
|
||||||
|
// spent.
|
||||||
|
ReasonParserUnavailable = "parser_unavailable"
|
||||||
|
// ReasonStorageUnavailable — the storage root itself is not there. Never terminal, and never
|
||||||
|
// stored on a book: it says something about the host, and the book it happened to be read for is
|
||||||
|
// no more at fault than any other. See ErrStorageGone.
|
||||||
|
ReasonStorageUnavailable = "storage_unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotProvisioned is a book directory with no engine configuration in it.
|
||||||
|
//
|
||||||
|
// ⚠ This is the seam of an OPEN question, and it is named here rather than papered over. A book's
|
||||||
|
// project configuration (`book.yaml`) carries the pair, the ceilings and the paths the engine reads,
|
||||||
|
// and D39.110 §2b says the platform does not write that file. Creating a directory for an uploaded
|
||||||
|
// book therefore leaves somebody having to put the first one there, and who that is — the operator
|
||||||
|
// from a deployment template, or the platform rendering one at intake and never touching it again —
|
||||||
|
// is a ratification the zone asked for and does not have (zone journal, P5).
|
||||||
|
//
|
||||||
|
// Until it arrives the intake is complete up to this line: the file is received, stored, counted and
|
||||||
|
// handed to the engine, and a book whose directory carries no configuration is rejected with a
|
||||||
|
// reason that says so to an operator.
|
||||||
|
var ErrNotProvisioned = errors.New("books: the book has no engine configuration")
|
||||||
|
|
||||||
|
// ErrDirectoryGone is a book whose whole project directory is missing.
|
||||||
|
//
|
||||||
|
// Told apart from ErrNotProvisioned deliberately, and the difference is terminal-vs-not: a missing
|
||||||
|
// CONFIGURATION is a deployment question somebody can still answer, while a missing DIRECTORY means
|
||||||
|
// the source this platform received is not there any more and no amount of waiting brings it back.
|
||||||
|
// It is also the crash window of the rejection path — the directory is removed before the row is
|
||||||
|
// written — and reading it as "not configured" left such a book in `parsing` for good.
|
||||||
|
var ErrDirectoryGone = errors.New("books: the book's directory is gone")
|
||||||
|
|
||||||
|
// ErrStorageGone is the ROOT of the book storage missing — an unmounted volume, or a deployment
|
||||||
|
// pointed at a path that is not there yet.
|
||||||
|
//
|
||||||
|
// Told apart from ErrDirectoryGone because the two look identical from one book (both are ENOENT on
|
||||||
|
// the same Stat) and mean opposite things. A book's own directory being gone is that book's own
|
||||||
|
// terminal end; the root being gone is the host's, and reading it as the first would have ONE sweep
|
||||||
|
// reject EVERY book in intake with "the source cannot be read" — a reason that blames the user's
|
||||||
|
// file, is terminal by design, and has no way back (there is no re-parse and no un-reject). Found by
|
||||||
|
// cross-family review of the acceptance dofix.
|
||||||
|
var ErrStorageGone = errors.New("books: the book storage root is gone")
|
||||||
|
|
||||||
|
// Parse turns a received file into a chapter tree, or into a reason it is not one.
|
||||||
|
//
|
||||||
|
// It is the body of the queue's worker AND of the backstop sweep, and it is safe to call twice: the
|
||||||
|
// claim is a compare-and-set, so the loser does nothing. Losing that race is the ordinary case, not
|
||||||
|
// a failure — two `tmctl manifest` processes on one project directory would be two writers of a file
|
||||||
|
// the engine holds exclusively.
|
||||||
|
func (s *Service) Parse(ctx context.Context, bookID string) error {
|
||||||
|
now := s.now()
|
||||||
|
claim, err := s.Store.ClaimParse(ctx, bookID, now, now.Add(-claimGrace))
|
||||||
|
if errors.Is(err, pgstore.ErrParseClaimed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m, err := s.manifest(ctx, claim.Workdir)
|
||||||
|
if err == nil {
|
||||||
|
if m.ChaptersTotal < 1 {
|
||||||
|
// A source the engine read and found nothing in. The book's own fault — but it goes through
|
||||||
|
// the same budget as everything else, because "the engine answered no" and "the engine could
|
||||||
|
// not answer" are told apart by an exit code the engine gives BOTH.
|
||||||
|
return s.defer_(ctx, claim, ReasonSourceUnreadable)
|
||||||
|
}
|
||||||
|
// Counts and versions, no book id: the same rule as everywhere else on this side of the log
|
||||||
|
// (ENGINEERING_STANDARDS §Наблюдаемость). What an operator needs per book — including why a
|
||||||
|
// book was rejected — is a column, not a log line.
|
||||||
|
s.log().InfoContext(ctx, "book parsed", "chapters", m.ChaptersTotal,
|
||||||
|
"manifest_version", m.Version, "chunker", m.ChunkerVersion)
|
||||||
|
c, cancel := writeCtx(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return s.Store.FinishParse(c, bookID, claim.At, pgstore.ParsedBook{
|
||||||
|
Chapters: m.ChaptersTotal,
|
||||||
|
SourceSHA256: m.SourceSHA256Bytes(),
|
||||||
|
ChunkerVersion: m.ChunkerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrStorageGone) {
|
||||||
|
// The host cannot see its own storage. Nothing about this book is known yet, so it waits with
|
||||||
|
// the budget untouched — exactly like a book waiting for a configuration.
|
||||||
|
s.log().ErrorContext(ctx, "the book storage root is not there: intake waits rather than rejects",
|
||||||
|
"reason", ReasonStorageUnavailable)
|
||||||
|
return s.defer_(ctx, claim, ReasonStorageUnavailable)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrDirectoryGone) {
|
||||||
|
// Nothing to wait for and nothing to retry: what this platform received is not on disk. It is
|
||||||
|
// also how the rejection path heals from a crash between removing the directory and writing
|
||||||
|
// the row — the next pass finds no directory and finishes the job.
|
||||||
|
s.log().ErrorContext(ctx, "book rejected: its directory is gone", "reason", ReasonSourceUnreadable)
|
||||||
|
return s.reject(ctx, claim, ReasonSourceUnreadable)
|
||||||
|
}
|
||||||
|
reason := ReasonParserUnavailable
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotProvisioned):
|
||||||
|
reason = ReasonNotConfigured
|
||||||
|
case refusedTheSource(err):
|
||||||
|
reason = ReasonSourceUnreadable
|
||||||
|
}
|
||||||
|
// ⚠ err can carry the book's path (the engine is asked about a directory), which is the open class
|
||||||
|
// of PD-139, so it travels only at WARN/ERROR and never at INFO.
|
||||||
|
return s.defer_(ctx, claim, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// defer_ spends one attempt of the budget and, when the budget is gone, ends the intake.
|
||||||
|
//
|
||||||
|
// ONE path for every way a parse can fail, and that is the correction the cross-family review
|
||||||
|
// forced: the engine maps ALL of its failures onto exit 1 (its own comment says so), so "this source
|
||||||
|
// cannot be cut" is not distinguishable from "the disk was full", "an operator's own tmctl held the
|
||||||
|
// lock", or "another pass of this platform took the claim and is holding it". Rejecting — and
|
||||||
|
// DELETING the user's file — on the first exit 1 turned any of those into irreversible data loss.
|
||||||
|
// Spending an attempt instead costs a broken host five engine calls spread over the claim grace and
|
||||||
|
// costs a genuinely unreadable book the same five before it is rejected.
|
||||||
|
//
|
||||||
|
// ⚠ `not_configured` is the one reason that NEVER becomes terminal. The platform does not write
|
||||||
|
// `book.yaml` (D39.110 §2b) and who does is a ratification this zone does not have, so a book whose
|
||||||
|
// directory has no configuration is waiting on a DEPLOYMENT question. Rejecting it — and it would be
|
||||||
|
// every uploaded book on a deployment without that answer — would destroy a user's upload over a
|
||||||
|
// gap they cannot see. It stays `parsing`, visible in the intake metric, and an operator dropping
|
||||||
|
// the file in is all it takes.
|
||||||
|
func (s *Service) defer_(ctx context.Context, claim pgstore.ParseClaim, reason string) error {
|
||||||
|
if !waitsForTheDeployment(reason) && claim.Attempts >= parseAttempts {
|
||||||
|
s.log().ErrorContext(ctx, "book rejected: intake has spent its attempts on it",
|
||||||
|
"attempts", claim.Attempts, "reason", reason)
|
||||||
|
return s.reject(ctx, claim, reason)
|
||||||
|
}
|
||||||
|
// The claim is NOT given back: it is what spaces the retries. Released, the sweep would re-offer
|
||||||
|
// the book on its very next tick (the staleness predicate falls back to `added_at`, which is
|
||||||
|
// already old), and the attempts would burn in as many ticks — 75 seconds at the default sweep
|
||||||
|
// interval — rather than over the time this budget is for.
|
||||||
|
//
|
||||||
|
// The ATTEMPT, though, is given back when the engine was never asked. `ClaimParse` counts every
|
||||||
|
// claim, and a book waiting for its configuration claims once per grace forever — so without this
|
||||||
|
// the budget was spent by WAITING, and the first real answer from the engine afterwards was
|
||||||
|
// terminal on arrival. A typo in a hand-written `book.yaml` would then delete the user's upload
|
||||||
|
// on the first attempt at reading it. Waiting must not bring deletion closer.
|
||||||
|
if waitsForTheDeployment(reason) {
|
||||||
|
c, cancel := writeCtx(ctx)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.Store.RefundParseAttempt(c, claim.BookID, claim.At); err != nil {
|
||||||
|
s.log().ErrorContext(ctx, "the parse attempt could not be refunded", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.log().WarnContext(ctx, "parse deferred", "attempts", claim.Attempts, "reason", reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitsForTheDeployment reports the reasons that never end an intake and never spend its budget: the
|
||||||
|
// engine was not asked at all, and what is missing belongs to the deployment — a configuration
|
||||||
|
// nobody has written yet, or the storage root itself. Both are answerable by a human, and both would
|
||||||
|
// otherwise destroy every upload on a host that is merely misconfigured.
|
||||||
|
func waitsForTheDeployment(reason string) bool {
|
||||||
|
return reason == ReasonNotConfigured || reason == ReasonStorageUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCtx is the context a TERMINAL write uses: detached from the caller's deadline and bounded on
|
||||||
|
// its own.
|
||||||
|
//
|
||||||
|
// The engine call this follows can legitimately consume the whole budget of the pass — and then the
|
||||||
|
// write that records what happened would run on an already-expired context and be lost, leaving the
|
||||||
|
// book to be retried and the attempt to be spent again, forever. What must survive is the record.
|
||||||
|
func writeCtx(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.WithoutCancel(ctx), writeBudget)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBudget is what a terminal write gets. Short: it is one statement against a database this
|
||||||
|
// process is already connected to.
|
||||||
|
const writeBudget = 30 * time.Second
|
||||||
|
|
||||||
|
// manifest asks the engine to cut the book, once its configuration is there to cut it against.
|
||||||
|
func (s *Service) manifest(ctx context.Context, workdir string) (ingest.Manifest, error) {
|
||||||
|
if s.Engine == nil {
|
||||||
|
return ingest.Manifest{}, errors.New("books: no engine is configured")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(workdir); err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
// Which of the two absences this is decides whether the book dies, so it is decided by the
|
||||||
|
// SENTINEL rather than by the root's own existence (see storageIsThere).
|
||||||
|
if !s.storageIsThere() {
|
||||||
|
return ingest.Manifest{}, ErrStorageGone
|
||||||
|
}
|
||||||
|
return ingest.Manifest{}, ErrDirectoryGone
|
||||||
|
}
|
||||||
|
return ingest.Manifest{}, fmt.Errorf("books: read book directory: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(workdir, runner.ConfigFile)); err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return ingest.Manifest{}, ErrNotProvisioned
|
||||||
|
}
|
||||||
|
return ingest.Manifest{}, fmt.Errorf("books: read book configuration: %w", err)
|
||||||
|
}
|
||||||
|
return s.Engine.Manifest(ctx, s.Cfg.EngineBinary, workdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refusedTheSource tells the engine's ANSWER from the engine's absence.
|
||||||
|
//
|
||||||
|
// The engine maps every failure that is not one of its two sentinels onto exit 1 (backend
|
||||||
|
// cmd/tmctl/main.go exitCode), so "the source cannot be cut" arrives as an ExitError — a process
|
||||||
|
// that ran and said no. Everything else here is this host: no binary at that path, no permission to
|
||||||
|
// execute it, a working directory that is gone, a context that expired. Those retry; an answer does
|
||||||
|
// not.
|
||||||
|
func refusedTheSource(err error) bool {
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A signal is not an answer: an engine the machine killed said nothing about the book.
|
||||||
|
return exit.Exited()
|
||||||
|
}
|
||||||
|
|
||||||
|
// reject records the terminal end of an intake and removes what is left of it.
|
||||||
|
//
|
||||||
|
// The source is deleted, and it is a decision rather than housekeeping: the file cannot be parsed,
|
||||||
|
// no path in the contract re-parses or downloads it, and an authenticated route that writes bytes to
|
||||||
|
// an operator's disk and never removes them is a hole this pack would otherwise be opening. The ROW
|
||||||
|
// stays — the user has to be able to see that the book they uploaded did not make it.
|
||||||
|
func (s *Service) reject(ctx context.Context, claim pgstore.ParseClaim, reason string) error {
|
||||||
|
// The DIRECTORY goes first and the row second, and the order is the crash window: dying between
|
||||||
|
// the two then leaves a book still `parsing` with no source, which the next attempt fails on and
|
||||||
|
// rejects properly. The other order leaves a directory with a full source and a row that says
|
||||||
|
// `rejected` — and nothing ever looks at a rejected book again, so those bytes stay forever.
|
||||||
|
//
|
||||||
|
// ⚠ Only where the source is the BOOK's fault. A deployment that could not run the engine keeps
|
||||||
|
// the file: deleting a user's upload because this host was misconfigured is not a decision to
|
||||||
|
// make on their behalf.
|
||||||
|
if reason == ReasonSourceUnreadable {
|
||||||
|
s.removeDir(claim.Workdir)
|
||||||
|
}
|
||||||
|
// On a context of its own: this write is the only record that the intake is over, and the engine
|
||||||
|
// call that led here may have used up everything the pass had.
|
||||||
|
c, cancel := writeCtx(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return s.Store.RejectBook(c, claim.BookID, claim.At, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sweep finishes the intake walk of every book whose own process did not.
|
||||||
|
//
|
||||||
|
// The two halves are the two ways a walk stops: a request that went away mid-upload, and a parse
|
||||||
|
// whose process is gone. Neither is reachable from the thing that started it, which is what makes
|
||||||
|
// this the only cure — the same reason the run reconciler exists.
|
||||||
|
//
|
||||||
|
// One book's failure never stops the sweep: these are independent books of independent accounts.
|
||||||
|
func (s *Service) Sweep(ctx context.Context) error {
|
||||||
|
now := s.now()
|
||||||
|
stuck, err := s.Store.StuckIntake(ctx, now.Add(-UploadGrace), now.Add(-claimGrace))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, b := range stuck {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch b.Status {
|
||||||
|
case "uploading":
|
||||||
|
s.log().InfoContext(ctx, "removing an upload that never finished")
|
||||||
|
s.abandon(ctx, b.ID, b.Workdir)
|
||||||
|
case "parsing":
|
||||||
|
// Each book gets its OWN budget, and it has to be one a parse can live inside: the engine
|
||||||
|
// call is the same one the queue gives fifteen minutes. Sharing the pass's deadline meant a
|
||||||
|
// large book was killed by it, the kill was read as a host that cannot run the engine, and
|
||||||
|
// the attempt was spent — every pass, until the book was rejected for being big.
|
||||||
|
//
|
||||||
|
// ⚠ A budget the book gets is not a budget the PASS still has, and that gap was the rest of
|
||||||
|
// the same defect (re-check of the dofix): the second book of a pass inherited whatever the
|
||||||
|
// first one left, so a slow first parse handed the second a stub of a deadline and burned
|
||||||
|
// its attempt on the timeout. A book that cannot be given its whole budget is therefore not
|
||||||
|
// STARTED — the claim is taken inside Parse, so a book left for the next tick has spent
|
||||||
|
// nothing — and the pass says so once rather than per book.
|
||||||
|
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < jobs.JobTimeout {
|
||||||
|
s.log().InfoContext(ctx, "intake pass ends early: what is left of it is shorter than a parse",
|
||||||
|
"remaining", time.Until(deadline).String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c, cancel := context.WithTimeout(ctx, jobs.JobTimeout)
|
||||||
|
err := s.Parse(c, b.ID)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
s.log().ErrorContext(ctx, "book could not be parsed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -4,18 +4,22 @@ package config
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"textmachine/platform/internal/books"
|
||||||
"textmachine/platform/internal/money"
|
"textmachine/platform/internal/money"
|
||||||
"textmachine/platform/internal/pricing"
|
"textmachine/platform/internal/pricing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the whole configuration surface. Environment only: a control plane is deployed, not
|
// Config is the whole configuration surface. Environment only: a control plane is deployed, not
|
||||||
// hand-run, and a settings file is one more thing to keep in sync with the deployment.
|
// hand-run, and a settings file is one more thing to keep in sync with the deployment. Ratified
|
||||||
|
// 09.08 (register row PD-114) together with the obligation this file now also carries — an operator
|
||||||
|
// must be able to read what actually applied, which is what Settings and LogEffective are for.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Addr string
|
Addr string
|
||||||
// DSN is the Postgres connection string. Empty is allowed and means "start unready": the
|
// DSN is the Postgres connection string. Empty is allowed and means "start unready": the
|
||||||
|
|
@ -54,9 +58,18 @@ type Config struct {
|
||||||
// SignupGrantMicroUSD is the credit a new account is created with (the free tier, owner 05.08:
|
// SignupGrantMicroUSD is the credit a new account is created with (the free tier, owner 05.08:
|
||||||
// default five dollars, settable per account by granting a different amount).
|
// default five dollars, settable per account by granting a different amount).
|
||||||
SignupGrantMicroUSD int64
|
SignupGrantMicroUSD int64
|
||||||
|
// MetricsAddr is where the telemetry listener binds. A SEPARATE listener from the API on
|
||||||
|
// purpose — see the runner's own note — and empty turns it off.
|
||||||
|
MetricsAddr string
|
||||||
|
|
||||||
// Runner is everything about starting engine runs.
|
// Runner is everything about starting engine runs.
|
||||||
Runner RunnerConfig
|
Runner RunnerConfig
|
||||||
|
// Intake is everything about receiving a book.
|
||||||
|
Intake IntakeConfig
|
||||||
|
|
||||||
|
// Settings is every value above with where it came from, in the order it was read. It is what
|
||||||
|
// the boot line prints (PD-114) and it carries no secret and no money amount — see Setting.
|
||||||
|
Settings []Setting
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunnerConfig is the runner's operator-facing surface.
|
// RunnerConfig is the runner's operator-facing surface.
|
||||||
|
|
@ -95,25 +108,72 @@ type RunnerConfig struct {
|
||||||
Workers int
|
Workers int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IntakeConfig is the book upload's operator-facing surface.
|
||||||
|
type IntakeConfig struct {
|
||||||
|
// BooksDir is the root under which the platform creates one directory per uploaded book. Empty
|
||||||
|
// means this deployment takes no uploads, and then POST /books is not mounted at all: an
|
||||||
|
// instance that accepted files it has nowhere to put would reject every one of them after the
|
||||||
|
// upload rather than before it.
|
||||||
|
//
|
||||||
|
// Absolute, for the same reason StateDir is: a relative root names a different directory to the
|
||||||
|
// daemon and to anything else that reads a book's workdir out of the database.
|
||||||
|
BooksDir string
|
||||||
|
// MaxUploadBytes caps ONE upload's request body, and it is the per-route limit PD-72 was opened
|
||||||
|
// for. It is a refusal and not a sizing: the acceptance corpus is a 23 MB book.
|
||||||
|
MaxUploadBytes int64
|
||||||
|
// UploadDeadline is how long one upload's body may take to arrive. It REPLACES the server's
|
||||||
|
// 30-second ReadTimeout for that route alone and stays finite; clearing a deadline instead is
|
||||||
|
// what re-created PD-2 and is forbidden (STACK_DECISIONS §12).
|
||||||
|
UploadDeadline time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting is one resolved value as an operator may read it in a log.
|
||||||
|
type Setting struct {
|
||||||
|
// Key is the environment variable.
|
||||||
|
Key string
|
||||||
|
// Value is what applied — or a REDACTION where the value may not be logged. Two classes are
|
||||||
|
// redacted and both are rules of this project rather than taste: a secret (a DSN carries a
|
||||||
|
// password; §11 keeps it out of the process environment for the same reason), and any amount of
|
||||||
|
// MONEY, which does not reach an INFO log in any form (D39.84, PD-99). For those the source is
|
||||||
|
// the whole point — "did my file get picked up" is answerable without the value.
|
||||||
|
Value string
|
||||||
|
// Source is where the value came from: default, environment, or a *_FILE.
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceDefault = "default"
|
||||||
|
sourceEnv = "environment"
|
||||||
|
sourceFile = "file"
|
||||||
|
)
|
||||||
|
|
||||||
|
// redacted values. They are written out rather than left empty so that a line always says something:
|
||||||
|
// an empty value column reads as "unset", which is a different fact.
|
||||||
|
const (
|
||||||
|
valueSet = "(set)"
|
||||||
|
valueUnset = "(unset)"
|
||||||
|
valueAmount = "(an amount; not logged)"
|
||||||
|
)
|
||||||
|
|
||||||
// Load reads the environment.
|
// Load reads the environment.
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
|
var l loader
|
||||||
c := Config{
|
c := Config{
|
||||||
Addr: env("TM_PLATFORM_ADDR", "127.0.0.1:8080"),
|
Addr: l.env("TM_PLATFORM_ADDR", "127.0.0.1:8080"),
|
||||||
DSN: "", // read below: it may come from a file
|
OIDCProvider: l.env("TM_PLATFORM_OIDC_PROVIDER", "google"),
|
||||||
|
OIDCIssuer: l.env("TM_PLATFORM_OIDC_ISSUER", ""),
|
||||||
|
OIDCClientID: l.env("TM_PLATFORM_OIDC_CLIENT_ID", ""),
|
||||||
|
OIDCRedirectURL: l.env("TM_PLATFORM_OIDC_REDIRECT_URL", ""),
|
||||||
|
AfterLogin: l.env("TM_PLATFORM_AFTER_LOGIN", "/"),
|
||||||
|
MetricsAddr: metricsAddr(l.env("TM_PLATFORM_METRICS_ADDR", "127.0.0.1:9464")),
|
||||||
|
SignupGrantMicroUSD: 5 * 1_000_000,
|
||||||
// 14 days idle, 30 days absolute. The absolute one is the NIST SP 800-63B-4 AAL1 figure
|
// 14 days idle, 30 days absolute. The absolute one is the NIST SP 800-63B-4 AAL1 figure
|
||||||
// ("SHOULD be no more than 30 days"), not a preference: it was 90 days, and a deviation from
|
// ("SHOULD be no more than 30 days"), not a preference: it was 90 days, and a deviation from
|
||||||
// a SHOULD needs a reason that survives inspection, which that one did not (PD-58, §13).
|
// a SHOULD needs a reason that survives inspection, which that one did not (PD-58, §13).
|
||||||
SessionIdleTTL: 14 * 24 * time.Hour,
|
SessionIdleTTL: 14 * 24 * time.Hour,
|
||||||
SessionMaxAge: 30 * 24 * time.Hour,
|
SessionMaxAge: 30 * 24 * time.Hour,
|
||||||
OIDCProvider: env("TM_PLATFORM_OIDC_PROVIDER", "google"),
|
|
||||||
OIDCIssuer: os.Getenv("TM_PLATFORM_OIDC_ISSUER"),
|
|
||||||
OIDCClientID: os.Getenv("TM_PLATFORM_OIDC_CLIENT_ID"),
|
|
||||||
OIDCClientSecret: "", // read below: it may come from a file
|
|
||||||
OIDCRedirectURL: os.Getenv("TM_PLATFORM_OIDC_REDIRECT_URL"),
|
|
||||||
AfterLogin: env("TM_PLATFORM_AFTER_LOGIN", "/"),
|
|
||||||
SignupGrantMicroUSD: 5 * 1_000_000,
|
|
||||||
}
|
}
|
||||||
if raw := os.Getenv("TM_PLATFORM_TRUSTED_ORIGINS"); raw != "" {
|
if raw := l.env("TM_PLATFORM_TRUSTED_ORIGINS", ""); raw != "" {
|
||||||
for _, o := range strings.Split(raw, ",") {
|
for _, o := range strings.Split(raw, ",") {
|
||||||
if o = strings.TrimSpace(o); o != "" {
|
if o = strings.TrimSpace(o); o != "" {
|
||||||
c.TrustedOrigins = append(c.TrustedOrigins, o)
|
c.TrustedOrigins = append(c.TrustedOrigins, o)
|
||||||
|
|
@ -121,38 +181,34 @@ func Load() (Config, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
if c.Migrate, err = boolean("TM_PLATFORM_MIGRATE"); err != nil {
|
if c.Migrate, err = l.boolean("TM_PLATFORM_MIGRATE"); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if c.InsecureCookies, err = boolean("TM_PLATFORM_INSECURE_COOKIES"); err != nil {
|
if c.InsecureCookies, err = l.boolean("TM_PLATFORM_INSECURE_COOKIES"); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if c.DSN, err = secret("TM_PLATFORM_DSN"); err != nil {
|
if c.DSN, err = l.secret("TM_PLATFORM_DSN"); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if c.OIDCClientSecret, err = secret("TM_PLATFORM_OIDC_CLIENT_SECRET"); err != nil {
|
if c.OIDCClientSecret, err = l.secret("TM_PLATFORM_OIDC_CLIENT_SECRET"); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if c.SessionIdleTTL, err = duration("TM_PLATFORM_SESSION_IDLE", c.SessionIdleTTL); err != nil {
|
if c.SessionIdleTTL, err = l.duration("TM_PLATFORM_SESSION_IDLE", c.SessionIdleTTL); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if c.SessionMaxAge, err = duration("TM_PLATFORM_SESSION_MAX_AGE", c.SessionMaxAge); err != nil {
|
if c.SessionMaxAge, err = l.duration("TM_PLATFORM_SESSION_MAX_AGE", c.SessionMaxAge); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if c.SessionIdleTTL > c.SessionMaxAge {
|
if c.SessionIdleTTL > c.SessionMaxAge {
|
||||||
return Config{}, fmt.Errorf("config: session idle TTL %s exceeds max age %s", c.SessionIdleTTL, c.SessionMaxAge)
|
return Config{}, fmt.Errorf("config: session idle TTL %s exceeds max age %s", c.SessionIdleTTL, c.SessionMaxAge)
|
||||||
}
|
}
|
||||||
if raw := os.Getenv("TM_PLATFORM_SIGNUP_GRANT_USD"); raw != "" {
|
if c.SignupGrantMicroUSD, err = l.amount("TM_PLATFORM_SIGNUP_GRANT_USD", c.SignupGrantMicroUSD, false); err != nil {
|
||||||
v, err := money.ParseUSD(raw)
|
return Config{}, err
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("config: TM_PLATFORM_SIGNUP_GRANT_USD: %w", err)
|
|
||||||
}
|
|
||||||
if v < 0 {
|
|
||||||
return Config{}, errors.New("config: TM_PLATFORM_SIGNUP_GRANT_USD cannot be negative")
|
|
||||||
}
|
|
||||||
c.SignupGrantMicroUSD = int64(v)
|
|
||||||
}
|
}
|
||||||
if err := c.loadRunner(); err != nil {
|
if err := c.loadRunner(&l); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if err := c.loadIntake(&l); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
// Half a login configuration is worse than none: the surface would mount and fail at the first
|
// Half a login configuration is worse than none: the surface would mount and fail at the first
|
||||||
|
|
@ -167,9 +223,39 @@ func Load() (Config, error) {
|
||||||
if set != 0 && set != len(oidc) {
|
if set != 0 && set != len(oidc) {
|
||||||
return Config{}, errors.New("config: OIDC needs all of TM_PLATFORM_OIDC_ISSUER, _CLIENT_ID, _CLIENT_SECRET, _REDIRECT_URL, or none")
|
return Config{}, errors.New("config: OIDC needs all of TM_PLATFORM_OIDC_ISSUER, _CLIENT_ID, _CLIENT_SECRET, _REDIRECT_URL, or none")
|
||||||
}
|
}
|
||||||
|
c.Settings = l.settings
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metricsAddr turns the operator's word for "do not serve metrics" into the empty address the
|
||||||
|
// listener understands.
|
||||||
|
//
|
||||||
|
// A word is needed because an EMPTY environment variable is indistinguishable from an unset one to
|
||||||
|
// os.Getenv, and unset takes the default — so without this the switch documented as "empty turns it
|
||||||
|
// off" could not be reached from the environment at all.
|
||||||
|
func metricsAddr(v string) string {
|
||||||
|
if strings.EqualFold(v, "off") || strings.EqualFold(v, "none") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogEffective prints what actually applied, one line per setting (PD-114, ratified 09.08).
|
||||||
|
//
|
||||||
|
// It exists because an environment-only configuration has no artifact an operator can read back: a
|
||||||
|
// deployment whose EnvironmentFile= did not load looks exactly like one whose values were chosen,
|
||||||
|
// and the difference used to be discoverable only from behaviour. The line carries the SOURCE as
|
||||||
|
// well as the value, because "did my setting take effect" is the actual question and a value alone
|
||||||
|
// cannot answer it — a default and an environment variable holding the same string are the same line
|
||||||
|
// otherwise.
|
||||||
|
//
|
||||||
|
// Secrets and money amounts are redacted here rather than at the call site (see Setting.Value).
|
||||||
|
func (c Config) LogEffective(log *slog.Logger) {
|
||||||
|
for _, s := range c.Settings {
|
||||||
|
log.Info("config", "key", s.Key, "value", s.Value, "source", s.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LoginEnabled reports whether a sign-in provider is configured.
|
// LoginEnabled reports whether a sign-in provider is configured.
|
||||||
func (c Config) LoginEnabled() bool { return c.OIDCIssuer != "" }
|
func (c Config) LoginEnabled() bool { return c.OIDCIssuer != "" }
|
||||||
|
|
||||||
|
|
@ -177,17 +263,22 @@ func (c Config) LoginEnabled() bool { return c.OIDCIssuer != "" }
|
||||||
// engine binary still serves every read: the library and the book card are not run machinery.
|
// engine binary still serves every read: the library and the book card are not run machinery.
|
||||||
func (c Config) RunsEnabled() bool { return c.Runner.EngineBinary != "" }
|
func (c Config) RunsEnabled() bool { return c.Runner.EngineBinary != "" }
|
||||||
|
|
||||||
|
// IntakeEnabled reports whether this instance can receive a book. It needs somewhere to put the file
|
||||||
|
// AND an engine to cut it with: parsing is one call of tmctl, so a deployment without the binary
|
||||||
|
// could only take uploads it would have to reject.
|
||||||
|
func (c Config) IntakeEnabled() bool { return c.Intake.BooksDir != "" && c.RunsEnabled() }
|
||||||
|
|
||||||
// loadRunner reads the runner's settings.
|
// loadRunner reads the runner's settings.
|
||||||
func (c *Config) loadRunner() error {
|
func (c *Config) loadRunner(l *loader) error {
|
||||||
r := RunnerConfig{
|
r := RunnerConfig{
|
||||||
EngineBinary: os.Getenv("TM_PLATFORM_ENGINE_BIN"),
|
EngineBinary: l.env("TM_PLATFORM_ENGINE_BIN", ""),
|
||||||
StateDir: env("TM_PLATFORM_STATE_DIR", "/var/lib/tmplatform"),
|
StateDir: l.env("TM_PLATFORM_STATE_DIR", "/var/lib/tmplatform"),
|
||||||
MarkerBinary: os.Getenv("TM_PLATFORM_CTL_BIN"),
|
MarkerBinary: l.env("TM_PLATFORM_CTL_BIN", ""),
|
||||||
CeilingArg: env("TM_PLATFORM_ENGINE_CEILING_ARG", "--ceiling-usd {{usd}}"),
|
CeilingArg: l.env("TM_PLATFORM_ENGINE_CEILING_ARG", "--ceiling-usd {{usd}}"),
|
||||||
// A run gets a generous but finite share of the machine. The figure is a BACKSTOP, not a
|
// A run gets a generous but finite share of the machine. The figure is a BACKSTOP, not a
|
||||||
// sizing: a translation is bounded by its ceiling in dollars, and this bounds the one way a
|
// sizing: a translation is bounded by its ceiling in dollars, and this bounds the one way a
|
||||||
// single run can hurt the others regardless of money (PD-13).
|
// single run can hurt the others regardless of money (PD-13).
|
||||||
MemoryMax: env("TM_PLATFORM_RUN_MEMORY_MAX", "4G"),
|
MemoryMax: l.env("TM_PLATFORM_RUN_MEMORY_MAX", "4G"),
|
||||||
TasksMax: 64,
|
TasksMax: 64,
|
||||||
SweepEvery: 15 * time.Second,
|
SweepEvery: 15 * time.Second,
|
||||||
ResyncEvery: 5 * time.Minute,
|
ResyncEvery: 5 * time.Minute,
|
||||||
|
|
@ -203,56 +294,97 @@ func (c *Config) loadRunner() error {
|
||||||
return fmt.Errorf("config: TM_PLATFORM_STATE_DIR must be an absolute path, got %q", r.StateDir)
|
return fmt.Errorf("config: TM_PLATFORM_STATE_DIR must be an absolute path, got %q", r.StateDir)
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
if r.TasksMax, err = number("TM_PLATFORM_RUN_TASKS_MAX", r.TasksMax); err != nil {
|
if r.TasksMax, err = l.number("TM_PLATFORM_RUN_TASKS_MAX", r.TasksMax); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if r.AllowEngineVersionChange, err = boolean("TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE"); err != nil {
|
if r.AllowEngineVersionChange, err = l.boolean("TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if r.Workers, err = number("TM_PLATFORM_RUN_WORKERS", r.Workers); err != nil {
|
if r.Workers, err = l.number("TM_PLATFORM_RUN_WORKERS", r.Workers); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if r.SweepEvery, err = duration("TM_PLATFORM_SWEEP_EVERY", r.SweepEvery); err != nil {
|
if r.SweepEvery, err = l.duration("TM_PLATFORM_SWEEP_EVERY", r.SweepEvery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if r.ResyncEvery, err = duration("TM_PLATFORM_RESYNC_EVERY", r.ResyncEvery); err != nil {
|
if r.ResyncEvery, err = l.duration("TM_PLATFORM_RESYNC_EVERY", r.ResyncEvery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw := os.Getenv("TM_PLATFORM_USD_PER_CHAPTER"); raw != "" {
|
if r.PerChapterMicroUSD, err = l.amount("TM_PLATFORM_USD_PER_CHAPTER", r.PerChapterMicroUSD, true); err != nil {
|
||||||
v, err := money.ParseUSD(raw)
|
return err
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("config: TM_PLATFORM_USD_PER_CHAPTER: %w", err)
|
|
||||||
}
|
|
||||||
if v <= 0 {
|
|
||||||
return errors.New("config: TM_PLATFORM_USD_PER_CHAPTER must be positive")
|
|
||||||
}
|
|
||||||
r.PerChapterMicroUSD = int64(v)
|
|
||||||
}
|
}
|
||||||
c.Runner = r
|
c.Runner = r
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func number(key string, def int) (int, error) {
|
// loadIntake reads the book upload's settings.
|
||||||
raw := os.Getenv(key)
|
func (c *Config) loadIntake(l *loader) error {
|
||||||
if raw == "" {
|
in := IntakeConfig{
|
||||||
return def, nil
|
BooksDir: l.env("TM_PLATFORM_BOOKS_DIR", ""),
|
||||||
|
// 64 MiB and ten minutes: the acceptance corpus is a 23 MB book, and ten minutes is what a
|
||||||
|
// file that size takes on an uplink an ordinary reader has. Both are refusals rather than
|
||||||
|
// promises — the numbers an operator raises when their users hit them.
|
||||||
|
MaxUploadBytes: 64 << 20,
|
||||||
|
UploadDeadline: 10 * time.Minute,
|
||||||
}
|
}
|
||||||
v, err := strconv.Atoi(raw)
|
if in.BooksDir != "" && !filepath.IsAbs(in.BooksDir) {
|
||||||
if err != nil || v <= 0 {
|
return fmt.Errorf("config: TM_PLATFORM_BOOKS_DIR must be an absolute path, got %q", in.BooksDir)
|
||||||
return 0, fmt.Errorf("config: %s must be a positive whole number, got %q", key, raw)
|
|
||||||
}
|
}
|
||||||
return v, nil
|
var err error
|
||||||
|
if in.MaxUploadBytes, err = l.bytes("TM_PLATFORM_MAX_UPLOAD_BYTES", in.MaxUploadBytes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.UploadDeadline, err = l.duration("TM_PLATFORM_UPLOAD_DEADLINE", in.UploadDeadline); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The two are a PAIR, and the boot is where a mismatched pair is cheap to find. The intake sweep
|
||||||
|
// treats a book that has been `uploading` longer than its grace as an upload whose request is
|
||||||
|
// gone — true only while one request cannot legitimately take that long. Longer, and the sweep
|
||||||
|
// deletes the row and the directory under a request that is still writing into them.
|
||||||
|
if in.UploadDeadline >= books.UploadGrace {
|
||||||
|
return fmt.Errorf("config: TM_PLATFORM_UPLOAD_DEADLINE (%s) must be shorter than the intake sweep's grace (%s)",
|
||||||
|
in.UploadDeadline, books.UploadGrace)
|
||||||
|
}
|
||||||
|
c.Intake = in
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loader reads the environment and REMEMBERS what it read, so that the boot line can say where each
|
||||||
|
// value came from. Every setting of this service goes through it: a print that covers most of the
|
||||||
|
// configuration is worse than none, because the variable an operator is hunting is exactly the one
|
||||||
|
// nobody thought to record.
|
||||||
|
type loader struct{ settings []Setting }
|
||||||
|
|
||||||
|
func (l *loader) record(key, value, source string) {
|
||||||
|
l.settings = append(l.settings, Setting{Key: key, Value: value, Source: source})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loader) env(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
l.record(key, v, sourceEnv)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if def == "" {
|
||||||
|
l.record(key, valueUnset, sourceDefault)
|
||||||
|
} else {
|
||||||
|
l.record(key, def, sourceDefault)
|
||||||
|
}
|
||||||
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret is the shared way to read a credential: from KEY, or preferably from the file named by
|
// Secret is the shared way to read a credential: from KEY, or preferably from the file named by
|
||||||
// KEY_FILE. Exported so the admin CLI reads the DSN the same way the daemon does — an operator who
|
// KEY_FILE. Exported so the admin CLI reads the DSN the same way the daemon does — an operator who
|
||||||
// followed the deploy notes has it in a file, not in the environment.
|
// followed the deploy notes has it in a file, not in the environment.
|
||||||
func Secret(key string) (string, error) { return secret(key) }
|
func Secret(key string) (string, error) {
|
||||||
|
var l loader
|
||||||
|
return l.secret(key)
|
||||||
|
}
|
||||||
|
|
||||||
// secret reads a value from KEY, or — preferably — from the file named by KEY_FILE. A secret in a
|
// secret reads a value from KEY, or — preferably — from the file named by KEY_FILE. A secret in a
|
||||||
// file does not show up in /proc/<pid>/environ, is not inherited by child processes, and is exactly
|
// file does not show up in /proc/<pid>/environ, is not inherited by child processes, and is exactly
|
||||||
// what systemd's LoadCredential= hands over (deploy/tmplatformd.service).
|
// what systemd's LoadCredential= hands over (deploy/tmplatformd.service).
|
||||||
func secret(key string) (string, error) {
|
//
|
||||||
|
// What is recorded is that it was set and WHERE from, never the value: a DSN carries a password.
|
||||||
|
func (l *loader) secret(key string) (string, error) {
|
||||||
if path := os.Getenv(key + "_FILE"); path != "" {
|
if path := os.Getenv(key + "_FILE"); path != "" {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -260,35 +392,67 @@ func secret(key string) (string, error) {
|
||||||
// the error goes to the log.
|
// the error goes to the log.
|
||||||
return "", fmt.Errorf("config: %s_FILE: %w", key, err)
|
return "", fmt.Errorf("config: %s_FILE: %w", key, err)
|
||||||
}
|
}
|
||||||
|
l.record(key, valueSet, sourceFile)
|
||||||
return strings.TrimSpace(string(b)), nil
|
return strings.TrimSpace(string(b)), nil
|
||||||
}
|
}
|
||||||
return os.Getenv(key), nil
|
v := os.Getenv(key)
|
||||||
|
switch v {
|
||||||
|
case "":
|
||||||
|
l.record(key, valueUnset, sourceDefault)
|
||||||
|
default:
|
||||||
|
l.record(key, valueSet, sourceEnv)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// boolean reads a flag. strconv.ParseBool rather than a comparison with "1": an operator who wrote
|
// boolean reads a flag. strconv.ParseBool rather than a comparison with "1": an operator who wrote
|
||||||
// `true` deserves an error or the truth, not a silent no.
|
// `true` deserves an error or the truth, not a silent no.
|
||||||
func boolean(key string) (bool, error) {
|
func (l *loader) boolean(key string) (bool, error) {
|
||||||
raw := os.Getenv(key)
|
raw := os.Getenv(key)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
l.record(key, "false", sourceDefault)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
v, err := strconv.ParseBool(raw)
|
v, err := strconv.ParseBool(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("config: %s: %q is not a boolean", key, raw)
|
return false, fmt.Errorf("config: %s: %q is not a boolean", key, raw)
|
||||||
}
|
}
|
||||||
|
l.record(key, strconv.FormatBool(v), sourceEnv)
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func env(key, def string) string {
|
func (l *loader) number(key string, def int) (int, error) {
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func duration(key string, def time.Duration) (time.Duration, error) {
|
|
||||||
raw := os.Getenv(key)
|
raw := os.Getenv(key)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
l.record(key, strconv.Itoa(def), sourceDefault)
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return 0, fmt.Errorf("config: %s must be a positive whole number, got %q", key, raw)
|
||||||
|
}
|
||||||
|
l.record(key, strconv.Itoa(v), sourceEnv)
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loader) bytes(key string, def int64) (int64, error) {
|
||||||
|
raw := os.Getenv(key)
|
||||||
|
if raw == "" {
|
||||||
|
l.record(key, strconv.FormatInt(def, 10), sourceDefault)
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return 0, fmt.Errorf("config: %s must be a positive number of bytes, got %q", key, raw)
|
||||||
|
}
|
||||||
|
l.record(key, strconv.FormatInt(v, 10), sourceEnv)
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loader) duration(key string, def time.Duration) (time.Duration, error) {
|
||||||
|
raw := os.Getenv(key)
|
||||||
|
if raw == "" {
|
||||||
|
l.record(key, def.String(), sourceDefault)
|
||||||
return def, nil
|
return def, nil
|
||||||
}
|
}
|
||||||
d, err := time.ParseDuration(raw)
|
d, err := time.ParseDuration(raw)
|
||||||
|
|
@ -298,5 +462,32 @@ func duration(key string, def time.Duration) (time.Duration, error) {
|
||||||
if d <= 0 {
|
if d <= 0 {
|
||||||
return 0, fmt.Errorf("config: %s must be positive", key)
|
return 0, fmt.Errorf("config: %s must be positive", key)
|
||||||
}
|
}
|
||||||
|
l.record(key, d.String(), sourceEnv)
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// amount reads a sum in dollars into micro-USD. positiveOnly refuses zero as well as negatives,
|
||||||
|
// which is the difference between a rate (a zero rate makes every scale infinite) and a grant (a
|
||||||
|
// deployment that grants nothing is a legitimate choice).
|
||||||
|
//
|
||||||
|
// The VALUE is never recorded — money does not reach an INFO log in any form (D39.84, PD-99) — and
|
||||||
|
// the source is, which is what an operator needs to see that their figure was picked up.
|
||||||
|
func (l *loader) amount(key string, def int64, positiveOnly bool) (int64, error) {
|
||||||
|
raw := os.Getenv(key)
|
||||||
|
if raw == "" {
|
||||||
|
l.record(key, valueAmount, sourceDefault)
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
v, err := money.ParseUSD(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case positiveOnly && v <= 0:
|
||||||
|
return 0, fmt.Errorf("config: %s must be positive", key)
|
||||||
|
case v < 0:
|
||||||
|
return 0, fmt.Errorf("config: %s cannot be negative", key)
|
||||||
|
}
|
||||||
|
l.record(key, valueAmount, sourceEnv)
|
||||||
|
return int64(v), nil
|
||||||
|
}
|
||||||
|
|
|
||||||
192
platform/internal/config/effective_test.go
Normal file
192
platform/internal/config/effective_test.go
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"textmachine/platform/internal/books"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PD-114. An environment-only configuration leaves no artifact to read back, so the boot line IS the
|
||||||
|
// artifact — and the two things it must never carry are a secret and a sum of money.
|
||||||
|
|
||||||
|
func effective(t *testing.T) (map[string]Setting, string) {
|
||||||
|
t.Helper()
|
||||||
|
c, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
c.LogEffective(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||||
|
by := map[string]Setting{}
|
||||||
|
for _, s := range c.Settings {
|
||||||
|
if _, dup := by[s.Key]; dup {
|
||||||
|
t.Errorf("%s is printed twice", s.Key)
|
||||||
|
}
|
||||||
|
by[s.Key] = s
|
||||||
|
}
|
||||||
|
return by, buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The completeness gate, and the reason it is written against the SOURCE rather than a list: a print
|
||||||
|
// that covers most of the configuration is worse than none, because the variable an operator is
|
||||||
|
// hunting is exactly the one nobody remembered to record. A setting added without a record fails
|
||||||
|
// here, in the same commit that adds it.
|
||||||
|
func TestEverySettingThisServiceReadsIsPrinted(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("config.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, m := range regexp.MustCompile(`TM_PLATFORM_[A-Z0-9_]+`).FindAllString(string(src), -1) {
|
||||||
|
// A *_FILE variable is the alternative SOURCE of the key beside it, not a setting of its own:
|
||||||
|
// it is recorded as `source=file` on that key, and its path is never printed.
|
||||||
|
want[strings.TrimSuffix(m, "_FILE")] = true
|
||||||
|
}
|
||||||
|
got, _ := effective(t)
|
||||||
|
var missing []string
|
||||||
|
for k := range want {
|
||||||
|
if _, ok := got[k]; !ok {
|
||||||
|
missing = append(missing, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(missing)
|
||||||
|
if len(missing) != 0 {
|
||||||
|
t.Fatalf("read but never printed: %v", missing)
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("%d settings printed, %d read from the environment", len(got), len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A DSN carries a password and a client secret is a client secret. What the operator gets is the
|
||||||
|
// fact and the SOURCE, which is what answers "did my file get picked up".
|
||||||
|
func TestASecretIsNamedAndNeverPrinted(t *testing.T) {
|
||||||
|
path := t.TempDir() + "/dsn"
|
||||||
|
if err := os.WriteFile(path, []byte("postgres://u:hunter2@db/x\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("TM_PLATFORM_DSN_FILE", path)
|
||||||
|
// The whole OIDC quartet, because half a login configuration is refused at boot.
|
||||||
|
t.Setenv("TM_PLATFORM_OIDC_CLIENT_SECRET", "s3cret")
|
||||||
|
t.Setenv("TM_PLATFORM_OIDC_ISSUER", "https://accounts.example.org")
|
||||||
|
t.Setenv("TM_PLATFORM_OIDC_CLIENT_ID", "client")
|
||||||
|
t.Setenv("TM_PLATFORM_OIDC_REDIRECT_URL", "https://app.example.org/auth/callback")
|
||||||
|
got, line := effective(t)
|
||||||
|
if got["TM_PLATFORM_DSN"].Source != sourceFile || got["TM_PLATFORM_DSN"].Value != valueSet {
|
||||||
|
t.Errorf("DSN printed as %+v", got["TM_PLATFORM_DSN"])
|
||||||
|
}
|
||||||
|
if got["TM_PLATFORM_OIDC_CLIENT_SECRET"].Source != sourceEnv {
|
||||||
|
t.Errorf("client secret printed as %+v", got["TM_PLATFORM_OIDC_CLIENT_SECRET"])
|
||||||
|
}
|
||||||
|
for _, leaked := range []string{"hunter2", "s3cret", path} {
|
||||||
|
if strings.Contains(line, leaked) {
|
||||||
|
t.Errorf("the boot line carries %q", leaked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And the value really was read, not lost by being redacted.
|
||||||
|
c, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.DSN, "hunter2") || c.OIDCClientSecret != "s3cret" {
|
||||||
|
t.Fatal("the secret was redacted out of the configuration itself, not only out of the log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money does not reach an INFO log in ANY form (D39.84, PD-99) — including the amounts an operator
|
||||||
|
// configured. The source is what they need, and the source is all they get.
|
||||||
|
func TestAConfiguredAmountIsNeverPrinted(t *testing.T) {
|
||||||
|
t.Setenv("TM_PLATFORM_SIGNUP_GRANT_USD", "7.50")
|
||||||
|
t.Setenv("TM_PLATFORM_USD_PER_CHAPTER", "0.0425")
|
||||||
|
got, line := effective(t)
|
||||||
|
for _, k := range []string{"TM_PLATFORM_SIGNUP_GRANT_USD", "TM_PLATFORM_USD_PER_CHAPTER"} {
|
||||||
|
if got[k].Source != sourceEnv {
|
||||||
|
t.Errorf("%s: source %q, want the environment it came from", k, got[k].Source)
|
||||||
|
}
|
||||||
|
if got[k].Value != valueAmount {
|
||||||
|
t.Errorf("%s: value %q, want it withheld", k, got[k].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, amount := range []string{"7.50", "7500000", "0.0425", "42500"} {
|
||||||
|
if strings.Contains(line, amount) {
|
||||||
|
t.Errorf("the boot line carries the amount %q", amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.SignupGrantMicroUSD != 7_500_000 || c.Runner.PerChapterMicroUSD != 42_500 {
|
||||||
|
t.Fatalf("the amounts did not apply: %d and %d", c.SignupGrantMicroUSD, c.Runner.PerChapterMicroUSD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A default and an environment variable holding the same string are the same line without the
|
||||||
|
// source, and telling them apart is the whole question ("did my setting take effect").
|
||||||
|
func TestTheSourceOfEveryValueIsPrintedBesideIt(t *testing.T) {
|
||||||
|
t.Setenv("TM_PLATFORM_ADDR", "127.0.0.1:8080") // exactly the default
|
||||||
|
got, line := effective(t)
|
||||||
|
if got["TM_PLATFORM_ADDR"].Source != sourceEnv {
|
||||||
|
t.Errorf("a value set to the default reads as %q", got["TM_PLATFORM_ADDR"].Source)
|
||||||
|
}
|
||||||
|
if got["TM_PLATFORM_AFTER_LOGIN"].Source != sourceDefault {
|
||||||
|
t.Errorf("an unset value reads as %q", got["TM_PLATFORM_AFTER_LOGIN"].Source)
|
||||||
|
}
|
||||||
|
// One line per setting, as structured fields rather than a rendered sentence.
|
||||||
|
var first map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(strings.SplitN(line, "\n", 2)[0]), &first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, k := range []string{"key", "value", "source"} {
|
||||||
|
if _, ok := first[k]; !ok {
|
||||||
|
t.Errorf("the boot line has no %q field: %v", k, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An intake root that is not absolute names a different directory to every process that reads a
|
||||||
|
// book's workdir out of the database — the same class as PD-149 on the state directory, refused in
|
||||||
|
// the same place.
|
||||||
|
func TestARelativeBooksDirectoryIsRefusedAtBoot(t *testing.T) {
|
||||||
|
t.Setenv("TM_PLATFORM_BOOKS_DIR", "books")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("a relative TM_PLATFORM_BOOKS_DIR was accepted")
|
||||||
|
}
|
||||||
|
t.Setenv("TM_PLATFORM_BOOKS_DIR", "/srv/tm/books")
|
||||||
|
c, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.IntakeEnabled() {
|
||||||
|
t.Fatal("intake is enabled without an engine binary: a deployment that cannot parse would take uploads it can only reject")
|
||||||
|
}
|
||||||
|
t.Setenv("TM_PLATFORM_ENGINE_BIN", "/opt/engine/2026.08.01/tmctl")
|
||||||
|
if c, err = Load(); err != nil || !c.IntakeEnabled() {
|
||||||
|
t.Fatalf("intake stayed off with a root and an engine: %v %+v", err, c.Intake)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The upload deadline and the intake sweep's grace are a pair: the sweep treats a book that has been
|
||||||
|
// `uploading` longer than its grace as an upload whose request is gone, which is only true while one
|
||||||
|
// request cannot legitimately take that long. Longer, and the sweep deletes the row and the directory
|
||||||
|
// out from under a request still writing into them — so the boot refuses the combination.
|
||||||
|
func TestAnUploadDeadlineLongerThanTheSweepsGraceIsRefused(t *testing.T) {
|
||||||
|
t.Setenv("TM_PLATFORM_BOOKS_DIR", "/srv/tm/books")
|
||||||
|
t.Setenv("TM_PLATFORM_ENGINE_BIN", "/opt/engine/2026.08.01/tmctl")
|
||||||
|
t.Setenv("TM_PLATFORM_UPLOAD_DEADLINE", (books.UploadGrace + time.Minute).String())
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("an upload deadline longer than the sweep's grace was accepted")
|
||||||
|
}
|
||||||
|
t.Setenv("TM_PLATFORM_UPLOAD_DEADLINE", (books.UploadGrace - time.Minute).String())
|
||||||
|
if _, err := Load(); err != nil {
|
||||||
|
t.Fatalf("a deadline inside the grace was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
platform/internal/gates/toolchain_test.go
Normal file
97
platform/internal/gates/toolchain_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// Package gates holds the tests of the zone's BUILD gates — the checks that run before any code
|
||||||
|
// does. They live in their own package because their subject is the Makefile and go.mod rather than
|
||||||
|
// any package's behaviour, and a gate nobody tests is a gate that quietly stops gating: this file
|
||||||
|
// exists because the toolchain floor was raised in three documents and one echo, while the check
|
||||||
|
// itself went on accepting the version it was raised against (re-check of the P5 dofix, FP5-9).
|
||||||
|
package gates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// zoneRoot is where the Makefile and go.mod live, two levels up from this package.
|
||||||
|
const zoneRoot = "../.."
|
||||||
|
|
||||||
|
// The gate is exercised with versions THIS HOST does not have. That is the whole point of asserting
|
||||||
|
// on the comparison rather than on a run: a gate tested only against the toolchain that happens to be
|
||||||
|
// installed proves nothing about the one it is meant to refuse.
|
||||||
|
func TestTheToolchainGateComparesVersionsRatherThanMatchingThem(t *testing.T) {
|
||||||
|
min := minVersion(t)
|
||||||
|
if min != "1.26.6" {
|
||||||
|
// Not a failure — the floor is allowed to move — but the table below is written against it.
|
||||||
|
t.Logf("GO_MIN_VERSION is %s; the cases below assume the 1.26.x floor", min)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
version string
|
||||||
|
accept bool
|
||||||
|
}{
|
||||||
|
{"go1.26.6", true}, // the floor itself
|
||||||
|
{"go1.26.7", true}, // a later patch
|
||||||
|
{"go1.26.10", true}, // double digits: the regex this replaced ordered 1.26.10 BELOW 1.26.9
|
||||||
|
{"go1.27.0", true}, // a later minor
|
||||||
|
{"go2.0.0", true},
|
||||||
|
{"go1.26.5", false}, // the release the floor was raised against — the case that used to pass
|
||||||
|
{"go1.26.4", false},
|
||||||
|
{"go1.25.9", false},
|
||||||
|
{"go1.9.9", false}, // one-digit minor must not sort above 1.26
|
||||||
|
{"go1.26.6rc1", false}, // a prerelease does not carry the fixes its number promises
|
||||||
|
{"devel go1.27-abcdef", false},
|
||||||
|
} {
|
||||||
|
err := makeTarget(t, "version-check", "GO_VERSION="+c.version)
|
||||||
|
if c.accept && err != nil {
|
||||||
|
t.Errorf("the gate refused %s, which is at or above the floor %s: %v", c.version, min, err)
|
||||||
|
}
|
||||||
|
if !c.accept && err == nil {
|
||||||
|
t.Errorf("the gate ACCEPTED %s, below the floor %s", c.version, min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of the same floor: the Makefile is a convenience, go.mod is what every build reads —
|
||||||
|
// including one that never calls make. The two must name the same version, or a build that skips the
|
||||||
|
// battery gets a toolchain the battery would have refused.
|
||||||
|
func TestGoModPinsTheSameToolchainTheBatteryDemands(t *testing.T) {
|
||||||
|
src, err := os.ReadFile(filepath.Join(zoneRoot, "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := regexp.MustCompile(`(?m)^toolchain go(\S+)$`).FindSubmatch(src)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("go.mod carries no toolchain directive: the floor then lives only in the Makefile, and a build that does not call make ignores it")
|
||||||
|
}
|
||||||
|
if got, want := string(m[1]), minVersion(t); got != want {
|
||||||
|
t.Errorf("go.mod pins the toolchain at %s and the battery demands %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func minVersion(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
src, err := os.ReadFile(filepath.Join(zoneRoot, "Makefile"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := regexp.MustCompile(`(?m)^GO_MIN_VERSION := (\S+)$`).FindSubmatch(src)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("the Makefile no longer declares GO_MIN_VERSION")
|
||||||
|
}
|
||||||
|
return string(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTarget(t *testing.T, args ...string) error {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := exec.LookPath("make"); err != nil {
|
||||||
|
t.Skip("make is not on this host: the build gates cannot be exercised")
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(t.Context(), "make", args...)
|
||||||
|
cmd.Dir = zoneRoot
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil && !strings.Contains(string(out), "required") {
|
||||||
|
t.Fatalf("make %v failed for a reason other than the gate: %s", args, out)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
169
platform/internal/httpapi/control_test.go
Normal file
169
platform/internal/httpapi/control_test.go
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"textmachine/platform/internal/metrics"
|
||||||
|
"textmachine/platform/internal/pgstore"
|
||||||
|
"textmachine/platform/internal/runs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The stop and resume handles answer 202 with a Run, and the run they act on is the one in the path.
|
||||||
|
func TestStopAndResumeAnswerWithTheRunTheContractDescribes(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path string
|
||||||
|
seen func(*fakeRuns) string
|
||||||
|
}{
|
||||||
|
{"stop", func(f *fakeRuns) string { return f.stopped }},
|
||||||
|
{"resume", func(f *fakeRuns) string { return f.resumed }},
|
||||||
|
} {
|
||||||
|
rn := &fakeRuns{run: pgstore.Run{
|
||||||
|
ID: "run_9", Revision: 12, Status: "stopped", VerifyBank: true, CeilingChapters: 100,
|
||||||
|
StartedAt: time.Unix(0, 0).UTC(),
|
||||||
|
}}
|
||||||
|
w := call(t, v0Server(t, &fakeLibrary{}, rn), "POST", "/v0/runs/run_9/"+tc.path, "")
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("%s: status %d, want 202: %s", tc.path, w.Code, w.Body)
|
||||||
|
}
|
||||||
|
got := decode(t, w)
|
||||||
|
for _, k := range []string{"id", "revision", "status", "verify_bank", "ceiling_chapters",
|
||||||
|
"paused_reason", "started_at"} {
|
||||||
|
if _, ok := got[k]; !ok {
|
||||||
|
t.Errorf("%s: the Run is missing the required field %q", tc.path, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got["paused_reason"] != nil {
|
||||||
|
t.Errorf("%s: paused_reason is %v, want null outside a pause", tc.path, got["paused_reason"])
|
||||||
|
}
|
||||||
|
if id := tc.seen(rn); id != "run_9" {
|
||||||
|
t.Errorf("%s: the service was asked about %q, not the run in the path", tc.path, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The four ways these two calls fail, each mapped to the code the contract names. 404 and 409 are
|
||||||
|
// the pair that matters: a run of another account is NOT FOUND, and one that cannot be acted on is a
|
||||||
|
// conflict — swapping them either tells a stranger the run exists or tells an owner theirs does not.
|
||||||
|
func TestTheControlHandlesMapEveryRefusalToItsContractCode(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
err error
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{pgstore.ErrNoRun, http.StatusNotFound},
|
||||||
|
{fmt.Errorf("%w: it is ready", runs.ErrNotStoppable), http.StatusConflict},
|
||||||
|
{fmt.Errorf("%w: the glossary is not signed", runs.ErrNotResumable), http.StatusConflict},
|
||||||
|
{errors.New("pgstore: dial tcp 127.0.0.1:5432: connection refused"), http.StatusInternalServerError},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
h := v0Server(t, &fakeLibrary{}, &fakeRuns{err: tc.err})
|
||||||
|
for _, path := range []string{"stop", "resume"} {
|
||||||
|
w := call(t, h, "POST", "/v0/runs/run_9/"+path, "")
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Errorf("%s on %v: %d, want %d", path, tc.err, w.Code, tc.want)
|
||||||
|
}
|
||||||
|
if w.Header().Get("Content-Type") != "application/problem+json" {
|
||||||
|
t.Errorf("%s: content-type %q", path, w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if bytes.Contains(w.Body.Bytes(), []byte("5432")) {
|
||||||
|
t.Errorf("%s: the response carries internals: %s", path, w.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both handles END or RESTART a paid run, so both are unsafe requests on the cookie path and both
|
||||||
|
// need the CSRF header. The audit of P4 called this the weightiest of its findings: the check
|
||||||
|
// survives everything else being right.
|
||||||
|
func TestTheControlHandlesRequireTheCSRFHeaderOnTheCookiePath(t *testing.T) {
|
||||||
|
h := v0Server(t, &fakeLibrary{}, &fakeRuns{})
|
||||||
|
for _, path := range []string{"/v0/runs/run_9/stop", "/v0/runs/run_9/resume", "/v0/books"} {
|
||||||
|
r := httptest.NewRequest("POST", path, nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: "__Host-tm_session", Value: "token"})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("%s without X-TM-Client: %d, want 403", path, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The upload's own deadline, on a REAL server: a book is minutes of a domestic uplink and the
|
||||||
|
// server's ReadTimeout — the whole of what bounds a half-fed request — covers the entire body.
|
||||||
|
//
|
||||||
|
// The negative half is the point of the test. The same slow body on a route that does NOT extend the
|
||||||
|
// deadline is cut off, which is what proves the extension is doing the work rather than the timeout
|
||||||
|
// being generous.
|
||||||
|
func TestTheUploadRouteExtendsItsOwnReadDeadlineAndOnlyItsOwn(t *testing.T) {
|
||||||
|
in := &fakeIntake{book: pgstore.Book{ID: "bk_1", Status: "parsing"}}
|
||||||
|
h := v0ServerWith(t, Deps{
|
||||||
|
Library: &fakeLibrary{}, Runs: &fakeRuns{}, Intake: in,
|
||||||
|
Upload: UploadLimits{MaxBytes: 1 << 20, Deadline: 10 * time.Second},
|
||||||
|
// With the telemetry wrapper in the chain, exactly as the daemon builds it. It is here on
|
||||||
|
// purpose: http.ResponseController walks the wrappers through Unwrap, and a wrapper that
|
||||||
|
// forgets it turns the deadline extension into a silent no-op — the upload would then die at
|
||||||
|
// the server's own ReadTimeout and nothing in this file would notice.
|
||||||
|
Observe: metrics.New().Middleware(),
|
||||||
|
})
|
||||||
|
to := fastTimeouts() // Read: 250ms — far less than the body below takes to arrive
|
||||||
|
ln := start(t, h, to)
|
||||||
|
|
||||||
|
ct, body := form(t, "book.txt", 2048, true)
|
||||||
|
raw, err := io.ReadAll(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code := slowPost(t, ln, "/v0/books", ct, raw); code != http.StatusCreated {
|
||||||
|
t.Fatalf("a slow upload answered %d, want 201: the route's own deadline did not apply", code)
|
||||||
|
}
|
||||||
|
// The same shape of body on the JSON route keeps the server's short ReadTimeout and dies with it.
|
||||||
|
payload := append([]byte(`{"verify_bank":false,"ceiling_chapters":1,"pad":"`),
|
||||||
|
append(bytes.Repeat([]byte("a"), 2048), []byte(`"}`)...)...)
|
||||||
|
if code := slowPost(t, ln, "/v0/books/bk_1/runs", "application/json", payload); code == http.StatusAccepted {
|
||||||
|
t.Fatal("a slow request on an ordinary route was served: the upload's deadline leaked onto it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slowPost sends a request whose body arrives in two halves a second apart, which is a slow client
|
||||||
|
// and not a malicious one: the bytes keep coming, just not fast enough for a 250ms deadline.
|
||||||
|
func slowPost(t *testing.T, ln net.Listener, path, contentType string, body []byte) int {
|
||||||
|
t.Helper()
|
||||||
|
var d net.Dialer
|
||||||
|
conn, err := d.DialContext(t.Context(), "tcp", ln.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
head := fmt.Sprintf("POST %s HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer token\r\n"+
|
||||||
|
"Content-Type: %s\r\nContent-Length: %d\r\n\r\n", path, contentType, len(body))
|
||||||
|
if _, err := conn.Write([]byte(head)); err != nil {
|
||||||
|
t.Fatalf("write head: %v", err)
|
||||||
|
}
|
||||||
|
half := len(body) / 2
|
||||||
|
if _, err := conn.Write(body[:half]); err != nil {
|
||||||
|
t.Fatalf("write first half: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
if _, err := conn.Write(body[half:]); err != nil {
|
||||||
|
// A server that gave up has closed the connection; that is an answer too.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
346
platform/internal/httpapi/intake_test.go
Normal file
346
platform/internal/httpapi/intake_test.go
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"textmachine/platform/internal/books"
|
||||||
|
"textmachine/platform/internal/pgstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The upload is the only streaming route on this surface, and the only one whose body limit is not
|
||||||
|
// the default. Both facts are asserted here on the REAL handler chain — through New, with the guard,
|
||||||
|
// the CSRF layer and the per-route cap in place — because a limit tested on a bare handler is a
|
||||||
|
// limit tested somewhere other than where it applies (the shape of PD-46).
|
||||||
|
|
||||||
|
type fakeIntake struct {
|
||||||
|
book pgstore.Book
|
||||||
|
err error
|
||||||
|
got books.Intake
|
||||||
|
// read is what the service actually pulled out of the part, which is how a test tells "the file
|
||||||
|
// was streamed" from "the handler read it into memory and handed over a copy".
|
||||||
|
read int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIntake) Accept(_ context.Context, in books.Intake) (pgstore.Book, error) {
|
||||||
|
f.got = in
|
||||||
|
if in.File != nil {
|
||||||
|
n, err := io.Copy(io.Discard, in.File)
|
||||||
|
f.read = n
|
||||||
|
if err != nil {
|
||||||
|
return pgstore.Book{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.err != nil {
|
||||||
|
return pgstore.Book{}, f.err
|
||||||
|
}
|
||||||
|
return f.book, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// form builds a multipart body. The order of the parts is the caller's on purpose: the file MUST
|
||||||
|
// come last, and the test that says so builds it the other way round.
|
||||||
|
func form(t *testing.T, file string, size int, fieldsFirst bool) (string, io.Reader) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
writeFields := func() {
|
||||||
|
for k, v := range map[string]string{"source_lang": "zh", "target_lang": "ru", "genre": "xianxia"} {
|
||||||
|
if err := w.WriteField(k, v); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fieldsFirst {
|
||||||
|
writeFields()
|
||||||
|
}
|
||||||
|
part, err := w.CreateFormFile("file", file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(bytes.Repeat([]byte("a"), size)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !fieldsFirst {
|
||||||
|
writeFields()
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return w.FormDataContentType(), &buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func upload(t *testing.T, h http.Handler, contentType string, body io.Reader) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest("POST", "/v0/books", body)
|
||||||
|
r.Header.Set("Content-Type", contentType)
|
||||||
|
r.Header.Set("Authorization", "Bearer token")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAcceptedUploadAnswersWithTheBookTheContractDescribes(t *testing.T) {
|
||||||
|
in := &fakeIntake{book: pgstore.Book{
|
||||||
|
ID: "bk_7c1", Title: "蛊真人", SourceLang: "zh", TargetLang: "ru", Genre: "xianxia",
|
||||||
|
Status: "parsing", AddedAt: time.Unix(0, 0).UTC(),
|
||||||
|
}}
|
||||||
|
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in})
|
||||||
|
ct, body := form(t, "蛊真人.txt", 1024, true)
|
||||||
|
w := upload(t, h, ct, body)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status %d: %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
got := decode(t, w)
|
||||||
|
// Every field the contract marks required on Book, present on the intake's own response.
|
||||||
|
for _, k := range []string{"id", "title", "source_lang", "target_lang", "status", "chapter_count",
|
||||||
|
"added_at", "progress", "note_count"} {
|
||||||
|
if _, ok := got[k]; !ok {
|
||||||
|
t.Errorf("the created book is missing the required field %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got["status"] != "parsing" {
|
||||||
|
t.Errorf("status %v, want the state the book is actually in", got["status"])
|
||||||
|
}
|
||||||
|
if in.got.SourceLang != "zh" || in.got.TargetLang != "ru" || in.got.Genre != "xianxia" {
|
||||||
|
t.Errorf("the form did not reach the service: %+v", in.got)
|
||||||
|
}
|
||||||
|
if in.got.Filename != "蛊真人.txt" {
|
||||||
|
t.Errorf("filename %q did not reach the service", in.got.Filename)
|
||||||
|
}
|
||||||
|
if in.read != 1024 {
|
||||||
|
t.Errorf("the service was handed %d bytes of the file, want 1024", in.read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PD-72, and the reason the register kept the row open until this route landed: the per-route cap is
|
||||||
|
// only OBSERVABLE once a route asks for more than the default, and the test has to arrive with it.
|
||||||
|
func TestAnUploadLargerThanTheRouteAllowsIsRefusedAsTooLarge(t *testing.T) {
|
||||||
|
in := &fakeIntake{}
|
||||||
|
h := v0ServerWith(t, Deps{
|
||||||
|
Library: &fakeLibrary{}, Intake: in,
|
||||||
|
Upload: UploadLimits{MaxBytes: 4096, Deadline: time.Minute},
|
||||||
|
})
|
||||||
|
ct, body := form(t, "book.txt", 16*1024, true)
|
||||||
|
w := upload(t, h, ct, body)
|
||||||
|
if w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("status %d, want 413: %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "application/problem+json" {
|
||||||
|
t.Errorf("content-type %q, want application/problem+json", ct)
|
||||||
|
}
|
||||||
|
problem := decode(t, w)
|
||||||
|
if problem["status"] != float64(http.StatusRequestEntityTooLarge) || problem["title"] == "" {
|
||||||
|
t.Errorf("problem body %v", problem)
|
||||||
|
}
|
||||||
|
// And a body UNDER the limit goes through the same route untouched: a cap that refuses
|
||||||
|
// everything is not a cap.
|
||||||
|
ct2, body2 := form(t, "book.txt", 1024, true)
|
||||||
|
if w := upload(t, h, ct2, body2); w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("a 1 KiB upload under a 4 KiB cap: %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of the same property (PD-35): raising the limit for ONE route must not raise it for
|
||||||
|
// the rest of the surface.
|
||||||
|
func TestTheUploadLimitBelongsToTheUploadRouteAlone(t *testing.T) {
|
||||||
|
h := v0ServerWith(t, Deps{
|
||||||
|
Library: &fakeLibrary{}, Runs: &fakeRuns{}, Intake: &fakeIntake{},
|
||||||
|
Upload: UploadLimits{MaxBytes: 8 << 20, Deadline: time.Minute},
|
||||||
|
})
|
||||||
|
big := strings.Repeat("a", DefaultMaxBody+1)
|
||||||
|
w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"verify_bank":false,"ceiling_chapters":1,"pad":"`+big+`"}`)
|
||||||
|
if w.Code == http.StatusAccepted {
|
||||||
|
t.Fatal("a run request larger than the default body cap was accepted: the upload's limit leaked onto every route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deployment with nowhere to put a file mounts no upload route, exactly like every other contract
|
||||||
|
// route it cannot serve — and an anonymous caller still meets 401 before 404.
|
||||||
|
func TestWithoutAnIntakeTheUploadRouteIsAGuarded404(t *testing.T) {
|
||||||
|
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}})
|
||||||
|
ct, body := form(t, "book.txt", 16, true)
|
||||||
|
if w := upload(t, h, ct, body); w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status %d, want 404 where no intake is configured", w.Code)
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("POST", "/v0/books", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("an anonymous caller got %d, want 401 before anything else", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wire rule a streaming reader forces: the languages have to be known before the file part is
|
||||||
|
// handed over, so a form that sends them afterwards is refused rather than half-processed.
|
||||||
|
func TestAFormWhoseFileComesBeforeItsFieldsIsRefused(t *testing.T) {
|
||||||
|
in := &fakeIntake{}
|
||||||
|
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in})
|
||||||
|
ct, body := form(t, "book.txt", 64, false)
|
||||||
|
w := upload(t, h, ct, body)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status %d, want 400: %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
if in.read != 0 {
|
||||||
|
t.Errorf("the file was streamed before the form was known to be usable (%d bytes)", in.read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUploadWithNoFileAtAllIsRefused(t *testing.T) {
|
||||||
|
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{}})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
if err := w.WriteField("source_lang", "zh"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := upload(t, h, w.FormDataContentType(), &buf); got.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status %d, want 400", got.Code)
|
||||||
|
}
|
||||||
|
// And a body that is not multipart at all.
|
||||||
|
r := httptest.NewRequest("POST", "/v0/books", strings.NewReader(`{"not":"multipart"}`))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set("Authorization", "Bearer token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, r)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("a JSON body on the intake route: %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A field the contract does not name is IGNORED, like an unknown JSON property: inside 0.x a minor
|
||||||
|
// bump is where optional fields appear, and refusing the upload would break a client generated
|
||||||
|
// against a later version for something it was free to ignore.
|
||||||
|
func TestAnUnknownFormFieldDoesNotRefuseTheUpload(t *testing.T) {
|
||||||
|
in := &fakeIntake{book: pgstore.Book{ID: "bk_1", Status: "parsing"}}
|
||||||
|
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
for _, kv := range [][2]string{{"source_lang", "zh"}, {"target_lang", "ru"}, {"title", "as yet unratified"}} {
|
||||||
|
if err := w.WriteField(kv[0], kv[1]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
part, err := w.CreateFormFile("file", "b.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write([]byte("text")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := upload(t, h, w.FormDataContentType(), &buf); got.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status %d, want 201: an unknown field must not refuse the upload", got.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A form field long enough to be a payload of its own is refused rather than truncated.
|
||||||
|
func TestAFormFieldThatIsAPayloadIsRefused(t *testing.T) {
|
||||||
|
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{},
|
||||||
|
Upload: UploadLimits{MaxBytes: 8 << 20, Deadline: time.Minute}})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
if err := w.WriteField("genre", strings.Repeat("g", maxIntakeField+1)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := upload(t, h, w.FormDataContentType(), &buf); got.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status %d, want 400", got.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service's own refusals reach the wire as the contract's codes and never as engine or database
|
||||||
|
// text (§Problem).
|
||||||
|
func TestWhatTheIntakeRefusesBecomesAProductPhrase(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
err error
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{fmt.Errorf("%w: the languages must be codes", books.ErrBadIntake), http.StatusBadRequest},
|
||||||
|
{pgstore.ErrNoAccount, http.StatusNotFound},
|
||||||
|
{errors.New("pgstore: connection refused: dial tcp 127.0.0.1:5432"), http.StatusInternalServerError},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{err: tc.err}})
|
||||||
|
ct, body := form(t, "b.txt", 16, true)
|
||||||
|
w := upload(t, h, ct, body)
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Errorf("%v -> %d, want %d", tc.err, w.Code, tc.want)
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), "pgstore") || strings.Contains(w.Body.String(), "5432") {
|
||||||
|
t.Errorf("the response carries internals: %s", w.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FP5-8(в) (acceptance): the cap allowed one part past its own number. The assertion is not the
|
||||||
|
// status — a form that simply runs out of parts is a 400 too, so a status-only test says nothing —
|
||||||
|
// but that the part BEHIND the cap is never handed to the intake. The file sits at exactly
|
||||||
|
// maxIntakeParts+1 here: with the cap off by one it is reached, accepted and streamed, which is the
|
||||||
|
// work the cap exists to refuse.
|
||||||
|
func TestAFormWithTooManyPartsIsRefusedAtTheCap(t *testing.T) {
|
||||||
|
in := &fakeIntake{book: pgstore.Book{ID: "bk_cap"}}
|
||||||
|
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in,
|
||||||
|
Upload: UploadLimits{MaxBytes: 8 << 20, Deadline: time.Minute}})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
for k, v := range map[string]string{"source_lang": "zh", "target_lang": "ru"} {
|
||||||
|
if err := w.WriteField(k, v); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range maxIntakeParts - 2 {
|
||||||
|
if err := w.WriteField(fmt.Sprintf("filler-%d", i), "x"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fw, err := w.CreateFormFile("file", "book.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(bytes.Repeat([]byte("x"), 1024)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := upload(t, h, w.FormDataContentType(), &buf)
|
||||||
|
if got.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status %d with %d parts, want 400 at the cap of %d", got.Code, maxIntakeParts+1, maxIntakeParts)
|
||||||
|
}
|
||||||
|
if in.read != 0 {
|
||||||
|
t.Fatalf("the file behind the cap was streamed anyway: %d bytes", in.read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FP5-6 (acceptance): a body that outlives the route's own deadline is the client being slow, not
|
||||||
|
// this service being broken — and a 500 tells the client the opposite of the truth about retrying.
|
||||||
|
func TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError(t *testing.T) {
|
||||||
|
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{err: os.ErrDeadlineExceeded}})
|
||||||
|
ct, body := form(t, "book.txt", 64, true)
|
||||||
|
w := upload(t, h, ct, body)
|
||||||
|
if w.Code == http.StatusInternalServerError {
|
||||||
|
t.Fatalf("an expired upload deadline answered 500: %s", w.Body)
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusRequestTimeout {
|
||||||
|
t.Fatalf("status %d, want 408", w.Code)
|
||||||
|
}
|
||||||
|
if w.Header().Get("Content-Type") != "application/problem+json" {
|
||||||
|
t.Errorf("content-type %q", w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,40 @@ type Deps struct {
|
||||||
// service started without a database serves — the routes below all read one.
|
// service started without a database serves — the routes below all read one.
|
||||||
Library Library
|
Library Library
|
||||||
Runs Runs
|
Runs Runs
|
||||||
|
// Intake is the book upload. Nil where this deployment has nowhere to put a file or no engine to
|
||||||
|
// cut it with, and then POST /books is a guarded 404 like every other unbuilt route: an instance
|
||||||
|
// that accepted uploads it could only reject would be worse than one that says it takes none.
|
||||||
|
Intake Intake
|
||||||
|
// Upload bounds the one route that carries a file.
|
||||||
|
Upload UploadLimits
|
||||||
|
// Observe wraps every request in this deployment's telemetry. A plain middleware rather than an
|
||||||
|
// interface so this package stays free of a metrics library; nil is a service that measures
|
||||||
|
// nothing.
|
||||||
|
Observe func(http.Handler) http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadLimits is what the intake route is allowed to cost.
|
||||||
|
//
|
||||||
|
// Both numbers are the operator's, and both are refusals rather than sizings: a book is tens of
|
||||||
|
// megabytes and minutes of a domestic connection, and the defaults of the surface — a one-megabyte
|
||||||
|
// body and a thirty-second read — are correct for every OTHER route and would make this one
|
||||||
|
// impossible.
|
||||||
|
type UploadLimits struct {
|
||||||
|
// MaxBytes caps the request body of the intake route (http.MaxBytesReader, the second half of
|
||||||
|
// PD-2 and the whole of PD-72).
|
||||||
|
MaxBytes int64
|
||||||
|
// Deadline is how long the body of ONE upload may take to arrive. It replaces the server's
|
||||||
|
// ReadTimeout for this route through http.ResponseController and is still a finite bound — the
|
||||||
|
// forbidden thing is CLEARING the deadline, which puts a half-fed request back to being
|
||||||
|
// unbounded (STACK_DECISIONS §12, register row PD-51).
|
||||||
|
Deadline time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultUploadLimits are the intake's bounds when an operator chooses none: a book file large
|
||||||
|
// enough for the acceptance corpus (23 MB) with room around it, and long enough to arrive over a
|
||||||
|
// slow domestic uplink.
|
||||||
|
func DefaultUploadLimits() UploadLimits {
|
||||||
|
return UploadLimits{MaxBytes: 64 << 20, Deadline: 10 * time.Minute}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the handler.
|
// New builds the handler.
|
||||||
|
|
@ -71,10 +105,16 @@ func New(d Deps) (http.Handler, error) {
|
||||||
//
|
//
|
||||||
// The body limit is per ROUTE, not a blanket outer layer: MaxBytesReader wrapping an already
|
// The body limit is per ROUTE, not a blanket outer layer: MaxBytesReader wrapping an already
|
||||||
// wrapped body keeps the tighter limit, so an upload route could never raise its own above a
|
// wrapped body keeps the tighter limit, so an upload route could never raise its own above a
|
||||||
// shared default. The book upload registers guard(maxUpload, …) when it lands.
|
// shared default. The book upload is the route that needed it and registers guard(MaxBytes, …).
|
||||||
guard := func(maxBody int64, h http.Handler) http.Handler {
|
guard := func(maxBody int64, h http.Handler) http.Handler {
|
||||||
return LimitBody(maxBody)(csrf(d.Auth.Require(h)))
|
return LimitBody(maxBody)(csrf(d.Auth.Require(h)))
|
||||||
}
|
}
|
||||||
|
if d.Upload.MaxBytes <= 0 || d.Upload.Deadline <= 0 {
|
||||||
|
// A zero here would be a route with no bound at all — MaxBytesReader treats a negative count
|
||||||
|
// as zero and a zero deadline as none — so it is filled rather than trusted. The intake is the
|
||||||
|
// one route where an unset limit is a hole and not an inconvenience.
|
||||||
|
d.Upload = DefaultUploadLimits()
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /healthz", http.HandlerFunc(healthz))
|
mux.Handle("GET /healthz", http.HandlerFunc(healthz))
|
||||||
|
|
@ -94,8 +134,13 @@ func New(d Deps) (http.Handler, error) {
|
||||||
mux.Handle(APIPrefix+"/", guard(DefaultMaxBody, ProblemHandler(http.StatusNotFound, "Object not found")))
|
mux.Handle(APIPrefix+"/", guard(DefaultMaxBody, ProblemHandler(http.StatusNotFound, "Object not found")))
|
||||||
|
|
||||||
// Recover sits INSIDE AccessLog: a panic converted to a 500 still produces a log line, whereas
|
// Recover sits INSIDE AccessLog: a panic converted to a 500 still produces a log line, whereas
|
||||||
// a panic unwinding past the logger produces none.
|
// a panic unwinding past the logger produces none. Telemetry sits beside the log and for the same
|
||||||
return reqid.Middleware(SecurityHeaders(d.HSTS)(AccessLog(d.Log)(Recover(d.Log)(mux)))), nil
|
// reason — it reads the route the mux matched and the status the recovery produced.
|
||||||
|
served := http.Handler(Recover(d.Log)(mux))
|
||||||
|
if d.Observe != nil {
|
||||||
|
served = d.Observe(served)
|
||||||
|
}
|
||||||
|
return reqid.Middleware(SecurityHeaders(d.HSTS)(AccessLog(d.Log)(served))), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// healthz is liveness: the process is up and serving. It touches nothing, so a database outage
|
// healthz is liveness: the process is up and serving. It touches nothing, so a database outage
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,17 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"textmachine/platform/internal/auth"
|
"textmachine/platform/internal/auth"
|
||||||
|
"textmachine/platform/internal/books"
|
||||||
"textmachine/platform/internal/pgstore"
|
"textmachine/platform/internal/pgstore"
|
||||||
"textmachine/platform/internal/pricing"
|
"textmachine/platform/internal/pricing"
|
||||||
"textmachine/platform/internal/runner"
|
"textmachine/platform/internal/runner"
|
||||||
|
|
@ -28,6 +33,14 @@ type Library interface {
|
||||||
type Runs interface {
|
type Runs interface {
|
||||||
Bounds(ctx context.Context, userID, bookID string) (pricing.Bounds, error)
|
Bounds(ctx context.Context, userID, bookID string) (pricing.Bounds, error)
|
||||||
Start(ctx context.Context, in runs.StartRequest) (pgstore.Run, error)
|
Start(ctx context.Context, in runs.StartRequest) (pgstore.Run, error)
|
||||||
|
Stop(ctx context.Context, userID, runID string) (pgstore.Run, error)
|
||||||
|
Resume(ctx context.Context, userID, runID string) (pgstore.Run, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intake is the book upload, as the HTTP layer needs to see it. The service takes a READER: the
|
||||||
|
// file is streamed to its place on disk and is never held in this process's memory.
|
||||||
|
type Intake interface {
|
||||||
|
Accept(ctx context.Context, in books.Intake) (pgstore.Book, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// contractRoutes registers the /v0 surface.
|
// contractRoutes registers the /v0 surface.
|
||||||
|
|
@ -36,7 +49,7 @@ type Runs interface {
|
||||||
// ops endpoints: a nested mux behind StripPrefix hands the inner handler a copy of the request and
|
// ops endpoints: a nested mux behind StripPrefix hands the inner handler a copy of the request and
|
||||||
// the pattern never comes back, which would put raw paths carrying book ids into the access log.
|
// the pattern never comes back, which would put raw paths carrying book ids into the access log.
|
||||||
func contractRoutes(mux *http.ServeMux, d Deps, guard func(int64, http.Handler) http.Handler) {
|
func contractRoutes(mux *http.ServeMux, d Deps, guard func(int64, http.Handler) http.Handler) {
|
||||||
h := &v0{lib: d.Library, runs: d.Runs, log: d.Log}
|
h := &v0{lib: d.Library, runs: d.Runs, intake: d.Intake, upload: d.Upload, log: d.Log}
|
||||||
// The READS need a read model and nothing else. An instance with no engine binary is a read
|
// The READS need a read model and nothing else. An instance with no engine binary is a read
|
||||||
// replica, not a broken one, and mounting nothing unless it could ALSO start runs made it answer
|
// replica, not a broken one, and mounting nothing unless it could ALSO start runs made it answer
|
||||||
// 404 to a library it was holding — while its own boot line said it was serving one.
|
// 404 to a library it was holding — while its own boot line said it was serving one.
|
||||||
|
|
@ -50,13 +63,26 @@ func contractRoutes(mux *http.ServeMux, d Deps, guard func(int64, http.Handler)
|
||||||
if d.Runs != nil {
|
if d.Runs != nil {
|
||||||
mux.Handle("GET "+APIPrefix+"/books/{bookId}/run-options", guard(DefaultMaxBody, http.HandlerFunc(h.runOptions)))
|
mux.Handle("GET "+APIPrefix+"/books/{bookId}/run-options", guard(DefaultMaxBody, http.HandlerFunc(h.runOptions)))
|
||||||
mux.Handle("POST "+APIPrefix+"/books/{bookId}/runs", guard(DefaultMaxBody, http.HandlerFunc(h.startRun)))
|
mux.Handle("POST "+APIPrefix+"/books/{bookId}/runs", guard(DefaultMaxBody, http.HandlerFunc(h.startRun)))
|
||||||
|
mux.Handle("POST "+APIPrefix+"/runs/{runId}/stop", guard(DefaultMaxBody, http.HandlerFunc(h.stopRun)))
|
||||||
|
mux.Handle("POST "+APIPrefix+"/runs/{runId}/resume", guard(DefaultMaxBody, http.HandlerFunc(h.resumeRun)))
|
||||||
|
}
|
||||||
|
// Intake needs somewhere to put a file and an engine to cut it with; an instance with neither is
|
||||||
|
// the read replica again, and its /books stays a guarded 404 for POST.
|
||||||
|
//
|
||||||
|
// This is the ONE route whose body limit is not the default, and it is why the limit is a
|
||||||
|
// per-route argument in the first place (PD-35/PD-72): a book is tens of megabytes and every
|
||||||
|
// other route on this surface carries a few kilobytes of JSON.
|
||||||
|
if d.Intake != nil {
|
||||||
|
mux.Handle("POST "+APIPrefix+"/books", guard(d.Upload.MaxBytes, http.HandlerFunc(h.createBook)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type v0 struct {
|
type v0 struct {
|
||||||
lib Library
|
lib Library
|
||||||
runs Runs
|
runs Runs
|
||||||
log *slog.Logger
|
intake Intake
|
||||||
|
upload UploadLimits
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// The wire shapes below are the contract's, field for field (openapi 0.2.0). They are written out
|
// The wire shapes below are the contract's, field for field (openapi 0.2.0). They are written out
|
||||||
|
|
@ -169,14 +195,13 @@ func (h *v0) getBook(w http.ResponseWriter, r *http.Request) {
|
||||||
h.fail(w, r, err)
|
h.fail(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// ONE counter per book, and it is the book's (contract §Revision: "every book-scoped read and the
|
// ONE counter per book (contract §Revision: "every book-scoped read and the id of every stream
|
||||||
// id of every stream frame of that book's run carry the same number"). Reading it off the run row
|
// frame of that book's run carry the same number"). The store answers a run's revision from its
|
||||||
// made the card lag: a unit_done bumps the book and the chapter and not the run, so a client that
|
// BOOK on every path that hands one out — the card here, and the stop and resume handles — so this
|
||||||
// had applied frame id=2 got revision 0 back and, obeying the contract, dropped the read.
|
// layer projects what it was given rather than carrying a second copy of the rule.
|
||||||
out := wireBookDetail{Revision: book.Revision, Book: projectBook(book)}
|
out := wireBookDetail{Revision: book.Revision, Book: projectBook(book)}
|
||||||
if run != nil {
|
if run != nil {
|
||||||
wr := projectRun(*run)
|
wr := projectRun(*run)
|
||||||
wr.Revision = book.Revision
|
|
||||||
out.Run = &wr
|
out.Run = &wr
|
||||||
}
|
}
|
||||||
writeJSON(w, r, http.StatusOK, out, h.log)
|
writeJSON(w, r, http.StatusOK, out, h.log)
|
||||||
|
|
@ -238,6 +263,169 @@ func (h *v0) startRun(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, r, http.StatusAccepted, projectRun(run), h.log)
|
writeJSON(w, r, http.StatusAccepted, projectRun(run), h.log)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stopRun is the product "stop" action (contract §stopRun). The engine stops gracefully on a signal
|
||||||
|
// and the reconciler turns the exit into a status; what this call does is record that the stop was
|
||||||
|
// OURS — which is the only way the exit can afterwards be told from a crash (register row PD-152).
|
||||||
|
func (h *v0) stopRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := principal(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run, err := h.runs.Stop(r.Context(), user, r.PathValue("runId"))
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, http.StatusAccepted, projectRun(run), h.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resumeRun continues a run that was stopped (contract §resumeRun).
|
||||||
|
func (h *v0) resumeRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := principal(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run, err := h.runs.Resume(r.Context(), user, r.PathValue("runId"))
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, http.StatusAccepted, projectRun(run), h.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxIntakeField bounds one text field of the intake form, and maxIntakeParts how many parts may
|
||||||
|
// arrive before the file. Neither is the contract's business: they are what keeps a form with a
|
||||||
|
// megabyte-long genre, or a hundred thousand empty parts, from being work this process does.
|
||||||
|
const (
|
||||||
|
maxIntakeField = 1 << 10
|
||||||
|
maxIntakeParts = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// createBook receives a book (contract §createBook).
|
||||||
|
//
|
||||||
|
// It is the only route on this surface that streams. The body is read part by part through
|
||||||
|
// r.MultipartReader and the file is handed to the intake as a READER, so a 60 MB upload costs a
|
||||||
|
// buffer and not 60 MB of this process — which is what ParseMultipartForm, the reflex alternative,
|
||||||
|
// would have cost in memory or in a second copy through a temporary file.
|
||||||
|
//
|
||||||
|
// ⚠ The FILE PART MUST COME LAST. A streaming reader hands parts over in wire order, and the book's
|
||||||
|
// row — which is what makes an upload visible while it arrives and findable when it dies halfway —
|
||||||
|
// cannot be written before the languages that row requires. The same constraint is how S3's own
|
||||||
|
// browser upload is specified ("the file or content must be the last field in the form"). It is a
|
||||||
|
// wire rule the contract does not yet state; raised to the contract's owner rather than resolved
|
||||||
|
// here (zone journal, P5).
|
||||||
|
func (h *v0) createBook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := principal(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A book is minutes of upload on a domestic connection, and the server's ReadTimeout — the whole
|
||||||
|
// of what bounds a half-fed request (PD-2) — covers the entire body. It is EXTENDED here for this
|
||||||
|
// route and never cleared: net/http's own documentation points at a per-request deadline for
|
||||||
|
// exactly this case, and clearing one instead is the mistake that re-created PD-2 (STACK §12).
|
||||||
|
if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(h.upload.Deadline)); err != nil {
|
||||||
|
// Not fatal: a ResponseWriter that cannot carry a deadline is a test double, and the real
|
||||||
|
// server's own timeout then still applies.
|
||||||
|
h.log.DebugContext(r.Context(), "upload deadline not extended", "err", err)
|
||||||
|
}
|
||||||
|
parts, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
WriteProblem(w, http.StatusBadRequest, "Request could not be read", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in := books.Intake{UserID: user}
|
||||||
|
for n := 0; ; n++ {
|
||||||
|
if n >= maxIntakeParts {
|
||||||
|
WriteProblem(w, http.StatusBadRequest, "Request could not be read", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
part, err := parts.NextPart()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
// Every field and no file: the form the contract requires was not sent.
|
||||||
|
WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.uploadFailed(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if part.FormName() == "file" {
|
||||||
|
if in.SourceLang == "" || in.TargetLang == "" {
|
||||||
|
// Either the languages were not sent, or they were sent AFTER the file — and the
|
||||||
|
// second is indistinguishable from the first to a reader that streams.
|
||||||
|
WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Filename, in.File = part.FileName(), part
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value, err := readField(part)
|
||||||
|
if err != nil {
|
||||||
|
h.uploadFailed(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch part.FormName() {
|
||||||
|
case "source_lang":
|
||||||
|
in.SourceLang = value
|
||||||
|
case "target_lang":
|
||||||
|
in.TargetLang = value
|
||||||
|
case "genre":
|
||||||
|
in.Genre = value
|
||||||
|
}
|
||||||
|
// An unknown field is IGNORED rather than refused, for the same reason an unknown JSON
|
||||||
|
// property is: inside 0.x a minor bump is where optional fields appear, and a server that
|
||||||
|
// rejected the whole upload would break a client generated against a later contract.
|
||||||
|
}
|
||||||
|
book, err := h.intake.Accept(r.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
h.uploadFailed(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, http.StatusCreated, projectBook(book), h.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readField reads one text field of the form, refusing one that is too long rather than silently
|
||||||
|
// keeping its first kilobyte.
|
||||||
|
func readField(part io.Reader) (string, error) {
|
||||||
|
b, err := io.ReadAll(io.LimitReader(part, maxIntakeField+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(b) > maxIntakeField {
|
||||||
|
return "", fmt.Errorf("%w: a form field is too long", books.ErrBadIntake)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadFailed maps the ways an upload ends badly.
|
||||||
|
//
|
||||||
|
// The size refusal is the reason PD-72 was opened and is closed with this route: MaxBytesReader is
|
||||||
|
// what enforces the cap, and *http.MaxBytesError is how it says so — the same error whether the
|
||||||
|
// client announced the size or simply kept sending.
|
||||||
|
func (h *v0) uploadFailed(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &tooLarge):
|
||||||
|
WriteProblem(w, http.StatusRequestEntityTooLarge, "The file is larger than this service accepts", "")
|
||||||
|
case errors.Is(err, os.ErrDeadlineExceeded):
|
||||||
|
// The body did not finish inside the route's own deadline. 408 is what RFC 9110 §15.5.9 calls
|
||||||
|
// exactly this ("the server did not receive a complete request message within the time it was
|
||||||
|
// prepared to wait"), and it tells a client that RETRYING is the remedy — which a 500 does not.
|
||||||
|
// ⚠ Outside the codes the spec enumerates for this operation; named in the question package of
|
||||||
|
// PD-180 rather than chosen silently.
|
||||||
|
h.log.InfoContext(r.Context(), "upload did not finish inside the route's deadline")
|
||||||
|
WriteProblem(w, http.StatusRequestTimeout, "The upload did not finish in time", "")
|
||||||
|
case errors.Is(err, books.ErrBadIntake):
|
||||||
|
WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "")
|
||||||
|
case errors.Is(err, context.Canceled), errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF):
|
||||||
|
// The client went away mid-body. Nothing reaches it; the line is what an operator sees.
|
||||||
|
h.log.InfoContext(r.Context(), "upload did not finish", "err", err)
|
||||||
|
WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "")
|
||||||
|
default:
|
||||||
|
h.fail(w, r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *v0) usage(w http.ResponseWriter, r *http.Request) {
|
func (h *v0) usage(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := principal(w, r)
|
user, ok := principal(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -331,12 +519,20 @@ func pageLimit(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||||
// the sentence a reader gets.
|
// the sentence a reader gets.
|
||||||
func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) {
|
func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, pgstore.ErrNoBook), errors.Is(err, pgstore.ErrNoAccount):
|
case errors.Is(err, pgstore.ErrNoBook), errors.Is(err, pgstore.ErrNoAccount), errors.Is(err, pgstore.ErrNoRun):
|
||||||
WriteProblem(w, http.StatusNotFound, "Object not found", "")
|
WriteProblem(w, http.StatusNotFound, "Object not found", "")
|
||||||
case errors.Is(err, pgstore.ErrBadCursor):
|
case errors.Is(err, pgstore.ErrBadCursor), errors.Is(err, books.ErrBadIntake):
|
||||||
WriteProblem(w, http.StatusBadRequest, "Request could not be read", "")
|
WriteProblem(w, http.StatusBadRequest, "Request could not be read", "")
|
||||||
case errors.Is(err, pgstore.ErrRunInFlight):
|
case errors.Is(err, pgstore.ErrRunInFlight):
|
||||||
WriteProblem(w, http.StatusConflict, "This book is already being translated", "")
|
WriteProblem(w, http.StatusConflict, "This book is already being translated", "")
|
||||||
|
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.
|
||||||
|
WriteProblem(w, http.StatusConflict, "This book is not ready to be translated", "")
|
||||||
|
case errors.Is(err, runs.ErrNotStoppable):
|
||||||
|
WriteProblem(w, http.StatusConflict, "This run is not running", "")
|
||||||
|
case errors.Is(err, runs.ErrNotResumable):
|
||||||
|
WriteProblem(w, http.StatusConflict, "This translation cannot be continued yet", "")
|
||||||
case errors.Is(err, runs.ErrCeilingOutOfBounds), errors.Is(err, pgstore.ErrInsufficientCredit):
|
case errors.Is(err, runs.ErrCeilingOutOfBounds), errors.Is(err, pgstore.ErrInsufficientCredit):
|
||||||
// 409 and not 400: the request was legal when the bounds were read, and a hold taken for
|
// 409 and not 400: the request was legal when the bounds were read, and a hold taken for
|
||||||
// another book between that read and this call is what moved them (contract §startRun).
|
// another book between that read and this call is what moved them (contract §startRun).
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,10 @@ type fakeRuns struct {
|
||||||
run pgstore.Run
|
run pgstore.Run
|
||||||
err error
|
err error
|
||||||
got runs.StartRequest
|
got runs.StartRequest
|
||||||
|
// stopped and resumed record which run each control handle was called for, so a test can assert
|
||||||
|
// that the path parameter reaches the service rather than only that the status code is right.
|
||||||
|
stopped string
|
||||||
|
resumed string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRuns) Bounds(context.Context, string, string) (pricing.Bounds, error) {
|
func (f *fakeRuns) Bounds(context.Context, string, string) (pricing.Bounds, error) {
|
||||||
|
|
@ -58,17 +62,32 @@ func (f *fakeRuns) Start(_ context.Context, in runs.StartRequest) (pgstore.Run,
|
||||||
return f.run, f.err
|
return f.run, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeRuns) Stop(_ context.Context, _, runID string) (pgstore.Run, error) {
|
||||||
|
f.stopped = runID
|
||||||
|
return f.run, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRuns) Resume(_ context.Context, _, runID string) (pgstore.Run, error) {
|
||||||
|
f.resumed = runID
|
||||||
|
return f.run, f.err
|
||||||
|
}
|
||||||
|
|
||||||
func v0Server(t *testing.T, lib Library, rn Runs) http.Handler {
|
func v0Server(t *testing.T, lib Library, rn Runs) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
h, err := New(Deps{
|
return v0ServerWith(t, Deps{Library: lib, Runs: rn})
|
||||||
Log: slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)),
|
}
|
||||||
Auth: &auth.Authenticator{
|
|
||||||
Sessions: liveSessions{}, IdleTTL: time.Hour,
|
// v0ServerWith builds the real handler chain with whatever this test wants mounted on it. Every
|
||||||
Deny: ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"),
|
// wire test goes through New — not through a handler in isolation — because the guard, the body cap
|
||||||
},
|
// and the security headers are part of the surface being asserted.
|
||||||
Library: lib,
|
func v0ServerWith(t *testing.T, d Deps) http.Handler {
|
||||||
Runs: rn,
|
t.Helper()
|
||||||
})
|
d.Log = slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil))
|
||||||
|
d.Auth = &auth.Authenticator{
|
||||||
|
Sessions: liveSessions{}, IdleTTL: time.Hour,
|
||||||
|
Deny: ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"),
|
||||||
|
}
|
||||||
|
h, err := New(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -435,7 +454,16 @@ func TestAnUnknownRequestPropertyIsIgnoredRatherThanRefused(t *testing.T) {
|
||||||
// ONE counter per book. Reading the card's revision off the RUN row made it lag: a unit_done bumps
|
// ONE counter per book. Reading the card's revision off the RUN row made it lag: a unit_done bumps
|
||||||
// the book and its chapter and not the run, so a client that had applied stream frame id=2 got 0
|
// the book and its chapter and not the run, so a client that had applied stream frame id=2 got 0
|
||||||
// back and — obeying the contract — dropped the read.
|
// back and — obeying the contract — dropped the read.
|
||||||
func TestTheCardsRevisionIsTheBooksAndNotTheRuns(t *testing.T) {
|
// ONE counter per book on the wire. ⚠ The RULE moved in P5 and this test moved with it: the store now
|
||||||
|
// answers a run's revision from its book on every path that hands one out — the card here and the
|
||||||
|
// stop/resume handles, which have no book to override from — so the pin on the rule itself lives in
|
||||||
|
// `pgstore.TestEveryRunTheStoreHandsOutCarriesItsBooksRevision` and `runs.TestEveryRunCarryingAnswerUsesTheBooksRevision`.
|
||||||
|
// What THIS layer owes is that it projects the number it was given and invents nothing, which is
|
||||||
|
// what a fake store can prove and the rule itself no longer is.
|
||||||
|
func TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone(t *testing.T) {
|
||||||
|
// The two numbers are DIFFERENT on purpose: equal ones cannot tell "projected what it was given"
|
||||||
|
// from "overrode it with the book's", which is exactly the discriminating power an earlier
|
||||||
|
// revision of this test lost when the fixture was levelled (found by acceptance).
|
||||||
lib := &fakeLibrary{
|
lib := &fakeLibrary{
|
||||||
book: pgstore.Book{ID: "bk_1", Revision: 41},
|
book: pgstore.Book{ID: "bk_1", Revision: 41},
|
||||||
run: &pgstore.Run{ID: "run_1", Status: "translating", Revision: 12},
|
run: &pgstore.Run{ID: "run_1", Status: "translating", Revision: 12},
|
||||||
|
|
@ -445,8 +473,9 @@ func TestTheCardsRevisionIsTheBooksAndNotTheRuns(t *testing.T) {
|
||||||
t.Errorf("BookDetail.revision = %v, want the book's 41", got["revision"])
|
t.Errorf("BookDetail.revision = %v, want the book's 41", got["revision"])
|
||||||
}
|
}
|
||||||
run, _ := got["run"].(map[string]any)
|
run, _ := got["run"].(map[string]any)
|
||||||
if run["revision"] != float64(41) {
|
if run["revision"] != float64(12) {
|
||||||
t.Errorf("Run.revision = %v, want the same book counter", run["revision"])
|
t.Errorf("Run.revision = %v, want the 12 the store handed over: this layer projects, it does not decide",
|
||||||
|
run["revision"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
54
platform/internal/ingest/manifest.go
Normal file
54
platform/internal/ingest/manifest.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package ingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifest is the ALLOWLISTED summary of the engine's chapter manifest (`tmctl manifest --json`,
|
||||||
|
// unified backlog row 100, form ratified with D39.122 as `tm-manifest-v2`).
|
||||||
|
//
|
||||||
|
// What is deliberately absent is most of the document: the per-chapter and per-unit arrays carry the
|
||||||
|
// engine's stable ids, its dense ordinals and its rendered headings, and none of them has a reader
|
||||||
|
// on this side yet — the platform's chapter tree is written by the materializer from the stream, not
|
||||||
|
// from here. Taking them now would store engine vocabulary nobody asks for and would have to be
|
||||||
|
// maintained against a document that re-cuts itself.
|
||||||
|
//
|
||||||
|
// What IS taken is what intake decides with: how many chapters the book has (the ceiling scale is
|
||||||
|
// built on it), and the two identifiers that say WHICH cut of WHICH bytes produced that number.
|
||||||
|
type Manifest struct {
|
||||||
|
// Version is `manifest_version`. Recorded and logged rather than gated: the fields below are
|
||||||
|
// counts and identities whose meaning is stable, and the engine already refuses the version it
|
||||||
|
// cannot produce. A shape this platform truly could not read arrives as a zero chapter count,
|
||||||
|
// which is a loud failure and not a silent one.
|
||||||
|
Version string `json:"manifest_version"`
|
||||||
|
ChaptersTotal int `json:"chapters_total"`
|
||||||
|
UnitsTotal int `json:"units_total"`
|
||||||
|
// SourceSHA256 is the digest of the ingested source, hex. It answers "is the file on disk still
|
||||||
|
// the one that was cut" without the platform reading the file again.
|
||||||
|
SourceSHA256 string `json:"source_sha256"`
|
||||||
|
SourceBytes int64 `json:"source_bytes"`
|
||||||
|
// ChunkerVersion is one of the inputs of the manifest's validity key: a change re-numbers
|
||||||
|
// chapters, so a stored tree is only comparable within one of these (register row PD-166).
|
||||||
|
ChunkerVersion string `json:"chunker_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceSHA256Bytes is the digest as the column stores it. An unparsable value is stored as nothing
|
||||||
|
// rather than as garbage: the field is evidence, and evidence that cannot be decoded is absence.
|
||||||
|
func (m Manifest) SourceSHA256Bytes() []byte {
|
||||||
|
b, err := hex.DecodeString(m.SourceSHA256)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeManifest parses a manifest document.
|
||||||
|
func DecodeManifest(b []byte) (Manifest, error) {
|
||||||
|
var m Manifest
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
return Manifest{}, fmt.Errorf("ingest: decode manifest: %w", err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
183
platform/internal/jobs/jobs.go
Normal file
183
platform/internal/jobs/jobs.go
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
// Package jobs is the platform's queue: one River client, and the job kinds the control plane hands
|
||||||
|
// to itself.
|
||||||
|
//
|
||||||
|
// It knows nothing about what a job MEANS. Both workers below call one method of one service and
|
||||||
|
// return, because the work itself belongs to the package that owns the object — the run lifecycle to
|
||||||
|
// `runs`, the intake to `books` — and a queue that also owned the work would be a second place where
|
||||||
|
// a run can be started.
|
||||||
|
//
|
||||||
|
// One client for both kinds, deliberately: River runs its own maintenance (scheduler, rescuer,
|
||||||
|
// cleaner) per client, and a second client in one process would be a second set of those against the
|
||||||
|
// same tables for no gain.
|
||||||
|
package jobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/riverqueue/river"
|
||||||
|
"github.com/riverqueue/river/riverdriver/riverpgxv5"
|
||||||
|
|
||||||
|
"textmachine/platform/internal/pgstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spawner starts the engine for a run that has already been admitted.
|
||||||
|
type Spawner interface {
|
||||||
|
Spawn(ctx context.Context, runID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser turns a received book file into a chapter tree.
|
||||||
|
type Parser interface {
|
||||||
|
Parse(ctx context.Context, bookID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpawnArgs is one queued permission to start a run.
|
||||||
|
//
|
||||||
|
// The job carries an id and nothing else. Everything about the run — the book, the ceiling, the
|
||||||
|
// binary it is pinned to — is in Postgres, and a job that carried its own copy would be a second
|
||||||
|
// answer that goes stale the moment the run is restarted with a smaller budget.
|
||||||
|
type SpawnArgs struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind is River's name for this job type.
|
||||||
|
func (SpawnArgs) Kind() string { return "tm_spawn_run" }
|
||||||
|
|
||||||
|
// InsertOpts pins the queue-level policy.
|
||||||
|
//
|
||||||
|
// MaxAttempts is 1 on purpose, and it is the opposite of the usual queue reflex. A retry here does
|
||||||
|
// not repeat lost work: the run row already exists and holds the account's money, and the thing that
|
||||||
|
// would be repeated is spawning an engine. What recovers a run whose spawn failed is the reconciler,
|
||||||
|
// which reads the world instead of assuming the job's view of it — and which is the ONLY component
|
||||||
|
// that can tell "the unit never started" from "the unit is running and this platform was restarted".
|
||||||
|
func (SpawnArgs) InsertOpts() river.InsertOpts {
|
||||||
|
return river.InsertOpts{MaxAttempts: 1, Queue: river.QueueDefault}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseArgs is one queued book waiting to be cut into chapters.
|
||||||
|
type ParseArgs struct {
|
||||||
|
BookID string `json:"book_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind is River's name for this job type.
|
||||||
|
func (ParseArgs) Kind() string { return "tm_parse_book" }
|
||||||
|
|
||||||
|
// InsertOpts pins the queue-level policy.
|
||||||
|
//
|
||||||
|
// MaxAttempts is 1 for the same reason as the spawn's, arrived at from the other direction: a parse
|
||||||
|
// is free and idempotent, so a retry would be harmless — but it would also be a SECOND recovery
|
||||||
|
// mechanism next to the intake sweep, which already re-claims a parse whose process is gone and
|
||||||
|
// which is the only one that can bound how many times a broken host re-runs the engine.
|
||||||
|
func (ParseArgs) InsertOpts() river.InsertOpts {
|
||||||
|
return river.InsertOpts{MaxAttempts: 1, Queue: river.QueueDefault}
|
||||||
|
}
|
||||||
|
|
||||||
|
type spawnWorker struct {
|
||||||
|
river.WorkerDefaults[SpawnArgs]
|
||||||
|
svc Spawner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *spawnWorker) Work(ctx context.Context, job *river.Job[SpawnArgs]) error {
|
||||||
|
return w.svc.Spawn(ctx, job.Args.RunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type parseWorker struct {
|
||||||
|
river.WorkerDefaults[ParseArgs]
|
||||||
|
svc Parser
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *parseWorker) Work(ctx context.Context, job *river.Job[ParseArgs]) error {
|
||||||
|
return w.svc.Parse(ctx, job.Args.BookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobTimeout bounds ONE job. Generous because the work is an engine call over a whole book, and
|
||||||
|
// finite because a worker that never returns is a worker slot that never comes back.
|
||||||
|
//
|
||||||
|
// Exported because it is half of a pair: the intake's claim grace must OUTLIVE it, or a backstop
|
||||||
|
// sweep takes the claim off a parse this queue is still running. The other half is written as this
|
||||||
|
// constant plus a margin.
|
||||||
|
const JobTimeout = 15 * time.Minute
|
||||||
|
|
||||||
|
// Queue is the River client, wired to the services that do the work.
|
||||||
|
type Queue struct {
|
||||||
|
client *river.Client[pgstore.Tx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the queue and its worker pool.
|
||||||
|
//
|
||||||
|
// Concurrency is deliberately small: a spawn worker's whole job is to create a transient unit, and
|
||||||
|
// the runs themselves are bounded by their own cgroups and by the one-live-run-per-book index, not
|
||||||
|
// by how many workers exist.
|
||||||
|
func New(pool *pgxpool.Pool, spawner Spawner, parser Parser, log *slog.Logger, workers int) (*Queue, error) {
|
||||||
|
if workers <= 0 {
|
||||||
|
workers = 4
|
||||||
|
}
|
||||||
|
w := river.NewWorkers()
|
||||||
|
if spawner != nil {
|
||||||
|
if err := river.AddWorkerSafely(w, &spawnWorker{svc: spawner}); err != nil {
|
||||||
|
return nil, fmt.Errorf("jobs: register spawn worker: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parser != nil {
|
||||||
|
if err := river.AddWorkerSafely(w, &parseWorker{svc: parser}); err != nil {
|
||||||
|
return nil, fmt.Errorf("jobs: register parse worker: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c, err := river.NewClient(riverpgxv5.New(pool), &river.Config{
|
||||||
|
Logger: log,
|
||||||
|
Workers: w,
|
||||||
|
Queues: map[string]river.QueueConfig{river.QueueDefault: {MaxWorkers: workers}},
|
||||||
|
// CHOSEN, not inherited. River's own default is one minute, and both jobs here call the engine
|
||||||
|
// against a whole book: a parse ingests and cuts a source of up to the intake's limit, and a
|
||||||
|
// spawn reads the book's meter first (seconds of CPU on a 23 MB book, unified backlog row 100).
|
||||||
|
// A minute would kill those, and a killed process is indistinguishable from a host that cannot
|
||||||
|
// run the engine — so the intake would count a deployment fault against a book whose only sin
|
||||||
|
// was being large.
|
||||||
|
JobTimeout: JobTimeout,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("jobs: river client: %w", err)
|
||||||
|
}
|
||||||
|
return &Queue{client: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnqueueRun inserts the job in the CALLER's transaction, so the run row, its hold and its queue
|
||||||
|
// entry commit together or not at all.
|
||||||
|
func (q *Queue) EnqueueRun(ctx context.Context, tx pgstore.Tx, runID string) error {
|
||||||
|
if _, err := q.client.InsertTx(ctx, tx, SpawnArgs{RunID: runID}, nil); err != nil {
|
||||||
|
return fmt.Errorf("jobs: enqueue run %s: %w", runID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnqueueRunNow inserts the job on its own, outside any transaction.
|
||||||
|
//
|
||||||
|
// The caller is a RESUME: the run it names has already been re-opened and its money already held, in
|
||||||
|
// a transaction that is committed by the time this runs. A failed insert therefore costs one sweep
|
||||||
|
// interval and nothing else — the reconciler finds an attempt with no unit and spawns it — which is
|
||||||
|
// exactly what a lost queue entry costs on the admission path too.
|
||||||
|
func (q *Queue) EnqueueRunNow(ctx context.Context, runID string) error {
|
||||||
|
if _, err := q.client.Insert(ctx, SpawnArgs{RunID: runID}, nil); err != nil {
|
||||||
|
return fmt.Errorf("jobs: enqueue run %s: %w", runID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnqueueParse inserts the job in the CALLER's transaction, so a book that says it is being parsed
|
||||||
|
// and the job that parses it commit together.
|
||||||
|
func (q *Queue) EnqueueParse(ctx context.Context, tx pgstore.Tx, bookID string) error {
|
||||||
|
if _, err := q.client.InsertTx(ctx, tx, ParseArgs{BookID: bookID}, nil); err != nil {
|
||||||
|
return fmt.Errorf("jobs: enqueue parse %s: %w", bookID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins working jobs.
|
||||||
|
func (q *Queue) Start(ctx context.Context) error { return q.client.Start(ctx) }
|
||||||
|
|
||||||
|
// Stop drains the workers. The runs themselves are untouched: they are transient units, not
|
||||||
|
// children, and outliving this process is what they are for.
|
||||||
|
func (q *Queue) Stop(ctx context.Context) error { return q.client.Stop(ctx) }
|
||||||
37
platform/internal/jobs/jobs_test.go
Normal file
37
platform/internal/jobs/jobs_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package jobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/riverqueue/river"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Two properties of the queue that are one line each to write and expensive to get wrong, because
|
||||||
|
// both fail SILENTLY: a renamed kind orphans every job already in the table (River dispatches by
|
||||||
|
// that string, and a job whose kind no worker claims simply waits forever), and a MaxAttempts above
|
||||||
|
// one turns "start a run" into "start it again" — the reflex this zone deliberately refuses, since
|
||||||
|
// a retry here spawns a SECOND engine rather than finishing lost work.
|
||||||
|
func TestTheQueuesKindsAndRetryPolicyAreWhatTheRestOfTheSystemAssumes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
kind string
|
||||||
|
opts river.InsertOpts
|
||||||
|
}{
|
||||||
|
{"spawn", SpawnArgs{}.Kind(), SpawnArgs{}.InsertOpts()},
|
||||||
|
{"parse", ParseArgs{}.Kind(), ParseArgs{}.InsertOpts()},
|
||||||
|
}
|
||||||
|
want := map[string]string{"spawn": "tm_spawn_run", "parse": "tm_parse_book"}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if tc.kind != want[tc.name] {
|
||||||
|
t.Errorf("%s kind is %q, want %q — a renamed kind orphans the jobs already queued under the old one",
|
||||||
|
tc.name, tc.kind, want[tc.name])
|
||||||
|
}
|
||||||
|
if tc.opts.MaxAttempts != 1 {
|
||||||
|
t.Errorf("%s MaxAttempts is %d, want 1: recovery is the reconciler's job, not the queue's",
|
||||||
|
tc.name, tc.opts.MaxAttempts)
|
||||||
|
}
|
||||||
|
if tc.opts.Queue != river.QueueDefault {
|
||||||
|
t.Errorf("%s queue is %q, want the default one the client works", tc.name, tc.opts.Queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
231
platform/internal/metrics/metrics.go
Normal file
231
platform/internal/metrics/metrics.go
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
// Package metrics is the platform's telemetry: the numbers an operator needs to see that the
|
||||||
|
// control plane is doing its job, in the form the ecosystem scrapes.
|
||||||
|
//
|
||||||
|
// # Why a Prometheus client and not expvar
|
||||||
|
//
|
||||||
|
// stdlib `expvar` is the alternative the zone's own norm points at first ("stdlib before a library",
|
||||||
|
// ENGINEERING_STANDARDS §1), and it does not carry this job: it has no labels, so "requests by route
|
||||||
|
// and status" cannot be expressed at all; no histograms, so a latency question is answerable only as
|
||||||
|
// an average, which is the one statistic that hides the tail; and its JSON is not a format any
|
||||||
|
// scraper reads without a translator. What it would save is a dependency, and what it would cost is
|
||||||
|
// writing the missing three by hand — which is the self-written path the same norm rules out.
|
||||||
|
//
|
||||||
|
// `prometheus/client_golang` v1.24.1 (released 24.07.2026, pin verified live) is the de-facto
|
||||||
|
// standard exposition, and the OpenTelemetry alternative is heavier for what this deployment is: a
|
||||||
|
// collector process to run, an export protocol to configure, and a scrape endpoint at the end of it
|
||||||
|
// anyway. The baseline this axis is measured against — the external one PD-115 asks for — is
|
||||||
|
// Prometheus's own naming practices (base units, `_total` on counters, no units in labels) and the
|
||||||
|
// four golden signals for WHAT to expose.
|
||||||
|
//
|
||||||
|
// # Cardinality
|
||||||
|
//
|
||||||
|
// Labels carry the route PATTERN, never the path: a raw path carries book and run ids, which is a
|
||||||
|
// user's library in an operator's index (PD-3) and an unbounded number of time series besides.
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// namespace prefixes every series of this service.
|
||||||
|
const namespace = "tm_platform"
|
||||||
|
|
||||||
|
// Metrics is the registry and the instruments of one process.
|
||||||
|
type Metrics struct {
|
||||||
|
registry *prometheus.Registry
|
||||||
|
|
||||||
|
// The runner's state, refreshed by the sweep. Gauges rather than collectors that query on
|
||||||
|
// scrape: the sweep already reads the database on a timer, and a scrape that ran its own queries
|
||||||
|
// would let anyone with access to the endpoint set the load on the control plane's database.
|
||||||
|
queueDepth prometheus.Gauge
|
||||||
|
oldestHold prometheus.Gauge
|
||||||
|
quarantined prometheus.Gauge
|
||||||
|
liveRuns prometheus.Gauge
|
||||||
|
tailerLag prometheus.Gauge
|
||||||
|
booksInIntake *prometheus.GaugeVec
|
||||||
|
sweepDuration *prometheus.HistogramVec
|
||||||
|
sweepUnfinished *prometheus.CounterVec
|
||||||
|
|
||||||
|
requests *prometheus.CounterVec
|
||||||
|
latency *prometheus.HistogramVec
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the instruments on a registry of this process's own.
|
||||||
|
//
|
||||||
|
// Its own, and not the package-global default: a global registry is shared with every library that
|
||||||
|
// ever registers into it, and a duplicate registration there is a panic at init in a process whose
|
||||||
|
// job is to stay up.
|
||||||
|
func New() *Metrics {
|
||||||
|
m := &Metrics{registry: prometheus.NewRegistry()}
|
||||||
|
m.queueDepth = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Namespace: namespace, Name: "queue_depth",
|
||||||
|
Help: "Jobs in the queue that have not finished (pending, available, running, scheduled or retryable).",
|
||||||
|
})
|
||||||
|
m.oldestHold = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Namespace: namespace, Name: "oldest_open_hold_seconds",
|
||||||
|
Help: "Age of the oldest reservation still open. A hold outlives its run only when a settlement could not be made.",
|
||||||
|
})
|
||||||
|
m.quarantined = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Namespace: namespace, Name: "quarantined_attempts",
|
||||||
|
Help: "Live attempts whose journal is no longer being materialized. Such a run keeps going and keeps spending.",
|
||||||
|
})
|
||||||
|
m.liveRuns = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Namespace: namespace, Name: "live_runs",
|
||||||
|
Help: "Runs the database believes are still going.",
|
||||||
|
})
|
||||||
|
m.tailerLag = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Namespace: namespace, Name: "tailer_lag_bytes",
|
||||||
|
Help: "Largest number of bytes any live run's journal holds beyond the platform's cursor.",
|
||||||
|
})
|
||||||
|
m.booksInIntake = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
|
Namespace: namespace, Name: "books_in_intake",
|
||||||
|
Help: "Books that have not finished intake, by status.",
|
||||||
|
}, []string{"status"})
|
||||||
|
m.sweepDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: namespace, Name: "sweep_duration_seconds",
|
||||||
|
Help: "How long one pass of a sweep took.",
|
||||||
|
Buckets: sweepBuckets,
|
||||||
|
}, []string{"sweep"})
|
||||||
|
m.sweepUnfinished = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: namespace, Name: "sweep_unfinished_total",
|
||||||
|
Help: "Passes that ran out of their time budget with work left. A rising count is starvation: the tail of the list is never reached (register row PD-169).",
|
||||||
|
}, []string{"sweep"})
|
||||||
|
m.requests = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: namespace, Name: "http_requests_total",
|
||||||
|
Help: "Requests served, by route pattern, method and status.",
|
||||||
|
}, []string{"route", "method", "code"})
|
||||||
|
m.latency = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: namespace, Name: "http_request_duration_seconds",
|
||||||
|
Help: "Time to serve a request, by route pattern and method.",
|
||||||
|
Buckets: latencyBuckets,
|
||||||
|
}, []string{"route", "method"})
|
||||||
|
m.registry.MustRegister(m.queueDepth, m.oldestHold, m.quarantined, m.liveRuns, m.tailerLag,
|
||||||
|
m.booksInIntake, m.sweepDuration, m.sweepUnfinished, m.requests, m.latency,
|
||||||
|
// The runtime and the process itself: memory, goroutines, file descriptors, CPU. They are
|
||||||
|
// what answers "is this instance healthy" when none of the numbers above has moved.
|
||||||
|
collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepBuckets span one tick to well past the sweep's own budget: the question they answer is "is a
|
||||||
|
// pass still finishing", and the interesting shape is entirely in the tail.
|
||||||
|
var sweepBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300}
|
||||||
|
|
||||||
|
// latencyBuckets carry both kinds of request this service serves: a read that must be milliseconds
|
||||||
|
// and an upload that is legitimately minutes.
|
||||||
|
var latencyBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 300}
|
||||||
|
|
||||||
|
// Runner is what the reconciler's sweep observed about the world. Written as one struct because the
|
||||||
|
// numbers are read together, in one query, and a set of them from different moments would describe a
|
||||||
|
// state that never existed.
|
||||||
|
type Runner struct {
|
||||||
|
QueueDepth int64
|
||||||
|
OldestHoldSeconds float64
|
||||||
|
QuarantinedAttempts int64
|
||||||
|
LiveRuns int64
|
||||||
|
BooksUploading int64
|
||||||
|
BooksParsing int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveRunner publishes one reading of the runner's state.
|
||||||
|
func (m *Metrics) ObserveRunner(r Runner) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.queueDepth.Set(float64(r.QueueDepth))
|
||||||
|
m.oldestHold.Set(r.OldestHoldSeconds)
|
||||||
|
m.quarantined.Set(float64(r.QuarantinedAttempts))
|
||||||
|
m.liveRuns.Set(float64(r.LiveRuns))
|
||||||
|
m.booksInIntake.WithLabelValues("uploading").Set(float64(r.BooksUploading))
|
||||||
|
m.booksInIntake.WithLabelValues("parsing").Set(float64(r.BooksParsing))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveTailerLag publishes how far the furthest-behind live run's cursor is from the end of its
|
||||||
|
// journal, in bytes.
|
||||||
|
func (m *Metrics) ObserveTailerLag(bytes int64) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.tailerLag.Set(float64(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveSweep records one pass. unfinished means the pass ran out of time with work left, which is
|
||||||
|
// the starvation the per-run budget bounds and this counter makes visible.
|
||||||
|
func (m *Metrics) ObserveSweep(name string, d time.Duration, unfinished bool) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.sweepDuration.WithLabelValues(name).Observe(d.Seconds())
|
||||||
|
if unfinished {
|
||||||
|
m.sweepUnfinished.WithLabelValues(name).Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves the exposition endpoint.
|
||||||
|
func (m *Metrics) Handler() http.Handler {
|
||||||
|
return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware counts and times every request.
|
||||||
|
//
|
||||||
|
// It sits where the access log sits — around the mux, so `Request.Pattern` is filled by the time it
|
||||||
|
// reads it, and outside the panic recovery, so a request that panicked is counted as the 500 it
|
||||||
|
// became.
|
||||||
|
func (m *Metrics) Middleware() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
if m == nil {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
next.ServeHTTP(rec, r)
|
||||||
|
route := r.Pattern
|
||||||
|
if route == "" {
|
||||||
|
// One series for everything unmatched, never the path: an unrouted request is where
|
||||||
|
// an attacker chooses the label, and a label an attacker chooses is unbounded
|
||||||
|
// cardinality.
|
||||||
|
route = "(unmatched)"
|
||||||
|
}
|
||||||
|
method := knownMethod(r.Method)
|
||||||
|
m.requests.WithLabelValues(route, method, strconv.Itoa(rec.status)).Inc()
|
||||||
|
m.latency.WithLabelValues(route, method).Observe(time.Since(start).Seconds())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownMethod folds anything that is not a method this service serves into one label. A method is a
|
||||||
|
// token the CALLER chooses, and an unrouted request carries whatever they sent — so without this the
|
||||||
|
// series count is theirs to grow, exactly like the raw path would be.
|
||||||
|
func knownMethod(m string) string {
|
||||||
|
switch m {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch,
|
||||||
|
http.MethodDelete, http.MethodOptions:
|
||||||
|
return m
|
||||||
|
default:
|
||||||
|
return "(other)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recorder captures the status code.
|
||||||
|
//
|
||||||
|
// Unwrap is NOT optional: http.ResponseController walks the wrappers through it, and the upload
|
||||||
|
// route reaches its own read deadline that way. A wrapper without it silently turns that call into
|
||||||
|
// "not supported" — and the upload then dies at the server's 30-second ReadTimeout instead.
|
||||||
|
type recorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recorder) WriteHeader(code int) {
|
||||||
|
r.status = code
|
||||||
|
r.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
||||||
147
platform/internal/metrics/metrics_test.go
Normal file
147
platform/internal/metrics/metrics_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scrape renders the exposition the way a scraper would read it.
|
||||||
|
func scrape(t *testing.T, m *Metrics) string {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
m.Handler().ServeHTTP(w, httptest.NewRequest("GET", "/metrics", nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("scrape: %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels carry the route PATTERN and never the path. A raw path is a user's library in an operator's
|
||||||
|
// index (PD-3) and an unbounded number of series besides — the label would be chosen by whoever
|
||||||
|
// sends the request.
|
||||||
|
func TestRequestsAreCountedByRoutePatternAndNeverByPath(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("GET /v0/books/{bookId}", m.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
})))
|
||||||
|
mux.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/v0/books/bk_secret", nil))
|
||||||
|
|
||||||
|
body := scrape(t, m)
|
||||||
|
if !strings.Contains(body, `tm_platform_http_requests_total{code="404",method="GET",route="GET /v0/books/{bookId}"} 1`) {
|
||||||
|
t.Errorf("the counter does not carry the route pattern:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "bk_secret") {
|
||||||
|
t.Errorf("a book id reached the exposition:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "tm_platform_http_request_duration_seconds_bucket") {
|
||||||
|
t.Error("no latency histogram: an average is the one statistic that hides the tail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unrouted request is where the label would be chosen by an attacker, so there is exactly one
|
||||||
|
// series for all of them.
|
||||||
|
func TestAnUnroutedRequestGetsOneSeriesAndNotOnePerPath(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
h := m.Middleware()(http.NotFoundHandler())
|
||||||
|
for _, p := range []string{"/../etc/passwd", "/random-1", "/random-2"} {
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", p, nil))
|
||||||
|
}
|
||||||
|
body := scrape(t, m)
|
||||||
|
if !strings.Contains(body, `route="(unmatched)"} 3`) {
|
||||||
|
t.Errorf("unmatched requests are not folded into one series:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "random-1") {
|
||||||
|
t.Errorf("a caller-chosen path became a label:\n%s", body)
|
||||||
|
}
|
||||||
|
// The METHOD is a token the caller chooses too, and on an unrouted request it is theirs to invent.
|
||||||
|
for _, verb := range []string{"FOOBAR", "WHATEVER"} {
|
||||||
|
r := httptest.NewRequest("GET", "/nope", nil)
|
||||||
|
r.Method = verb
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||||
|
}
|
||||||
|
body = scrape(t, m)
|
||||||
|
for _, verb := range []string{"FOOBAR", "WHATEVER"} {
|
||||||
|
if strings.Contains(body, verb) {
|
||||||
|
t.Errorf("a caller-chosen method became a label:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `method="(other)"`) {
|
||||||
|
t.Errorf("unknown methods are not folded into one series:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wrapper must stay transparent to http.ResponseController, which is how the upload route
|
||||||
|
// reaches its own read deadline. Asserted here as well as through the real server, because this is
|
||||||
|
// where the property lives.
|
||||||
|
func TestTheWrapperKeepsTheResponseControllerReachable(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
var sawFlush bool
|
||||||
|
h := m.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
// httptest's recorder supports Flush and nothing else, which is enough: what is being
|
||||||
|
// asserted is that the controller finds the wrapped writer at all.
|
||||||
|
sawFlush = http.NewResponseController(w).Flush() == nil
|
||||||
|
}))
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||||
|
if !sawFlush {
|
||||||
|
t.Fatal("the wrapper hides the ResponseWriter from http.ResponseController: Unwrap is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The numbers П-11 was opened for: each one used to be answerable only in psql.
|
||||||
|
func TestTheRunnersStateIsExposedWithItsUnits(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
m.ObserveRunner(Runner{QueueDepth: 3, OldestHoldSeconds: 61.5, QuarantinedAttempts: 1,
|
||||||
|
LiveRuns: 2, BooksUploading: 4, BooksParsing: 5})
|
||||||
|
m.ObserveTailerLag(4096)
|
||||||
|
m.ObserveSweep("runs", 1500*time.Millisecond, true)
|
||||||
|
body := scrape(t, m)
|
||||||
|
for _, want := range []string{
|
||||||
|
"tm_platform_queue_depth 3",
|
||||||
|
"tm_platform_oldest_open_hold_seconds 61.5",
|
||||||
|
"tm_platform_quarantined_attempts 1",
|
||||||
|
"tm_platform_live_runs 2",
|
||||||
|
"tm_platform_tailer_lag_bytes 4096",
|
||||||
|
`tm_platform_books_in_intake{status="uploading"} 4`,
|
||||||
|
`tm_platform_books_in_intake{status="parsing"} 5`,
|
||||||
|
`tm_platform_sweep_unfinished_total{sweep="runs"} 1`,
|
||||||
|
`tm_platform_sweep_duration_seconds_sum{sweep="runs"} 1.5`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("the exposition is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Base units and no unit inside a label, per the naming practices this axis is measured against.
|
||||||
|
// Judged over OUR namespace only: the Go collector's own series are its to name (and one of them
|
||||||
|
// is `go_memstats_mspan_inuse_bytes`, which is how this assertion first failed).
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
if !strings.HasPrefix(line, namespace+"_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, wrong := range []string{"_ms ", "_ms{", "_millis", `unit="`} {
|
||||||
|
if strings.Contains(line, wrong) {
|
||||||
|
t.Errorf("%q is not a base-unit name", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A process that measures nothing must still serve: every instrument is nil-safe, because the
|
||||||
|
// alternative is a telemetry failure taking down the thing it measures.
|
||||||
|
func TestAServiceWithoutTelemetryStillWorks(t *testing.T) {
|
||||||
|
var m *Metrics
|
||||||
|
m.ObserveRunner(Runner{QueueDepth: 1})
|
||||||
|
m.ObserveTailerLag(1)
|
||||||
|
m.ObserveSweep("runs", time.Second, false)
|
||||||
|
h := m.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
}))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||||
|
if w.Code != http.StatusTeapot {
|
||||||
|
t.Fatalf("status %d through a nil registry", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,11 +57,11 @@ type NewBook struct {
|
||||||
// AddBook registers a book. `not_started` and not `uploading`: the file is already on disk, and a
|
// AddBook registers a book. `not_started` and not `uploading`: the file is already on disk, and a
|
||||||
// status describing an upload this route never performed would be a lie the library then shows.
|
// status describing an upload this route never performed would be a lie the library then shows.
|
||||||
func (s *Store) AddBook(ctx context.Context, in NewBook) (string, error) {
|
func (s *Store) AddBook(ctx context.Context, in NewBook) (string, error) {
|
||||||
id := newID("bk")
|
id := NewBookID()
|
||||||
const q = `
|
const q = `
|
||||||
insert into books (id, owner_id, title, source_lang, target_lang, genre, status,
|
insert into books (id, owner_id, title, source_lang, target_lang, genre, status,
|
||||||
chapter_count, character_count, added_at, workdir, engine_book_id)
|
chapter_count, character_count, added_at, workdir, engine_book_id, revision)
|
||||||
values ($1, $2, $3, $4, $5, $6, 'not_started', $7, $8, $9, $10, $11)`
|
values ($1, $2, $3, $4, $5, $6, 'not_started', $7, $8, $9, $10, $11, ` + nextLibraryRevision + `)`
|
||||||
// engine_book_id is the platform's id until the engine's handshake reports its own. It is not
|
// engine_book_id is the platform's id until the engine's handshake reports its own. It is not
|
||||||
// left empty: the column is the join back to whatever the engine calls this book, and an empty
|
// left empty: the column is the join back to whatever the engine calls this book, and an empty
|
||||||
// one would read as "the engine has no name for it" rather than "we have not been told yet".
|
// one would read as "the engine has no name for it" rather than "we have not been told yet".
|
||||||
|
|
@ -77,6 +77,323 @@ func (s *Store) AddBook(ctx context.Context, in NewBook) (string, error) {
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewBookID mints a book identifier. Exported because the intake needs one BEFORE the row exists:
|
||||||
|
// the book's directory is named after it, and the row records that directory.
|
||||||
|
func NewBookID() string { return newID("bk") }
|
||||||
|
|
||||||
|
// nextLibraryRevision is the revision a book JOINS the library with, as a SQL fragment over $2 (the
|
||||||
|
// owner).
|
||||||
|
//
|
||||||
|
// The library's revision is derived as the maximum over the account's books, and the contract asks
|
||||||
|
// a client to DROP a read whose revision is not above what it already applied. A book inserted at
|
||||||
|
// revision 0 therefore left the library's number unchanged, and the screen that had just uploaded
|
||||||
|
// it dropped the very read that carried it (register row PD-122). Starting above the account's
|
||||||
|
// current maximum is what makes an addition visible.
|
||||||
|
//
|
||||||
|
// It is the greatest of BOTH halves the library's own revision is read from — the maximum over the
|
||||||
|
// books and the account's floor — because taking only the maximum reproduced the very defect the
|
||||||
|
// floor exists to prevent, one step later: after a cancelled upload raised the floor above the
|
||||||
|
// maximum, the NEXT book joined below it, and the library then answered the same number for three
|
||||||
|
// different states of itself. Found by acceptance; the first version of this constant read only the
|
||||||
|
// maximum.
|
||||||
|
//
|
||||||
|
// Two concurrent additions can compute the same number, and that is harmless: the value still rises,
|
||||||
|
// so the client still refetches and still sees both. What stays open in PD-122 is the account-scope
|
||||||
|
// counter itself — one writer for membership and statuses — which is what would let two SIMULTANEOUS
|
||||||
|
// writers get distinct numbers instead of merely rising ones.
|
||||||
|
const nextLibraryRevision = `(select greatest(
|
||||||
|
coalesce((select max(revision) from books where owner_id = $2), 0),
|
||||||
|
coalesce((select library_revision from users where id = $2), 0)) + 1)`
|
||||||
|
|
||||||
|
// nextRevisionOfThisBooksLibrary is the same number for a statement that has the book but not its
|
||||||
|
// owner: the owner is read from the row being updated.
|
||||||
|
//
|
||||||
|
// ⚠ A status change may NOT simply do `revision + 1`, and that is the same defect as the one above
|
||||||
|
// wearing different clothes. A book whose own counter sits below the account's floor — it was
|
||||||
|
// uploaded before another upload was cancelled — would walk `uploading → parsing → not_started`
|
||||||
|
// three increments deep and still be under the floor, so `greatest(max, floor)` would not move at
|
||||||
|
// all. A client obeying the contract drops every read that is not ABOVE what it applied, and the
|
||||||
|
// screen that uploaded the book would keep showing it as arriving until some unrelated event pushed
|
||||||
|
// the number. Taking the library's own next number instead makes every status visible exactly once.
|
||||||
|
const nextRevisionOfThisBooksLibrary = `(select greatest(
|
||||||
|
coalesce((select max(b.revision) from books b where b.owner_id = books.owner_id), 0),
|
||||||
|
coalesce((select u.library_revision from users u where u.id = books.owner_id), 0)) + 1)`
|
||||||
|
|
||||||
|
// NewUpload is one book arriving through the contract's intake, before its file has been received.
|
||||||
|
type NewUpload struct {
|
||||||
|
OwnerID string
|
||||||
|
Title string
|
||||||
|
SourceLang string
|
||||||
|
TargetLang string
|
||||||
|
Genre string
|
||||||
|
// Workdir is the engine project directory this book will own. It is named after the book id and
|
||||||
|
// created before the row, so a row always points at a directory that exists.
|
||||||
|
Workdir string
|
||||||
|
Now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUpload registers a book whose file is still arriving.
|
||||||
|
//
|
||||||
|
// `uploading` is a real, observable state and not a formality: the row is written BEFORE the body is
|
||||||
|
// read, so a 60 MB upload is visible in the library — with its languages, without its size — while
|
||||||
|
// it is still on the wire. The alternative, inserting once the bytes have landed, gives the status
|
||||||
|
// no writer at all and makes a half-finished upload invisible to everything, including the sweep
|
||||||
|
// that has to clean it up.
|
||||||
|
func (s *Store) CreateUpload(ctx context.Context, id string, in NewUpload) (Book, error) {
|
||||||
|
const q = `
|
||||||
|
insert into books (id, owner_id, title, source_lang, target_lang, genre, status,
|
||||||
|
added_at, workdir, engine_book_id, revision)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, 'uploading', $7, $8, $1, ` + nextLibraryRevision + `)
|
||||||
|
returning id, title, source_lang, target_lang, genre, status, chapter_count, character_count,
|
||||||
|
note_count, added_at, revision`
|
||||||
|
var b Book
|
||||||
|
err := s.pool.QueryRow(ctx, q, id, in.OwnerID, in.Title, in.SourceLang, in.TargetLang, in.Genre,
|
||||||
|
in.Now, in.Workdir).
|
||||||
|
Scan(&b.ID, &b.Title, &b.SourceLang, &b.TargetLang, &b.Genre, &b.Status, &b.ChapterCount,
|
||||||
|
&b.CharacterCount, &b.NoteCount, &b.AddedAt, &b.Revision)
|
||||||
|
if err != nil {
|
||||||
|
var pg *pgconn.PgError
|
||||||
|
if errors.As(err, &pg) && pg.ConstraintName == "books_owner_id_fkey" {
|
||||||
|
return Book{}, ErrNoAccount
|
||||||
|
}
|
||||||
|
return Book{}, fmt.Errorf("pgstore: create upload: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartParsing hands a received file to the parser: `uploading → parsing`, with the size the intake
|
||||||
|
// counted while the bytes went past.
|
||||||
|
//
|
||||||
|
// Conditional on the status, so an upload the sweep has already given up on cannot be resurrected by
|
||||||
|
// a request that finally finished.
|
||||||
|
//
|
||||||
|
// enqueue joins the caller's transaction for the same reason the run admission's does: a status that
|
||||||
|
// says "being parsed" with no job to do it waits for the backstop sweep's grace, and a job for a
|
||||||
|
// book that never became `parsing` is a worker with nothing to claim.
|
||||||
|
func (s *Store) StartParsing(ctx context.Context, id string, characters int64,
|
||||||
|
enqueue func(context.Context, Tx, string) error) (Book, error) {
|
||||||
|
var b Book
|
||||||
|
err := s.inTx(ctx, func(tx pgx.Tx) error {
|
||||||
|
const q = `
|
||||||
|
update books set status = 'parsing', character_count = $2,
|
||||||
|
revision = ` + nextRevisionOfThisBooksLibrary + `
|
||||||
|
where id = $1 and status = 'uploading'
|
||||||
|
returning id, title, source_lang, target_lang, genre, status, chapter_count,
|
||||||
|
character_count, note_count, added_at, revision`
|
||||||
|
err := tx.QueryRow(ctx, q, id, characters).
|
||||||
|
Scan(&b.ID, &b.Title, &b.SourceLang, &b.TargetLang, &b.Genre, &b.Status, &b.ChapterCount,
|
||||||
|
&b.CharacterCount, &b.NoteCount, &b.AddedAt, &b.Revision)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrNoBook
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pgstore: start parsing: %w", err)
|
||||||
|
}
|
||||||
|
if enqueue != nil {
|
||||||
|
return enqueue(ctx, tx, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Book{}, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseClaim is one book this process may parse.
|
||||||
|
type ParseClaim struct {
|
||||||
|
BookID string
|
||||||
|
Workdir string
|
||||||
|
Attempts int
|
||||||
|
// At is the stamp this claim was taken with. Every write that ENDS the parse carries it back, so
|
||||||
|
// a pass whose claim was taken over by another cannot record a verdict about work it is no longer
|
||||||
|
// doing — the same re-check-under-the-write the money paths use.
|
||||||
|
At time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrParseClaimed is a parse someone else is already doing, or a book that is no longer waiting for
|
||||||
|
// one. The ordinary case, not a failure: the queue job and the backstop sweep both aim at the same
|
||||||
|
// book on purpose.
|
||||||
|
var ErrParseClaimed = errors.New("pgstore: the parse of this book is already claimed")
|
||||||
|
|
||||||
|
// ClaimParse takes the right to parse a book, once.
|
||||||
|
//
|
||||||
|
// A compare-and-set rather than a plain read, for the same reason the spawn claim is one: the queue
|
||||||
|
// worker and the backstop sweep legitimately arrive together, and two `tmctl manifest` processes on
|
||||||
|
// one project directory would be two writers of one SQLite file — which is exactly what the engine's
|
||||||
|
// exclusive lock exists to refuse, noisily and after the work.
|
||||||
|
//
|
||||||
|
// staleAfter is what makes the claim recoverable: a process that died holding one leaves a stamp
|
||||||
|
// nothing clears, and without a grace the book would wait for parsing forever.
|
||||||
|
func (s *Store) ClaimParse(ctx context.Context, id string, now, staleBefore time.Time) (ParseClaim, error) {
|
||||||
|
const q = `
|
||||||
|
update books set parse_started_at = $2, parse_attempts = parse_attempts + 1
|
||||||
|
where id = $1 and status = 'parsing'
|
||||||
|
and (parse_started_at is null or parse_started_at < $3)
|
||||||
|
returning id, workdir, parse_attempts`
|
||||||
|
var c ParseClaim
|
||||||
|
err := s.pool.QueryRow(ctx, q, id, now, staleBefore).Scan(&c.BookID, &c.Workdir, &c.Attempts)
|
||||||
|
c.At = now
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ParseClaim{}, ErrParseClaimed
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ParseClaim{}, fmt.Errorf("pgstore: claim parse: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundParseAttempt gives back an attempt that was claimed but never spent on the engine.
|
||||||
|
//
|
||||||
|
// The budget exists to bound how many times a broken HOST is asked to parse a book, and a book that
|
||||||
|
// is only waiting for its configuration asks nobody: counting those claims meant the budget ran out
|
||||||
|
// while nothing had been tried, and the first real answer from the engine was terminal the moment it
|
||||||
|
// arrived. Conditional on the claim, like every other write that ends a pass.
|
||||||
|
func (s *Store) RefundParseAttempt(ctx context.Context, id string, claimedAt time.Time) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
update books set parse_attempts = greatest(parse_attempts - 1, 0)
|
||||||
|
where id = $1 and status = 'parsing' and parse_started_at = $2`, id, claimedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pgstore: refund parse attempt: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedBook is what the engine's manifest told the platform about a book it has now cut.
|
||||||
|
type ParsedBook struct {
|
||||||
|
Chapters int
|
||||||
|
SourceSHA256 []byte
|
||||||
|
ChunkerVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishParse records a parsed book: `parsing → not_started`.
|
||||||
|
//
|
||||||
|
// Conditional on the CLAIM still being this caller's: a pass whose claim was taken over is not the
|
||||||
|
// pass whose answer counts, and two answers about one book are one answer too many.
|
||||||
|
func (s *Store) FinishParse(ctx context.Context, id string, claimedAt time.Time, in ParsedBook) error {
|
||||||
|
const q = `
|
||||||
|
update books set status = 'not_started', chapter_count = $2, source_sha256 = $3,
|
||||||
|
chunker_version = $4, parse_started_at = null,
|
||||||
|
revision = ` + nextRevisionOfThisBooksLibrary + `
|
||||||
|
where id = $1 and status = 'parsing' and parse_started_at = $5`
|
||||||
|
tag, err := s.pool.Exec(ctx, q, id, in.Chapters, in.SourceSHA256, in.ChunkerVersion, claimedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pgstore: finish parse: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNoBook
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectBook is the terminal end of an intake that failed: `parsing → rejected`.
|
||||||
|
//
|
||||||
|
// The reason is the platform's own word for the class of failure and is not projected — contract v0
|
||||||
|
// has no field for it (migration 00013).
|
||||||
|
//
|
||||||
|
// Conditional on the CLAIM, like FinishParse and for a sharper reason: rejecting is destructive —
|
||||||
|
// the book's source goes with it — and a pass whose claim was stolen may be holding an exit code
|
||||||
|
// that says nothing about the book. The engine maps every failure onto exit 1, including a lock held
|
||||||
|
// by the parse that took the claim over.
|
||||||
|
func (s *Store) RejectBook(ctx context.Context, id string, claimedAt time.Time, reason string) error {
|
||||||
|
const q = `
|
||||||
|
update books set status = 'rejected', reject_reason = $2, parse_started_at = null,
|
||||||
|
revision = ` + nextRevisionOfThisBooksLibrary + `
|
||||||
|
where id = $1 and status = 'parsing' and parse_started_at = $3`
|
||||||
|
tag, err := s.pool.Exec(ctx, q, id, reason, claimedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pgstore: reject book: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNoBook
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUpload removes a book whose file never arrived.
|
||||||
|
//
|
||||||
|
// Not `rejected`: that status means "the file could not be parsed" (contract §BookStatus), and a
|
||||||
|
// request the client abandoned produced no file to judge. The user's library must not accumulate a
|
||||||
|
// permanent row for every cancelled upload — there is no delete handle in the contract to clear one
|
||||||
|
// with.
|
||||||
|
//
|
||||||
|
// Narrow on purpose: only a book still in `uploading`, and only one no run was ever started for, so
|
||||||
|
// this can never become a way to delete a book that has cost money.
|
||||||
|
//
|
||||||
|
// ⚠ It also carries the library's revision ACROSS the deletion, and that is not bookkeeping: the
|
||||||
|
// library's number is derived as the maximum over the account's books, so removing the newest one
|
||||||
|
// would lower it — and the contract asks a client to DROP a read whose revision is below what it
|
||||||
|
// applied (§Revision). The screen would then keep rendering a book that no longer exists until some
|
||||||
|
// other book's counter grew past it. The floor lives in `users.library_revision`, the account-scope
|
||||||
|
// counter that has existed unused since 00001 (register row PD-122).
|
||||||
|
func (s *Store) DeleteUpload(ctx context.Context, id string) error {
|
||||||
|
return s.inTx(ctx, func(tx pgx.Tx) error {
|
||||||
|
// Book first, as in every transaction that touches two of these tables (lockBook). `users` is
|
||||||
|
// taken after it, and no path in this package takes them the other way round.
|
||||||
|
if err := lockBook(ctx, tx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var owner string
|
||||||
|
var revision int64
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
delete from books where id = $1 and status = 'uploading'
|
||||||
|
and not exists (select 1 from runs where book_id = $1)
|
||||||
|
returning owner_id, revision`, id).Scan(&owner, &revision)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrNoBook
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pgstore: delete upload: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
update users set library_revision = greatest(library_revision, $2) where id = $1`,
|
||||||
|
owner, revision+1); err != nil {
|
||||||
|
return fmt.Errorf("pgstore: carry the library revision: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntakeBook is a book the backstop sweep has to make a decision about.
|
||||||
|
type IntakeBook struct {
|
||||||
|
ID string
|
||||||
|
Status string
|
||||||
|
Workdir string
|
||||||
|
Attempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
// StuckIntake lists books that stopped moving through intake.
|
||||||
|
//
|
||||||
|
// Both halves have the same cause and different cures: an `uploading` row whose request is gone is
|
||||||
|
// litter to remove, a `parsing` row whose claim went stale is work to retry. Neither is reachable
|
||||||
|
// from the request that created it — the process holding it is the one that died — so the sweep is
|
||||||
|
// the only thing that can finish the walk.
|
||||||
|
func (s *Store) StuckIntake(ctx context.Context, uploadingBefore, claimedBefore time.Time) ([]IntakeBook, error) {
|
||||||
|
// coalesce(claimed, added): an unclaimed parse is stuck when the BOOK has been waiting, a claimed
|
||||||
|
// one when the CLAIM has. Written as one expression because they are the same question — how long
|
||||||
|
// has this book had nobody working on it — and a NULL claim is simply the first answer to it.
|
||||||
|
const q = `
|
||||||
|
select id, status, workdir, parse_attempts
|
||||||
|
from books
|
||||||
|
where (status = 'uploading' and added_at < $1)
|
||||||
|
or (status = 'parsing' and coalesce(parse_started_at, added_at) < $2)
|
||||||
|
order by added_at`
|
||||||
|
rows, err := s.pool.Query(ctx, q, uploadingBefore, claimedBefore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgstore: list stuck intake: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []IntakeBook
|
||||||
|
for rows.Next() {
|
||||||
|
var b IntakeBook
|
||||||
|
if err := rows.Scan(&b.ID, &b.Status, &b.Workdir, &b.Attempts); err != nil {
|
||||||
|
return nil, fmt.Errorf("pgstore: scan stuck intake: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Progress is per phase, in units (contract §Progress).
|
// Progress is per phase, in units (contract §Progress).
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
DraftDone, DraftTotal int
|
DraftDone, DraftTotal int
|
||||||
|
|
@ -158,8 +475,15 @@ func (s *Store) ListBooks(ctx context.Context, userID string, limit int, cursor
|
||||||
}
|
}
|
||||||
// The revision of the LIBRARY scope — membership and statuses — and never compared with the
|
// The revision of the LIBRARY scope — membership and statuses — and never compared with the
|
||||||
// revision of a book (contract §Revision).
|
// revision of a book (contract §Revision).
|
||||||
if err := s.pool.QueryRow(ctx,
|
//
|
||||||
`select coalesce(max(revision), 0) from books where owner_id = $1`, userID).Scan(&out.Revision); err != nil {
|
// The maximum over the account's books, FLOORED by the account's own counter. The floor is what
|
||||||
|
// makes the number monotonic across a deletion: the maximum alone falls when the newest book goes
|
||||||
|
// (DeleteUpload raises the floor as it deletes), and a library revision that goes backwards is a
|
||||||
|
// library the client stops rendering.
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
select greatest(coalesce((select max(revision) from books where owner_id = $1), 0),
|
||||||
|
coalesce((select library_revision from users where id = $1), 0))`,
|
||||||
|
userID).Scan(&out.Revision); err != nil {
|
||||||
return Library{}, fmt.Errorf("pgstore: library revision: %w", err)
|
return Library{}, fmt.Errorf("pgstore: library revision: %w", err)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|
@ -191,6 +515,13 @@ func (s *Store) GetBook(ctx context.Context, userID, bookID string) (Book, *Run,
|
||||||
}
|
}
|
||||||
if run != nil {
|
if run != nil {
|
||||||
b.Progress = run.progress
|
b.Progress = run.progress
|
||||||
|
// ONE counter per book, and this is the single place that says so for every run this store
|
||||||
|
// hands out (§Revision: "every book-scoped read … carry the same number"). Read off the run's
|
||||||
|
// own column the card lagged — a unit_done bumps the book and the chapter and not the run, so
|
||||||
|
// a client that had applied frame id=2 got revision 0 back and, obeying the contract, dropped
|
||||||
|
// the read. The stop and resume handles answer from ReadRun/RequestStop, which select the
|
||||||
|
// book's revision for the same reason.
|
||||||
|
run.Revision = b.Revision
|
||||||
return b, &run.Run, nil
|
return b, &run.Run, nil
|
||||||
}
|
}
|
||||||
return b, nil, nil
|
return b, nil, nil
|
||||||
|
|
@ -225,6 +556,10 @@ func (s *Store) lastRun(ctx context.Context, bookID string) (*runWithProgress, e
|
||||||
// ownership so a stranger's book is indistinguishable from a missing one.
|
// ownership so a stranger's book is indistinguishable from a missing one.
|
||||||
type BookRunContext struct {
|
type BookRunContext struct {
|
||||||
Workdir string
|
Workdir string
|
||||||
|
// Status is the book's own state. A run may only be started on a book whose intake finished:
|
||||||
|
// the directory of a book still `uploading` holds half a file and the directory of a `rejected`
|
||||||
|
// one may hold nothing at all.
|
||||||
|
Status string
|
||||||
// ChaptersLeft is what is still untranslated, and it clamps the ceiling scale. Until the engine
|
// ChaptersLeft is what is still untranslated, and it clamps the ceiling scale. Until the engine
|
||||||
// persists a chapter manifest (unified backlog row 100) the platform knows the chapter COUNT
|
// persists a chapter manifest (unified backlog row 100) the platform knows the chapter COUNT
|
||||||
// from intake and nothing per chapter, so a book with no materialized chapters answers with its
|
// from intake and nothing per chapter, so a book with no materialized chapters answers with its
|
||||||
|
|
@ -236,14 +571,14 @@ type BookRunContext struct {
|
||||||
// ReadBookForRun gathers the facts a run start is judged on.
|
// ReadBookForRun gathers the facts a run start is judged on.
|
||||||
func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (BookRunContext, error) {
|
func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (BookRunContext, error) {
|
||||||
const q = `
|
const q = `
|
||||||
select b.workdir,
|
select b.workdir, b.status,
|
||||||
b.chapter_count - (select count(*) from chapters c
|
b.chapter_count - (select count(*) from chapters c
|
||||||
where c.book_id = b.id and c.units_total > 0
|
where c.book_id = b.id and c.units_total > 0
|
||||||
and c.units_done >= c.units_total),
|
and c.units_done >= c.units_total),
|
||||||
exists (select 1 from runs r where r.book_id = b.id and r.finished_at is null)
|
exists (select 1 from runs r where r.book_id = b.id and r.finished_at is null)
|
||||||
from books b where b.id = $1 and b.owner_id = $2`
|
from books b where b.id = $1 and b.owner_id = $2`
|
||||||
var out BookRunContext
|
var out BookRunContext
|
||||||
err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.ChaptersLeft, &out.HasLiveRun)
|
err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.Status, &out.ChaptersLeft, &out.HasLiveRun)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return BookRunContext{}, ErrNoBook
|
return BookRunContext{}, ErrNoBook
|
||||||
}
|
}
|
||||||
|
|
|
||||||
287
platform/internal/pgstore/intake_test.go
Normal file
287
platform/internal/pgstore/intake_test.go
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
package pgstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The store half of the two walks this pack added: a book through intake, and a run through a stop
|
||||||
|
// the user asked for. What is asserted here is what only SQL can get wrong — the conditions on the
|
||||||
|
// updates, which are what keep two processes from finishing one walk twice.
|
||||||
|
|
||||||
|
func upload(t *testing.T, s *Store, ctx context.Context, owner string, now time.Time) Book {
|
||||||
|
t.Helper()
|
||||||
|
id := NewBookID()
|
||||||
|
b, err := s.CreateUpload(ctx, id, NewUpload{OwnerID: owner, Title: "蛊真人", SourceLang: "zh",
|
||||||
|
TargetLang: "ru", Workdir: "/srv/books/" + id, Now: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABookWalksThroughIntakeOnlyForwards(t *testing.T) {
|
||||||
|
s, ctx := testDB(t)
|
||||||
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
||||||
|
b := upload(t, s, ctx, "u1", now)
|
||||||
|
if b.Status != "uploading" {
|
||||||
|
t.Fatalf("a created upload is %q", b.Status)
|
||||||
|
}
|
||||||
|
if _, err := s.StartParsing(ctx, b.ID, 42, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The same call again finds no book in `uploading`: a request that finally finished must not be
|
||||||
|
// able to resurrect an upload the sweep has already given up on.
|
||||||
|
if _, err := s.StartParsing(ctx, b.ID, 42, nil); !errors.Is(err, ErrNoBook) {
|
||||||
|
t.Fatalf("a second StartParsing gave %v, want ErrNoBook", err)
|
||||||
|
}
|
||||||
|
claim, err := s.ClaimParse(ctx, b.ID, now, now.Add(-time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A verdict may only be written by the pass that still HOLDS the claim: an answer from a pass
|
||||||
|
// whose claim was taken over is an answer about work it is no longer doing — and for a rejection
|
||||||
|
// it would also delete the book's source.
|
||||||
|
stale := claim.At.Add(-time.Minute)
|
||||||
|
if err := s.FinishParse(ctx, b.ID, stale, ParsedBook{Chapters: 7}); !errors.Is(err, ErrNoBook) {
|
||||||
|
t.Fatalf("a stale claim finished the parse: %v, want ErrNoBook", err)
|
||||||
|
}
|
||||||
|
if err := s.RejectBook(ctx, b.ID, stale, ReasonForTest); !errors.Is(err, ErrNoBook) {
|
||||||
|
t.Fatalf("a stale claim rejected the book: %v, want ErrNoBook", err)
|
||||||
|
}
|
||||||
|
if err := s.FinishParse(ctx, b.ID, claim.At, ParsedBook{Chapters: 500, ChunkerVersion: "chunk-1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.FinishParse(ctx, b.ID, claim.At, ParsedBook{Chapters: 7}); !errors.Is(err, ErrNoBook) {
|
||||||
|
t.Fatalf("a second FinishParse gave %v, want ErrNoBook", err)
|
||||||
|
}
|
||||||
|
// And a parsed book can no longer be rejected: `rejected` is an END of intake, not a state a
|
||||||
|
// later pass may drop a working book into.
|
||||||
|
if err := s.RejectBook(ctx, b.ID, claim.At, ReasonForTest); !errors.Is(err, ErrNoBook) {
|
||||||
|
t.Fatalf("rejecting a parsed book gave %v, want ErrNoBook", err)
|
||||||
|
}
|
||||||
|
got, _, err := s.GetBook(ctx, "u1", b.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Status != "not_started" || got.ChapterCount != 500 || got.CharacterCount != 42 {
|
||||||
|
t.Fatalf("after the walk: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReasonForTest stands for one of the platform's own reject reasons; the vocabulary itself lives in
|
||||||
|
// the intake package, and this file only needs a value.
|
||||||
|
const ReasonForTest = "source_unreadable"
|
||||||
|
|
||||||
|
// The claim is what keeps two `tmctl manifest` processes off one project directory, and the grace is
|
||||||
|
// what keeps a claim from being permanent when the process holding it dies.
|
||||||
|
func TestAParseClaimIsTakenOnceAndRecoveredAfterTheGrace(t *testing.T) {
|
||||||
|
s, ctx := testDB(t)
|
||||||
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
||||||
|
b := upload(t, s, ctx, "u1", now)
|
||||||
|
if _, err := s.StartParsing(ctx, b.ID, 1, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stale := now.Add(-time.Hour)
|
||||||
|
first, err := s.ClaimParse(ctx, b.ID, now, stale)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.Attempts != 1 || first.Workdir == "" {
|
||||||
|
t.Fatalf("first claim: %+v", first)
|
||||||
|
}
|
||||||
|
if _, err := s.ClaimParse(ctx, b.ID, now, stale); !errors.Is(err, ErrParseClaimed) {
|
||||||
|
t.Fatalf("a second claim gave %v, want ErrParseClaimed", err)
|
||||||
|
}
|
||||||
|
// The holder is gone and its claim has aged past the grace: the next pass may take it, and the
|
||||||
|
// attempt counter — which is what bounds a broken host — keeps counting. An hour later, with the
|
||||||
|
// grace the intake actually uses, the first claim is well behind the line.
|
||||||
|
later := now.Add(time.Hour)
|
||||||
|
again, err := s.ClaimParse(ctx, b.ID, later, later.Add(-10*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again.Attempts != 2 {
|
||||||
|
t.Fatalf("the attempt counter reads %d after a recovered claim", again.Attempts)
|
||||||
|
}
|
||||||
|
// ⚠ And the claim is what SPACES the retries: it stays held after a failed parse, so the book is
|
||||||
|
// not claimable again until the grace passes. Without that the sweep re-offers it on its very next
|
||||||
|
// tick — the staleness predicate falls back to `added_at`, which is already old — and the attempt
|
||||||
|
// budget burns in five ticks of the sweep instead of in the time it is meant to bound.
|
||||||
|
if _, err := s.ClaimParse(ctx, b.ID, later, later.Add(-10*time.Minute)); !errors.Is(err, ErrParseClaimed) {
|
||||||
|
t.Fatalf("a fresh claim was retakeable at once: %v, want ErrParseClaimed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUpload is narrow on purpose: it is the cure for a request that went away, and it must never
|
||||||
|
// become a way to delete a book that has cost money.
|
||||||
|
func TestOnlyAnUnfinishedUploadCanBeDeleted(t *testing.T) {
|
||||||
|
s, ctx := testDB(t)
|
||||||
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
||||||
|
b := upload(t, s, ctx, "u1", now)
|
||||||
|
if _, err := s.StartParsing(ctx, b.ID, 1, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteUpload(ctx, b.ID); !errors.Is(err, ErrNoBook) {
|
||||||
|
t.Fatalf("deleting a book that is being parsed gave %v, want ErrNoBook", err)
|
||||||
|
}
|
||||||
|
// A book back in `uploading` but with a run against it stays as well: the run's money is keyed to
|
||||||
|
// it, and the cascade would take the row out from under the ledger.
|
||||||
|
if _, err := s.pool.Exec(ctx, `update books set status = 'uploading' where id = $1`, b.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: b.ID, CeilingChapters: 1,
|
||||||
|
Ceiling: 1_000_000, Now: now}, 0, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteUpload(ctx, b.ID); !errors.Is(err, ErrNoBook) {
|
||||||
|
t.Fatalf("deleting a book with a run gave %v, want ErrNoBook", err)
|
||||||
|
}
|
||||||
|
other := upload(t, s, ctx, "u1", now)
|
||||||
|
if err := s.DeleteUpload(ctx, other.ID); err != nil {
|
||||||
|
t.Fatalf("an untouched upload could not be deleted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stop intent: idempotent, first timestamp kept, and the two refusals told apart.
|
||||||
|
func TestAStopRequestIsIdempotentAndKeepsTheFirstTime(t *testing.T) {
|
||||||
|
s, ctx := testDB(t)
|
||||||
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
||||||
|
fundedAccount(t, s, ctx, "u2", "10")
|
||||||
|
bookID, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru",
|
||||||
|
ChapterCount: 10, Workdir: "/srv/books/b", Now: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: bookID, CeilingChapters: 1,
|
||||||
|
Ceiling: 1_000_000, Now: now}, 0, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.RequestStop(ctx, "u2", started.ID, now); !errors.Is(err, ErrNoRun) {
|
||||||
|
t.Fatalf("a stranger's stop gave %v, want ErrNoRun", err)
|
||||||
|
}
|
||||||
|
run, unit, err := s.RequestStop(ctx, "u1", started.ID, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if run.ID != started.ID || unit != "" {
|
||||||
|
t.Fatalf("stop returned %+v with unit %q (nothing was spawned)", run, unit)
|
||||||
|
}
|
||||||
|
if _, _, err := s.RequestStop(ctx, "u1", started.ID, now.Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("a second stop: %v", err)
|
||||||
|
}
|
||||||
|
var at time.Time
|
||||||
|
if err := s.pool.QueryRow(ctx, `select stop_requested_at from runs where id = $1`, started.ID).Scan(&at); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !at.Equal(now) {
|
||||||
|
t.Fatalf("the stop is stamped %s, want the FIRST request at %s", at, now)
|
||||||
|
}
|
||||||
|
// Over means over: a finished run is a conflict, not a missing one.
|
||||||
|
if _, err := s.FinishRun(ctx, started.ID, started.AttemptID, "stopped", "success", nil, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.RequestStop(ctx, "u1", started.ID, now); !errors.Is(err, ErrRunNotLive) {
|
||||||
|
t.Fatalf("stopping a finished run gave %v, want ErrRunNotLive", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A resumed run is LIVE again, and the three columns that say otherwise have to go with it: an
|
||||||
|
// unfinished run the one-live-per-book index cannot see, money marked resolved that the new attempt
|
||||||
|
// has not spent, and a stop request from the previous life that would classify this attempt's ending
|
||||||
|
// as a stop nobody asked for.
|
||||||
|
func TestReopeningARunClearsWhatSaidItWasOver(t *testing.T) {
|
||||||
|
s, ctx := testDB(t)
|
||||||
|
now := fundedAccount(t, s, ctx, "u1", "20")
|
||||||
|
bookID, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru",
|
||||||
|
ChapterCount: 10, Workdir: "/srv/books/b", Now: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: bookID, CeilingChapters: 1,
|
||||||
|
Ceiling: 1_000_000, Now: now}, 0, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.RequestStop(ctx, "u1", started.ID, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.FinishRun(ctx, started.ID, started.AttemptID, "stopped", "exit-code", nil, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.MarkSettled(ctx, started.ID, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.RestartRun(ctx, RestartInput{RunID: started.ID, AttemptID: started.AttemptID,
|
||||||
|
UserID: "u1", BookID: bookID, Ceiling: 500_000, Now: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var finished, settled, stop *time.Time
|
||||||
|
var status, bookStatus string
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
select r.finished_at, r.settled_at, r.stop_requested_at, r.status, b.status
|
||||||
|
from runs r join books b on b.id = r.book_id where r.id = $1`, started.ID).
|
||||||
|
Scan(&finished, &settled, &stop, &status, &bookStatus); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if finished != nil || settled != nil || stop != nil {
|
||||||
|
t.Fatalf("a reopened run still says it is over: finished %v settled %v stop %v", finished, settled, stop)
|
||||||
|
}
|
||||||
|
if status != "translating" || bookStatus != "translating" {
|
||||||
|
t.Fatalf("run %q, book %q after a reopen", status, bookStatus)
|
||||||
|
}
|
||||||
|
// The verdict of the attempt that ended is NOT overwritten: how the run the user stopped actually
|
||||||
|
// ended is the one fact a resume must not erase.
|
||||||
|
var result string
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`select exit_result from run_attempts where id = $1`, started.AttemptID).Scan(&result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result != "exit-code" {
|
||||||
|
t.Fatalf("the previous attempt's exit_result became %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One counter per book, and this store is where that rule lives: every Run it hands out — from the
|
||||||
|
// card, from a stop, from a plain read — carries its BOOK's revision. A run's own column lags by
|
||||||
|
// construction (a materialized unit bumps the book and the chapter, not the run), and the contract
|
||||||
|
// tells a client to DROP a read whose revision is below one it applied, so a handle answering from
|
||||||
|
// the run's column hands back a number the client is required to throw away.
|
||||||
|
func TestEveryRunTheStoreHandsOutCarriesItsBooksRevision(t *testing.T) {
|
||||||
|
s, ctx := testDB(t)
|
||||||
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
||||||
|
bookID, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru",
|
||||||
|
ChapterCount: 10, Workdir: "/srv/books/b", Now: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: bookID, CeilingChapters: 1,
|
||||||
|
Ceiling: 1_000_000, Now: now}, 0, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Something materializes: the book's counter moves and the run's own column stays where it was.
|
||||||
|
if _, err := s.pool.Exec(ctx, `update books set revision = revision + 7 where id = $1`, bookID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
book, card, err := s.GetBook(ctx, "u1", bookID)
|
||||||
|
if err != nil || card == nil {
|
||||||
|
t.Fatalf("card: %v", err)
|
||||||
|
}
|
||||||
|
read, err := s.ReadRun(ctx, "u1", started.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stop, _, err := s.RequestStop(ctx, "u1", started.ID, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, got := range map[string]int64{"card": card.Revision, "read": read.Revision, "stop": stop.Revision} {
|
||||||
|
if got != book.Revision {
|
||||||
|
t.Errorf("%s answered revision %d, the book is at %d", name, got, book.Revision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,3 +25,5 @@ c225e1a12bab8c62669848976096453aa84d66263fe211da0a5eb4b173ff2eff 00007_credits.
|
||||||
905b89bcecffd53cda7c628f7e1c3b9dad522a8947fbcd7dbe31dfb9644c644e 00010_spend_baseline.sql
|
905b89bcecffd53cda7c628f7e1c3b9dad522a8947fbcd7dbe31dfb9644c644e 00010_spend_baseline.sql
|
||||||
76f3bb045cf3a2d232065418357f534752ddb06e28e4122e1d45c8f59c23abf8 00011_ceiling_argument.sql
|
76f3bb045cf3a2d232065418357f534752ddb06e28e4122e1d45c8f59c23abf8 00011_ceiling_argument.sql
|
||||||
282ff2d515d9de16f631a5cd77f676c7742337f96406b8876c900f0b44ea19a0 00012_resync_stamp.sql
|
282ff2d515d9de16f631a5cd77f676c7742337f96406b8876c900f0b44ea19a0 00012_resync_stamp.sql
|
||||||
|
1cb0d8127ea8ad0391b6829c2d09a7b776ebc9729002ae5db76afe5daa7a790b 00013_book_intake.sql
|
||||||
|
4281cfce2a34179333843f72257f3d9e62c71480a129168d613066aa8ad3687e 00014_stop_intent.sql
|
||||||
|
|
|
||||||
39
platform/internal/pgstore/migrations/00013_book_intake.sql
Normal file
39
platform/internal/pgstore/migrations/00013_book_intake.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- The intake half of a book: what `POST /books` needs to move a row through
|
||||||
|
-- `uploading → parsing → not_started | rejected` and what a backstop sweep needs to finish that
|
||||||
|
-- walk when the process doing it died halfway.
|
||||||
|
--
|
||||||
|
-- Until this pack the three intake statuses existed in the contract and in the DDL check and had no
|
||||||
|
-- writer at all: a book could only be registered by the dev CLI, already parsed, straight into
|
||||||
|
-- `not_started`.
|
||||||
|
|
||||||
|
alter table books
|
||||||
|
-- How many times parsing has been CLAIMED for this book. Parsing is one $0 call of the engine
|
||||||
|
-- (`tmctl manifest`), and it fails in two different ways: the source cannot be cut (the book's
|
||||||
|
-- own fault, terminal) or the engine could not be run at all (the deployment's fault, and the
|
||||||
|
-- next sweep may well succeed). Only the second is retried, and only this counter bounds that
|
||||||
|
-- retry — without it a misconfigured host would re-run the engine over every uploaded book
|
||||||
|
-- forever.
|
||||||
|
add column parse_attempts integer not null default 0,
|
||||||
|
-- When the current parse was claimed. It is the CLAIM, not a timestamp for display: a book in
|
||||||
|
-- `parsing` whose claim is older than the grace is a parse whose process is gone, and the sweep
|
||||||
|
-- may take it again. Null means no claim is outstanding.
|
||||||
|
add column parse_started_at timestamptz,
|
||||||
|
-- Why a book was rejected, in the platform's own closed vocabulary — never the engine's text,
|
||||||
|
-- which reads like pipeline internals and must not cross the seam (contract §Problem, PT-33).
|
||||||
|
-- ⚠ NOT on the wire: contract v0 gives a book a `rejected` status and no reason field, so this
|
||||||
|
-- is written for an operator and for whatever later pack adds one. Inventing the field here is
|
||||||
|
-- not this zone's right (the precedent is last_resync_at, register row PD-150).
|
||||||
|
add column reject_reason text not null default '';
|
||||||
|
|
||||||
|
-- The sweep asks one question — "which books are stuck in intake" — every few seconds, and the
|
||||||
|
-- answer is a handful of rows in a table that grows with every book of every account.
|
||||||
|
create index books_intake_idx on books (status, added_at) where status in ('uploading', 'parsing');
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
drop index books_intake_idx;
|
||||||
|
alter table books
|
||||||
|
drop column reject_reason,
|
||||||
|
drop column parse_started_at,
|
||||||
|
drop column parse_attempts;
|
||||||
20
platform/internal/pgstore/migrations/00014_stop_intent.sql
Normal file
20
platform/internal/pgstore/migrations/00014_stop_intent.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- When THIS platform asked the run to stop.
|
||||||
|
--
|
||||||
|
-- It exists because the exit marker cannot answer "who ended this run". The engine catches SIGTERM
|
||||||
|
-- and exits with code 1, so `ExecStopPost` records `exit-code/exited/1` for a stop a user pressed
|
||||||
|
-- and for a crash alike (register row PD-152, measured on the stand). Guessing from the code would
|
||||||
|
-- be reading someone else's tea leaves; this column is the platform's OWN knowledge, written before
|
||||||
|
-- the signal is sent, and the reconciler classifies the marker by it.
|
||||||
|
--
|
||||||
|
-- The order matters and is the whole point: the intent is committed BEFORE systemd is asked, so a
|
||||||
|
-- platform that dies between the two still knows, on its next sweep, that the run it finds stopped
|
||||||
|
-- was stopped on purpose — and that it must not restart it.
|
||||||
|
--
|
||||||
|
-- Null means nobody asked. Cleared when a run is re-opened, because a resumed run has not been
|
||||||
|
-- asked to stop; a stale intent would classify its next ending as a stop nobody requested.
|
||||||
|
alter table runs add column stop_requested_at timestamptz;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
alter table runs drop column stop_requested_at;
|
||||||
48
platform/internal/pgstore/observe.go
Normal file
48
platform/internal/pgstore/observe.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package pgstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Observations is one reading of the state an operator cannot otherwise see.
|
||||||
|
//
|
||||||
|
// Every figure here used to be answerable only by opening psql (zone backlog П-11): how much work is
|
||||||
|
// queued, whether money is stuck in a hold nothing closes, whether a live run's projection has been
|
||||||
|
// blinded, how many books are stuck in intake. They are read in ONE statement because a set of them
|
||||||
|
// taken at different moments describes a state the system was never in.
|
||||||
|
type Observations struct {
|
||||||
|
QueueDepth int64
|
||||||
|
OldestHoldSeconds float64
|
||||||
|
QuarantinedAttempts int64
|
||||||
|
LiveRuns int64
|
||||||
|
BooksUploading int64
|
||||||
|
BooksParsing int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe reads the control plane's own state.
|
||||||
|
//
|
||||||
|
// The queue's table is asked for through to_regclass rather than assumed: River owns that schema and
|
||||||
|
// migrates it separately (STACK_DECISIONS §19), so an instance whose queue migration has not run
|
||||||
|
// answers a readiness probe about it and must not fail its telemetry over it as well.
|
||||||
|
func (s *Store) Observe(ctx context.Context) (Observations, error) {
|
||||||
|
const q = `
|
||||||
|
select
|
||||||
|
(select case when to_regclass('river_job') is null then 0 else
|
||||||
|
(select count(*) from river_job
|
||||||
|
where state in ('available', 'running', 'scheduled', 'retryable', 'pending')) end),
|
||||||
|
(select coalesce(extract(epoch from (now() - min(opened_at))), 0)
|
||||||
|
from reservations where state = 'open'),
|
||||||
|
(select count(*) from run_attempts
|
||||||
|
where quarantine_reason is not null and ended_at is null),
|
||||||
|
(select count(*) from runs where finished_at is null),
|
||||||
|
(select count(*) from books where status = 'uploading'),
|
||||||
|
(select count(*) from books where status = 'parsing')`
|
||||||
|
var o Observations
|
||||||
|
err := s.pool.QueryRow(ctx, q).Scan(&o.QueueDepth, &o.OldestHoldSeconds, &o.QuarantinedAttempts,
|
||||||
|
&o.LiveRuns, &o.BooksUploading, &o.BooksParsing)
|
||||||
|
if err != nil {
|
||||||
|
return Observations{}, fmt.Errorf("pgstore: observe: %w", err)
|
||||||
|
}
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
|
@ -105,7 +105,8 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
update books set status = 'translating', revision = revision + 1 where id = $1`, in.BookID); err != nil {
|
update books set status = 'translating',
|
||||||
|
revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, in.BookID); err != nil {
|
||||||
return fmt.Errorf("pgstore: mark book translating: %w", err)
|
return fmt.Errorf("pgstore: mark book translating: %w", err)
|
||||||
}
|
}
|
||||||
if enqueue != nil {
|
if enqueue != nil {
|
||||||
|
|
@ -168,6 +169,13 @@ type LiveRun struct {
|
||||||
// PausedReason is what the stream already said about this run. A run that reported a ceiling halt
|
// PausedReason is what the stream already said about this run. A run that reported a ceiling halt
|
||||||
// is `paused` however its process then ended (contract §BookStatus: never `failed`).
|
// is `paused` however its process then ended (contract §BookStatus: never `failed`).
|
||||||
PausedReason string
|
PausedReason string
|
||||||
|
// Status is the run's product status. The reconciler works on live runs, where it is
|
||||||
|
// `translating`; the resume path reads a run that has already ended and decides by it.
|
||||||
|
Status string
|
||||||
|
// StopRequestedAt is when THIS platform asked the run to stop, and it is the only thing that can
|
||||||
|
// tell a stop from a crash: the engine catches SIGTERM and exits 1, so the marker says
|
||||||
|
// `exit-code/exited/1` for both (register row PD-152). Nil means nobody asked.
|
||||||
|
StopRequestedAt *time.Time
|
||||||
// StartedAt is the RUN's start; AttemptStartedAt is THIS attempt's. The reconciler's grace is
|
// StartedAt is the RUN's start; AttemptStartedAt is THIS attempt's. The reconciler's grace is
|
||||||
// measured against the second: a restarted attempt inherits a start time hours old, and the
|
// measured against the second: a restarted attempt inherits a start time hours old, and the
|
||||||
// grace then expires before systemd has had a chance to create anything.
|
// grace then expires before systemd has had a chance to create anything.
|
||||||
|
|
@ -202,7 +210,7 @@ func (s *Store) ListLiveRuns(ctx context.Context) ([]LiveRun, error) {
|
||||||
// differ only in which runs they select, and a scan list copied twice is a scan list that drifts.
|
// differ only in which runs they select, and a scan list copied twice is a scan list that drifts.
|
||||||
const runColumns = `
|
const runColumns = `
|
||||||
select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, r.ceiling_chapters,
|
select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, r.ceiling_chapters,
|
||||||
coalesce(r.paused_reason, ''), r.started_at,
|
coalesce(r.paused_reason, ''), r.started_at, r.status, r.stop_requested_at,
|
||||||
a.id, a.attempt_no, coalesce(a.unit_name, ''), coalesce(a.engine_run_id, ''),
|
a.id, a.attempt_no, coalesce(a.unit_name, ''), coalesce(a.engine_run_id, ''),
|
||||||
a.last_offset, a.last_seq, a.last_line_sha256, a.quarantine_reason is not null,
|
a.last_offset, a.last_seq, a.last_line_sha256, a.quarantine_reason is not null,
|
||||||
coalesce(res.amount_micro_usd, 0), a.engine_binary, a.spend_baseline_micro_usd,
|
coalesce(res.amount_micro_usd, 0), a.engine_binary, a.spend_baseline_micro_usd,
|
||||||
|
|
@ -210,8 +218,8 @@ const runColumns = `
|
||||||
from runs r
|
from runs r
|
||||||
join books b on b.id = r.book_id`
|
join books b on b.id = r.book_id`
|
||||||
|
|
||||||
func (s *Store) queryRuns(ctx context.Context, tail string) ([]LiveRun, error) {
|
func (s *Store) queryRuns(ctx context.Context, tail string, args ...any) ([]LiveRun, error) {
|
||||||
rows, err := s.pool.Query(ctx, runColumns+tail)
|
rows, err := s.pool.Query(ctx, runColumns+tail, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("pgstore: list runs: %w", err)
|
return nil, fmt.Errorf("pgstore: list runs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +230,7 @@ func (s *Store) queryRuns(ctx context.Context, tail string) ([]LiveRun, error) {
|
||||||
var ceiling, ceilingArg int64
|
var ceiling, ceilingArg int64
|
||||||
var baseline *int64
|
var baseline *int64
|
||||||
if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.CeilingChapters,
|
if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.CeilingChapters,
|
||||||
&l.PausedReason, &l.StartedAt,
|
&l.PausedReason, &l.StartedAt, &l.Status, &l.StopRequestedAt,
|
||||||
&l.AttemptID, &l.AttemptNo, &l.UnitName, &l.EngineRunID,
|
&l.AttemptID, &l.AttemptNo, &l.UnitName, &l.EngineRunID,
|
||||||
&l.Position.Offset, &l.Position.LastSeq, &l.Position.LastHash, &l.Quarantined, &ceiling,
|
&l.Position.Offset, &l.Position.LastSeq, &l.Position.LastHash, &l.Quarantined, &ceiling,
|
||||||
&l.EngineBinary, &baseline, &ceilingArg, &l.AttemptStartedAt); err != nil {
|
&l.EngineBinary, &baseline, &ceilingArg, &l.AttemptStartedAt); err != nil {
|
||||||
|
|
@ -282,6 +290,114 @@ func (s *Store) SpendBound(ctx context.Context, bookID string, attemptID int64)
|
||||||
return &bound, nil
|
return &bound, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrRunNotLive is a run that cannot be stopped because it is already over. Distinct from ErrNoRun,
|
||||||
|
// which is a run this account cannot see at all: the first is the contract's 409 and the second its
|
||||||
|
// 404, and answering the wrong one either tells a stranger that a run exists or tells an owner that
|
||||||
|
// theirs does not.
|
||||||
|
var ErrRunNotLive = errors.New("pgstore: the run is not live")
|
||||||
|
|
||||||
|
// ErrStopRequested is a restart refused because the run it would restart has been asked to stop. Not
|
||||||
|
// an error of the caller: the reconciler ends the run instead.
|
||||||
|
var ErrStopRequested = errors.New("pgstore: the run has been asked to stop")
|
||||||
|
|
||||||
|
// RequestStop records that THIS platform asked a run to stop, and hands back the unit to ask.
|
||||||
|
//
|
||||||
|
// The order is the whole design (migration 00014): the intent is COMMITTED before systemd is
|
||||||
|
// touched, so a platform that dies between the two still knows on its next sweep that the run it
|
||||||
|
// finds ended was stopped on purpose — and, just as important, that it must not restart it.
|
||||||
|
//
|
||||||
|
// Idempotent by coalesce: pressing stop twice is one intent with the FIRST timestamp, because the
|
||||||
|
// timestamp is evidence about which of the two events came first and a later one would erase that.
|
||||||
|
//
|
||||||
|
// The revision it answers with is the BOOK's — see ReadRun for why every run-carrying response uses
|
||||||
|
// one counter.
|
||||||
|
func (s *Store) RequestStop(ctx context.Context, userID, runID string, now time.Time) (Run, string, error) {
|
||||||
|
const q = `
|
||||||
|
update runs r set stop_requested_at = coalesce(r.stop_requested_at, $3)
|
||||||
|
from books b
|
||||||
|
where r.id = $1 and b.id = r.book_id and b.owner_id = $2 and r.finished_at is null
|
||||||
|
returning r.id, r.book_id, b.revision, r.status, r.verify_bank, r.ceiling_chapters,
|
||||||
|
coalesce(r.paused_reason, ''), r.started_at, r.finished_at,
|
||||||
|
coalesce((select a.unit_name from run_attempts a
|
||||||
|
where a.run_id = r.id and a.ended_at is null
|
||||||
|
order by a.attempt_no desc limit 1), '')`
|
||||||
|
var out Run
|
||||||
|
var unit string
|
||||||
|
err := s.pool.QueryRow(ctx, q, runID, userID, now).Scan(&out.ID, &out.BookID, &out.Revision,
|
||||||
|
&out.Status, &out.VerifyBank, &out.CeilingChapters, &out.PausedReason, &out.StartedAt,
|
||||||
|
&out.FinishedAt, &unit)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Nothing matched, and the two reasons need different answers. Asked separately and only on
|
||||||
|
// this path, so the ordinary stop stays one round trip.
|
||||||
|
return Run{}, "", s.whyNotLive(ctx, userID, runID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Run{}, "", fmt.Errorf("pgstore: request stop: %w", err)
|
||||||
|
}
|
||||||
|
return out, unit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) whyNotLive(ctx context.Context, userID, runID string) error {
|
||||||
|
var visible bool
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
select exists (select 1 from runs r join books b on b.id = r.book_id
|
||||||
|
where r.id = $1 and b.owner_id = $2)`, runID, userID).Scan(&visible); err != nil {
|
||||||
|
return fmt.Errorf("pgstore: read run: %w", err)
|
||||||
|
}
|
||||||
|
if visible {
|
||||||
|
return ErrRunNotLive
|
||||||
|
}
|
||||||
|
return ErrNoRun
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadRunForResume loads a run and its LAST attempt, whether or not either is still live.
|
||||||
|
//
|
||||||
|
// The reconciler's own list is deliberately not reusable here: it selects the attempt that has not
|
||||||
|
// ended, and every run this call is about has ended. What resume needs is the state the run stopped
|
||||||
|
// in and the attempt whose money and journal position it stopped at.
|
||||||
|
func (s *Store) ReadRunForResume(ctx context.Context, userID, runID string) (LiveRun, error) {
|
||||||
|
rows, err := s.queryRuns(ctx, `
|
||||||
|
join run_attempts a on a.run_id = r.id
|
||||||
|
and a.attempt_no = (select max(attempt_no) from run_attempts
|
||||||
|
where run_id = r.id)
|
||||||
|
left join reservations res on res.engine_run_id = r.id || '#' || a.attempt_no
|
||||||
|
and res.state = 'open'
|
||||||
|
where r.id = $1 and b.owner_id = $2`, runID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return LiveRun{}, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return LiveRun{}, ErrNoRun
|
||||||
|
}
|
||||||
|
return rows[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadRun is the run row as the contract projects it, read under the caller's ownership.
|
||||||
|
//
|
||||||
|
// ⚠ The revision is the BOOK's, not the `runs.revision` column, and that is the contract rather than
|
||||||
|
// a shortcut: "the counter is PER BOOK — every book-scoped read and the id of every stream frame of
|
||||||
|
// that book's run carry the same number" (§Revision), and a client MUST DROP a read whose revision is
|
||||||
|
// below one it has applied. The book card already answers this way (httpapi getBook); a handle that
|
||||||
|
// answered from the run's own column would hand the client a number below the card's and the client,
|
||||||
|
// obeying the contract, would drop the answer to the button it just pressed.
|
||||||
|
func (s *Store) ReadRun(ctx context.Context, userID, runID string) (Run, error) {
|
||||||
|
const q = `
|
||||||
|
select r.id, r.book_id, b.revision, r.status, r.verify_bank, r.ceiling_chapters,
|
||||||
|
coalesce(r.paused_reason, ''), r.started_at, r.finished_at
|
||||||
|
from runs r join books b on b.id = r.book_id
|
||||||
|
where r.id = $1 and b.owner_id = $2`
|
||||||
|
var out Run
|
||||||
|
err := s.pool.QueryRow(ctx, q, runID, userID).Scan(&out.ID, &out.BookID, &out.Revision, &out.Status,
|
||||||
|
&out.VerifyBank, &out.CeilingChapters, &out.PausedReason, &out.StartedAt, &out.FinishedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Run{}, ErrNoRun
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Run{}, fmt.Errorf("pgstore: read run: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RestartInput is one interrupted attempt being replaced.
|
// RestartInput is one interrupted attempt being replaced.
|
||||||
type RestartInput struct {
|
type RestartInput struct {
|
||||||
RunID string
|
RunID string
|
||||||
|
|
@ -311,12 +427,48 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error
|
||||||
if err := lockBook(ctx, tx, in.BookID); err != nil {
|
if err := lockBook(ctx, tx, in.BookID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// The RUN row second — the order lockBook writes down, which this transaction used to take
|
||||||
|
// last, after the money. Taking it here also does the work below: what is read from the row is
|
||||||
|
// read under the lock that will do the writing.
|
||||||
|
var stopRequested, finished *time.Time
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`select stop_requested_at, finished_at from runs where id = $1 for update`, in.RunID).
|
||||||
|
Scan(&stopRequested, &finished); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrNoRun
|
||||||
|
}
|
||||||
|
return fmt.Errorf("pgstore: lock run: %w", err)
|
||||||
|
}
|
||||||
|
// A stop asked for on a run that is STILL LIVE outranks a restart, and the check belongs here
|
||||||
|
// rather than in the caller's snapshot: the reconciler decides to restart, spends seconds
|
||||||
|
// settling (a `tmctl status` call), and the user presses stop inside that window. Restarting
|
||||||
|
// then cleared the fresh intent, opened a second attempt and took a new hold — the user had a
|
||||||
|
// 202 for a stop that never happened and paid for the work they had just cancelled.
|
||||||
|
//
|
||||||
|
// On a FINISHED run the same column is history: a resume re-opens a run that was stopped, and
|
||||||
|
// the intent belongs to the life that ended — it is cleared below with the rest of what said
|
||||||
|
// the run was over.
|
||||||
|
//
|
||||||
|
// A stop arriving DURING this transaction is not lost either: it waits on this row lock and
|
||||||
|
// lands on the re-opened run, which the next sweep then stops.
|
||||||
|
if stopRequested != nil && finished == nil {
|
||||||
|
return ErrStopRequested
|
||||||
|
}
|
||||||
|
// The previous attempt is closed if it is still open, and left exactly as it is if it is not.
|
||||||
|
// Both callers arrive here: the reconciler replaces an attempt that was INTERRUPTED and is
|
||||||
|
// still open, while a resume continues a run whose attempt already ended with a verdict of its
|
||||||
|
// own — and overwriting that verdict would erase how the run the user stopped actually ended.
|
||||||
|
//
|
||||||
|
// What serializes two callers is no longer this row but the next one: `unique (run_id,
|
||||||
|
// attempt_no)` lets exactly one of them insert attempt N+1, and the loser gets the same
|
||||||
|
// ErrNoRun it always got.
|
||||||
var attemptNo int
|
var attemptNo int
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
update run_attempts set ended_at = $2, exit_result = 'interrupted'
|
update run_attempts set ended_at = coalesce(ended_at, $2),
|
||||||
where id = $1 and ended_at is null returning attempt_no`, in.AttemptID, in.Now).Scan(&attemptNo); err != nil {
|
exit_result = coalesce(exit_result, 'interrupted')
|
||||||
|
where id = $1 returning attempt_no`, in.AttemptID, in.Now).Scan(&attemptNo); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return ErrNoRun // another sweep got there first
|
return ErrNoRun // the attempt is gone
|
||||||
}
|
}
|
||||||
return fmt.Errorf("pgstore: close interrupted attempt: %w", err)
|
return fmt.Errorf("pgstore: close interrupted attempt: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -333,17 +485,41 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error
|
||||||
from run_attempts prev where prev.id = $6
|
from run_attempts prev where prev.id = $6
|
||||||
returning id, engine_binary`, in.RunID, next, in.Now, in.Offset, in.EngineBinary, in.AttemptID).
|
returning id, engine_binary`, in.RunID, next, in.Now, in.Offset, in.EngineBinary, in.AttemptID).
|
||||||
Scan(&id, &pinned); err != nil {
|
Scan(&id, &pinned); err != nil {
|
||||||
|
if isUnique(err, "run_attempts_run_id_attempt_no_key") {
|
||||||
|
return ErrNoRun // another caller opened this attempt first
|
||||||
|
}
|
||||||
return fmt.Errorf("pgstore: open next attempt: %w", err)
|
return fmt.Errorf("pgstore: open next attempt: %w", err)
|
||||||
}
|
}
|
||||||
if err := holdTx(ctx, tx, in.UserID, in.BookID, engineRunKey(in.RunID, next), in.Ceiling, in.Now); err != nil {
|
if err := holdTx(ctx, tx, in.UserID, in.BookID, engineRunKey(in.RunID, next), in.Ceiling, in.Now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// finished_at, settled_at and stop_requested_at are CLEARED, and each for its own reason. The
|
||||||
|
// reconciler's restart works on a run where all three are already null, so there it changes
|
||||||
|
// nothing; a RESUME re-opens a run that ended, and leaving them would mean a live run the
|
||||||
|
// one-live-per-book index does not see, money marked resolved that this attempt has not spent
|
||||||
|
// yet, and a stop request from the previous life that would classify this attempt's ending as
|
||||||
|
// a stop nobody asked for.
|
||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
update runs set status = 'translating', paused_reason = null,
|
update runs set status = 'translating', paused_reason = null, finished_at = null,
|
||||||
|
settled_at = null, stop_requested_at = null,
|
||||||
revision = (select revision + 1 from books where id = runs.book_id)
|
revision = (select revision + 1 from books where id = runs.book_id)
|
||||||
where id = $1`, in.RunID); err != nil {
|
where id = $1`, in.RunID); err != nil {
|
||||||
|
// Clearing finished_at puts the run back under the one-live-run-per-book index, and the
|
||||||
|
// book may already have a NEWER live run — nothing stops an account starting one after it
|
||||||
|
// stopped this one. That is a conflict and not a failure: the whole transaction rolls back,
|
||||||
|
// so the hold taken three lines above is undone with it, and the caller gets the same error
|
||||||
|
// a second admission would have got.
|
||||||
|
if isUnique(err, "runs_one_live_per_book") {
|
||||||
|
return ErrRunInFlight
|
||||||
|
}
|
||||||
return fmt.Errorf("pgstore: reopen run: %w", err)
|
return fmt.Errorf("pgstore: reopen run: %w", err)
|
||||||
}
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
update books set status = 'translating',
|
||||||
|
revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`,
|
||||||
|
in.BookID); err != nil {
|
||||||
|
return fmt.Errorf("pgstore: mark book translating: %w", err)
|
||||||
|
}
|
||||||
out = LiveRun{RunID: in.RunID, BookID: in.BookID, UserID: in.UserID, AttemptID: id,
|
out = LiveRun{RunID: in.RunID, BookID: in.BookID, UserID: in.UserID, AttemptID: id,
|
||||||
AttemptNo: next, Ceiling: in.Ceiling, StartedAt: in.Now, AttemptStartedAt: in.Now,
|
AttemptNo: next, Ceiling: in.Ceiling, StartedAt: in.Now, AttemptStartedAt: in.Now,
|
||||||
EngineBinary: pinned, Position: Position{Offset: in.Offset}}
|
EngineBinary: pinned, Position: Position{Offset: in.Offset}}
|
||||||
|
|
@ -403,21 +579,49 @@ func (s *Store) PauseRun(ctx context.Context, runID string, attemptID int64, rea
|
||||||
}
|
}
|
||||||
return fmt.Errorf("pgstore: lock book: %w", err)
|
return fmt.Errorf("pgstore: lock book: %w", err)
|
||||||
}
|
}
|
||||||
|
// A stop the user asked for outranks a pause, and the check belongs inside this TRANSACTION,
|
||||||
|
// under the same locks: the reconciler decides to pause after settling — seconds of a
|
||||||
|
// `tmctl status` call — and a stop landing inside that window would otherwise be answered with
|
||||||
|
// `paused/credit_exhausted`, which
|
||||||
|
// says the money ran out when what happened is that its owner stopped it.
|
||||||
|
var stopRequested *time.Time
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`select stop_requested_at from runs where id = $1 for update`, runID).Scan(&stopRequested); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("pgstore: lock run: %w", err)
|
||||||
|
}
|
||||||
|
if stopRequested != nil {
|
||||||
|
return ErrStopRequested
|
||||||
|
}
|
||||||
|
// The attempt being closed must still be a LIVE attempt OF THIS RUN — the third path to carry
|
||||||
|
// the guard `FinishRun` (PD-181) and `FinishUnspawnedStop` (FP5-2) already have, and it was
|
||||||
|
// missing here. Without it a pass holding an old snapshot pauses a run whose attempt has since
|
||||||
|
// been restarted: the run reads `finished`, the SECOND attempt's hold stays open and falls out
|
||||||
|
// of both `ListLiveRuns` (the run is finished) and `UnsettledRuns` (the attempt is not), and an
|
||||||
|
// engine keeps spending under a run its owner is told ran out of money. Found by cross-family
|
||||||
|
// review of the acceptance dofix.
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
update runs set status = 'paused', paused_reason = $2, finished_at = $3,
|
update runs set status = 'paused', paused_reason = $2, finished_at = $3,
|
||||||
revision = (select revision + 1 from books where id = runs.book_id)
|
revision = (select revision + 1 from books where id = runs.book_id)
|
||||||
where id = $1 and finished_at is null returning book_id`, runID, reason, now).Scan(&bookID); err != nil {
|
where id = $1 and finished_at is null
|
||||||
|
and exists (select 1 from run_attempts a
|
||||||
|
where a.id = $4 and a.run_id = runs.id and a.ended_at is null)
|
||||||
|
returning book_id`, runID, reason, now, attemptID).Scan(&bookID); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("pgstore: pause run: %w", err)
|
return fmt.Errorf("pgstore: pause run: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
update run_attempts set ended_at = coalesce(ended_at, $2) where id = $1`, attemptID, now); err != nil {
|
update run_attempts set ended_at = coalesce(ended_at, $3)
|
||||||
|
where id = $1 and run_id = $2`, attemptID, runID, now); err != nil {
|
||||||
return fmt.Errorf("pgstore: end attempt: %w", err)
|
return fmt.Errorf("pgstore: end attempt: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
update books set status = 'paused', revision = revision + 1 where id = $1`, bookID); err != nil {
|
update books set status = 'paused',
|
||||||
|
revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, bookID); err != nil {
|
||||||
return fmt.Errorf("pgstore: pause book: %w", err)
|
return fmt.Errorf("pgstore: pause book: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -467,12 +671,24 @@ type SpawnRecord struct {
|
||||||
// Written BEFORE the unit is created, so a crash between the two leaves a record to reconcile rather
|
// Written BEFORE the unit is created, so a crash between the two leaves a record to reconcile rather
|
||||||
// than an unattributable process.
|
// than an unattributable process.
|
||||||
//
|
//
|
||||||
// claimed is false when the attempt already has a unit name. It is a COMPARE-AND-SET rather than a
|
// claimed is false when the attempt already has a unit name, when the attempt has ENDED, or when its
|
||||||
// plain update because two callers legitimately reach here at once — the queue worker that was
|
// run is over. It is a COMPARE-AND-SET rather than a plain update because two callers legitimately
|
||||||
// handed the run and the reconciler that found it unspawned — and the loser must not start a second
|
// reach here at once — the queue worker that was handed the run and the reconciler that found it
|
||||||
// engine. systemd would refuse the duplicate NAME, so the accident was survivable; surviving by
|
// unspawned — and the loser must not start a second engine. systemd would refuse the duplicate NAME,
|
||||||
// someone else's uniqueness rule is not the same as being correct, and the day a resume changes the
|
// so that accident was survivable; surviving by someone else's uniqueness rule is not the same as
|
||||||
// naming it stops holding.
|
// being correct, and the day a resume changes the naming it stops holding.
|
||||||
|
//
|
||||||
|
// ⚠ The three conditions BESIDES the unit name are money, and they were not here until a run could be
|
||||||
|
// stopped before it ever spawned. The window: a worker sits inside `bookMeter` for the seconds a
|
||||||
|
// `tmctl status` takes, the user stops the run, the sweep ends it and gives the whole hold back
|
||||||
|
// (nothing was spawned) — and the worker then wakes up and creates a unit for a run that is finished
|
||||||
|
// and settled. That engine would spend against its own book cap with NO open reservation, and
|
||||||
|
// nothing would ever look at it: the reconciler lists runs by `finished_at is null` and settlements
|
||||||
|
// by an open reservation, so it is in neither list. The stop INTENT is asked about one step earlier
|
||||||
|
// for the same reason: it lands while the worker is inside that same `bookMeter`, before any sweep
|
||||||
|
// could have finished the run, and a claim granted then starts an engine for work its owner
|
||||||
|
// cancelled before it began. All three are asked in the statement that claims, because a check made
|
||||||
|
// before it is a check with a window after it.
|
||||||
func (s *Store) RecordSpawn(ctx context.Context, r SpawnRecord) (claimed bool, err error) {
|
func (s *Store) RecordSpawn(ctx context.Context, r SpawnRecord) (claimed bool, err error) {
|
||||||
// What a PREVIOUS claim of this attempt decided is kept. The claim is given back when the unit
|
// What a PREVIOUS claim of this attempt decided is kept. The claim is given back when the unit
|
||||||
// could not be created (ReleaseSpawnClaim), and "could not be created" is not the same as "was not
|
// could not be created (ReleaseSpawnClaim), and "could not be created" is not the same as "was not
|
||||||
|
|
@ -487,7 +703,9 @@ func (s *Store) RecordSpawn(ctx context.Context, r SpawnRecord) (claimed bool, e
|
||||||
ceiling_arg_micro_usd = case when spend_baseline_micro_usd is null
|
ceiling_arg_micro_usd = case when spend_baseline_micro_usd is null
|
||||||
then $5 else ceiling_arg_micro_usd end,
|
then $5 else ceiling_arg_micro_usd end,
|
||||||
spend_baseline_micro_usd = coalesce(spend_baseline_micro_usd, $6)
|
spend_baseline_micro_usd = coalesce(spend_baseline_micro_usd, $6)
|
||||||
where id = $1 and unit_name is null`,
|
where id = $1 and unit_name is null and ended_at is null
|
||||||
|
and exists (select 1 from runs r where r.id = run_attempts.run_id
|
||||||
|
and r.finished_at is null and r.stop_requested_at is null)`,
|
||||||
r.AttemptID, r.Unit, r.Binary, int64(r.Ceiling), int64(r.CeilingArg), int64(r.Baseline))
|
r.AttemptID, r.Unit, r.Binary, int64(r.Ceiling), int64(r.CeilingArg), int64(r.Baseline))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("pgstore: record spawn: %w", err)
|
return false, fmt.Errorf("pgstore: record spawn: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -425,11 +425,11 @@ func TestFinishingARunTwiceChangesNothingTheSecondTime(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
code := 0
|
code := 0
|
||||||
if err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil {
|
if _, err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
later := now.Add(time.Hour)
|
later := now.Add(time.Hour)
|
||||||
if err := s.FinishRun(ctx, run.ID, run.AttemptID, "failed", "exit-code", nil, later); err != nil {
|
if _, err := s.FinishRun(ctx, run.ID, run.AttemptID, "failed", "exit-code", nil, later); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var status string
|
var status string
|
||||||
|
|
@ -465,7 +465,7 @@ func TestAFinishedRunWithAnOpenHoldIsListedAsUnsettled(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
code := 0
|
code := 0
|
||||||
if err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil {
|
if _, err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
open, err := s.UnsettledRuns(ctx)
|
open, err := s.UnsettledRuns(ctx)
|
||||||
|
|
@ -750,7 +750,7 @@ func TestTheSettlementWorklistAndItsStampAreBothIdempotent(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
code := 0
|
code := 0
|
||||||
if err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil {
|
if _, err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := s.Settle(ctx, ReservationKey(run.ID, 1), money.MicroUSD(100_000), now); err != nil {
|
if err := s.Settle(ctx, ReservationKey(run.ID, 1), money.MicroUSD(100_000), now); err != nil {
|
||||||
|
|
@ -904,3 +904,106 @@ func TestOnlyOneSweepCanRestartAnInterruptedAttempt(t *testing.T) {
|
||||||
t.Errorf("%d attempts after four concurrent restarts", attempts)
|
t.Errorf("%d attempts after four concurrent restarts", attempts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-family review of the acceptance dofix (M1): a pause is a CLOSING path, and every closing path
|
||||||
|
// must check that the attempt it closes is still a live attempt of this run. `FinishRun` and
|
||||||
|
// `FinishUnspawnedStop` do; `PauseRun` did not, so a pass holding a snapshot of the attempt that was
|
||||||
|
// already restarted answered "the credit ran out" for a run whose second attempt is spending right
|
||||||
|
// now — and that second hold then belongs to no list at all.
|
||||||
|
func TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt(t *testing.T) {
|
||||||
|
s, ctx := testDB(t)
|
||||||
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
||||||
|
seedBook(t, s, ctx, "bk1", "u1", 500)
|
||||||
|
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", CeilingChapters: 100,
|
||||||
|
Ceiling: money.MicroUSD(3_000_000), Now: now}, 0, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.Settle(ctx, ReservationKey(run.ID, 1), money.MicroUSD(500_000), now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
next, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID, UserID: "u1",
|
||||||
|
BookID: "bk1", Ceiling: money.MicroUSD(1_000_000), Now: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.PauseRun(ctx, run.ID, run.AttemptID, PausedCreditExhausted, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
var finished *time.Time
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`select status, finished_at from runs where id = $1`, run.ID).Scan(&status, &finished); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if finished != nil {
|
||||||
|
t.Fatalf("a stale pause closed the run as %q while attempt %d is live", status, next.AttemptID)
|
||||||
|
}
|
||||||
|
var ended *time.Time
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`select ended_at from run_attempts where id = $1`, next.AttemptID).Scan(&ended); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ended != nil {
|
||||||
|
t.Fatal("a stale pause ended the LIVE attempt")
|
||||||
|
}
|
||||||
|
// The money half of the same fact: the live attempt's hold must be findable. A closed run with an
|
||||||
|
// open hold is in neither list — not in the live runs (the run is finished) and not in the
|
||||||
|
// unsettled ones (the attempt is not) — which is how a hold is lost for good.
|
||||||
|
live, err := s.ListLiveRuns(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, l := range live {
|
||||||
|
if l.AttemptID == next.AttemptID {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("the live attempt %d is not in ListLiveRuns after a stale pause", next.AttemptID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check of the dofix (FP5-11): the rule the intake writers got — take the LIBRARY's next number,
|
||||||
|
// not your own plus one — has to hold for the run's writers too. A book that is not the account's
|
||||||
|
// newest carries a counter below the library's, so `translating`, `paused` and `stopped` on it left
|
||||||
|
// `greatest(max, floor)` exactly where it was and the library screen never saw the run start.
|
||||||
|
func TestARunTransitionOnAnOlderBookStillMovesTheLibrary(t *testing.T) {
|
||||||
|
s, ctx := testDB(t)
|
||||||
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
||||||
|
seedBook(t, s, ctx, "bk1", "u1", 500)
|
||||||
|
// A second, NEWER book carries the library's maximum, so bk1's own counter is not it.
|
||||||
|
seedBook(t, s, ctx, "bk2", "u1", 500)
|
||||||
|
exec(t, s, ctx, `update books set revision = 41 where id = 'bk2'`)
|
||||||
|
before, err := s.ListBooks(ctx, "u1", 50, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", CeilingChapters: 10,
|
||||||
|
Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := s.ListBooks(ctx, "u1", 50, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after.Revision <= before.Revision {
|
||||||
|
t.Fatalf("a run started on an older book and the library still answers %d (was %d): a client "+
|
||||||
|
"obeying the contract drops that read and never sees the book start translating",
|
||||||
|
after.Revision, before.Revision)
|
||||||
|
}
|
||||||
|
// And the same for the closing half of the lifecycle.
|
||||||
|
mid := after.Revision
|
||||||
|
if err := s.PauseRun(ctx, run.ID, run.AttemptID, PausedCreditExhausted, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
end, err := s.ListBooks(ctx, "u1", 50, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if end.Revision <= mid {
|
||||||
|
t.Fatalf("the run was paused and the library still answers %d (was %d)", end.Revision, mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,15 +34,23 @@ func (s *Store) NewRunSink(attemptID int64, runID, bookID string) *RunSink {
|
||||||
// The binding is refused if the attempt already carries a DIFFERENT id: that means two engine
|
// The binding is refused if the attempt already carries a DIFFERENT id: that means two engine
|
||||||
// processes wrote into one journal under one attempt, and materializing either of them would mix
|
// processes wrote into one journal under one attempt, and materializing either of them would mix
|
||||||
// two runs' counters into one projection.
|
// two runs' counters into one projection.
|
||||||
|
//
|
||||||
|
// It is refused for an ENDED attempt too, and that half is newer than the rule above. A stop that
|
||||||
|
// landed before its engine was spawned leaves an attempt that is closed and carries no engine run id
|
||||||
|
// — a shape this zone could not produce before P5 — and an unbound, ended attempt would otherwise
|
||||||
|
// ADOPT the handshake of the attempt that replaced it: the same events would then be materialized
|
||||||
|
// twice, once per attempt, and every counter they increment would be counted twice. Found by
|
||||||
|
// cross-family review of the acceptance dofix.
|
||||||
func (r *RunSink) Begin(ctx context.Context, h ingest.Hello) error {
|
func (r *RunSink) Begin(ctx context.Context, h ingest.Hello) error {
|
||||||
tag, err := r.store.pool.Exec(ctx, `
|
tag, err := r.store.pool.Exec(ctx, `
|
||||||
update run_attempts set engine_run_id = $2
|
update run_attempts set engine_run_id = $2
|
||||||
where id = $1 and (engine_run_id is null or engine_run_id = $2)`, r.attemptID, h.EngineRunID)
|
where id = $1 and ended_at is null
|
||||||
|
and (engine_run_id is null or engine_run_id = $2)`, r.attemptID, h.EngineRunID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("pgstore: bind engine run: %w", err)
|
return fmt.Errorf("pgstore: bind engine run: %w", err)
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return fmt.Errorf("pgstore: attempt %d is already bound to another engine run", r.attemptID)
|
return fmt.Errorf("pgstore: attempt %d is over, or already bound to another engine run", r.attemptID)
|
||||||
}
|
}
|
||||||
if h.ChunkerVersion != "" {
|
if h.ChunkerVersion != "" {
|
||||||
if _, err := r.store.pool.Exec(ctx,
|
if _, err := r.store.pool.Exec(ctx,
|
||||||
|
|
@ -257,11 +265,23 @@ func (s *Store) ApplyStatus(ctx context.Context, runID, bookID string, rep inges
|
||||||
|
|
||||||
// FinishRun closes a run and its attempt: the read-model status, the end of the attempt and what
|
// FinishRun closes a run and its attempt: the read-model status, the end of the attempt and what
|
||||||
// systemd said about it. Money is NOT touched here — see Settlement.
|
// systemd said about it. Money is NOT touched here — see Settlement.
|
||||||
func (s *Store) FinishRun(ctx context.Context, runID string, attemptID int64, status, exitResult string, exitCode *int, now time.Time) error {
|
//
|
||||||
|
// closed is false when the write did not apply, and the caller must then do nothing else: the run was
|
||||||
|
// already finished, or — the case that made this a bool — the attempt it was asked to close is no
|
||||||
|
// longer the run's live one.
|
||||||
|
//
|
||||||
|
// ⚠ That second guard is money. A sweep decides from a snapshot and writes seconds later; between the
|
||||||
|
// two, the user can stop the run and RESUME it, and the resumed run is live again with a second
|
||||||
|
// attempt holding a second reservation. The old attempt's exit marker is still on disk — nothing
|
||||||
|
// deletes markers of attempts that ended — so the stale pass would close the run from it, leaving
|
||||||
|
// attempt 2's hold in NO worklist: `ListLiveRuns` selects runs with `finished_at is null` and
|
||||||
|
// `UnsettledRuns` attempts with `ended_at is not null`, and the resumed run matches neither once it
|
||||||
|
// has been re-finished. Reachable only since a run can come back to life at all, which is this pack.
|
||||||
|
func (s *Store) FinishRun(ctx context.Context, runID string, attemptID int64, status, exitResult string, exitCode *int, now time.Time) (closed bool, err error) {
|
||||||
if !validRunStatus(status) {
|
if !validRunStatus(status) {
|
||||||
return fmt.Errorf("pgstore: %q is not a run status", status)
|
return false, fmt.Errorf("pgstore: %q is not a run status", status)
|
||||||
}
|
}
|
||||||
return s.inTx(ctx, func(tx pgx.Tx) error {
|
err = s.inTx(ctx, func(tx pgx.Tx) error {
|
||||||
// The BOOK's row is locked first, here and in the materializer. The two used to take them in
|
// The BOOK's row is locked first, here and in the materializer. The two used to take them in
|
||||||
// opposite orders — the materializer books-then-runs, this one runs-then-books — and two
|
// opposite orders — the materializer books-then-runs, this one runs-then-books — and two
|
||||||
// reconcilers on one run (overlapping deploy generations) then deadlocked in both directions;
|
// reconcilers on one run (overlapping deploy generations) then deadlocked in both directions;
|
||||||
|
|
@ -278,9 +298,14 @@ func (s *Store) FinishRun(ctx context.Context, runID string, attemptID int64, st
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
update runs set status = $2, finished_at = $3,
|
update runs set status = $2, finished_at = $3,
|
||||||
revision = (select revision + 1 from books where id = runs.book_id)
|
revision = (select revision + 1 from books where id = runs.book_id)
|
||||||
where id = $1 and finished_at is null returning book_id`, runID, status, now).Scan(&bookID); err != nil {
|
where id = $1 and finished_at is null
|
||||||
|
and exists (select 1 from run_attempts a
|
||||||
|
where a.id = $4 and a.run_id = runs.id and a.ended_at is null)
|
||||||
|
returning book_id`, runID, status, now, attemptID).Scan(&bookID); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil // already finished: finishing is idempotent by design, the reconciler retries
|
// Already finished, or asked about an attempt that is no longer the live one. Both are
|
||||||
|
// "someone else got here first"; finishing is idempotent by refusal, not by repetition.
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("pgstore: finish run: %w", err)
|
return fmt.Errorf("pgstore: finish run: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -290,11 +315,14 @@ func (s *Store) FinishRun(ctx context.Context, runID string, attemptID int64, st
|
||||||
return fmt.Errorf("pgstore: finish attempt: %w", err)
|
return fmt.Errorf("pgstore: finish attempt: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
update books set status = $2, revision = revision + 1 where id = $1`, bookID, status); err != nil {
|
update books set status = $2,
|
||||||
|
revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, bookID, status); err != nil {
|
||||||
return fmt.Errorf("pgstore: finish book: %w", err)
|
return fmt.Errorf("pgstore: finish book: %w", err)
|
||||||
}
|
}
|
||||||
|
closed = true
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
return closed, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func validRunStatus(s string) bool {
|
func validRunStatus(s string) bool {
|
||||||
|
|
@ -305,6 +333,82 @@ func validRunStatus(s string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FinishUnspawnedStop closes a run that was stopped before its unit ever existed, and only if that is
|
||||||
|
// still true when the write happens.
|
||||||
|
//
|
||||||
|
// The re-check is the whole method. The reconciler decides from a snapshot in which the attempt had
|
||||||
|
// no unit; between that read and this write the queue worker can claim the attempt and create one,
|
||||||
|
// and closing the run then leaves an engine spending against a book with no open reservation and no
|
||||||
|
// list that looks at it — the reconciler lists by `finished_at is null` and settlement by an open
|
||||||
|
// reservation, so it would be in neither. `ReleaseUnspawned` already guards the MONEY of exactly this
|
||||||
|
// window under a lock (PD-159); this is the same guard for the lifecycle the money follows.
|
||||||
|
//
|
||||||
|
// finished is false when the attempt was spawned after all: the caller does nothing, and the next
|
||||||
|
// sweep meets an ordinary live run — with a unit to signal and an intent that says to.
|
||||||
|
func (s *Store) FinishUnspawnedStop(ctx context.Context, runID string, attemptID int64, now time.Time) (finished bool, err error) {
|
||||||
|
err = s.inTx(ctx, func(tx pgx.Tx) error {
|
||||||
|
// Book first, then the attempt: the order every transaction in this package takes (lockBook).
|
||||||
|
var bookID string
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`select id from books where id = (select book_id from runs where id = $1) for update`,
|
||||||
|
runID).Scan(&bookID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil // the run is gone
|
||||||
|
}
|
||||||
|
return fmt.Errorf("pgstore: lock book: %w", err)
|
||||||
|
}
|
||||||
|
var unit *string
|
||||||
|
var ended *time.Time
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`select unit_name, ended_at from run_attempts where id = $1 and run_id = $2 for update`,
|
||||||
|
attemptID, runID).Scan(&unit, &ended); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("pgstore: lock attempt: %w", err)
|
||||||
|
}
|
||||||
|
if unit != nil && *unit != "" {
|
||||||
|
return nil // spawned inside the window; not this path's business any more
|
||||||
|
}
|
||||||
|
if ended != nil {
|
||||||
|
// The attempt this pass is holding is over, which means the run moved on without it — a stop
|
||||||
|
// and a RESUME can both have happened since the snapshot was taken, and the run is live
|
||||||
|
// again on a second attempt holding a second reservation. Closing it from here would put
|
||||||
|
// that hold in no worklist at all. The same guard FinishRun carries (PD-181); acceptance
|
||||||
|
// found this path missing it.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
update runs set status = 'stopped', finished_at = $2,
|
||||||
|
revision = (select revision + 1 from books where id = runs.book_id)
|
||||||
|
where id = $1 and finished_at is null`, runID, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pgstore: finish stopped run: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return nil // already finished by an earlier pass
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
update run_attempts set ended_at = $2, exit_result = $3
|
||||||
|
where id = $1 and ended_at is null`, attemptID, now, StopRequestedResult); err != nil {
|
||||||
|
return fmt.Errorf("pgstore: finish attempt: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
update books set status = 'stopped',
|
||||||
|
revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, bookID); err != nil {
|
||||||
|
return fmt.Errorf("pgstore: finish book: %w", err)
|
||||||
|
}
|
||||||
|
finished = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return finished, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopRequestedResult is the exit_result of an attempt that ended by this platform's request without
|
||||||
|
// systemd getting to write a marker. Deliberately a value systemd cannot produce: an operator reading
|
||||||
|
// the column must be able to tell what the machine saw from what the platform concluded.
|
||||||
|
const StopRequestedResult = "stop-requested"
|
||||||
|
|
||||||
// MarkSettled records that the money of a run has been resolved.
|
// MarkSettled records that the money of a run has been resolved.
|
||||||
func (s *Store) MarkSettled(ctx context.Context, runID string, now time.Time) error {
|
func (s *Store) MarkSettled(ctx context.Context, runID string, now time.Time) error {
|
||||||
_, err := s.pool.Exec(ctx, `update runs set settled_at = $2 where id = $1 and settled_at is null`, runID, now)
|
_, err := s.pool.Exec(ctx, `update runs set settled_at = $2 where id = $1 and settled_at is null`, runID, now)
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,8 @@ func TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock(t *testing.T) {
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
errs <- s.FinishRun(ctx, runID, run.AttemptID, "ready", "exit-code", nil, now)
|
_, err := s.FinishRun(ctx, runID, run.AttemptID, "ready", "exit-code", nil, now)
|
||||||
|
errs <- err
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
@ -378,3 +379,33 @@ func TestAResyncRecordsWhenItWasTaken(t *testing.T) {
|
||||||
t.Errorf("the second resync left the stamp at %v, want %s", stamp, later)
|
t.Errorf("the second resync left the stamp at %v, want %s", stamp, later)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-family review of the acceptance dofix (M2): a stop that lands before its engine was spawned
|
||||||
|
// leaves an attempt that is ENDED and carries no engine run id — a shape this zone could not produce
|
||||||
|
// before the stop path existed. An old materializer still holding that attempt would then bind it to
|
||||||
|
// the handshake of the attempt that REPLACED it and apply the same journal a second time, counting
|
||||||
|
// every unit and chapter twice. Binding is refused for an attempt that is over.
|
||||||
|
func TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine(t *testing.T) {
|
||||||
|
s, ctx := testDB(t)
|
||||||
|
now := fundedAccount(t, s, ctx, "u1", "10")
|
||||||
|
seedBook(t, s, ctx, "bk1", "u1", 500)
|
||||||
|
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", CeilingChapters: 10,
|
||||||
|
Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The stop-before-spawn shape: the attempt is closed without ever having been bound.
|
||||||
|
exec(t, s, ctx, `update run_attempts set ended_at = now() where id = $1`, run.AttemptID)
|
||||||
|
stale := s.NewRunSink(run.AttemptID, run.ID, "bk1")
|
||||||
|
if err := stale.Begin(ctx, ingest.Hello{EngineRunID: "eng-of-the-next-attempt"}); err == nil {
|
||||||
|
t.Fatal("an ended attempt adopted an engine run: its journal would be materialized twice")
|
||||||
|
}
|
||||||
|
var bound *string
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`select engine_run_id from run_attempts where id = $1`, run.AttemptID).Scan(&bound); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bound != nil {
|
||||||
|
t.Fatalf("the ended attempt was bound to %q", *bound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -96,6 +97,57 @@ func StatusArgs(workdir string) []string {
|
||||||
return []string{"status", "--config", filepath.Join(workdir, ConfigFile), "--json"}
|
return []string{"status", "--config", filepath.Join(workdir, ConfigFile), "--json"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ManifestArgs is the intake channel: `tmctl manifest --config <book.yaml> --json`.
|
||||||
|
//
|
||||||
|
// $0 and key-less like `status` — it ingests and cuts the source and writes a sidecar, with no
|
||||||
|
// provider call and no wave — and it is the ONE engine command the platform runs before a book has
|
||||||
|
// ever been translated. ⚠ Its first touch of a project CREATES and migrates the engine's database
|
||||||
|
// (ratified D39.122), which is why intake needs the book's directory writable and why it is this
|
||||||
|
// command rather than `status`: `status` has the same side effect and answers a different question.
|
||||||
|
func ManifestArgs(workdir string) []string {
|
||||||
|
return []string{"manifest", "--config", filepath.Join(workdir, ConfigFile), "--json"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxManifest bounds what the platform will read from the manifest command. The document is counts
|
||||||
|
// and identities — no source text — so a real one is single-digit megabytes even for a 2283-chapter
|
||||||
|
// book; the cap is against a process that is not the engine, not against the engine.
|
||||||
|
const maxManifest = 64 << 20
|
||||||
|
|
||||||
|
// Manifest builds the book's chapter tree and returns the allowlisted summary of it.
|
||||||
|
func (r *Runner) Manifest(ctx context.Context, binary, workdir string) (ingest.Manifest, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, binary, ManifestArgs(workdir)...)
|
||||||
|
cmd.Dir = workdir
|
||||||
|
var errOut bytes.Buffer
|
||||||
|
cmd.Stderr = &errOut
|
||||||
|
out, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return ingest.Manifest{}, fmt.Errorf("runner: tmctl manifest: %w", err)
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return ingest.Manifest{}, fmt.Errorf("runner: tmctl manifest: %w", err)
|
||||||
|
}
|
||||||
|
doc, readErr := io.ReadAll(io.LimitReader(out, maxManifest+1))
|
||||||
|
// Whatever is left is DRAINED, and that is not tidiness: a pipe nobody reads blocks the child on
|
||||||
|
// its next write and Wait then blocks on the child. Stopping at the cap without draining does not
|
||||||
|
// truncate the document — it hangs the caller, or, once the pipe is closed, kills the engine with
|
||||||
|
// EPIPE and reports that as a host which cannot run the engine.
|
||||||
|
_, _ = io.Copy(io.Discard, out)
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
// The engine's stderr is engine vocabulary and must not become a platform log line verbatim,
|
||||||
|
// let alone a product phrase; what travels is the fact and the first line. The error is
|
||||||
|
// returned UNWRAPPED as an *exec.ExitError underneath, because the caller tells "the engine
|
||||||
|
// answered no" from "the engine could not be run" by exactly that type.
|
||||||
|
return ingest.Manifest{}, fmt.Errorf("runner: tmctl manifest: %w: %s", err, firstLine(errOut.Bytes()))
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return ingest.Manifest{}, fmt.Errorf("runner: read manifest: %w", readErr)
|
||||||
|
}
|
||||||
|
if int64(len(doc)) > maxManifest {
|
||||||
|
return ingest.Manifest{}, fmt.Errorf("runner: the manifest is over %d bytes, which no book produces", maxManifest)
|
||||||
|
}
|
||||||
|
return ingest.DecodeManifest(doc)
|
||||||
|
}
|
||||||
|
|
||||||
// Status runs the reconciliation channel and decodes the allowlisted subset of its report.
|
// Status runs the reconciliation channel and decodes the allowlisted subset of its report.
|
||||||
func (r *Runner) Status(ctx context.Context, binary, workdir string) (ingest.StatusReport, error) {
|
func (r *Runner) Status(ctx context.Context, binary, workdir string) (ingest.StatusReport, error) {
|
||||||
cmd := exec.CommandContext(ctx, binary, StatusArgs(workdir)...)
|
cmd := exec.CommandContext(ctx, binary, StatusArgs(workdir)...)
|
||||||
|
|
|
||||||
127
platform/internal/runner/manifest_test.go
Normal file
127
platform/internal/runner/manifest_test.go
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"go/parser"
|
||||||
|
"go/printer"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The intake channel is the one engine command the platform runs before a book has ever been
|
||||||
|
// translated. These tests run a REAL process — a shell script standing in for tmctl — because what
|
||||||
|
// the caller decides on is the difference between "the engine answered no" and "the engine could not
|
||||||
|
// be run", and that difference is a type from os/exec rather than a value the platform chooses.
|
||||||
|
|
||||||
|
// fakeEngine writes an executable that behaves as told and returns its path.
|
||||||
|
func fakeEngine(t *testing.T, script string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "tmctl")
|
||||||
|
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheManifestCommandIsTheZeroCostOneAndCarriesTheBooksConfig(t *testing.T) {
|
||||||
|
got := ManifestArgs("/srv/books/bk_1")
|
||||||
|
want := []string{"manifest", "--config", "/srv/books/bk_1/book.yaml", "--json"}
|
||||||
|
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("argv %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAManifestIsReadForTheFourThingsIntakeDecidesWith(t *testing.T) {
|
||||||
|
// A document with far more in it than the platform takes: the per-chapter and per-unit arrays are
|
||||||
|
// the engine's and have no reader on this side.
|
||||||
|
bin := fakeEngine(t, `cat <<'JSON'
|
||||||
|
{"manifest_version":"tm-manifest-v2","book_id":"gu","key":"abcdef",
|
||||||
|
"chunker_version":"chunk-2026.07","source_sha256":"`+strings.Repeat("ab", 32)+`",
|
||||||
|
"source_bytes":24117248,"source_lang":"zh","target_lang":"ru","encoding":"utf-8",
|
||||||
|
"chapters_total":2283,"units_total":4402,"chunks_total":9130,
|
||||||
|
"chapters":[{"id":"0011223344556677","number":1,"heading":"Глава 1","units_total":2,
|
||||||
|
"chunks_total":4,"units":[{"id":"0011223344556677:aabbccdd:0","first_chunk_idx":0,
|
||||||
|
"chunk_count":2,"edit_unit_id":1}]}]}
|
||||||
|
JSON`)
|
||||||
|
r := New(nil)
|
||||||
|
m, err := r.Manifest(t.Context(), bin, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.ChaptersTotal != 2283 || m.UnitsTotal != 4402 {
|
||||||
|
t.Errorf("counts %d/%d", m.ChaptersTotal, m.UnitsTotal)
|
||||||
|
}
|
||||||
|
if m.Version != "tm-manifest-v2" || m.ChunkerVersion != "chunk-2026.07" {
|
||||||
|
t.Errorf("identity %q/%q", m.Version, m.ChunkerVersion)
|
||||||
|
}
|
||||||
|
if len(m.SourceSHA256Bytes()) != 32 {
|
||||||
|
t.Errorf("the source digest did not decode: %q", m.SourceSHA256)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The classifier the intake keys on: a process that RAN and refused carries *exec.ExitError, and one
|
||||||
|
// that could not be run does not. Getting this backwards either retries a book forever or rejects a
|
||||||
|
// user's file because a host was misconfigured.
|
||||||
|
func TestARefusalAndAnAbsenceAreDifferentErrors(t *testing.T) {
|
||||||
|
r := New(nil)
|
||||||
|
refused := fakeEngine(t, `echo "chunk: ingest txt: not a book" >&2; exit 1`)
|
||||||
|
_, err := r.Manifest(t.Context(), refused, t.TempDir())
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) {
|
||||||
|
t.Fatalf("a refusal came back as %T: %v", err, err)
|
||||||
|
}
|
||||||
|
_, err = r.Manifest(t.Context(), filepath.Join(t.TempDir(), "no-such-binary"), t.TempDir())
|
||||||
|
if errors.As(err, &exit) {
|
||||||
|
t.Fatalf("a missing binary came back as an exit status: %v", err)
|
||||||
|
}
|
||||||
|
// The engine's own words stay out of the platform's error, which is what a product phrase is
|
||||||
|
// eventually written from (contract §Problem).
|
||||||
|
if _, err := r.Manifest(t.Context(), refused, t.TempDir()); !strings.Contains(err.Error(), "tmctl manifest") {
|
||||||
|
t.Errorf("the error does not name what failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAManifestThatIsNotJSONIsAnError(t *testing.T) {
|
||||||
|
r := New(nil)
|
||||||
|
bin := fakeEngine(t, `echo "=== MANIFEST: gu ==="`)
|
||||||
|
if _, err := r.Manifest(t.Context(), bin, t.TempDir()); err == nil {
|
||||||
|
t.Fatal("a human summary was accepted as a manifest document")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FP5-7 (acceptance): the gate on these tests must ask whether this host CAN run them, not whether
|
||||||
|
// systemd phrased its refusal the way it did in 2026. It skipped on "Failed to connect to bus" and
|
||||||
|
// systemd 259 answers "Failed to connect to user scope bus", so on a host with no user manager the
|
||||||
|
// tests failed instead of skipping — a gate that stopped gating when somebody else edited a string.
|
||||||
|
func TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("systemd_test.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The CODE only: the comments there explain the history of this very defect and quote the message
|
||||||
|
// the gate used to match, which a plain text scan would read as the defect itself.
|
||||||
|
file, err := parser.ParseFile(token.NewFileSet(), "systemd_test.go", src, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var code bytes.Buffer
|
||||||
|
if err := printer.Fprint(&code, token.NewFileSet(), file); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// ⚠ The assertion is on the WORDS systemd could change, not on one spelling of the old check:
|
||||||
|
// pinning two exact expressions is a gate that a third spelling walks straight past (found by
|
||||||
|
// cross-family review of the acceptance dofix). Any occurrence of systemd's refusal text in this
|
||||||
|
// file means the gate is reading messages again.
|
||||||
|
if bytes.Contains(code.Bytes(), []byte("Failed to connect")) ||
|
||||||
|
bytes.Contains(code.Bytes(), []byte("No medium found")) {
|
||||||
|
t.Fatal("the systemd gate matches a message again: it must skip on the failure to reach the manager, whatever systemd calls it")
|
||||||
|
}
|
||||||
|
if !bytes.Contains(code.Bytes(), []byte("systemctl")) {
|
||||||
|
t.Fatal("the systemd gate no longer asks the manager anything: a gate that probes nothing cannot know whether these tests can run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -21,9 +22,16 @@ func systemdOrSkip(t *testing.T) {
|
||||||
if _, err := exec.LookPath("systemd-run"); err != nil {
|
if _, err := exec.LookPath("systemd-run"); err != nil {
|
||||||
t.Skip("no systemd-run: the transient-unit properties cannot be measured on this host")
|
t.Skip("no systemd-run: the transient-unit properties cannot be measured on this host")
|
||||||
}
|
}
|
||||||
|
// The gate is the CAPABILITY, not a sentence: this used to skip on the words "Failed to connect to
|
||||||
|
// bus" and systemd 259 says "Failed to connect to user scope bus", so on a host without a manager
|
||||||
|
// the tests FAILED instead of skipping — a gate that stopped gating when someone else edited a
|
||||||
|
// string. What is asked instead is the thing these tests need: can this process reach its own user
|
||||||
|
// manager at all. The skip is loud, like the database one, because `make check` names skips and a
|
||||||
|
// silent one reads as coverage.
|
||||||
out, err := exec.CommandContext(t.Context(), "systemctl", "--user", "show", "--property=Version").CombinedOutput()
|
out, err := exec.CommandContext(t.Context(), "systemctl", "--user", "show", "--property=Version").CombinedOutput()
|
||||||
if err != nil && strings.Contains(string(out), "Failed to connect to bus") {
|
if err != nil {
|
||||||
t.Skip("no systemd user manager: the transient-unit properties cannot be measured on this host")
|
t.Skipf("no reachable systemd user manager (%s): the transient-unit properties cannot be measured on this host",
|
||||||
|
bytes.TrimSpace(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
938
platform/internal/runs/control_test.go
Normal file
938
platform/internal/runs/control_test.go
Normal file
|
|
@ -0,0 +1,938 @@
|
||||||
|
package runs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"textmachine/platform/internal/ingest"
|
||||||
|
"textmachine/platform/internal/money"
|
||||||
|
"textmachine/platform/internal/pgstore"
|
||||||
|
"textmachine/platform/internal/runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The control handles — stop and resume — are the two places where a USER's decision reaches a run
|
||||||
|
// that is not this process's child. What they have to get right is not the systemd call, which is
|
||||||
|
// one line, but the record around it: a stop that is not written down before the signal cannot be
|
||||||
|
// told from a crash afterwards, and a resume that forgets the previous attempt's hold reserves the
|
||||||
|
// run's ceiling twice.
|
||||||
|
|
||||||
|
// stopped drives a fixture to a run the user stopped: admitted, spawned, asked to stop, and then
|
||||||
|
// reconciled from the marker the unit left. It is the state resume starts from.
|
||||||
|
//
|
||||||
|
// spent is what the engine reports AFTER the spawn, which is the only order that models the real
|
||||||
|
// one: the spawn reads the book's meter to take this attempt's baseline, so a figure set before it
|
||||||
|
// would be read as money some earlier run had spent and this attempt would settle at nothing.
|
||||||
|
func (f *fixture) stopped(t *testing.T, chapters int, spent money.MicroUSD, marker runner.Marker) string {
|
||||||
|
t.Helper()
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: chapters})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
live := f.live(t)
|
||||||
|
marker.Unit = live.UnitName
|
||||||
|
if err := runner.WriteMarker(f.svc.markerPath(live.RunID, live.AttemptNo), marker); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.engine.set(statusSpending(spent), nil)
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return run.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondBook registers another book of the same account, with a directory of its own.
|
||||||
|
func (f *fixture) secondBook(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
id, err := f.store.AddBook(f.ctx, pgstore.NewBook{OwnerID: "u1", Title: "second", SourceLang: "zh",
|
||||||
|
TargetLang: "ru", ChapterCount: 100, Workdir: t.TempDir(), Now: f.now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastAttempt is the snapshot a slow sweep pass would still be holding: the run as it was, with the
|
||||||
|
// attempt that was live at the time.
|
||||||
|
func (f *fixture) lastAttempt(t *testing.T, runID string) pgstore.LiveRun {
|
||||||
|
t.Helper()
|
||||||
|
l, err := f.store.ReadRunForResume(f.ctx, "u1", runID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixture) run(t *testing.T, runID string) pgstore.Run {
|
||||||
|
t.Helper()
|
||||||
|
r, err := f.store.ReadRun(f.ctx, "u1", runID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// The heart of PD-152. The engine catches SIGTERM and exits 1, so the marker of a stop and the
|
||||||
|
// marker of a crash are the same bytes; what tells them apart is that the platform wrote down its
|
||||||
|
// own intent first.
|
||||||
|
func TestARunTheUserStoppedIsNotReportedAsFailed(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
runID := f.stopped(t, 100, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "1",
|
||||||
|
At: f.now.Add(time.Second)})
|
||||||
|
if got := f.run(t, runID); got.Status != "stopped" {
|
||||||
|
t.Fatalf("a stop the user asked for came back as %q, want stopped", got.Status)
|
||||||
|
}
|
||||||
|
// And the same marker WITHOUT the intent is still a failure: the discriminator has to be the
|
||||||
|
// record, not a new reading of the exit code.
|
||||||
|
g := newFixture(t, "10", 500)
|
||||||
|
run, err := g.svc.Start(g.ctx, StartRequest{UserID: "u1", BookID: g.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := g.svc.Spawn(g.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
live := g.live(t)
|
||||||
|
if err := runner.WriteMarker(g.svc.markerPath(live.RunID, live.AttemptNo),
|
||||||
|
runner.Marker{Unit: live.UnitName, Result: "exit-code", Code: "exited", Status: "1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := g.svc.Sweep(g.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := g.run(t, run.ID); got.Status != "failed" {
|
||||||
|
t.Fatalf("a run nobody stopped came back as %q, want failed", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The race the design owes an answer to: a run that finished by itself a moment before someone
|
||||||
|
// pressed stop was not stopped by them, and calling it `stopped` would hide a completed translation
|
||||||
|
// behind a cancelled one.
|
||||||
|
func TestARunThatEndedBeforeTheStopKeepsItsOwnOutcome(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
// The marker is written a minute BEFORE the stop request the fixture makes.
|
||||||
|
runID := f.stopped(t, 100, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "1",
|
||||||
|
At: f.now.Add(-time.Minute)})
|
||||||
|
if got := f.run(t, runID); got.Status != "failed" {
|
||||||
|
t.Fatalf("a run that had already ended came back as %q, want failed", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The engine's own clean answers outrank a stop that arrived while it was already finishing: a
|
||||||
|
// translation that COMPLETED must not be shown as cancelled.
|
||||||
|
func TestACleanExitOutranksAStopThatArrivedTooLate(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
runID := f.stopped(t, 100, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "0",
|
||||||
|
At: f.now.Add(time.Second)})
|
||||||
|
if got := f.run(t, runID); got.Status != "ready" {
|
||||||
|
t.Fatalf("a run that finished cleanly came back as %q, want ready", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The intent is committed BEFORE systemd is asked, and that order is the whole mechanism: a platform
|
||||||
|
// that died between the two must still know, on its next sweep, that the run was stopped on purpose.
|
||||||
|
func TestTheStopIsRecordedBeforeSystemdIsAsked(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var recordedFirst bool
|
||||||
|
f.runner.onStop = func(string) {
|
||||||
|
var at *time.Time
|
||||||
|
if err := f.store.Pool().QueryRow(f.ctx,
|
||||||
|
`select stop_requested_at from runs where id = $1`, run.ID).Scan(&at); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordedFirst = at != nil
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !recordedFirst {
|
||||||
|
t.Fatal("systemd was asked to stop the unit before the intent was committed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stop that systemd did not take must not be lost. The intent is durable, so the next sweep asks
|
||||||
|
// again — which is the only thing that closes "the platform died between the write and the call".
|
||||||
|
func TestAStopTheUnitDidNotTakeIsAskedAgainByTheSweep(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.runner.alive = true
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.stops()); n != 2 {
|
||||||
|
t.Fatalf("the unit was asked to stop %d times, want 2 (the handle and the sweep)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dangerous half: a stopped unit that left no marker looks exactly like a reboot, and the reboot
|
||||||
|
// path RESTARTS the run — which would spend the account's money on work its owner had just
|
||||||
|
// cancelled.
|
||||||
|
func TestAStoppedRunIsNotRestartedWhenItsMarkerIsMissing(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The unit is gone, no marker was written, and the spawn grace is long past.
|
||||||
|
f.runner.alive = false
|
||||||
|
f.svc.Now = func() time.Time { return f.now.Add(2 * time.Hour) }
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := f.run(t, run.ID); got.Status != "stopped" {
|
||||||
|
t.Fatalf("run is %q, want stopped", got.Status)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.starts()); n != 1 {
|
||||||
|
t.Fatalf("%d units were started; a stopped run must not be restarted", n)
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved != 0 {
|
||||||
|
t.Fatalf("the hold of a stopped run is still open: %s", acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A run stopped before its unit ever existed: there is nothing to signal, and the money must come
|
||||||
|
// back whole rather than wait for a unit that will never be created.
|
||||||
|
func TestStoppingARunThatNeverSpawnedGivesTheHoldBackWhole(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.stops()); n != 0 {
|
||||||
|
t.Fatalf("systemd was asked about a unit that does not exist (%d times)", n)
|
||||||
|
}
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := f.run(t, run.ID); got.Status != "stopped" {
|
||||||
|
t.Fatalf("run is %q, want stopped", got.Status)
|
||||||
|
}
|
||||||
|
acct := f.account(t)
|
||||||
|
if acct.Reserved != 0 || acct.Balance != money.MicroUSD(10_000_000) {
|
||||||
|
t.Fatalf("the hold of a run that never started did not come back whole: balance %s reserved %s",
|
||||||
|
acct.Balance.USD(), acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
if n := len(f.runner.starts()); n != 0 {
|
||||||
|
t.Fatalf("a stopped run was spawned anyway (%d units)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership, on the handle that ENDS a paid run: another account's run is not "forbidden", it is not
|
||||||
|
// there at all (API1/BOLA, and the same rule the library reads by).
|
||||||
|
func TestARunOfAnotherAccountCannotBeStoppedOrResumed(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.store.Pool().Exec(f.ctx,
|
||||||
|
`insert into users (id, email) values ('u2','u2@example.org')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u2", run.ID); !errors.Is(err, pgstore.ErrNoRun) {
|
||||||
|
t.Fatalf("stop by a stranger: %v, want ErrNoRun", err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Resume(f.ctx, "u2", run.ID); !errors.Is(err, pgstore.ErrNoRun) {
|
||||||
|
t.Fatalf("resume by a stranger: %v, want ErrNoRun", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopping a run that is already over is a conflict and NOT a not-found: the owner can see the run,
|
||||||
|
// and answering 404 would tell them their own run does not exist.
|
||||||
|
func TestStoppingARunThatIsOverIsAConflict(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
runID := f.stopped(t, 10, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "1",
|
||||||
|
At: f.now.Add(time.Second)})
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", runID); !errors.Is(err, ErrNotStoppable) {
|
||||||
|
t.Fatalf("stopping a finished run: %v, want ErrNotStoppable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume is the reconciler's restart with a user behind it: a NEW attempt, holding what is LEFT of
|
||||||
|
// the run's budget. The money is the assertion — one run may not reserve its ceiling twice.
|
||||||
|
func TestResumeContinuesTheRunWithWhatIsLeftOfItsBudget(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
spent := money.MicroUSD(500_000)
|
||||||
|
runID := f.stopped(t, 100, spent, runner.Marker{Result: "exit-code", Code: "exited", Status: "1",
|
||||||
|
At: f.now.Add(time.Second)})
|
||||||
|
budget := f.svc.Pricing.Ceiling(100)
|
||||||
|
if acct := f.account(t); acct.Reserved != 0 || acct.Balance != money.MicroUSD(10_000_000)-spent {
|
||||||
|
t.Fatalf("before the resume: balance %s reserved %s", acct.Balance.USD(), acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
got, err := f.svc.Resume(f.ctx, "u1", runID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Status != "translating" || got.FinishedAt != nil {
|
||||||
|
t.Fatalf("the resumed run is %+v, want a live translating run", got)
|
||||||
|
}
|
||||||
|
// The new attempt holds the REMAINDER, not the whole ceiling: the first attempt already spent
|
||||||
|
// part of it and settled at what it spent.
|
||||||
|
if acct := f.account(t); acct.Reserved != budget-spent {
|
||||||
|
t.Fatalf("the resumed run holds %s, want %s (the ceiling less what was already spent)",
|
||||||
|
acct.Reserved.USD(), (budget - spent).USD())
|
||||||
|
}
|
||||||
|
// The engine is QUEUED, not started inside the request: reading the book's meter costs seconds of
|
||||||
|
// the engine's CPU and creating a unit is a round trip to systemd, and a call that answers 202
|
||||||
|
// must not hold either. (Measured, not reasoned about: the live probe's resume held its request
|
||||||
|
// until the engine exited.) With no queue wired here, the reconciler is the backstop — which is
|
||||||
|
// the same backstop the admission path relies on.
|
||||||
|
live := f.live(t)
|
||||||
|
if live.AttemptNo != 2 || live.UnitName != "" {
|
||||||
|
t.Fatalf("the resumed run's attempt is %+v, want a second attempt waiting to be spawned", live)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.starts()); n != 1 {
|
||||||
|
t.Fatalf("%d units started; the resume spawned the engine inside the request", n)
|
||||||
|
}
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.starts()); n != 2 {
|
||||||
|
t.Fatalf("%d units started across the run, want 2 once the backstop ran", n)
|
||||||
|
}
|
||||||
|
// The book follows the run back into translation, and the library's revision moves with it.
|
||||||
|
book, _, err := f.store.GetBook(f.ctx, "u1", f.bookID(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if book.Status != "translating" {
|
||||||
|
t.Fatalf("the book of a resumed run is %q, want translating", book.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bank stop is the one resume the contract describes in detail, and its condition — a COMPLETE
|
||||||
|
// set of decisions — cannot be met by any deployment today: nothing materializes the bank at all
|
||||||
|
// (companion §3). Answering 202 would start a run that walks straight into the same stop.
|
||||||
|
func TestResumeRefusesABankStopWhileTheGlossaryIsUnsigned(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
runID := f.stopped(t, 100, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3",
|
||||||
|
At: f.now.Add(time.Second)})
|
||||||
|
if got := f.run(t, runID); got.Status != "awaiting_bank" {
|
||||||
|
t.Fatalf("run after the bank stop is %q", got.Status)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Resume(f.ctx, "u1", runID); !errors.Is(err, ErrNotResumable) {
|
||||||
|
t.Fatalf("resume of a bank stop: %v, want ErrNotResumable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Resume on a run paused by a ceiling returns it to the same state" (contract §resumeRun). It is
|
||||||
|
// answered rather than refused, and nothing moves: no second hold, no second unit — and, the half
|
||||||
|
// that is easy to get wrong, the run's own state is not rewritten on the way out. A run the user
|
||||||
|
// stopped comes back stopped; calling it `paused` because a resume failed would replace what
|
||||||
|
// happened to it with why it could not continue.
|
||||||
|
func TestResumeOfARunWithNothingLeftReturnsItUnchanged(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
// The attempt spends its entire ceiling, so the run has no budget left at all.
|
||||||
|
runID := f.stopped(t, 10, f.svc.Pricing.Ceiling(10), runner.Marker{Result: "exit-code",
|
||||||
|
Code: "exited", Status: "1", At: f.now.Add(time.Second)})
|
||||||
|
before := f.account(t)
|
||||||
|
got, err := f.svc.Resume(f.ctx, "u1", runID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resume of an exhausted run: %v, want the run back unchanged", err)
|
||||||
|
}
|
||||||
|
if got.Status != "stopped" || got.PausedReason != "" {
|
||||||
|
t.Fatalf("the run came back as %q/%q, want the stopped run it was", got.Status, got.PausedReason)
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved != 0 || acct.Balance != before.Balance {
|
||||||
|
t.Fatalf("a resume that changed nothing moved money: %+v -> %+v", before, acct)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.starts()); n != 1 {
|
||||||
|
t.Fatalf("%d units started, want 1: nothing was resumed", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reconciler's half of the same decision, which the shared body must not have taken away: a run
|
||||||
|
// that was LIVE and has nothing left is `paused` with the reason the contract has a word for, and a
|
||||||
|
// resume of that comes back paused too.
|
||||||
|
func TestAnInterruptedRunWithNothingLeftIsPausedAndStaysPausedThroughAResume(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The engine spent the whole ceiling and then its unit vanished without a marker, which is what a
|
||||||
|
// reboot leaves behind.
|
||||||
|
f.engine.set(statusSpending(f.svc.Pricing.Ceiling(10)), nil)
|
||||||
|
f.runner.alive = false
|
||||||
|
f.svc.Now = func() time.Time { return f.now.Add(2 * time.Hour) }
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := f.run(t, run.ID)
|
||||||
|
if got.Status != "paused" || got.PausedReason != pgstore.PausedCreditExhausted {
|
||||||
|
t.Fatalf("an interrupted run with no budget is %q/%q, want paused/credit_exhausted",
|
||||||
|
got.Status, got.PausedReason)
|
||||||
|
}
|
||||||
|
resumed, err := f.svc.Resume(f.ctx, "u1", run.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resume of a paused run: %v, want it back unchanged", err)
|
||||||
|
}
|
||||||
|
if resumed.Status != "paused" || resumed.PausedReason != pgstore.PausedCreditExhausted {
|
||||||
|
t.Fatalf("the paused run came back as %q/%q", resumed.Status, resumed.PausedReason)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.starts()); n != 1 {
|
||||||
|
t.Fatalf("%d units started, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A settlement is allowed to defer, and a resume that ignored that would open a SECOND hold on a run
|
||||||
|
// whose first is still reserved.
|
||||||
|
func TestResumeRefusesWhileThePreviousAttemptIsStillHoldingMoney(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
live := f.live(t)
|
||||||
|
if err := runner.WriteMarker(f.svc.markerPath(live.RunID, live.AttemptNo),
|
||||||
|
runner.Marker{Unit: live.UnitName, Result: "exit-code", Code: "exited", Status: "1",
|
||||||
|
At: f.now.Add(time.Second)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The engine cannot be asked what it spent, so the run finishes and its hold stays open.
|
||||||
|
f.engine.set(statusSpending(0), errors.New("status unavailable"))
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved == 0 {
|
||||||
|
t.Fatal("the hold was released even though the engine could not be asked")
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Resume(f.ctx, "u1", run.ID); !errors.Is(err, ErrNotResumable) {
|
||||||
|
t.Fatalf("resume while unsettled: %v, want ErrNotResumable", err)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.starts()); n != 1 {
|
||||||
|
t.Fatalf("%d units started: an unsettled run must not be resumed", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A book that is still arriving, or one the engine refused, has no chapter tree to translate — and
|
||||||
|
// while it is `uploading` it has half a file on disk. The refusal happens BEFORE the money moves.
|
||||||
|
func TestARunIsRefusedOnABookThatIsNotThroughIntake(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
for _, status := range []string{"uploading", "parsing", "rejected"} {
|
||||||
|
if _, err := f.store.Pool().Exec(f.ctx,
|
||||||
|
`update books set status = $1 where id = $2`, status, f.bookID(t)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10})
|
||||||
|
if !errors.Is(err, ErrBookNotReady) {
|
||||||
|
t.Fatalf("starting a run on a %s book: %v, want ErrBookNotReady", status, err)
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved != 0 {
|
||||||
|
t.Fatalf("a refused run held money: %s", acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusSpending is a report of an engine that has committed exactly this much on the book.
|
||||||
|
func statusSpending(v money.MicroUSD) ingest.StatusReport {
|
||||||
|
return ingest.StatusReport{Spend: usd(v), Reserved: usd(0)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PD-169. A pass of the sweep has one budget for every run in it, and the list is ordered the same
|
||||||
|
// way every time: a run whose engine hangs used to eat the pass, and the tail of the list — other
|
||||||
|
// people's books — was never reached at all. The per-run budget is what bounds that, and this is the
|
||||||
|
// assertion that it does.
|
||||||
|
func TestOneSlowRunDoesNotEatThePassOfTheWholeSweep(t *testing.T) {
|
||||||
|
f := newFixture(t, "20", 500)
|
||||||
|
f.svc.Cfg.RunBudget = 100 * time.Millisecond
|
||||||
|
// Two books, two live runs, and the sweep visits them in the order they started.
|
||||||
|
first, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second := f.secondBook(t)
|
||||||
|
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: second, CeilingChapters: 10}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The engine hangs for the FIRST run only — until the context that carries it is cancelled.
|
||||||
|
f.engine.block(first.ID, f.workdir)
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The second run was spawned in the same pass, which is the whole property: the slow one cost its
|
||||||
|
// own budget and nothing else's.
|
||||||
|
started := f.runner.starts()
|
||||||
|
if len(started) != 1 {
|
||||||
|
t.Fatalf("%d units started; the second run was starved by the first", len(started))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two clicks on one button. Both calls pass the state check together and race for attempt N+1 on the
|
||||||
|
// unique index; the loser must answer with the run the winner re-opened, not with a not-found — and
|
||||||
|
// exactly ONE hold may be taken.
|
||||||
|
func TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
runID := f.stopped(t, 100, money.MicroUSD(500_000), runner.Marker{Result: "exit-code",
|
||||||
|
Code: "exited", Status: "1", At: f.now.Add(time.Second)})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
runs := make([]pgstore.Run, 2)
|
||||||
|
errs := make([]error, 2)
|
||||||
|
for i := range runs {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
runs[i], errs[i] = f.svc.Resume(f.ctx, "u1", runID)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for i, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resume %d: %v, want the run back", i, err)
|
||||||
|
}
|
||||||
|
if runs[i].Status != "translating" {
|
||||||
|
t.Errorf("resume %d answered %q, want translating", i, runs[i].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved != f.svc.Pricing.Ceiling(100)-money.MicroUSD(500_000) {
|
||||||
|
t.Fatalf("two resumes reserved %s, want exactly one remainder", acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Balance != acct.LedgerSum {
|
||||||
|
t.Fatalf("the cached balance and the ledger disagree: %s vs %s", acct.Balance.USD(), acct.LedgerSum.USD())
|
||||||
|
}
|
||||||
|
var attempts int
|
||||||
|
if err := f.store.Pool().QueryRow(f.ctx,
|
||||||
|
`select count(*) from run_attempts where run_id = $1`, runID).Scan(&attempts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Fatalf("%d attempts after two resumes, want 2", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stopped run can be resumed — unless the account started ANOTHER run of the same book in the
|
||||||
|
// meantime. Re-opening then puts two live runs under an index that forbids exactly that, and the
|
||||||
|
// answer has to be the conflict a second admission gets, not an internal error. The money is the
|
||||||
|
// other half: the hold of the refused resume is taken in the same transaction and dies with it.
|
||||||
|
func TestResumeIsRefusedWhenTheBookHasAnotherLiveRun(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
stoppedRun := f.stopped(t, 10, money.MicroUSD(10_000), runner.Marker{Result: "exit-code",
|
||||||
|
Code: "exited", Status: "1", At: f.now.Add(time.Second)})
|
||||||
|
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := f.account(t)
|
||||||
|
if _, err := f.svc.Resume(f.ctx, "u1", stoppedRun); !errors.Is(err, pgstore.ErrRunInFlight) {
|
||||||
|
t.Fatalf("resume while another run is live: %v, want ErrRunInFlight", 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One counter per book, and every run-carrying answer uses it (contract §Revision). The stop handle
|
||||||
|
// used to answer from the run's own column, which sits BELOW the card's number the moment anything
|
||||||
|
// materializes — and a client that has applied the card's revision is required by the contract to
|
||||||
|
// drop the lower one, i.e. to drop the answer to the button it just pressed.
|
||||||
|
func TestEveryRunCarryingAnswerUsesTheBooksRevision(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Something materializes: the book's counter moves and the run's own column does not.
|
||||||
|
if _, err := f.store.Pool().Exec(f.ctx,
|
||||||
|
`update books set revision = revision + 5 where id = $1`, f.bookID(t)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
book, card, err := f.store.GetBook(f.ctx, "u1", f.bookID(t))
|
||||||
|
if err != nil || card == nil {
|
||||||
|
t.Fatalf("card: %v", err)
|
||||||
|
}
|
||||||
|
if card.Revision != book.Revision {
|
||||||
|
t.Fatalf("the card's run carries revision %d and its book %d: one counter per book", card.Revision, book.Revision)
|
||||||
|
}
|
||||||
|
stopped, err := f.svc.Stop(f.ctx, "u1", run.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stopped.Revision != book.Revision {
|
||||||
|
t.Fatalf("stop answered revision %d, the card answers %d — the client drops the lower one",
|
||||||
|
stopped.Revision, book.Revision)
|
||||||
|
}
|
||||||
|
read, err := f.store.ReadRun(f.ctx, "u1", run.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if read.Revision != book.Revision {
|
||||||
|
t.Fatalf("ReadRun answered revision %d, the card answers %d", read.Revision, book.Revision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A run that is not the caller's, or one that does not exist, is a 404 — even on a deployment that
|
||||||
|
// could not resume anything anyway. The other order answers "this service cannot start runs" to a
|
||||||
|
// question about someone else's run, which is both a worse answer and a small oracle.
|
||||||
|
func TestOwnershipIsJudgedBeforeTheDeploymentsHealth(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.svc.Cfg.MarkerArgv = nil // a deployment that cannot record how a run ends
|
||||||
|
if _, err := f.svc.Resume(f.ctx, "u1", "run_does_not_exist"); !errors.Is(err, pgstore.ErrNoRun) {
|
||||||
|
t.Fatalf("resume of a missing run on an unwired deployment: %v, want ErrNoRun", err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Resume(f.ctx, "u1", run.ID); !errors.Is(err, ErrRunnerIncomplete) {
|
||||||
|
t.Fatalf("resume of an OWN run on an unwired deployment: %v, want the deployment's refusal", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reconciler decides from a SNAPSHOT and writes seconds later; in between, the user presses
|
||||||
|
// stop. Three windows, one cure — the row is re-read under the lock that does the write. Each of
|
||||||
|
// these was reproduced by an independent reviewer before it was closed.
|
||||||
|
|
||||||
|
// Window 1 (money): a stop pressed while the reconciler is settling an interrupted attempt. The
|
||||||
|
// restart used to clear the fresh intent, open a second attempt and take a new hold — a 202 for a
|
||||||
|
// stop that never happened, and the user paying for the work they had just cancelled.
|
||||||
|
func TestAStopPressedWhileTheReconcilerRestartsIsNotLost(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The unit is gone with no marker — the shape of a reboot — and the grace is past, so the
|
||||||
|
// reconciler will restart. The stop lands while it is settling.
|
||||||
|
f.runner.alive = false
|
||||||
|
f.svc.Now = func() time.Time { return f.now.Add(2 * time.Hour) }
|
||||||
|
f.engine.onStatus = func() {
|
||||||
|
f.engine.onStatus = nil
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := f.run(t, run.ID)
|
||||||
|
if got.Status != "stopped" {
|
||||||
|
t.Fatalf("the run is %q, want stopped: the stop outranks the restart", got.Status)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.starts()); n != 1 {
|
||||||
|
t.Fatalf("%d units started; the restart went ahead over a stop", n)
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved != 0 {
|
||||||
|
t.Fatalf("a run stopped mid-restart still holds %s", acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Balance != acct.LedgerSum {
|
||||||
|
t.Fatalf("balance %s and ledger %s disagree", acct.Balance.USD(), acct.LedgerSum.USD())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window 2 (money): the sweep is about to close a run that was stopped before it spawned, and the
|
||||||
|
// queue worker creates the unit in between. Closing it anyway strands a live engine that no list
|
||||||
|
// looks at — not the live runs (finished), not the unsettled ones (its hold was released).
|
||||||
|
func TestARunSpawnedWhileTheSweepWasClosingItIsNotAbandoned(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The snapshot a sweep would decide from: taken while the attempt still had no unit.
|
||||||
|
live := f.live(t)
|
||||||
|
// The worker claims and creates the unit — BEFORE the stop, because the claim itself refuses once
|
||||||
|
// an intent is standing. This is the window that remains: the sweep is holding a snapshot older
|
||||||
|
// than the unit.
|
||||||
|
claimed, err := f.store.RecordSpawn(f.ctx, pgstore.SpawnRecord{AttemptID: live.AttemptID,
|
||||||
|
Unit: "tm-run-raced", Binary: "/opt/engine/tmctl", Ceiling: live.Ceiling,
|
||||||
|
CeilingArg: live.Ceiling, Baseline: 1})
|
||||||
|
if err != nil || !claimed {
|
||||||
|
t.Fatalf("the worker could not claim the attempt: %v %v", claimed, err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.finishStopped(f.ctx, live); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := f.run(t, run.ID)
|
||||||
|
if got.Status == "stopped" || got.FinishedAt != nil {
|
||||||
|
t.Fatalf("a run whose engine had just started was closed anyway: %+v", got)
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved == 0 {
|
||||||
|
t.Fatal("the hold of a run whose engine is running was released")
|
||||||
|
}
|
||||||
|
// And the next pass does the right thing with it: a live unit plus a standing intent is a stop.
|
||||||
|
f.runner.alive = true
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.stops()); n == 0 {
|
||||||
|
t.Fatal("the raced run was never asked to stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window 3: a stop pressed on a run whose queue job has not been picked up yet. The worker must not
|
||||||
|
// start an engine for it — the stop arrived before the run did anything at all.
|
||||||
|
func TestTheQueueWorkerDoesNotStartARunThatWasAlreadyStopped(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.starts()); n != 0 {
|
||||||
|
t.Fatalf("%d units started for a run that was stopped before its worker ran", n)
|
||||||
|
}
|
||||||
|
// The claim itself refuses too, which is the half that covers an intent written after the read.
|
||||||
|
live := f.live(t)
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
claimed, err := f.store.RecordSpawn(f.ctx, pgstore.SpawnRecord{AttemptID: live.AttemptID,
|
||||||
|
Unit: "tm-run-late", Binary: "/opt/engine/tmctl", Ceiling: live.Ceiling, CeilingArg: live.Ceiling})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if claimed {
|
||||||
|
t.Fatal("the spawn claim was granted for an attempt of a run that is over and settled")
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved != 0 || acct.Balance != money.MicroUSD(10_000_000) {
|
||||||
|
t.Fatalf("the hold did not come back whole: balance %s reserved %s",
|
||||||
|
acct.Balance.USD(), acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window a RESUME opened and nothing else could: a run comes back to life, and the exit marker of
|
||||||
|
// the attempt that ended is still on disk — nothing deletes markers of attempts that are over. A pass
|
||||||
|
// holding the old snapshot would close the resumed run from it, and attempt 2's hold would then be
|
||||||
|
// in NO worklist at all: live runs are selected by `finished_at is null`, unsettled ones by an
|
||||||
|
// attempt with `ended_at`, and a re-finished resumed run matches neither.
|
||||||
|
func TestAStaleSweepDoesNotReFinishAResumedRunFromTheOldAttemptsMarker(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
spent := money.MicroUSD(100_000)
|
||||||
|
runID := f.stopped(t, 100, spent, runner.Marker{Result: "exit-code", Code: "exited", Status: "1",
|
||||||
|
At: f.now.Add(time.Second)})
|
||||||
|
stale := f.lastAttempt(t, runID) // the snapshot a slow pass is still holding: attempt 1
|
||||||
|
if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := f.account(t).Reserved
|
||||||
|
if held == 0 {
|
||||||
|
t.Fatal("the resumed run holds nothing")
|
||||||
|
}
|
||||||
|
// The slow pass arrives with attempt 1 and its marker, which is still on disk.
|
||||||
|
if err := f.svc.reconcile(f.ctx, stale); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := f.run(t, runID)
|
||||||
|
if got.Status != "translating" || got.FinishedAt != nil {
|
||||||
|
t.Fatalf("a stale pass closed the resumed run: %+v", got)
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved != held {
|
||||||
|
t.Fatalf("the resumed run's hold moved: %s → %s", held.USD(), acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
// And the run is still findable, which is the half that makes the money recoverable at all.
|
||||||
|
live, err := f.store.ListLiveRuns(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(live) != 1 || live[0].AttemptNo != 2 {
|
||||||
|
t.Fatalf("live runs after the stale pass: %+v", live)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A claim that was GIVEN BACK is not proof that no engine exists: `systemd-run` killed after it had
|
||||||
|
// already asked leaves one running, which is why the spend baseline is kept on such an attempt. The
|
||||||
|
// stop path must ask systemd about the unit's deterministic name rather than close over it.
|
||||||
|
func TestAStopDoesNotCloseARunWhoseGivenBackClaimLeftAnEngineRunning(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The unit could "not be created" — but it was: the claim is given back and the baseline stays.
|
||||||
|
f.runner.startErr = errors.New("systemd-run was killed after it had asked")
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err == nil {
|
||||||
|
t.Fatal("a failed spawn reported success")
|
||||||
|
}
|
||||||
|
live := f.live(t)
|
||||||
|
if live.UnitName != "" || live.SpendBaseline == nil {
|
||||||
|
t.Fatalf("after a given-back claim: unit %q, baseline %v", live.UnitName, live.SpendBaseline)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// systemd says the orphan is alive.
|
||||||
|
f.runner.alive = true
|
||||||
|
f.runner.startErr = nil
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := f.run(t, run.ID)
|
||||||
|
if got.FinishedAt != nil {
|
||||||
|
t.Fatalf("the run was closed while its engine was still running: %+v", got)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.stops()); n == 0 {
|
||||||
|
t.Fatal("the orphaned unit was never asked to stop")
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved == 0 {
|
||||||
|
t.Fatal("the hold of a run whose engine is alive was released")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window the queue worker opens: it reads the run, then spends seconds in `bookMeter` asking the
|
||||||
|
// engine what the book has cost. A stop committed inside that window has no unit to signal — so the
|
||||||
|
// only thing that can refuse the engine is the claim itself.
|
||||||
|
func TestAStopCommittedWhileTheWorkerReadsTheMeterStopsTheSpawn(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The stop lands while the worker is inside the meter call, i.e. after it read its snapshot.
|
||||||
|
f.engine.onStatus = func() {
|
||||||
|
f.engine.onStatus = nil
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(f.runner.starts()); n != 0 {
|
||||||
|
t.Fatalf("%d units started for a run stopped while the worker was reading the meter", n)
|
||||||
|
}
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := f.run(t, run.ID); got.Status != "stopped" {
|
||||||
|
t.Fatalf("the run is %q, want stopped", got.Status)
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved != 0 || acct.Balance != money.MicroUSD(10_000_000) {
|
||||||
|
t.Fatalf("the hold did not come back whole: balance %s reserved %s",
|
||||||
|
acct.Balance.USD(), acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FP5-2 (acceptance, money). The guard FinishRun received (PD-181) was missing from the path that
|
||||||
|
// closes a stop which never spawned: a stale pass could close a RESUMED run through it, and attempt
|
||||||
|
// 2's hold would then be in no worklist at all — with the added twist that every later resume answers
|
||||||
|
// 409 forever, because the run it would continue is finished.
|
||||||
|
func TestAStaleUnspawnedStopDoesNotCloseAResumedRun(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stale := f.live(t) // attempt 1, no unit — the snapshot a slow pass holds
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Resume(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := f.account(t).Reserved
|
||||||
|
if held == 0 {
|
||||||
|
t.Fatal("the resumed run holds nothing")
|
||||||
|
}
|
||||||
|
// The slow pass arrives with attempt 1 and the intent that was on it.
|
||||||
|
if err := f.svc.finishStopped(f.ctx, stale); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := f.run(t, run.ID)
|
||||||
|
if got.Status != "translating" || got.FinishedAt != nil {
|
||||||
|
t.Fatalf("a stale unspawned-stop closed the resumed run: %+v", got)
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Reserved != held {
|
||||||
|
t.Fatalf("the resumed run's hold moved: %s → %s", held.USD(), acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
live, err := f.store.ListLiveRuns(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(live) != 1 || live[0].AttemptNo != 2 {
|
||||||
|
t.Fatalf("live runs after the stale pass: %+v", live)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FP5-8(а) (acceptance). A stop that lands while the reconciler is settling an exhausted run must not
|
||||||
|
// come back as `paused/credit_exhausted`: that says the money ran out, when what happened is that the
|
||||||
|
// owner stopped it.
|
||||||
|
func TestAStopDuringSettlementOutranksThePause(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The attempt spends the whole ceiling, so the restart path will find nothing left; the stop lands
|
||||||
|
// while that settlement is in flight.
|
||||||
|
f.engine.set(statusSpending(f.svc.Pricing.Ceiling(10)), nil)
|
||||||
|
f.engine.onStatus = func() {
|
||||||
|
f.engine.onStatus = nil
|
||||||
|
if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.runner.alive = false
|
||||||
|
f.svc.Now = func() time.Time { return f.now.Add(2 * time.Hour) }
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := f.run(t, run.ID)
|
||||||
|
if got.Status != "stopped" || got.PausedReason != "" {
|
||||||
|
t.Fatalf("the run came back as %q/%q, want stopped", got.Status, got.PausedReason)
|
||||||
|
}
|
||||||
|
if acct := f.account(t); acct.Balance != acct.LedgerSum {
|
||||||
|
t.Fatalf("balance %s and ledger %s disagree", acct.Balance.USD(), acct.LedgerSum.USD())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
package runs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/riverqueue/river"
|
|
||||||
"github.com/riverqueue/river/riverdriver/riverpgxv5"
|
|
||||||
|
|
||||||
"textmachine/platform/internal/pgstore"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SpawnArgs is one queued permission to start a run.
|
|
||||||
//
|
|
||||||
// The job carries an id and nothing else. Everything about the run — the book, the ceiling, the
|
|
||||||
// binary it is pinned to — is in Postgres, and a job that carried its own copy would be a second
|
|
||||||
// answer that goes stale the moment the run is restarted with a smaller budget.
|
|
||||||
type SpawnArgs struct {
|
|
||||||
RunID string `json:"run_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kind is River's name for this job type.
|
|
||||||
func (SpawnArgs) Kind() string { return "tm_spawn_run" }
|
|
||||||
|
|
||||||
// InsertOpts pins the queue-level policy.
|
|
||||||
//
|
|
||||||
// MaxAttempts is 1 on purpose, and it is the opposite of the usual queue reflex. A retry here does
|
|
||||||
// not repeat lost work: the run row already exists and holds the account's money, and the thing that
|
|
||||||
// would be repeated is spawning an engine. What recovers a run whose spawn failed is the reconciler,
|
|
||||||
// which reads the world instead of assuming the job's view of it — and which is the ONLY component
|
|
||||||
// that can tell "the unit never started" from "the unit is running and this platform was restarted".
|
|
||||||
func (SpawnArgs) InsertOpts() river.InsertOpts {
|
|
||||||
return river.InsertOpts{MaxAttempts: 1, Queue: river.QueueDefault}
|
|
||||||
}
|
|
||||||
|
|
||||||
// spawnWorker runs SpawnArgs jobs.
|
|
||||||
type spawnWorker struct {
|
|
||||||
river.WorkerDefaults[SpawnArgs]
|
|
||||||
svc *Service
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *spawnWorker) Work(ctx context.Context, job *river.Job[SpawnArgs]) error {
|
|
||||||
return w.svc.Spawn(ctx, job.Args.RunID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queue is the River client, wired to this service.
|
|
||||||
type Queue struct {
|
|
||||||
client *river.Client[pgstore.Tx]
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewQueue builds the queue and its worker pool.
|
|
||||||
//
|
|
||||||
// Concurrency is deliberately small: a worker's whole job is to create a transient unit, and the
|
|
||||||
// runs themselves are bounded by their own cgroups and by the one-live-run-per-book index, not by
|
|
||||||
// how many workers exist.
|
|
||||||
func NewQueue(pool *pgxpool.Pool, svc *Service, log *slog.Logger, workers int) (*Queue, error) {
|
|
||||||
if workers <= 0 {
|
|
||||||
workers = 4
|
|
||||||
}
|
|
||||||
w := river.NewWorkers()
|
|
||||||
if err := river.AddWorkerSafely(w, &spawnWorker{svc: svc}); err != nil {
|
|
||||||
return nil, fmt.Errorf("runs: register worker: %w", err)
|
|
||||||
}
|
|
||||||
c, err := river.NewClient(riverpgxv5.New(pool), &river.Config{
|
|
||||||
Logger: log,
|
|
||||||
Workers: w,
|
|
||||||
Queues: map[string]river.QueueConfig{river.QueueDefault: {MaxWorkers: workers}},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("runs: river client: %w", err)
|
|
||||||
}
|
|
||||||
return &Queue{client: c}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnqueueRun inserts the job in the CALLER's transaction, so the run row, its hold and its queue
|
|
||||||
// entry commit together or not at all.
|
|
||||||
func (q *Queue) EnqueueRun(ctx context.Context, tx pgstore.Tx, runID string) error {
|
|
||||||
if _, err := q.client.InsertTx(ctx, tx, SpawnArgs{RunID: runID}, nil); err != nil {
|
|
||||||
return fmt.Errorf("runs: enqueue %s: %w", runID, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins working jobs.
|
|
||||||
func (q *Queue) Start(ctx context.Context) error { return q.client.Start(ctx) }
|
|
||||||
|
|
||||||
// Stop drains the workers. The runs themselves are untouched: they are transient units, not
|
|
||||||
// children, and outliving this process is what they are for.
|
|
||||||
func (q *Queue) Stop(ctx context.Context) error { return q.client.Stop(ctx) }
|
|
||||||
|
|
@ -38,7 +38,7 @@ func (s *Service) Sweep(ctx context.Context) error {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.reconcile(ctx, l); err != nil {
|
if err := s.withBudget(ctx, func(ctx context.Context) error { return s.reconcile(ctx, l) }); err != nil {
|
||||||
s.log().ErrorContext(ctx, "run could not be reconciled", "run", l.RunID, "err", err)
|
s.log().ErrorContext(ctx, "run could not be reconciled", "run", l.RunID, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -50,13 +50,63 @@ func (s *Service) Sweep(ctx context.Context) error {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.settle(ctx, u); err != nil {
|
if err := s.withBudget(ctx, func(ctx context.Context) error { return s.settle(ctx, u) }); err != nil {
|
||||||
s.log().ErrorContext(ctx, "run could not be settled", "run", u.RunID, "err", err)
|
s.log().ErrorContext(ctx, "run could not be settled", "run", u.RunID, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runBudget is what ONE run may cost a pass of the sweep.
|
||||||
|
//
|
||||||
|
// It exists because the pass has a budget of its own and the work is not uniform: a spawn or a
|
||||||
|
// settlement is a `tmctl status` call, seconds of the engine's CPU each, and a handful of runs whose
|
||||||
|
// engine hangs used to spend the whole pass — after which the list, which is ordered the same way
|
||||||
|
// every time, was never reached past them. That is starvation with no upper bound, and it was
|
||||||
|
// invisible (register row PD-169; the counter that makes it visible is the sweep's own metric).
|
||||||
|
//
|
||||||
|
// Generous rather than tight: the call it bounds legitimately takes seconds on a large book, and the
|
||||||
|
// point is to bound the pathological case, not to race the ordinary one.
|
||||||
|
const defaultRunBudget = 60 * time.Second
|
||||||
|
|
||||||
|
func (s *Service) runBudget() time.Duration {
|
||||||
|
if s.Cfg.RunBudget > 0 {
|
||||||
|
return s.Cfg.RunBudget
|
||||||
|
}
|
||||||
|
return defaultRunBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
// withBudget runs one item of a sweep under its own deadline. A cancelled item is not a failed one:
|
||||||
|
// the next pass reads the same world and tries again, and the money paths inside are transactional,
|
||||||
|
// so an item cut off mid-write leaves nothing half-done.
|
||||||
|
func (s *Service) withBudget(ctx context.Context, fn func(context.Context) error) error {
|
||||||
|
c, cancel := context.WithTimeout(ctx, s.runBudget())
|
||||||
|
defer cancel()
|
||||||
|
return fn(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lag is how far the furthest-behind live run's journal is beyond this platform's cursor, in bytes.
|
||||||
|
//
|
||||||
|
// It is the tailer's own health, and until this pack nothing reported it: a projection that has
|
||||||
|
// stopped moving and a run that is simply quiet look the same from outside. Read separately from the
|
||||||
|
// sweep rather than folded into it because it is telemetry — a failure to measure must not be able
|
||||||
|
// to affect what the sweep does.
|
||||||
|
func (s *Service) Lag(ctx context.Context) (int64, error) {
|
||||||
|
live, err := s.Store.ListLiveRuns(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var worst int64
|
||||||
|
for _, l := range live {
|
||||||
|
size, err := journalSize(l.Workdir)
|
||||||
|
if err != nil {
|
||||||
|
continue // a book whose directory is unreadable is the reconciler's problem, not this one's
|
||||||
|
}
|
||||||
|
worst = max(worst, size-l.Position.Offset)
|
||||||
|
}
|
||||||
|
return worst, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) error {
|
func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) error {
|
||||||
// The cursor this call MOVED, not the one the sweep's snapshot was taken with. Everything below
|
// The cursor this call MOVED, not the one the sweep's snapshot was taken with. Everything below
|
||||||
// that asks "has the stream said anything" has to ask about now: the snapshot is read before the
|
// that asks "has the stream said anything" has to ask about now: the snapshot is read before the
|
||||||
|
|
@ -83,6 +133,12 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if l.UnitName == "" {
|
if l.UnitName == "" {
|
||||||
|
if l.StopRequestedAt != nil {
|
||||||
|
// Stopped before it ever started: the request was admitted, the money was held, and the
|
||||||
|
// unit was never created. There is nothing to signal and nothing to wait for, so the run
|
||||||
|
// ends here and its hold comes back whole through the ordinary settlement.
|
||||||
|
return s.finishStopped(ctx, l)
|
||||||
|
}
|
||||||
// Admitted and never spawned: the platform stopped between the transaction and the unit, or
|
// Admitted and never spawned: the platform stopped between the transaction and the unit, or
|
||||||
// the queue entry was lost. The reconciler is the backstop for both.
|
// the queue entry was lost. The reconciler is the backstop for both.
|
||||||
return s.spawnAttempt(ctx, l)
|
return s.spawnAttempt(ctx, l)
|
||||||
|
|
@ -94,14 +150,84 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if alive {
|
if alive {
|
||||||
|
if l.StopRequestedAt != nil {
|
||||||
|
// The intent is committed and the unit is still there. Either the signal never went out —
|
||||||
|
// the platform died between writing the intent and asking systemd — or the engine is
|
||||||
|
// finishing the chunk it has already paid for. Asking again is free and idempotent, and it
|
||||||
|
// is the only thing that closes the first case.
|
||||||
|
if err := s.Runner.Stop(ctx, l.UnitName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return s.maybeResync(ctx, l)
|
return s.maybeResync(ctx, l)
|
||||||
}
|
}
|
||||||
if s.now().Sub(l.AttemptStartedAt) < spawnGrace {
|
if s.now().Sub(l.AttemptStartedAt) < spawnGrace {
|
||||||
return nil // systemd has not started it yet
|
return nil // systemd has not started it yet
|
||||||
}
|
}
|
||||||
|
if l.StopRequestedAt != nil {
|
||||||
|
// The unit is gone and left no marker, which is the shape of a reboot — but this run was
|
||||||
|
// asked to stop, and restarting it would spend the account's money on work its owner had
|
||||||
|
// just cancelled. The intent decides, because it is the one fact here that is ours.
|
||||||
|
return s.finishStopped(ctx, l)
|
||||||
|
}
|
||||||
return s.restart(ctx, l)
|
return s.restart(ctx, l)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// finishStopped closes a run whose end this platform asked for and whose unit left no marker to read:
|
||||||
|
// it was never created, or the manager went away with it.
|
||||||
|
//
|
||||||
|
// The two cases are not the same write. Where a unit EXISTED the ordinary finish applies. Where it
|
||||||
|
// never did, the close has to re-check that under a lock: the queue worker may have claimed the
|
||||||
|
// attempt between this sweep's snapshot and now, and closing the run then strands a live engine that
|
||||||
|
// no list looks at any more.
|
||||||
|
func (s *Service) finishStopped(ctx context.Context, l pgstore.LiveRun) error {
|
||||||
|
if l.StopRequestedAt == nil {
|
||||||
|
// The SNAPSHOT may predate the request — that is the whole reason one of the two callers is
|
||||||
|
// here: the reconciler read the run, spent seconds settling, and the stop landed inside that
|
||||||
|
// window. Reaching this function is itself the establishment of the fact, so the local copy is
|
||||||
|
// made to say so rather than letting `outcome` read a nil and call the ending a failure.
|
||||||
|
now := s.now()
|
||||||
|
l.StopRequestedAt = &now
|
||||||
|
}
|
||||||
|
if l.UnitName != "" {
|
||||||
|
return s.finish(ctx, l, runner.Marker{Unit: l.UnitName, Result: pgstore.StopRequestedResult})
|
||||||
|
}
|
||||||
|
// ⚠ An empty unit name is not proof that no process exists. `ReleaseSpawnClaim` clears the name
|
||||||
|
// when the unit could NOT be created — and "could not be created" is not "was not created": a
|
||||||
|
// systemd-run killed after it had already asked leaves an engine running, which is why the spend
|
||||||
|
// baseline is deliberately kept on such an attempt (RecordSpawn). That baseline is the tombstone,
|
||||||
|
// and where it exists the unit's name is derivable, so systemd is asked rather than assumed.
|
||||||
|
// Closing over a live engine would leave it spending against a run this platform calls finished.
|
||||||
|
if l.SpendBaseline != nil {
|
||||||
|
unit := unitName(l.RunID, l.AttemptNo)
|
||||||
|
alive, err := s.Runner.Alive(ctx, unit)
|
||||||
|
if err != nil {
|
||||||
|
// "I could not ask" is never "the run is gone" — the same rule the reconciler follows
|
||||||
|
// everywhere else.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if alive {
|
||||||
|
s.log().InfoContext(ctx, "a claim was given back but its unit exists; stopping it instead of closing the run",
|
||||||
|
"run", l.RunID, "attempt", l.AttemptNo)
|
||||||
|
return s.Runner.Stop(ctx, unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finished, err := s.Store.FinishUnspawnedStop(ctx, l.RunID, l.AttemptID, s.now())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !finished {
|
||||||
|
// It was spawned after all. Nothing to do: the next pass sees a live unit and an intent that
|
||||||
|
// says to stop it.
|
||||||
|
s.log().InfoContext(ctx, "the stopped run was spawned after this sweep read it", "run", l.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.log().InfoContext(ctx, "run finished", "run", l.RunID, "status", "stopped",
|
||||||
|
"result", pgstore.StopRequestedResult)
|
||||||
|
return s.settle(ctx, l)
|
||||||
|
}
|
||||||
|
|
||||||
// drainJournal applies whatever the engine has written since the cursor, and returns where the
|
// drainJournal applies whatever the engine has written since the cursor, and returns where the
|
||||||
// cursor now stands.
|
// cursor now stands.
|
||||||
func (s *Service) drainJournal(ctx context.Context, l pgstore.LiveRun) (int64, error) {
|
func (s *Service) drainJournal(ctx context.Context, l pgstore.LiveRun) (int64, error) {
|
||||||
|
|
@ -216,9 +342,18 @@ func (s *Service) dueForResync(runID string) bool {
|
||||||
func (s *Service) finish(ctx context.Context, l pgstore.LiveRun, m runner.Marker) error {
|
func (s *Service) finish(ctx context.Context, l pgstore.LiveRun, m runner.Marker) error {
|
||||||
delete(s.resynced, l.RunID) // a finished run keeps no rate-limit entry: the map is per process
|
delete(s.resynced, l.RunID) // a finished run keeps no rate-limit entry: the map is per process
|
||||||
status, exit := outcome(l, m)
|
status, exit := outcome(l, m)
|
||||||
if err := s.Store.FinishRun(ctx, l.RunID, l.AttemptID, status, m.Result, exit, s.now()); err != nil {
|
closed, err := s.Store.FinishRun(ctx, l.RunID, l.AttemptID, status, m.Result, exit, s.now())
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if !closed {
|
||||||
|
// The run was already finished, or this pass is holding a snapshot of an attempt that is no
|
||||||
|
// longer the live one — a stop and a resume can both have happened since it was taken. Doing
|
||||||
|
// anything further here would settle the money of a run somebody else is now running.
|
||||||
|
s.log().InfoContext(ctx, "the run moved on since this pass read it; nothing to finish",
|
||||||
|
"run", l.RunID, "attempt", l.AttemptNo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
s.log().InfoContext(ctx, "run finished", "run", l.RunID, "status", status, "result", m.Result)
|
s.log().InfoContext(ctx, "run finished", "run", l.RunID, "status", status, "result", m.Result)
|
||||||
// Settling immediately rather than waiting for the next sweep: the hold is the account's money
|
// Settling immediately rather than waiting for the next sweep: the hold is the account's money
|
||||||
// and every second it stays reserved is a second the user cannot start another book.
|
// and every second it stays reserved is a second the user cannot start another book.
|
||||||
|
|
@ -238,29 +373,57 @@ func (s *Service) finish(ctx context.Context, l pgstore.LiveRun, m runner.Marker
|
||||||
// verdict returns errReserveCeiling). The contract requires a ceiling stop to be `paused` and never
|
// verdict returns errReserveCeiling). The contract requires a ceiling stop to be `paused` and never
|
||||||
// `failed`, so until the emitter lands the only ceiling stop this platform can report correctly is
|
// `failed`, so until the emitter lands the only ceiling stop this platform can report correctly is
|
||||||
// one it heard about on the stream — which is exactly the branch below that reads paused_reason.
|
// one it heard about on the stream — which is exactly the branch below that reads paused_reason.
|
||||||
|
//
|
||||||
|
// A stop this platform ASKED for is a different question and is answered here, because the answer is
|
||||||
|
// ours: the engine catches SIGTERM and exits 1, so the marker cannot tell a stop from a crash — but
|
||||||
|
// the request was written down before the signal went out, and that record is evidence rather than
|
||||||
|
// inference (register row PD-152).
|
||||||
func outcome(l pgstore.LiveRun, m runner.Marker) (status string, exitCode *int) {
|
func outcome(l pgstore.LiveRun, m runner.Marker) (status string, exitCode *int) {
|
||||||
if l.PausedReason != "" {
|
if l.PausedReason != "" {
|
||||||
return "paused", nil
|
return "paused", nil
|
||||||
}
|
}
|
||||||
code, exited := m.Exited()
|
code, exited := m.Exited()
|
||||||
if !exited {
|
if exited {
|
||||||
if m.Result == "success" {
|
switch code {
|
||||||
return "stopped", nil // asked to stop, and it did
|
case 0, 2:
|
||||||
|
// 2 is "completed with flagged units": a finished translation whose notes carry the flags.
|
||||||
|
// The engine's own clean answer wins over a stop that arrived while it was already done.
|
||||||
|
return "ready", &code
|
||||||
|
case 3:
|
||||||
|
// The bank-signing stop. The ATTEMPT is over — the engine exits — and the run is left in
|
||||||
|
// a state `resume` continues from once the decisions are complete.
|
||||||
|
return "awaiting_bank", &code
|
||||||
}
|
}
|
||||||
return "failed", nil
|
|
||||||
}
|
}
|
||||||
switch code {
|
if stoppedOnRequest(l, m) {
|
||||||
case 0, 2:
|
// Exit 1 after our SIGTERM is the ordinary shape of a graceful stop; a kill after the stop
|
||||||
// 2 is "completed with flagged units": a finished translation whose notes carry the flags.
|
// timeout, or an OOM in the same window, is an ungraceful one. Both are the stop the user
|
||||||
return "ready", &code
|
// asked for, and the machine's own word for what happened stays in exit_result.
|
||||||
case 3:
|
return "stopped", nil
|
||||||
// The bank-signing stop. The ATTEMPT is over — the engine exits — and so is this run row;
|
}
|
||||||
// the work is resumable and resuming it starts a new run, because the contract's resume
|
if !exited && m.Result == "success" {
|
||||||
// handle is not part of this pack.
|
// Killed by a signal and systemd calls the result success: a stop nobody recorded, which is
|
||||||
return "awaiting_bank", &code
|
// `systemctl stop` by hand on the host.
|
||||||
default:
|
return "stopped", nil
|
||||||
|
}
|
||||||
|
if exited {
|
||||||
return "failed", &code
|
return "failed", &code
|
||||||
}
|
}
|
||||||
|
return "failed", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stoppedOnRequest reports whether this platform's own stop is what ended the attempt.
|
||||||
|
//
|
||||||
|
// The timestamp comparison is the race guard the design owes: a run that finished by itself a moment
|
||||||
|
// before someone pressed stop was not stopped by them, and calling it `stopped` would hide a
|
||||||
|
// completed translation behind a cancelled one. Marker.At is written by the unit as it dies, on the
|
||||||
|
// same host and by the same clock the request was stamped with. A marker with no timestamp is
|
||||||
|
// evidence we do not have, and then the intent — the only fact left — decides.
|
||||||
|
func stoppedOnRequest(l pgstore.LiveRun, m runner.Marker) bool {
|
||||||
|
if l.StopRequestedAt == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return m.At.IsZero() || !l.StopRequestedAt.After(m.At)
|
||||||
}
|
}
|
||||||
|
|
||||||
// settle resolves the money of an attempt whose process is gone.
|
// settle resolves the money of an attempt whose process is gone.
|
||||||
|
|
@ -279,6 +442,23 @@ func (s *Service) settle(ctx context.Context, l pgstore.LiveRun) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
key := pgstore.ReservationKey(l.RunID, l.AttemptNo)
|
key := pgstore.ReservationKey(l.RunID, l.AttemptNo)
|
||||||
|
// An attempt with no unit never reached the engine, so there is nothing to ask it about — and
|
||||||
|
// asking anyway is what kept such a hold reserved for good on the very hosts that produce this
|
||||||
|
// case: a deployment whose engine cannot be run fails `Status` on every pass, and the money of a
|
||||||
|
// run that never ran would wait for an answer that never comes. The row is re-checked under the
|
||||||
|
// lock inside ReleaseUnspawned, so a snapshot that was spawned in the meantime is refused there
|
||||||
|
// and settles the ordinary way below. Found by cross-family review of the acceptance dofix.
|
||||||
|
if l.UnitName == "" && l.SpendBaseline == nil {
|
||||||
|
switch err := s.Store.ReleaseUnspawned(ctx, key, l.AttemptID, s.now()); {
|
||||||
|
case err == nil, errors.Is(err, pgstore.ErrNoReservation):
|
||||||
|
return s.Store.MarkSettled(ctx, l.RunID, s.now())
|
||||||
|
case !errors.Is(err, pgstore.ErrAttemptSpawned):
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.log().InfoContext(ctx, "settlement deferred: the attempt was spawned after this sweep read it",
|
||||||
|
"run", l.RunID, "attempt", l.AttemptNo)
|
||||||
|
return nil // the next sweep sees the unit and settles against its baseline
|
||||||
|
}
|
||||||
rep, err := s.Engine.Status(ctx, s.engineBinary(l), l.Workdir)
|
rep, err := s.Engine.Status(ctx, s.engineBinary(l), l.Workdir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log().WarnContext(ctx, "settlement deferred: the engine's committed spend could not be read",
|
s.log().WarnContext(ctx, "settlement deferred: the engine's committed spend could not be read",
|
||||||
|
|
@ -367,34 +547,89 @@ func (s *Service) restart(ctx context.Context, l pgstore.LiveRun) error {
|
||||||
if err := s.settle(ctx, l); err != nil {
|
if err := s.settle(ctx, l); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
next, v, err := s.reopen(ctx, l)
|
||||||
|
if errors.Is(err, pgstore.ErrStopRequested) {
|
||||||
|
// The user pressed stop while this pass was settling. The intent outranks the restart, and it
|
||||||
|
// is still there — the refusal is what kept it — so the run ends here instead.
|
||||||
|
s.log().InfoContext(ctx, "restart abandoned: the run was asked to stop", "run", l.RunID)
|
||||||
|
return s.finishStopped(ctx, l)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch v {
|
||||||
|
case exhausted:
|
||||||
|
// `paused` WITH its reason: the contract describes PausedReason as the reason a run is paused,
|
||||||
|
// and a paused run with a null reason gives the screen nothing to say. PauseRun is the path
|
||||||
|
// that sets it; FinishRun does not. Done HERE and not inside reopen because the other caller —
|
||||||
|
// a resume — works on a run that has already ended, and pausing one of those would rewrite how
|
||||||
|
// it ended.
|
||||||
|
//
|
||||||
|
// A stop that arrived while this pass was settling outranks the pause: the store refuses and
|
||||||
|
// the run ends as what it is, stopped.
|
||||||
|
if err := s.Store.PauseRun(ctx, l.RunID, l.AttemptID, pgstore.PausedCreditExhausted, s.now()); err != nil {
|
||||||
|
if errors.Is(err, pgstore.ErrStopRequested) {
|
||||||
|
s.log().InfoContext(ctx, "pause abandoned: the run was asked to stop", "run", l.RunID)
|
||||||
|
return s.finishStopped(ctx, l)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case deferred:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.log().InfoContext(ctx, "interrupted run restarted", "run", l.RunID, "attempt", next.AttemptNo)
|
||||||
|
return s.spawnAttempt(ctx, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verdict is what reopening a run came to. Three outcomes because the callers want different things
|
||||||
|
// from the two that are not "it started": the reconciler treats both as "nothing more this pass",
|
||||||
|
// while a user's resume must tell "your money is still being counted, ask again" from "there is
|
||||||
|
// nothing left of this run's budget".
|
||||||
|
type verdict int
|
||||||
|
|
||||||
|
const (
|
||||||
|
reopened verdict = iota
|
||||||
|
// deferred — the previous attempt's money is not settled yet. Temporary by construction: the
|
||||||
|
// settlement is retried by every sweep.
|
||||||
|
deferred
|
||||||
|
// exhausted — nothing is left of the run's budget, or the account cannot carry a new hold. What
|
||||||
|
// to DO about it differs by caller, so reopen only reports it.
|
||||||
|
exhausted
|
||||||
|
)
|
||||||
|
|
||||||
|
// reopen closes an attempt and opens the next one with what is LEFT of the run's budget.
|
||||||
|
//
|
||||||
|
// It is the shared body of the reconciler's restart and of the contract's resume, and sharing it is
|
||||||
|
// the point: both are "this run continues in a new process", both must give the new attempt the
|
||||||
|
// remainder rather than the whole ceiling — one run may not spend its ceiling twice — and both must
|
||||||
|
// refuse to open a second hold while the first is still open.
|
||||||
|
func (s *Service) reopen(ctx context.Context, l pgstore.LiveRun) (pgstore.LiveRun, verdict, error) {
|
||||||
// Settling is allowed to DEFER — the engine's figure may not be readable — and a deferral must not
|
// Settling is allowed to DEFER — the engine's figure may not be readable — and a deferral must not
|
||||||
// become a second reservation. Asked rather than assumed: proceeding on an unsettled attempt holds
|
// become a second reservation. Asked rather than assumed: proceeding on an unsettled attempt holds
|
||||||
// the ceiling twice and strands the first hold where no later sweep looks for it.
|
// the ceiling twice and strands the first hold where no later sweep looks for it.
|
||||||
open, err := s.Store.AttemptReservationOpen(ctx, l.RunID, l.AttemptNo)
|
open, err := s.Store.AttemptReservationOpen(ctx, l.RunID, l.AttemptNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return pgstore.LiveRun{}, deferred, err
|
||||||
}
|
}
|
||||||
if open {
|
if open {
|
||||||
s.log().WarnContext(ctx, "restart deferred: the interrupted attempt is not settled yet",
|
s.log().WarnContext(ctx, "restart deferred: the previous attempt is not settled yet",
|
||||||
"run", l.RunID, "attempt", l.AttemptNo)
|
"run", l.RunID, "attempt", l.AttemptNo)
|
||||||
return nil
|
return pgstore.LiveRun{}, deferred, nil
|
||||||
}
|
}
|
||||||
budget := s.Pricing.Ceiling(l.CeilingChapters)
|
budget := s.Pricing.Ceiling(l.CeilingChapters)
|
||||||
spent, err := s.Store.RunSpent(ctx, l.RunID)
|
spent, err := s.Store.RunSpent(ctx, l.RunID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return pgstore.LiveRun{}, deferred, err
|
||||||
}
|
}
|
||||||
remaining := budget - spent
|
remaining := budget - spent
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
// `paused` WITH its reason: the contract describes PausedReason as the reason a run is paused,
|
s.log().InfoContext(ctx, "the run has no budget left", "run", l.RunID)
|
||||||
// and a paused run with a null reason gives the screen nothing to say. PauseRun is the path
|
return pgstore.LiveRun{}, exhausted, nil
|
||||||
// that sets it; FinishRun does not.
|
|
||||||
s.log().InfoContext(ctx, "interrupted run has no budget left", "run", l.RunID)
|
|
||||||
return s.Store.PauseRun(ctx, l.RunID, l.AttemptID, pgstore.PausedCreditExhausted, s.now())
|
|
||||||
}
|
}
|
||||||
offset, err := journalSize(l.Workdir)
|
offset, err := journalSize(l.Workdir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return pgstore.LiveRun{}, deferred, err
|
||||||
}
|
}
|
||||||
// Nil = inherit the pinned build. A different one only when an operator asked for it (row 139).
|
// Nil = inherit the pinned build. A different one only when an operator asked for it (row 139).
|
||||||
var version *string
|
var version *string
|
||||||
|
|
@ -412,25 +647,142 @@ func (s *Service) restart(ctx context.Context, l pgstore.LiveRun) error {
|
||||||
Now: s.now(),
|
Now: s.now(),
|
||||||
})
|
})
|
||||||
if errors.Is(err, pgstore.ErrInsufficientCredit) {
|
if errors.Is(err, pgstore.ErrInsufficientCredit) {
|
||||||
// The balance went elsewhere while this run was down. Honest state, not a failure: the run is
|
// The balance went elsewhere while this run was down. Honest state, not a failure.
|
||||||
// paused for the reason the contract has a word for.
|
s.log().InfoContext(ctx, "the run cannot be continued on the current balance", "run", l.RunID)
|
||||||
s.log().InfoContext(ctx, "interrupted run cannot be resumed on the current balance", "run", l.RunID)
|
return pgstore.LiveRun{}, exhausted, nil
|
||||||
return s.Store.PauseRun(ctx, l.RunID, l.AttemptID, pgstore.PausedCreditExhausted, s.now())
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return pgstore.LiveRun{}, deferred, err
|
||||||
}
|
}
|
||||||
s.log().InfoContext(ctx, "interrupted run restarted", "run", l.RunID, "attempt", next.AttemptNo)
|
return next, reopened, nil
|
||||||
return s.spawnAttempt(ctx, next)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop asks a live run to shut down. The engine stops gracefully on SIGTERM and finishes the chunk
|
func (s *Service) enqueueNow(ctx context.Context, runID string) error {
|
||||||
// it has already paid for; the reconciler turns the exit into a status.
|
if s.Queue == nil {
|
||||||
func (s *Service) Stop(ctx context.Context, l pgstore.LiveRun) error {
|
return nil // no queue configured: the reconciler still picks the run up on its next sweep
|
||||||
if l.UnitName == "" {
|
|
||||||
return fmt.Errorf("runs: run %s has no unit to stop", l.RunID)
|
|
||||||
}
|
}
|
||||||
return s.Runner.Stop(ctx, l.UnitName)
|
return s.Queue.EnqueueRunNow(ctx, runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop is the contract's stop handle (§stopRun).
|
||||||
|
//
|
||||||
|
// What it does is record the INTENT and then ask systemd, in that order, and the order is the whole
|
||||||
|
// mechanism: the engine catches SIGTERM and exits 1, so the exit marker says the same thing for a
|
||||||
|
// stop and for a crash (register row PD-152). The row written before the signal is what tells them
|
||||||
|
// apart afterwards — and what stops the reconciler restarting a run whose owner just cancelled it.
|
||||||
|
//
|
||||||
|
// The systemd call is allowed to fail without failing the request: the intent is committed, the
|
||||||
|
// reconciler re-issues the stop on its next pass, and answering an error to a stop that WILL happen
|
||||||
|
// would invite the user to press it again.
|
||||||
|
func (s *Service) Stop(ctx context.Context, userID, runID string) (pgstore.Run, error) {
|
||||||
|
run, unit, err := s.Store.RequestStop(ctx, userID, runID, s.now())
|
||||||
|
if errors.Is(err, pgstore.ErrRunNotLive) {
|
||||||
|
// No status in the message: RequestStop answers a zero Run alongside this error, so anything
|
||||||
|
// read off it would be an empty string dressed up as a fact.
|
||||||
|
return pgstore.Run{}, ErrNotStoppable
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return pgstore.Run{}, err
|
||||||
|
}
|
||||||
|
s.log().InfoContext(ctx, "run stop requested", "run", runID)
|
||||||
|
if unit == "" {
|
||||||
|
// Admitted and not yet spawned. There is nothing to signal; the reconciler ends the run and
|
||||||
|
// gives the hold back on its next pass.
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
if err := s.Runner.Stop(ctx, unit); err != nil {
|
||||||
|
s.log().ErrorContext(ctx, "the stop was recorded but systemd did not take it; the reconciler will retry",
|
||||||
|
"run", runID, "err", err)
|
||||||
|
}
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume is the contract's resume handle (§resumeRun): it continues a run that was stopped.
|
||||||
|
//
|
||||||
|
// The mechanics are the reconciler's — a new attempt with what is LEFT of the run's budget — because
|
||||||
|
// a resume and a restart are the same event seen from two sides, and a second implementation of
|
||||||
|
// "give this run another process" is a second place for the money to be got wrong.
|
||||||
|
//
|
||||||
|
// What this adds is the state machine the contract describes, and its shape is dictated by that text
|
||||||
|
// rather than chosen:
|
||||||
|
// - a run the user stopped continues;
|
||||||
|
// - a bank stop answers 409 while the set of decisions is incomplete — and it is incomplete for
|
||||||
|
// every book today, because nothing materializes the bank at all (companion §3: the export
|
||||||
|
// artifact the read needs does not exist);
|
||||||
|
// - a run paused by a ceiling "returns it to the same state", so it answers 202 with the run
|
||||||
|
// unchanged rather than pretending an action happened;
|
||||||
|
// - anything else — already going, finished, failed — is a 409.
|
||||||
|
func (s *Service) Resume(ctx context.Context, userID, runID string) (pgstore.Run, error) {
|
||||||
|
// Ownership FIRST, deployment health second. The other order answers "this service cannot start
|
||||||
|
// runs" to a caller asking about a run that is not theirs and to one asking about a run that does
|
||||||
|
// not exist — which is both a worse answer and a small oracle about the shape of the deployment.
|
||||||
|
l, err := s.Store.ReadRunForResume(ctx, userID, runID)
|
||||||
|
if err != nil {
|
||||||
|
return pgstore.Run{}, err
|
||||||
|
}
|
||||||
|
if err := s.runnable(); err != nil {
|
||||||
|
return pgstore.Run{}, err
|
||||||
|
}
|
||||||
|
switch l.Status {
|
||||||
|
case "stopped", "paused":
|
||||||
|
case "awaiting_bank":
|
||||||
|
// The stop clears ONLY on a complete set of decisions (contract §resumeRun, §BankDecisions).
|
||||||
|
// There is no channel that could make one complete yet, so this is the honest answer rather
|
||||||
|
// than a placeholder: resuming would hit the same stop and spend the start of a run doing it.
|
||||||
|
return pgstore.Run{}, fmt.Errorf("%w: the glossary is not signed", ErrNotResumable)
|
||||||
|
default:
|
||||||
|
return pgstore.Run{}, fmt.Errorf("%w: it is %s", ErrNotResumable, l.Status)
|
||||||
|
}
|
||||||
|
next, v, err := s.reopen(ctx, l)
|
||||||
|
if errors.Is(err, pgstore.ErrStopRequested) {
|
||||||
|
// Cannot happen through this path today — a resume works on a run that has ENDED and the guard
|
||||||
|
// only fires on a live one — but the answer is written rather than left to fall through as an
|
||||||
|
// internal error if some later path reaches it.
|
||||||
|
return pgstore.Run{}, fmt.Errorf("%w: it has been asked to stop", ErrNotResumable)
|
||||||
|
}
|
||||||
|
if errors.Is(err, pgstore.ErrNoRun) {
|
||||||
|
// Another caller re-opened this run first — two clicks on the same button reach here together,
|
||||||
|
// and the second loses the race for attempt N+1 on the unique index. Its answer is the WORK OF
|
||||||
|
// THE FIRST: the run is continuing, so reporting "no such run" would be both false and
|
||||||
|
// alarming, and re-opening again would take a second hold.
|
||||||
|
return s.Store.ReadRun(ctx, userID, runID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return pgstore.Run{}, err
|
||||||
|
}
|
||||||
|
switch v {
|
||||||
|
case deferred:
|
||||||
|
// The previous attempt's money is still being resolved. A second hold now would reserve the
|
||||||
|
// ceiling twice, so the answer is "not yet" and the sweep clears it.
|
||||||
|
return pgstore.Run{}, fmt.Errorf("%w: its previous attempt is still being settled", ErrNotResumable)
|
||||||
|
case exhausted:
|
||||||
|
// Nothing left to continue with: the call returns the run in the state it was in, and the state
|
||||||
|
// is NOT rewritten on the way out — a run the user stopped stays stopped, because a resume that
|
||||||
|
// changed nothing must not change what the run was.
|
||||||
|
//
|
||||||
|
// ⚠ This is NOT the whole of what the contract asks about `paused`. It says a resume must not be
|
||||||
|
// the remedy for a ceiling pause, and this branch only produces that answer when there is
|
||||||
|
// nothing left to spend. Today the only reachable `paused` is `credit_exhausted` from the
|
||||||
|
// reconciler, where continuing after a grant IS the right thing; a CEILING pause needs the
|
||||||
|
// engine's ceiling event, which does not exist (register row PD-113). When it lands, resume has
|
||||||
|
// to refuse for that reason instead of re-opening the run — a run whose ceiling nothing raised
|
||||||
|
// would otherwise walk into the same stop and, with no event to name it, come back `failed`.
|
||||||
|
return s.Store.ReadRun(ctx, userID, runID)
|
||||||
|
}
|
||||||
|
s.log().InfoContext(ctx, "run resumed", "run", runID, "attempt", next.AttemptNo)
|
||||||
|
// QUEUED, not spawned here. Starting the engine inline would put two slow things inside a request
|
||||||
|
// that answers 202: reading the book's meter costs seconds of the engine's CPU, and creating the
|
||||||
|
// unit is a round trip to systemd. The admission path queues for the same reason, and the
|
||||||
|
// reconciler is the backstop if the entry is lost.
|
||||||
|
if err := s.enqueueNow(ctx, next.RunID); err != nil {
|
||||||
|
// The run IS re-opened and the money IS held: the row says `translating` and the reconciler
|
||||||
|
// picks up an attempt with no unit on its next pass, exactly as it does after an admission.
|
||||||
|
// Reporting a failure here would tell the user nothing had started while the row said
|
||||||
|
// otherwise.
|
||||||
|
s.log().ErrorContext(ctx, "the resumed run could not be queued; the reconciler will retry",
|
||||||
|
"run", runID, "err", err)
|
||||||
|
}
|
||||||
|
return s.Store.ReadRun(ctx, userID, runID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// attemptSpend turns the engine's figure into what THIS attempt cost.
|
// attemptSpend turns the engine's figure into what THIS attempt cost.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ type fakeRunner struct {
|
||||||
alive bool
|
alive bool
|
||||||
aliveErr error
|
aliveErr error
|
||||||
startErr error
|
startErr error
|
||||||
|
// onStop runs INSIDE the fake's Stop, which is what makes the ORDER of "write the intent, then
|
||||||
|
// ask systemd" assertable: by the time this runs, the database must already know.
|
||||||
|
onStop func(unit string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRunner) Start(_ context.Context, s runner.Spec) error {
|
func (f *fakeRunner) Start(_ context.Context, s runner.Spec) error {
|
||||||
|
|
@ -44,11 +47,21 @@ func (f *fakeRunner) Start(_ context.Context, s runner.Spec) error {
|
||||||
|
|
||||||
func (f *fakeRunner) Stop(_ context.Context, unit string) error {
|
func (f *fakeRunner) Stop(_ context.Context, unit string) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
hook := f.onStop
|
||||||
f.stopped = append(f.stopped, unit)
|
f.stopped = append(f.stopped, unit)
|
||||||
|
f.mu.Unlock()
|
||||||
|
if hook != nil {
|
||||||
|
hook(unit)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) stops() []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]string(nil), f.stopped...)
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeRunner) Alive(context.Context, string) (bool, error) { return f.alive, f.aliveErr }
|
func (f *fakeRunner) Alive(context.Context, string) (bool, error) { return f.alive, f.aliveErr }
|
||||||
|
|
||||||
func (f *fakeRunner) starts() []runner.Spec {
|
func (f *fakeRunner) starts() []runner.Spec {
|
||||||
|
|
@ -64,13 +77,38 @@ type fakeEngine struct {
|
||||||
report ingest.StatusReport
|
report ingest.StatusReport
|
||||||
err error
|
err error
|
||||||
calls int
|
calls int
|
||||||
|
// hangs is the workdir the engine refuses to answer about.
|
||||||
|
hangs string
|
||||||
|
// onStatus runs INSIDE the call, which is how a test opens the window the reconciler decides in:
|
||||||
|
// a status call is seconds of the engine's CPU, and a user can press stop inside it.
|
||||||
|
onStatus func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeEngine) Status(context.Context, string, string) (ingest.StatusReport, error) {
|
func (f *fakeEngine) Status(ctx context.Context, _, workdir string) (ingest.StatusReport, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.calls++
|
||||||
|
hang := f.hangs != "" && f.hangs == workdir
|
||||||
|
hook := f.onStatus
|
||||||
|
rep, err := f.report, f.err
|
||||||
|
f.mu.Unlock()
|
||||||
|
if hook != nil {
|
||||||
|
hook()
|
||||||
|
}
|
||||||
|
if hang {
|
||||||
|
// An engine that never answers, which is what a hung `tmctl status` looks like from here. It
|
||||||
|
// ends when the CALLER's budget does, and that is the property being measured.
|
||||||
|
<-ctx.Done()
|
||||||
|
return ingest.StatusReport{}, ctx.Err()
|
||||||
|
}
|
||||||
|
return rep, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// block makes the engine hang for one book's directory. The run id is taken only to make the call
|
||||||
|
// site read like the thing it models.
|
||||||
|
func (f *fakeEngine) block(_ string, workdir string) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
f.calls++
|
f.hangs = workdir
|
||||||
return f.report, f.err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeEngine) set(rep ingest.StatusReport, err error) {
|
func (f *fakeEngine) set(rep ingest.StatusReport, err error) {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,10 @@ type Config struct {
|
||||||
// the engine is deployed more often than a translation finishes, so the quiet behaviour would be
|
// the engine is deployed more often than a translation finishes, so the quiet behaviour would be
|
||||||
// for a resumed run to continue under a program nobody chose for it.
|
// for a resumed run to continue under a program nobody chose for it.
|
||||||
AllowEngineVersionChange bool
|
AllowEngineVersionChange bool
|
||||||
|
// RunBudget is what ONE run may cost a pass of the sweep; zero takes the default. It bounds the
|
||||||
|
// starvation the pass itself cannot: the list is ordered the same way every time, so a handful of
|
||||||
|
// runs whose engine hangs used to mean the tail was never reached (register row PD-169).
|
||||||
|
RunBudget time.Duration
|
||||||
// ResyncEvery is how often a live run is reconciled from `tmctl status --json`. It is a SLOW
|
// ResyncEvery is how often a live run is reconciled from `tmctl status --json`. It is a SLOW
|
||||||
// poll on purpose: every call re-ingests and re-chunks the source (~1.4-1.5 s of CPU on a 23 MB
|
// poll on purpose: every call re-ingests and re-chunks the source (~1.4-1.5 s of CPU on a 23 MB
|
||||||
// book, unified backlog row 100). Until the event emitter exists (row 103) it is the only thing
|
// book, unified backlog row 100). Until the event emitter exists (row 103) it is the only thing
|
||||||
|
|
@ -83,9 +87,16 @@ type Service struct {
|
||||||
resynced map[string]time.Time
|
resynced map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueuer hands a run to the queue inside the caller's transaction.
|
// Enqueuer hands a run to the queue.
|
||||||
|
//
|
||||||
|
// Two methods because the two callers need different atomicity, not different queues: an ADMISSION
|
||||||
|
// writes the run, its hold and its queue entry in one transaction (a job for a run that does not
|
||||||
|
// exist, or a hold with no job, are both ways to lose money or work), while a RESUME re-opens a run
|
||||||
|
// that already exists and only then asks for a worker. A lost entry costs a sweep interval either
|
||||||
|
// way — the reconciler is the backstop for both.
|
||||||
type Enqueuer interface {
|
type Enqueuer interface {
|
||||||
EnqueueRun(ctx context.Context, tx pgstore.Tx, runID string) error
|
EnqueueRun(ctx context.Context, tx pgstore.Tx, runID string) error
|
||||||
|
EnqueueRunNow(ctx context.Context, runID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) now() time.Time {
|
func (s *Service) now() time.Time {
|
||||||
|
|
@ -115,6 +126,18 @@ var ErrCeilingOutOfBounds = errors.New("runs: the requested ceiling is outside t
|
||||||
// one during exactly the operation that causes it, a binary upgrade.
|
// one during exactly the operation that causes it, a binary upgrade.
|
||||||
var ErrRunnerIncomplete = errors.New("runs: this deployment cannot record how a run ends")
|
var ErrRunnerIncomplete = errors.New("runs: this deployment cannot record how a run ends")
|
||||||
|
|
||||||
|
// ErrBookNotReady is a run asked for on a book whose intake has not finished — one still arriving,
|
||||||
|
// still being cut, or rejected. The contract's 409: the book exists and the client can see it, and
|
||||||
|
// what cannot happen yet is a translation of it.
|
||||||
|
var ErrBookNotReady = errors.New("runs: the book is not ready to be translated")
|
||||||
|
|
||||||
|
// ErrNotStoppable is a stop asked for on a run that is already over.
|
||||||
|
var ErrNotStoppable = errors.New("runs: the run is not live")
|
||||||
|
|
||||||
|
// ErrNotResumable is a resume that would not move the run: a run that is already going, one that
|
||||||
|
// finished, or a bank stop whose set of decisions is not complete (contract §resumeRun, 409).
|
||||||
|
var ErrNotResumable = errors.New("runs: the run cannot be continued")
|
||||||
|
|
||||||
// Bounds computes the run-ceiling scale for a book.
|
// Bounds computes the run-ceiling scale for a book.
|
||||||
func (s *Service) Bounds(ctx context.Context, userID, bookID string) (pricing.Bounds, error) {
|
func (s *Service) Bounds(ctx context.Context, userID, bookID string) (pricing.Bounds, error) {
|
||||||
book, err := s.Store.ReadBookForRun(ctx, userID, bookID)
|
book, err := s.Store.ReadBookForRun(ctx, userID, bookID)
|
||||||
|
|
@ -154,6 +177,13 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return pgstore.Run{}, err
|
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)
|
||||||
|
}
|
||||||
if book.HasLiveRun {
|
if book.HasLiveRun {
|
||||||
return pgstore.Run{}, pgstore.ErrRunInFlight
|
return pgstore.Run{}, pgstore.ErrRunInFlight
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +217,21 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro
|
||||||
return started.Run, nil
|
return started.Run, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readyToTranslate is the half of the book vocabulary a run may start from: everything a finished
|
||||||
|
// intake can leave behind, plus every state a previous run can leave a parsed book in.
|
||||||
|
//
|
||||||
|
// Written as an allowlist and not as "not uploading, not parsing, not rejected": a status added
|
||||||
|
// later is one this function has never thought about, and refusing it is the safe direction — the
|
||||||
|
// user gets a 409 they can report, rather than an engine started against a book in a state nobody
|
||||||
|
// designed for.
|
||||||
|
func readyToTranslate(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case "not_started", "translating", "awaiting_bank", "finalizing", "ready", "paused", "stopped", "failed":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) enqueue(ctx context.Context, tx pgstore.Tx, runID string) error {
|
func (s *Service) enqueue(ctx context.Context, tx pgstore.Tx, runID string) error {
|
||||||
if s.Queue == nil {
|
if s.Queue == nil {
|
||||||
return nil // no queue configured: the reconciler still picks the run up on its next sweep
|
return nil // no queue configured: the reconciler still picks the run up on its next sweep
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,14 @@ func (s *Service) spawnAttempt(ctx context.Context, l pgstore.LiveRun) error {
|
||||||
if err := s.runnable(); err != nil {
|
if err := s.runnable(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// A run the user has already asked to stop is not started. The claim in RecordSpawn refuses the
|
||||||
|
// window this cannot see — the intent written after this read — but the ordinary case is a stop
|
||||||
|
// pressed on a queued run before its worker got to it, and starting an engine there would spend
|
||||||
|
// the account's money on work that was cancelled before it began.
|
||||||
|
if l.StopRequestedAt != nil {
|
||||||
|
s.log().InfoContext(ctx, "not starting a run that has been asked to stop", "run", l.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// The engine's own money state for this book, read BEFORE anything is started, because afterwards
|
// The engine's own money state for this book, read BEFORE anything is started, because afterwards
|
||||||
// both of its numbers have already moved. It decides two different things: what this attempt will
|
// both of its numbers have already moved. It decides two different things: what this attempt will
|
||||||
// owe when it ends (the difference from the committed figure) and what limit it may be given (the
|
// owe when it ends (the difference from the committed figure) and what limit it may be given (the
|
||||||
|
|
|
||||||
|
|
@ -1431,3 +1431,38 @@ func TestAResumeMovesToANewEngineBuildOnlyWhenItIsAllowed(t *testing.T) {
|
||||||
t.Fatalf("the resumed attempt ran %q, want the new build", starts[len(starts)-1].Binary)
|
t.Fatalf("the resumed attempt ran %q, want the new build", starts[len(starts)-1].Binary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-family review of the acceptance dofix (M3): the same hold, on the host that actually produces
|
||||||
|
// this case. An attempt is unspawned BECAUSE the engine could not be run — and settlement used to ask
|
||||||
|
// that same engine for a committed spend before it would give the money back, so every pass failed on
|
||||||
|
// the call and the hold of a run that never ran stayed reserved for good.
|
||||||
|
func TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer(t *testing.T) {
|
||||||
|
f := newFixture(t, "10", 500)
|
||||||
|
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
live := f.live(t)
|
||||||
|
if live.UnitName != "" {
|
||||||
|
t.Fatal("the attempt was spawned; this test is about one that never was")
|
||||||
|
}
|
||||||
|
f.engine.set(ingest.StatusReport{}, errors.New("tmctl status: exec: no such file or directory"))
|
||||||
|
if err := runner.WriteMarker(f.svc.markerPath(run.ID, live.AttemptNo),
|
||||||
|
runner.Marker{Unit: "u", Result: "exit-code", Code: "exited", Status: "1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Sweep(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
acct := f.account(t)
|
||||||
|
if acct.Reserved != 0 {
|
||||||
|
t.Fatalf("reserved %s: the hold of a run that never started waits on an engine that cannot answer",
|
||||||
|
acct.Reserved.USD())
|
||||||
|
}
|
||||||
|
if acct.Balance != money.MicroUSD(10_000_000) {
|
||||||
|
t.Errorf("balance %s: a run that never started must cost nothing", acct.Balance.USD())
|
||||||
|
}
|
||||||
|
if f.engine.called() != 0 {
|
||||||
|
t.Errorf("the engine was asked %d times about an attempt that never reached it", f.engine.called())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue