textmachine/platform/deploy/README.md

45 KiB
Raw Permalink Blame History

Развёртывание платформы

Одна VM, systemd, бинари артефактами CI (PLATFORM_DIRECTION.md §3). Не Kubernetes: дети-tmctl живут часами и держат эксклюзивный лок на файлах книги на локальном диске — оркестратор, способный переселить под посреди прогона, этой нагрузке враждебен.

Файлы

  • tmplatformd.service — юнит контрольной панели. Тело юнита проверено systemd-analyze verify (systemd 259) — exit 0, без замечаний. ⚠ Проверять надо с ПОДСТАВЛЕННЫМ существующим ExecStart=: дословно юнит даёт exit 1, потому что verify проверяет и наличие бинаря, а /usr/local/bin/tmplatformd на стенде нет. ⚠ юнит целиком под systemd не запускался — нет sudo и нет бинаря на стенде.

    Отдельные свойства песочницы при этом проверены ЖИВЫМ прогоном в пользовательском systemd 259 (systemd-run --user --wait), а не вычитаны из доки: ProtectSystem=strict + ReadWritePaths= на несуществующем пути → 226/NAMESPACE, юнит не стартует; тот же путь созданным → 0/SUCCESS; префикс - на несуществующем → 0/SUCCESS (строка игнорируется — потому мы её и не префиксуем); ProtectHome=yes/home пуст, чтение /home/<user> даёт Permission denied; ProtectHome=tmpfs + BindPaths=<каталог> → каталог виден. Ресурсные потолки (MemoryMax=80%, OOMPolicy=continue) живьём не проверялись — вывод из systemd.resource-control(5)/systemd.service(5).

Откат релиза: не ниже версии 5

goose down до версии 4 и ниже НЕ РАБОТАЕТ на живой базе: down-путь 00005 восстанавливает users_email_key и email NOT NULL, а обе формы нарушают строки, которые пишет боевой код (неподтверждённая личность даёт email = NULL; один адрес законно принадлежит двум аккаунтам). Откат транзакционный, поэтому падение ничего не портит — но планировать откат ниже 5 нельзя, план отката — накатить вперёд. Разбор: docs/STACK_DECISIONS.md §8.

Что юнит закрывает содержательно

  • ПЕРЕ-ДИСПОЗИЦИЯ (P4, D39.106): прогон НЕ ребёнок этого юнита и не живёт в его cgroup. Каждый прогон — транзиентный юнит в СОБСТВЕННОМ systemd-менеджере пользователя tmplatform, в срезе tm-runs.slice, потому что перевод обязан пережить деплой платформы. Следствия для установки: обязателен loginctl enable-linger tmplatform; юниту нужен доступ к своей же пользовательской шине (ProtectHome=tmpfs + BindPaths=/run/user/%U + DBUS_SESSION_BUS_ADDRESS); ресурсные потолки НИЖЕ ограничивают только контрол-плейн.
  • PD-13 (осиротевшие процессы движка) закрыт теперь ТАМ, где прогон: собственным cgroup прогона (TM_PLATFORM_RUN_MEMORY_MAX/_TASKS_MAX), и это ИЗМЕРЕНО — в дефолтном app.slice пользовательского менеджера лимиты принимаются и не применяются (STACK_DECISIONS §16). Дев-путь супервизора с группой процессов остаётся дев-путём.
  • TimeoutStopSec=90 больше, чем дренаж платформы (15 с) плюс grace движка (30 с). Меньше — и systemd прибьёт tmctl посреди остановки, оставив лок проекта.
  • Секреты через LoadCredential=, а не через окружение: переменная окружения видна в /proc/<pid>/environ и наследуется каждым ребёнком-tmctl. Конфиг читает *_FILE первым.

Установка (набросок, исполняется владельцем)

