608 lines
59 KiB
Markdown
608 lines
59 KiB
Markdown
# Развёртывание платформы
|
||
|
||
Одна 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` первым.
|
||
|
||
## Установка (набросок, исполняется владельцем)
|
||
|
||
```sh
|
||
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-418` `ErrRunnerIncomplete` → `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:360` `notTheRunsOwnManager`) · и он не должен меняться при живых
|
||
прогонах: смена осиротляет 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), поэтому теперь это значение ОПЕРАТОРА и
|
||
платформа его не трогает.
|
||
|
||
```yaml
|
||
# /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` переключается ПОСЛЕ.
|
||
|
||
```sh
|
||
# 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` больше нуля.
|
||
|
||
```sh
|
||
tmplatformctl runs # живые прогоны И застрявшие расчёты
|
||
tmplatformctl runs --stalled # только те, что свип не доводит: сколько неудач подряд,
|
||
# когда следующая попытка, что сказал движок, сколько держит
|
||
```
|
||
|
||
⚠ **Колонка `PHASE` говорит, какая это половина, и лечение у них разное.** `live` —
|
||
прогон ещё идёт, и первый вопрос к systemd. `settling` — прогон УЖЕ кончился, движка
|
||
нет, а его расчёт не сходится: деньги пользователя заморожены в открытой резервации, и
|
||
вопрос к systemd бессмыслен.
|
||
|
||
Дальше решает человек, а не платформа: сперва спросить systemd (`systemctl --user
|
||
status <юнит>`). Если процесс жив — остановить его и дать реконсилятору закрыть прогон
|
||
штатно, это лучше во всём. Если движок не ответит уже никогда (каталог книги унесён,
|
||
маунт отвалился):
|
||
|
||
```sh
|
||
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` в каталоге книги остановлен), проекцию возвращает человек:
|
||
|
||
```sh
|
||
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 и потому проверяет его
|
||
полностью, так что для неё чужой мажор по-прежнему терминален для проекции — эта команда
|
||
её и возвращает.
|
||
|
||
**Книга, чью читательскую поверхность не удаётся построить.** После пяти неудач долг списывается,
|
||
книга остаётся с прежним текстом и попадает в список:
|
||
|
||
```sh
|
||
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)
|
||
|
||
Фронт разрабатывается против ЖИВОЙ платформы, а не против своих моков. Рецепт целиком:
|
||
|
||
```sh
|
||
# 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` §«Как поднять локально».
|
||
|
||
Сессию в браузер выдаёт тот же вызов, что делает сид, — один раз из консоли приложения:
|
||
|
||
```js
|
||
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`):
|
||
|
||
```sh
|
||
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. Всё это — работа с первым реальным деплоем, не раньше.
|