| .. | ||
| README.md | ||
| tmplatformd.service | ||
Развёртывание платформы
Одна 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).
Откат релиза: не ниже версии 15
⚠ Граница была названа «5» и это уже неверно. goose down до версии 14 и ниже НЕ РАБОТАЕТ на
живой базе: down-путь 00015 сужает runs_paused_reason_check обратно к одному credit_exhausted,
а боевой код пишет ещё два значения — daily_ceiling и ceiling_unknown (оба через
internal/pgstore/books.go CeilingPause). Значит DownTo(<15) на базе, где такая пауза
случалась, падает РАНЬШЕ, чем дойдёт до 00005. Носитель — открытый ряд PD-218.
Ниже 5 не работает и подавно: down-путь 00005 восстанавливает
users_email_key и email NOT NULL, а обе формы нарушают строки, которые пишет боевой код
(неподтверждённая личность даёт email = NULL; один адрес законно принадлежит двум аккаунтам).
Откат транзакционный, поэтому падение ничего не портит — но планировать откат ниже 15 нельзя,
план отката — накатить вперёд. Разбор: 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ограничивает остановку САМОГО ДЕМОНА: дренаж HTTP плюс останов очереди, с запасом. ⚠ Прежняя редакция объясняла его «grace движка 30 с» — такой грации нет:stopGraceравен 10 МИНУТАМ (internal/runner/runner.go:59) и штампуется какTimeoutStopSecна ТРАНЗИЕНТНОМ юните прогона, а не на этом. ⚠ Но и обратное неверно: четыре движковых вызова —manifest/status, сборка экспорта,bank-applyи самsystemd-run— идут ПРЯМЫМИ ДЕТЬМИ демона, лежат в его cgroup, и этот таймаут на них распространяется. Разбор — комментарий вdeploy/tmplatformd.serviceнад строкойTimeoutStopSec=.- Секреты через
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.
# ⚠ Формулировка «без этого не стартует НИ ОДИН прогон» снята 04.09 как ложная для стенда: в
# ЖИВОЙ сессии менеджер уже есть, и замер зоны (H2) дал `Linger=no` при шести стартовавших
# прогонах. Сессия, честно исполнившая прежнюю проверку, решала, что стенд сломан.
# Проверка: `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, ПЕРЕ-СНЯТО 04.09. Прежняя редакция говорила «без этих четырёх инстанс не
# запустит ни одного перевода» — неверно для ДВУХ из четырёх, разбор ниже под блоком. Держи их
# все, но знай, ЧТО именно ломает каждая: считать обязательными все четыре дешевле не выходит —
# оператор, увидев, что без CTL_BIN всё работает, перестаёт верить и остальным трём.
TM_PLATFORM_ENGINE_BIN=/opt/textmachine/engine/<версия>/tmctl # пусто = инстанс только читает
TM_PLATFORM_CTL_BIN=/usr/local/bin/tmplatformctl # его зовёт юнит как ExecStopPost
TM_PLATFORM_STATE_DIR=/var/lib/tmplatform/state # маркеры выхода; ТОЛЬКО абсолютный
TM_PLATFORM_ENGINE_KEYS_PATH=/etc/tmplatform/engine-keys # KEY=VALUE, едет --keys-file
# Дверь выдачи книги файлом (04.09). НЕОБЯЗАТЕЛЬНА: пусто = дверь не смонтирована и
# `export_formats` пуст — клиент видит это на /capabilities и кнопки не рисует.
TM_PLATFORM_EXPORT_FORMATS=epub,txt # что УМЕЕТ ЭТОТ движок; порядок = порядок в /capabilities
TM_PLATFORM_EXPORTS_DIR=/var/lib/tmplatform/exports # ТОЛЬКО абсолютный; пусто = <STATE_DIR>/exports
TM_PLATFORM_EXPORT_TTL=24h # сколько живут артефакт и ссылка
⚠ Что из этой четвёрки ДЕЙСТВИТЕЛЬНО гейтит прогон — одна переменная, а не четыре. Остальные три ломаются иначе, и знать чем дешевле, чем считать их все обязательными:
ENGINE_BIN— ЕДИНСТВЕННАЯ, чьё отсутствие останавливает прогоны прямо: пусто ⇒ инстанс объявляет себя читающей репликой, не спавнит ничего и не принимает загрузок (cmd/tmplatformd/runner.go:59-61);CTL_BIN— путь, который юнит зовёт вExecStopPost, чтобы записать маркер выхода. ⚠ ПУСТО — НЕ отказ: берётсяtmplatformctlРЯДОМ с демоном (internal/config/config.go:115, сиблинг-дефолтcmd/tmplatformd/runner.go:468-476), а рантбук ставит его туда же, куда и демона (install -D -m0755 tmplatformctl /usr/local/bin/tmplatformctlв блоке установки выше). Отказывает НЕВЕРНЫЙ путь:os.Statне находит его на буте (runner.go:87— WARN, не паника),MarkerArgvостаётся пустым, и каждый старт/резюм отвечает503(internal/runs/runs.go:415-418ErrRunnerIncomplete→internal/httpapi/v0.go:844-848). ⚠ Прежняя редакция объясняла его ОТСУТСТВИЕ симптомом «прогон завершается, а платформа не узнаёт» — такого исхода в коде нет: ровно чтобы он не наступил, зона и отказывает на старте (internal/runs/runs.go:151-152);STATE_DIR— каталог маркеров. ⚠ Прежняя редакция объясняла его ложно («дефолт внеReadWritePaths=, запись запрещена файловой системой»): дефолт —/var/lib/tmplatform(internal/config/config.go:463), а это ровно то, что юниту даётStateDirectory=tmplatform(строкаStateDirectory=tmplatformвdeploy/tmplatformd.service), — systemd создаёт каталог и делает его записываемым ПОДProtectSystem=strict, а сам маркер пишетExecStopPostТРАНЗИЕНТНОГО юнита прогона, на котором песочницы нет вовсе. ⚠ Задавать явно всё же стоит, но по ТРЁМ ДРУГИМ причинам, и они настоящие: путь обязан быть абсолютным (демон отказывает на старте,internal/config/config.go:484) · каталог обязан принадлежать пользователю ПРОГОНОВ — по его владельцу админ-CLI отличает свой systemd от чужого и без этого отказывается судить о судьбе прогона (cmd/tmplatformctl/runs.go:360notTheRunsOwnManager) · и он не должен меняться при живых прогонах: смена осиротляет exit-маркеры идущих (PD-155);EXPORT_FORMATS— не из четвёрки и не обязательна, но у неё своя ловушка: это ДЕКЛАРАЦИЯ ОПЕРАТОРА о том, что умеет РАЗВЁРНУТЫЙ здесь движок, а не открытие. Список форматов живёт Go-переменной внутри движка, импортировать который платформе запрещено (D39.85), и $0-команды, публикующей его данными, у движка нет. Объявите формат, которого этот движок не знает, — и заказ на него вернётся ОТКАЗАВШИМСЯ экспортом с кодомdeployment_error: честно, но чинить это вам. ⚠EXPORTS_DIRабсолютен по причине острее, чем уSTATE_DIR: путь уходит движку аргументом--out, а движок работает с каталогом КНИГИ как рабочим, поэтому относительный писал бы артефакты внутрь чужого проекта. ⚠ И он обязан быть ЗАПИСЫВАЕМ ВНУТРИ ПЕСОЧНИЦЫ ЮНИТА — дляBOOKS_DIRэта оговорка написана, для экспорта её не было. Дефолт<STATE_DIR>/exportsлежит подStateDirectory=tmplatformи потому пишется; любой путь вне его надо добавить вReadWritePaths=. ⚠ Симптом НЕ тот, которого ждёшь: каталог создаёт САМ ДЕМОН НА БУТЕ (cmd/tmplatformd/runner.go:208), и невозможность создать — ОТКАЗ СТАРТА демона (exports directory <путь>: …), а не отказ отдельного экспорта. Каталог, который есть, но доступен только на чтение, роняет создание подкаталога КНИГИ на первом же экспорте (internal/exports/exports.go:358), и код отказа тамbuild_failed, а неdeployment_error;ENGINE_KEYS_PATH(четвёртая из «четвёрки»; старту НЕ мешает — демон пишет один WARN и поднимается здоровым,cmd/tmplatformd/runner.go:80, принимает книгу и БЕРЁТ ХОЛД, а падает первый платный вызов, то есть уже после резервирования денег пользователя — замер H1 зоны) — ЕДИНСТВЕННЫЙ канал провайдерских ключей в движок (едет аргументом--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 обязателен: это единственная запись о том, почему оплаченный прогон объявлен
законченным.
⚠⚠ С 04.09 КОМАНДА СПРАШИВАЕТ SYSTEMD САМА, и это меняет то, что оператор делает
руками (PD-424, живая половина). Раньше она отказывала любой попытке, которая ещё
называет юнит или несёт базовую линию траты, и правильно отказывала: платформа не может
отличить НАЗВАННЫЙ юнит от РАБОТАЮЩЕГО. Чего она не делала — не спрашивала. Теперь
делает, и ответов ЧЕТЫРЕ:
- юнита нет — прогон закрывается, деньги закрываются в этой же транзакции;
- юнит активен — ОТКАЗ, с тем же советом, что и раньше: остановите его и дайте реконсилятору закрыть прогон штатно;
- systemd недоступен — тоже ОТКАЗ. Отсутствие ответа не есть отсутствие процесса, и цена ошибки здесь — живой движок, который продолжает тратить против книжного потолка, когда холд аккаунта уже закрыт: этот расход не оплатит никто, и провайдеру платит ДЕПЛОЙ. Ограничен он потолком того же прогона, но он есть;
- это НЕ ТОТ systemd — ОТКАЗ, и он самый неочевидный из четырёх.
systemctl --userотвечает про менеджер ТОГО, КТО СПРАШИВАЕТ, а прогоны живут в менеджере пользователя демона; юнит, о котором чужой менеджер никогда не слышал, и юнит, который кончился, выглядят одинаково (ActiveState=inactive, выход 0). Поэтому команда сперва устанавливает, ЧЕЙ это менеджер.
⚠⚠ ЧТО ИЗ ЭТОГО СЛЕДУЕТ ДЛЯ ОБОЛОЧКИ ОПЕРАТОРА: TM_PLATFORM_STATE_DIR ДЕПЛОЯ ДОЛЖЕН
БЫТЬ ЭКСПОРТИРОВАН, иначе команда ОТКАЖЕТ. Каталог состояния пишут и демон, и
ExecStopPost каждого прогона, оба под пользователем прогонов, — поэтому его владелец и
есть тот единственный факт, по которому CLI отличает свой менеджер от чужого. Не задана
переменная или каталог принадлежит другому uid — отказ с обоими номерами и с рецептом
systemctl --user -M <user>@ status <юнит>. Это не придирка: без неё команда закрыла бы
деньги живого прогона по ответу, которого никто не проверял.
Второе условие проверяет уже не команда, а хранилище, внутри транзакции: прогон должен
провалить реконсиляцию столько раз подряд, сколько нужно, чтобы попасть в
runs --stalled. Ниже этого порога — отказ: видеть, как прогон идёт не так, и иметь
право его уничтожить — разные разрешения, и свип, который закрыл бы его правильно, ещё
работает.
Что при этом происходит с деньгами — два случая, и они не равны. Попытка, вообще не
дошедшая до движка, ничего не потратила: холд возвращается ЦЕЛИКОМ, а --release-hold
решает лишь КОГДА — сразу или на ближайшем свипе (он нужен, когда демон остановлен и
свипа не будет). Попытка, которая до движка дошла и о чём-то отчиталась, СПИСЫВАЕТСЯ по
последней цифре потока (та же арифметика, что печатает колонка SPENT), а возвращается
только остаток; здесь флаг не спрашивают — свипа, который довёл бы этот расчёт, не
будет. Попытка без базовой линии ценится нечем — холд целиком, как в settling-ветви.
✅ Оговорка PD-418 СНЯТА 04.09 — ветвление ушло с runs.finished_at на НАЛИЧИЕ
осиротевшей попытки, то есть на то самое долговечное лечение, которое эта оговорка
называла. run abandon спрашивает про осиротевшую попытку ПЕРВЫМ делом и лечит её
одинаково — кончился прогон или нет. ⚠ Живой прогон при этом НЕ заканчивается и его
собственная попытка не трогается: застряли деньги ОДНОЙ попытки, а не перевод, и
заканчивать перевод из-за них значило бы отнять у пользователя работу, о которой он не
просил. Состояние по-прежнему недостижимо на не-тестовом пути (reopen не стартует
следующую попытку, пока холд предыдущей открыт), но документ больше не обещает того,
чего код не делает, — а если инвариант когда-нибудь ослабнет, команда уже готова.
Строка 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 & DAEMON=$!
# 3а. ⚠ ОПОЗНАТЬ ПРОЦЕСС ДО СИДА. Слушатель на порту обязан быть ЭТИМ pid: `200` от /healthz
# доказывает лишь, что по адресу кто-то есть, а сид ниже дарит деньги и грузит книгу.
sleep 1
ss -ltnp "sport = :${TM_PLATFORM_ADDR##*:}" | grep -q "pid=$DAEMON," || {
echo "на $TM_PLATFORM_ADDR отвечает НЕ этот демон (свой умер на bind?) — сид отменён"
exit 1
}
# 4. Сид: аккаунт + кредит + демо-книга ЧЕРЕЗ ЖИВОЙ ИНТЕЙК (не INSERT).
# ⚠ Адрес берётся из переменной, а не пишется вторым литералом: разъехавшиеся копии одного
# адреса — это и есть способ засеять чужой стенд, не заметив.
./tmplatformctl seed --url "http://$TM_PLATFORM_ADDR"
# 5. Фронт ходит на стенд своим дев-прокси (TM_PLATFORM на его стороне).
⚠ Шаг 3а появился не из осторожности, а по замеру 04.09. На машине разработки четвёртые сутки
жил ЧУЖОЙ tmplatformd на 127.0.0.1:8080 со своей базой. Демон этого рецепта умирает в такой
ситуации на bind: address already in use — молча, потому что запущен фоном, — и шаг 4 уезжает
в чужую базу: он выдаёт $25 кредита и грузит книгу через живой интейк, то есть пишет деньги и
данные в чужой деплой, отвечающий по тому же адресу. Проверено исполнением: /healthz и /readyz
на 8080 отвечали ok и ready при мёртвом собственном демоне. ⚠ Порт 8080 при этом остаётся
дефолтом зоны сознательно — коллизия здесь tmplatformd против tmplatformd, и любой другой
номер даст ту же аварию на втором одновременном стенде; чинится не номер, а посылка «по адресу
отвечают — значит это мой сервис». Разбор той же поломки со стороны дев-рецепта —
docs/STACK_DECISIONS.md §«Как поднять локально».
Сессию в браузер выдаёт тот же вызов, что делает сид, — один раз из консоли приложения:
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. Всё это — работа с первым реальным деплоем, не раньше.