useradd --system --home-dir /srv/textmachine tmplatform
# ⚠ Каталог создаём ЯВНО: --system не создаёт домашний каталог, а `ReadWritePaths=/srv/textmachine`
# при `ProtectSystem=strict` на несуществующем пути валит сборку mount-namespace — юнит не стартует
# вовсе (PD-91, systemd.exec(5)). Это первая команда, которую пропускают, читая набросок сверху вниз.
install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine
install -D -m0755 tmplatformd  /usr/local/bin/tmplatformd
install -D -m0755 tmplatformctl /usr/local/bin/tmplatformctl
# ⚠ БЕЗ ЭТОГО НИ ОДИН ПРОГОН НЕ СТАРТУЕТ. Прогоны — транзиентные юниты в пользовательском
# менеджере `tmplatform`, а он существует вне сессии входа только при включённом linger.
# Проверка: `systemctl --user -M tmplatform@ is-system-running` отвечает, а не «Failed to connect».
loginctl enable-linger tmplatform
# Движок кладётся по ВЕРСИОНИРОВАННОМУ пути: попытка прогона пиннится к тому, с которого началась
# (строка 139), поэтому выкат новой версии не подменяет исполняемое у идущего прогона.
install -D -m0755 tmctl /opt/textmachine/engine/<версия>/tmctl
install -d -m0700 -o root -g root /etc/tmplatform
printf '%s' 'postgres://...' > /etc/tmplatform/dsn && chmod 0400 /etc/tmplatform/dsn
printf '%s' '<oauth client secret>' > /etc/tmplatform/oidc_client_secret && chmod 0400 /etc/tmplatform/oidc_client_secret

/etc/tmplatform/env — несекретное окружение:

TM_PLATFORM_ADDR=127.0.0.1:8080
TM_PLATFORM_TRUSTED_ORIGINS=https://app.example.org
TM_PLATFORM_OIDC_ISSUER=https://accounts.google.com
TM_PLATFORM_OIDC_CLIENT_ID=...
TM_PLATFORM_OIDC_REDIRECT_URL=https://app.example.org/auth/callback
TM_PLATFORM_AFTER_LOGIN=/library
TM_PLATFORM_LANGUAGE_PAIRS=zh>ru
TM_PLATFORM_BOOKS_DIR=/srv/textmachine/books
TM_PLATFORM_BOOK_TEMPLATE=/srv/textmachine/book-template.yaml
TM_PLATFORM_METRICS_ADDR=127.0.0.1:9464

# ⚠⚠ ДОПИСАНО 29.08. БЕЗ ЭТИХ ЧЕТЫРЁХ ИНСТАНС НЕ ЗАПУСТИТ НИ ОДНОГО ПЕРЕВОДА, и это не
# «неполный пример», а окружение, при котором каждый ОПЛАЧЕННЫЙ прогон падает.
TM_PLATFORM_ENGINE_BIN=/opt/textmachine/engine/<версия>/tmctl   # пусто = инстанс только читает
TM_PLATFORM_CTL_BIN=/opt/textmachine/bin/tmplatformctl          # его зовёт юнит как ExecStopPost
TM_PLATFORM_STATE_DIR=/var/lib/tmplatform/state                 # маркеры выхода; ТОЛЬКО абсолютный
TM_PLATFORM_ENGINE_KEYS_PATH=/etc/tmplatform/engine-keys        # KEY=VALUE, едет --keys-file

Почему каждая из четырёх обязательна, а не желательна:

  • ENGINE_BIN пусто — инстанс объявляет себя читающей репликой и не спавнит ничего;
  • CTL_BIN — путь, который юнит зовёт в ExecStopPost, чтобы записать маркер выхода; без него прогон завершается, а платформа об этом не узнаёт никогда;
  • STATE_DIR — каталог маркеров. ⚠ Дефолт лежит ВНЕ ReadWritePaths= юнита, а deploy/ ставит ProtectSystem=strict, поэтому без явного значения запись маркера запрещена файловой системой, а не логикой;
  • ENGINE_KEYS_PATH — ЕДИНСТВЕННЫЙ канал провайдерских ключей в движок (едет аргументом --keys-file, мимо процесса платформы и мимо окружения юнита). Без него движок ищет .env рядом с book.yaml, которого SaaS-путь не пишет, и каждый платный прогон падает exit 10 («missing API keys»). На буте это WARN, а не отказ, — то есть тихо.

TM_PLATFORM_SIGNUP_GRANT_USD здесь НЕТ намеренно (PD-104, слово владельца 16.08): на бете грант по умолчанию НОЛЬ и начисляется руками — tmplatformctl grant. Строка =5 в этом блоке включала обратно ровно тот самообслуживаемый безлимитный грант, который дефолт выключает.

TM_PLATFORM_LANGUAGE_PAIRS обязателен, и правило ЖЁСТКОЕ (акт 5 P7). Инстанс, который ПРИНИМАЕТ ЗАГРУЗКИ и не объявил ни одной ДОСТУПНОЙ пары, не стартует вовсе (internal/config/config.go, проверка IntakeEnabled() && len(AvailablePairs()) == 0); объявленная, но недоступная пара отклоняется интейком кодом unsupported_pair. То есть «не объявлено ничего» и «объявлена эта пара» — разные ответы, и деплой больше не может принять книгу, которую он способен только провалить.

Шаблон книги (TM_PLATFORM_BOOK_TEMPLATE) — артефакт ОПЕРАТОРА

Загруженной книге нужен book.yaml, и пишет его платформа — ОДИН раз, при первом разборе, из этого шаблона (форма Б, D39.130). Дальше файл принадлежит оператору: платформа его не читает и никогда не перезаписывает (O_EXCL), поэтому правка потолка или пайплайна в конкретной книге переживает всё.

Платформа подставляет ровно то, что знает только она: book_id · title · source_lang · target_lang · source_file. Всё остальное едет из шаблона нетронутым — включая ключи, о которых эта версия платформы не слышала, и включая genre: он ушёл из контракта и из интейка в 0.3.0 (Б-23), поэтому теперь это значение ОПЕРАТОРА и платформа его не трогает.

# /srv/textmachine/book-template.yaml
pipeline: /opt/textmachine/configs/pipeline-c1.yaml
models: /opt/textmachine/configs/models.yaml
langpack_root: /opt/textmachine/configs/langpacks
audience: взрослые читатели вебновелл
venuti: 0.6
honorifics: keep
transcription: palladius
footnotes: minimal
ceilings:
  book_usd: 25

Пути внутри шаблона — АБСОЛЮТНЫЕ. Движок резолвит относительный путь от каталога КНИГИ, а не от каталога шаблона, поэтому ../configs/pipeline.yaml в шаблоне указывает на разные файлы для каждой книги и ни на один из них — правильно.

ceilings.day_usd в шаблоне НЕ НУЖЕН — и в примере его нет намеренно (решение владельца 15.08). Трату прогона уже жёстко ограничивает купленный пользователем объём: платформа держит холд и передаёт движку --ceiling-usd, флаш к этому холду. Дневная ось поверх этого — второй лимит на ту же трату, и стоит он дороже, чем даёт: остановленный им прогон приезжает paused (не failed), но продолжить его платформа не может — resume отвечает 409, потому что следующая попытка упёрлась бы в тот же лимит в тот же день, а пользователю эта пауза необъяснима: лимит не его и он его не видит.

Движка это не касается — ceilings.day_usd у него остаётся, и вписать его в конкретную книгу руками оператор вправе. Именно на этот случай платформенная обработка daily_ceiling/409 из P6 и остаётся предохранителем: она делает такую остановку различимой и честной, а не дефолтом, который её вызывает.

Битый или отсутствующий шаблон — беда деплоя, а не книги: загрузки не отклоняются и не удаляются, книги ждут в parsing (метрика tm_platform_books_in_intake{status="parsing"}), в логе — ERROR с причиной. Починили файл — ближайший свип разбирает всё накопившееся.

Апгрейд ПЛАТФОРМЫ: миграция и бинарь едут вместе

Схема и бинарь платформы — один шаг, а не два. Любая миграция, снимающая колонку, делает перекрытие версий невозможным: инстанс старой сборки рядом с уже мигрированной базой отвечает 500 на КАЖДОЕ чтение книги. Так было у 00016 (books.note_count, books.genre, chapters.heading, таблица notes) и у 00021 (chapters.units_done, акт 5 P7) — проверять надо на КАЖДОЙ. Порядок: остановить старую версию → накатить миграцию (TM_PLATFORM_MIGRATE=1 на одном инстансе или goose руками) → поднять новую. Роллинг-апгрейд с перекрытием версий на этом шаге не поддерживается; дефолт TM_PLATFORM_MIGRATE выключен именно поэтому — накат должен быть решением, а не побочным эффектом старта реплики.

Отредактированная миграция не перезапускается. goose ключуется НОМЕРОМ и не хранит ни имени, ни контрольной суммы, так что правка уже применённого файла невидима: на стенде это лечится сносом базы, в проде — гейтом internal/pgstore/migrations.sha256, который делает правку выпущенной миграции видимой ревьюеру. Поймано живой пробой P7 (правка 00016 после её применения на стенде).

Апгрейд ДВИЖКА: порядок против деадлока (строка 174 единого бэклога)

Read-only команды движка отказывают файлу проекта СТАРЕЕ бинаря — exit 13 и токен schema_mismatch found=N expected=M на stderr (форма финализирована D39.134 п.4), а платформа зовёт status перед каждым спавном и на расчёте денег. Значит выкат нового движка на хост с существующими книгами запирает их до миграции файла проекта.

Проверено исполнением на стенде (P7): проектная БД, отведённая на схему v14 при бинаре v15, даёт tmctl status --json → exit 13 с этим токеном; tmctl migrate --config <book.yaml> делает пред-миграционный бэкап и переводит v14 → v15; тот же status после неё — exit 0.

tmctl migrate СУЩЕСТВУЕТ и заленден — $0-команда движка, открывающая файл проекта на запись без прогона: backend/cmd/tmctl/migrate.go, в диспетчере main.go, свой код выхода (exitSchemaMismatch = 13, «run tmctl migrate»).

⚠⚠ ВТОРОЙ, НЕЗАВИСИМЫЙ шов — ФОРМА МАНИФЕСТА движка, и у её бампа безопасного порядка НЕТ ни в одну сторону. Платформа с P12 сверяет manifest_version с известной ей формой (ingest.KnownManifestVersion, зеркало manifestVersion движка) и на незнакомую отвечает НЕ-деструктивным классом parser_unavailable — файл пользователя цел, и это осознанный выбор: без сверки переименованный ключ декодируется в нули, а ноль глав интейк читает как «источник прочли, книги нет», то есть УДАЛЯЕТ аплоад (PD-213). Но класс всё равно ТЕРМИНАЛЕН по бюджету попыток: каждая загруженная книга уходит в rejected после пяти попыток. Гейт — СТРОГОЕ РАВЕНСТВО одной константе (internal/ingest/manifest.go, греп m.Version != KnownManifestVersion), окна двух форм в нём нет, поэтому односторонняя выкатка смертельна с ОБЕИХ сторон: движок впереди платформы ⇒ платформа не знает новую форму; платформа впереди движка ⇒ платформа не знает СТАРУЮ, и в parser_unavailable уезжает каждая книга ещё не обновлённого движка. Симптом один и тот же — интейк массово отклоняет при здоровом на вид движке. ⚠ Прежняя редакция этого абзаца советовала «сначала платформа, потом движок» и ссылалась на правило схемы хранилища — это было неверно дважды (PD-436): у формы манифеста порядка нет, а правило схемы (раздел ниже) — «движок первым → migrate → платформа», про другой шов. Бамп формы манифеста — стоп-мир: закрыть приём (systemctl stop tmplatformd; остановленный демон не принимает загрузок), выкатить ОБА билда, открыть приём. Безопасный порядок появится только вместе с окном двух форм в гейте (константа → набор известных форм) — сегодня его нет и это не заказано.

Гейт версии стоит ТОЛЬКО на интейке (ingest.Manifest.Readable, единственный вызывающий — internal/books/parse.go). Материализация читающей поверхности на конце прогона версию НЕ сверяет — она требует лишь самосогласованности документа (manifest.Whole() в internal/readmodel) и спрашивает манифест у ДЕПЛОЙНОГО бинаря (TM_PLATFORM_ENGINE_BIN), а не у того, к которому запинен прогон. Значит живой прогон стоп-мир переживает, а его книга в худшем случае теряет СВЕЖЕСТЬ: незнакомая форма декодируется в нули, Whole() отказывается переписывать дерево, долг откладывается и после пяти попыток списывается — книга остаётся с прежним текстом (books --abandoned, book refresh). Дренаж «трёх фактов» (шаг 1 ниже) обязателен не поэтому, а из-за схемы хранилища ДВИЖКА — это соседний раздел и другой отказ (exit 13).

Порядок (действующий). ⚠ Ключевое: migrate гоняется НОВЫМ бинарём — старый уводит файл в свою же схему, то есть не делает ничего, и деадлок остаётся. Поэтому бинарь кладётся ДО миграции, а TM_PLATFORM_ENGINE_BIN переключается ПОСЛЕ.

# 0. Закрыть приём. Между списком и миграцией окно, в которое пользователь может стартовать прогон
#    на книге, только что названной безопасной; остановленный демон не принимает ни стартов, ни
#    резюмов. Идущие прогоны переживают это по построению — они в своём менеджере (P4, D39.106).
systemctl stop tmplatformd

# 1. Дренаж. «Прогонов нет» — это ТРИ факта, а не один: нет живых юнитов, нет открытых
#    (резюмируемых) попыток И нет незакрытых холдов.
tmplatformctl books           # колонка MIGRATE и причина, если «no»

# 2. Новый бинарь — на ВЕРСИОНИРОВАННЫЙ путь. Безопасно ещё до переключения: идущие попытки
#    запинены к своим путям, а этот никто пока не зовёт.
install -D -m0755 tmctl /opt/textmachine/engine/<новая версия>/tmctl

# 3. Мигрируем ЕГО бинарём и только те книги, которые команда назвала безопасными.
for d in $(tmplatformctl books --migratable); do
    /opt/textmachine/engine/<новая версия>/tmctl migrate --config "$d/book.yaml"
done

# 4. И только теперь новая версия становится рабочей: TM_PLATFORM_ENGINE_BIN в /etc/tmplatform/env,
#    затем поднять демон.
systemctl start tmplatformd

Почему шаг 1 — про три факта, а не про один. Остановленная попытка ждёт резюма на бинаре, к которому она ЗАПИНЕНА (строка 139): миграция уводит файл вперёд, старый бинарь его больше не откроет, и резюм пришлось бы форсить на новую сборку — риск пере-оплаты уже купленных вызовов. По той же причине блокирует НЕЗАКРЫТЫЙ холд: расчёт денег читает committed_usd тем же запиненным бинарём, и после миграции цифру уже не прочитать. Все три предиката запинены тестом (TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList) — каждый по отдельности, потому что «блокер, который тихо перестал блокировать» виден только на уже прошедшем апгрейде.

Книга, застрявшая на daily_ceiling или на вечно незакрытом холде, блокирует апгрейд бессрочно, и выхода для оператора сегодня нет (строка регистра PD-217): такую книгу придётся либо дождаться, либо разрешить вручную в БД. Форсирующего флага у команды нет намеренно — он бы и был тем самым способом пере-оплатить купленное.

Хвост, который НЕ построен: самолечение платформы — поймала schema_mismatch → зовёт migrate, если у книги нет открытых попыток → повтор, без стоп-мира. Строка регистра PD-201.

Застрявшая работа: что оператор делает, когда свип не справляется

Диагноз и терминальный вердикт для двух состояний, которые свип не закрывает сам (P8-FIX).

Прогон, который реконсилятор не может закончить. Признак: растёт tm_platform_sweep_unfinished_total, гейдж tm_platform_runs_stalled больше нуля.

tmplatformctl runs                 # живые прогоны И застрявшие расчёты
tmplatformctl runs --stalled       # только те, что свип не доводит: сколько неудач подряд,
                                   # когда следующая попытка, что сказал движок, сколько держит

Колонка PHASE говорит, какая это половина, и лечение у них разное. live — прогон ещё идёт, и первый вопрос к systemd. settling — прогон УЖЕ кончился, движка нет, а его расчёт не сходится: деньги пользователя заморожены в открытой резервации, и вопрос к systemd бессмыслен.

Дальше решает человек, а не платформа: сперва спросить systemd (systemctl --user status <юнит>). Если процесс жив — остановить его и дать реконсилятору закрыть прогон штатно, это лучше во всём. Если движок не ответит уже никогда (каталог книги унесён, маунт отвалился):

tmplatformctl run abandon --run <id> --reason "почему" [--release-hold]

Команда ОТКАЖЕТ, если попытка ещё называет юнит или несёт базовую линию траты, — закрывать прогон над живым движком нельзя. --reason обязателен: это единственная запись о том, почему оплаченный прогон объявлен законченным. Холд возвращается ЦЕЛИКОМ в обоих случаях (к abandon допускается только попытка, не дошедшая до движка, значит она ничего не потратила); флаг решает лишь КОГДА — сразу или на ближайшем свипе. Он нужен, когда демон остановлен и свипа не будет.

Оговорка PD-418: ветвление run abandon идёт по runs.finished_at, а не по наличию осиротевшей попытки. Значит команда лечит settling-строку только у прогона, который УЖЕ кончился. Живой прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой ушёл бы в живую ветку: осиротевший холд команда не тронет, а ответит про процесс. Сегодня это состояние недостижимо — единственный не-тестовый путь ко второй открытой резервации, reopen, отказывается стартовать следующую попытку, пока холд предыдущей открыт, — и пак P12 его достижимее НЕ сделал (блокированная расплата теперь считается неудачей и видна, но рестарт по-прежнему не происходит, PD-424). Оговорка стоит здесь, потому что документ иначе обещает оператору то, чего код не делает, и разойдётся заметно, если этот инвариант когда-нибудь ослабнет. Долговечное лечение — ветвить по НАЛИЧИЮ осиротевшей попытки вместо finished_at, как уже делает settling-ветвь StalledRuns.

Строка PHASE = settling — та же команда, другой исход. Прогон уже кончился, свипа, который довёл бы его расчёт, не будет никогда, поэтому run abandon закрывает деньги В СВОЕЙ транзакции независимо от флага: резервация закрывается, холд возвращается ЦЕЛИКОМ, прогон помечается рассчитанным. Целиком — потому что не хватает как раз движковой цифры (это и есть определение состояния), и суммы, которую платформа могла бы обосновать, не существует; альтернатива — деньги заморожены навсегда. Статус самого прогона НЕ переписывается: он уже закончился со своим исходом. Повторный вызов отвечает «its money is already closed», а не «нет такого прогона».

Прогон, чей экран замер: проекция в карантине. Признак: гейдж tm_platform_quarantined_attempts больше нуля, а в tmplatformctl runs у строки непустая колонка QUARANTINE — причина, по которой платформа перестала читать журнал этой попытки (строка, которую эта сборка не может прочитать: разрыв seq, другой payload под тем же seq, чужой мажор потока, битая строка). Движок при этом идёт и тратит холд — карантин останавливает только проекцию, свежесть экрана падает на медленный ресинк. Деньги целы. Когда причина ушла (сборка платформы обновлена, чужой tmctl в каталоге книги остановлен), проекцию возвращает человек:

tmplatformctl run unquarantine --run <id>   # печатает причину и курсор, с которого свип читает дальше

Курсор команда НЕ трогает: следующий свип читает журнал с того места, где проекция остановилась, и если те же байты по-прежнему нечитаемы — карантинит снова с той же причиной, и это честный ответ, а не сбой команды. Три отказа своими словами: нет такого прогона · у прогона нет живой попытки (законченный прогон не материализуется, снимать нечего) · попытка не в карантине. ⚠ Что с P13 карантин больше НЕ вызывает, и что вызывает по-прежнему — разница в том, удалось ли прочитать, ЧЕЙ это handshake. У попытки, чей поток платформа именовала, чужой handshake (ручной tmctl оператора в каталоге книги, чужой мажор потока, пустой engine_run_id, seq не 1) карантина больше не даёт: владелец распознаётся до проверки формы, а чтение ОСТАНАВЛИВАЕТСЯ на этой строке — курсор в чужую область не переезжает, поэтому и следующий свип не примет чужие события за наши (PD-214, PD-426, PD-438). ⚠ Битая строка — по-прежнему карантин, и это по построению: engine_run_id лежит ВНУТРИ payload, значит у строки, которая не распарсилась, владельца просто нет, и пропустить её как чужую нельзя. Карантинят и настоящие сигналы порчи НАШЕГО потока: разрыв seq, другой payload под тем же seq, строка длиннее буфера. ⚠ Что видно оператору у остановленной («припаркованной») попытки: проекция стоит и tm_platform_tailer_lag_bytes растёт, но свежесть НЕ теряется — платформа спрашивает движок напрямую (tmctl status), как делает для карантина, и в лог идёт WARN с прогоном, попыткой и смещением. Чего у неё НЕТ: колонка QUARANTINE пуста, гейдж tm_platform_quarantined_attempts её не считает, runs --stalled её не показывает и run unquarantine отвечает «не в карантине» — парковка живёт длину одного прохода свипа и в базу не пишется. ⚠ Ждать, что она сама рассосётся, НЕЛЬЗЯ: «чужой» handshake может писать живой процесс этой же попытки — платформа отдаёт каждому её спавну один и тот же id потока, а движок на повторе минтит свежий, — так что прогон способен простоять припаркованным весь свой срок. Разбор и радиус — PD-438. ⚠ Исключение — попытка БЕЗ engine_run_id (заведена сборкой до того, как платформа стала именовать поток): она усыновляет первый встреченный handshake и потому проверяет его полностью, так что для неё чужой мажор по-прежнему терминален для проекции — эта команда её и возвращает.

Книга, чью читательскую поверхность не удаётся построить. После пяти неудач долг списывается, книга остаётся с прежним текстом и попадает в список:

tmplatformctl books --abandoned    # книга, когда сдались, что сказал движок
tmplatformctl book refresh --book <id>   # попросить заново, с чистым бюджетом попыток

Гейдж — tm_platform_reading_surfaces_abandoned. Отказ хоста (движок не запускается, чужой лок, немигрированная схема) попытку НЕ тратит: он про все книги сразу, а не про эту.

Две ручки бюджета свипа (обе печатаются на буте с источником):

Переменная Дефолт Что это
TM_PLATFORM_SWEEP_BUDGET 2m сколько всего может занять один проход свипа
TM_PLATFORM_RUN_BUDGET 1m сколько может занять ОДИН прогон внутри прохода

⚠ Поднимать надо ПАРУ, а не одну: проход делится пополам между сверкой и расчётом денег, поэтому RUN_BUDGET выше половины SWEEP_BUDGET наблюдаемо ничего не меняет (PD-368).

Дев-стенд: живая платформа для фронта (П-16)

Фронт разрабатывается против ЖИВОЙ платформы, а не против своих моков. Рецепт целиком:

# 1. Postgres без root (docs/STACK_DECISIONS.md §«Postgres на стенде без root»).
~/.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

# 2. Окружение стенда. ⚠ ДВА гейта на старте, оба про «стендовое окружение уехало в прод»:
#    TM_PLATFORM_DEV_LOGIN вместе с OIDC — отказ; TM_PLATFORM_INSECURE_COOKIES вместе с OIDC,
#    чей callback НЕ http:// — тоже отказ (боевой вход в куках без Secure и с выключенным HSTS).
#    Локальный провайдер по http на стенде при этом законен и работает.
export TM_PLATFORM_DSN='postgres://postgres@/tmstand?host=/tmp&port=55433&sslmode=disable'
export TM_PLATFORM_ADDR=127.0.0.1:8080
export TM_PLATFORM_MIGRATE=1
export TM_PLATFORM_INSECURE_COOKIES=1      # дев-профиль куки: __Host- требует Secure
export TM_PLATFORM_DEV_LOGIN=dev@stand     # вход без внешнего провайдера — ТОЛЬКО стенд
export TM_PLATFORM_BOOKS_DIR=$HOME/.local/share/tmstand/books
export TM_PLATFORM_STATE_DIR=$HOME/.local/share/tmstand/state
export TM_PLATFORM_ENGINE_BIN=$HOME/.local/bin/tmctl
export TM_PLATFORM_CTL_BIN=$PWD/tmplatformctl          # абсолютный: systemd отвергает иной
export TM_PLATFORM_BOOK_TEMPLATE=$HOME/.local/share/tmstand/book-template.yaml
# ⚠ ОБЯЗАТЕЛЬНО, как и в бою: без хотя бы одной ДОСТУПНОЙ пары демон, принимающий загрузки, не
#    стартует. Правило и его причина — блок ⚠ `TM_PLATFORM_LANGUAGE_PAIRS` выше.
export TM_PLATFORM_LANGUAGE_PAIRS='zh>ru'
mkdir -p "$TM_PLATFORM_BOOKS_DIR" "$TM_PLATFORM_STATE_DIR"

# 3. Демон (мигрирует сам при TM_PLATFORM_MIGRATE=1).
./tmplatformd &

# 4. Сид: аккаунт + кредит + демо-книга ЧЕРЕЗ ЖИВОЙ ИНТЕЙК (не INSERT).
./tmplatformctl seed --url http://127.0.0.1:8080

# 5. Фронт ходит на стенд своим дев-прокси (TM_PLATFORM на его стороне).

Сессию в браузер выдаёт тот же вызов, что делает сид, — один раз из консоли приложения:

await fetch('/auth/dev-login', {method: 'POST'})

Дев-вход не должен доехать до прода, и это гарантировано ЧЕТЫРЬМЯ независимыми свойствами — перечень и довод каждого лежат единственным носителем в docs/STACK_DECISIONS.md §30, разбор в доккомментарии internal/login/dev.go. На каждый выданный сеанс пишется WARN.

TM_PLATFORM_BOOKS_DIR — каталог, который сервис ПИШЕТ и, на отклонённом интейке, УДАЛЯЕТ. Всё под ним создано загрузкой книг; книга, заведённая дев-инструментом с чужим --workdir, туда не попадает и не удаляется никогда (гард books.owns). Каталог должен существовать и принадлежать tmplatform, и он обязан быть в ReadWritePaths= юнита — при ProtectSystem=strict отсутствующий путь валит сборку mount-namespace целиком (PD-91, та же грабля, что и с /srv/textmachine):

install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine/books

/metrics не аутентифицированего защищает только адрес привязки, и дефолт 127.0.0.1 снаружи недостижим. Скрейпер живёт на том же хосте либо ходит через тот же edge, что и API; что именно открывает внешний адрес и почему риск принят — PD-179, разбор docs/STACK_DECISIONS.md §24.

Загрузка книги идёт минуты, и это касается edge-прокси. Маршрут POST /v0/books принимает до TM_PLATFORM_MAX_UPLOAD_BYTES (64 МиБ) и держит соединение до TM_PLATFORM_UPLOAD_DEADLINE (10 минут). Прокси перед сервисом обязан разрешать столько же: у него свои client_max_body_size и свои таймауты чтения, и молчаливо режет их он, а не мы.

TM_PLATFORM_STATE_DIR меняется только когда живых прогонов нет. Exit-маркер пишется по пути, вычисленному при СПАВНЕ, а читается по пути из текущей конфигурации: после смены каталога конец идущего прогона становится невидим, и реконсилятор перезапускает его как потерянный (PD-155). Путь обязан быть абсолютным — относительный демон отвергает на старте, потому что маркер пишет юнит из каталога книги, а читает демон из своего.

Миграции выкатываются ОДИН раз, не каждой репликой: TM_PLATFORM_MIGRATE=1 tmplatformd разово либо отдельный шаг деплоя. goose держит advisory-лок, так что параллельный запуск не гонка, но и не норма.

Где на сервере лежат книги

/srv/textmachine — корень библиотеки НА СЕРВЕРЕ. Решение владельца «книги живут в ~/books» относится к машине разработки: под этим юнитом домашние каталоги недоступны и детям-tmctl тоже (ProtectHome=yes, проверено живым прогоном — §«Файлы»), поэтому библиотека под домашним каталогом на сервере не «сработает медленнее», а НЕ НАЙДЁТСЯ. Кому это нужно, юнит называет ровно две строки замены (ProtectHome=tmpfs + BindPaths=); других изменений не требуется.

Чего здесь ещё нет

TLS и домен (перед юнитом предполагается edge-прокси), ограничитель соединений на edge, ротация логов, бэкап Postgres. Всё это — работа с первым реальным деплоем, не раньше.