Skip to content

Сборка и запуск ​

Ограничения эксплуатации ​

API/worker/CLI образа работают с UID/GID 10001. У API/worker сброшены все capabilities, корневая файловая система read-only. Запись разрешена в volume /app/data/game-assets. Перед деплоем штатный swarm-start.sh запускает одноразовую root-задачу Swarm: владелец существующего playflow_game-assets становится 10001, каталоги получают права 0750, файлы — 0640. Задача сохраняет данные и снимается после завершения. При сбое запуск прекращается. Фактические UID/TaskSpec/права проверяются tests/platform-24-image.test.sh.

Postgres/Dragonfly не публикуют порты 5432/6379. scripts/run-platform-db-tests.sh использует временные порты только на 127.0.0.1 через docker exec -i/nc уже работающего Postgres-контейнера. Второго контейнера или сервера БД нет; временная база и туннели удаляются на успехе и ошибке. Ручному клиенту БД нужен явно настроенный доступ оператора; localhost:5432 больше не работает.

TRUSTED_PROXIES принимает явные IP/CIDR; штатный запуск по умолчанию читает реальные overlay-сети Traefik/admin nginx. Пустой список самого API никому не доверяет, /0 и ошибочный синтаксис запрещены. При смене топологии оператор проверяет состав сетей и при необходимости задаёт точные IP proxy. Общего доверия 10/8, private-адресам и localhost нет.

Локальная панель Traefik выключена без TRAEFIK_DASHBOARD_CREDENTIALS, заданного вне Git. Публичного пароля по умолчанию нет. Новое правило к существующему edge применяет отдельный ./edge-start.sh 2 --no-hosting --update; приложение edge не перезапускает.

Базовые образы закреплены multiarch OCI digest для amd64/arm64. Dependabot еженедельно проверяет Cargo/npm/Docker/Actions. CI выполняет cargo-audit 0.22.2, npm audit и Grype 0.120.1; SHA256 бинарника проверяется. Полные отчёты Grype для всех пяти образов продукта создаются обязательным job image-audit на каждом прогоне CI, включая PR только с редактурой документов; пропуск Swarm-проверки не пропускает аудит. Registry/production этим не проверяются. Отчёты сохраняют все уровни, включая unfixed/wont-fix; gate блокирует исправимые High/Critical, остальные требуют разбора в POST-PLAN-AUDIT. npm gate блокирует High/Critical; low остаются видны. Исключений по CVE/общего ignore-файла нет. Ошибка загрузки сканера/базы не считается чистым аудитом.

Kubernetes в deploy/kubernetes — неподдерживаемый исторический эскиз. Рабочий путь остаётся build.sh → swarm-start.sh → swarm-stop.sh.

Основной Dockerfile собирает целочисленное ядро Parcel Pilot отдельной стадией parcel-wasm-builder перед games-builder. Она устанавливает wasm32-unknown-unknown для закреплённого Rust и использует профиль wasm-release; профиль release API/worker сохраняется. Модуль включён в клиентский выпуск Parcel Pilot и загружается с домена сервиса по манифесту. Сборка продукта остаётся ./build.sh <target> --env 2; статическая локальная проверка WASM описана в документации виджета.

Для изолированной локальной проверки можно передать POSTGRES_VOLUME_NAME=retentionplay_review ./swarm-start.sh 2: PostgreSQL использует отдельный том. Без переменной сохраняется прежний том infrastructure_postgres-data. Не меняйте выбор тома у работающего стека. В production (swarm-start.sh 1) доступны POSTGRES_VOLUME_NAME и DRAGONFLY_VOLUME_NAME: до первого запуска они позволяют выбрать новые постоянные тома рядом со старыми данными другого проекта. По умолчанию используются infrastructure_postgres-data и infrastructure_dragonfly-data. Выбор имени не переносит данные; существующую установку переключать нельзя. PLATFORM_TEST_DRAGONFLY_DB=1 ./scripts/run-platform-db-tests.sh выбирает отдельную логическую БД кеша для тестов; скрипт ничего в ней не очищает. Это позволяет отделить тестовые счётчики ограничения запросов от рабочего стека и предыдущих ручных прогонов. По умолчанию остаётся БД 0.

В режиме web незаданные API_IMAGE, ADMIN_IMAGE, LANDING_IMAGE, DOCS_IMAGE одинаково вычисляются сборкой и запуском из Git SHA (с суффиксом -dirty при локальных изменениях). Собирайте и запускайте одно состояние checkout; явные теги в конфигурации имеют приоритет.

Проверка scripts/pilot-health-check.sh читает cold_sync_last_success и учитывает резервные копии .dump, а также старые .sql.gz. Для первого запуска до синхронизации есть SKIP_COLD_SYNC_CHECK=1; старое имя SKIP_SNAPSHOT_CHECK поддерживается как совместимый псевдоним. Часовые задачи billing и fraud_retention получают час до следующего heartbeat плюс 15 минут запаса; остальные задачи — прежние 15 минут. Проверка worker /ready учитывает heartbeat (отметку работы) фоновых циклов, включая sync, который обновляется и при пустой очереди. cold_sync показывает время последней фактической записи прогресса в PostgreSQL и сохраняется в метриках и уведомлениях; простой этой операции не вызывает перезапуск worker.

Проект использует Docker Swarm и Traefik. Поддерживается один пользовательский путь: edge-start.sh отдельно запускает постоянный входной стек; build.sh собирает образы, swarm-start.sh запускает стек продукта, а swarm-stop.sh его останавливает. Отдельного способа поднять сервисы для тестов нет.

Версии инструментов ​

Сборка использует Rust 1.99.0 и Node.js 24.20.0 LTS. Эти версии согласованы в Dockerfile, CI и backend/rust-toolchain.toml; локальные JavaScript-проверки требуют Node.js >=24.20 <25. Статические сайты выдаёт Nginx 1.30.4. Rust-образ собирается с cargo build --locked, поэтому версии зависимостей берутся из проверенного backend/Cargo.lock. Базовый Rust-образ — 1.99.0-bookworm; rust-toolchain.toml закрепляет ту же версию для локальных проверок, серверной сборки и WASM.

Админка и игры собираются Vite 8.2.2, проверяются Vitest 5.0.0 и TypeScript 6.0.3. TypeScript 7 пока несовместим с диапазоном версий, поддерживаемым typescript-eslint; Phaser 4.2.1 уже является последним стабильным выпуском на 4 сентября 2026 года.

Для VitePress 1.6.4 в docs-site/package.json задана точечная замена транзитивной зависимости (overrides): Vite 6.4.3 вместо уязвимой ветки 5.x. Это отдельная сборка документации, не использующая настройки Vite 8 из админки или игр. При обновлении VitePress следует пересмотреть эту замену; проверки — npm ci, npm audit и npm run build из docs-site/.

Сборка образов ​

bash
./build.sh all --env 2
./build.sh all --env 1
  • --env 2 — локальные теги playflow-*:latest; цель all собирает API, админку, лендинг, документацию и demo-site (локальный тестовый сайт).
  • --env 1 — production-теги из start/web/.env.production; demo-site в публичный стек не входит.
  • Доступные отдельные цели: api, admin, landing, docs, demo. API-образ также содержит бинарник playflow-worker (фоновый обработчик) и файлы старого виджета, поэтому не зависит от каталогов рабочей копии.

Локальный запуск ​

build.sh переносит один предыдущий выпуск игр из образа работающего playflow_api (иначе — playflow-api:latest) через .build/previous-shell. При сборке на другом узле задайте PREVIOUS_API_IMAGE; образ должен быть доступен локально. Первый образ может не иметь предыдущего выпуска. Не делайте лишних сборок API между выкаткой и проверкой: без работающего сервиса источником станет промежуточный latest. Откат на образ с выпуском возвращает его клиент; откат миграций регулируется отдельно.

Один раз на новой машине включите Swarm:

bash
docker swarm init

Обычная последовательность работы:

bash
./edge-start.sh 2 --no-hosting
./build.sh all --env 2
./swarm-start.sh 2
./swarm-stop.sh

swarm-start.sh требует уже работающий edge того же окружения. Он поднимает стек infrastructure с Postgres и Dragonfly, ждёт его готовности, затем запускает стек playflow: API, worker, админку, лендинг, документацию и demo-site. API не выполняет фоновые задания; ими занимается отдельный сервис worker. swarm-stop.sh останавливает только эти два стека; Traefik и хостинг остаются работающими.

Локальные адреса ​

СервисАдрес
API и Swaggerhttp://api.localhost/docs
Админкаhttp://admin.localhost
Лендингhttp://playflow.localhost
Документацияhttp://docs.localhost
Demo-sitehttp://demo.localhost
Панель Traefikhttp://traefik.localhost/dashboard/

Если система сама не разрешает домены .localhost, добавьте их в /etc/hosts:

bash
echo "127.0.0.1 api.localhost admin.localhost playflow.localhost docs.localhost demo.localhost traefik.localhost" | sudo tee -a /etc/hosts

Постоянный edge и хостинг ​

Первый production-сервер настроен в примерах на l2c1.com: основной сайт создаётся в 1Panel, панель находится на hosting.l2c1.com, dashboard — на traefik.l2c1.com. RetentionPlay занимает rp-api.l2c1.com, rp-admin.l2c1.com, rp-doc.l2c1.com и rp-landing.l2c1.com. Корень l2c1.com не маршрутизируется в приложение.

DNS: A для @ и перечисленных поддоменов направить на VDS. Можно использовать A wildcard * для будущих поддоменов; сайты и алиасы всё равно создавать в панели по одному. DNS wildcard не равен wildcard-сертификату: HTTPS выпускается для каждого точного домена. Первый сайт — l2c1.com:18080, HTTP внутри OpenResty; www.l2c1.com можно добавить алиасом. Для нового поддомена с отдельной папкой создать отдельный сайт в 1Panel.

Позже перенести только продукт: обновить четыре домена, PUBLIC_API_BASE и ADMIN_PUBLIC_BASE в start/web/.env.production и перезапустить его стек. Общий Traefik, панель и основной сайт сохраняются. Лендинг получает ссылки, примеры API и canonical URL из env при старте контейнера. Админка вычисляет ссылку документации для rp-admin.* → rp-doc.* и admin.* → docs.*. Произвольную пару задать через VITE_DOCS_PUBLIC_BASE при сборке админки.

Конфигурация общего входа находится в start/edge/local/stack.yml и start/edge/web/stack.yml. Он использует внешнюю overlay-сеть traefik. Любой независимый Swarm-проект подключает к ней публичные сервисы и указывает в deploy.labels домен, внутренний порт и TLS resolver (механизм выпуска сертификата) myresolver. Метки маршрутов должны иметь уникальные имена. Приложения не публикуют наружу 80/443 и не получают доступ к сетям чужих БД.

Обычный edge-start.sh сохраняет уже работающий Traefik. --update применяет изменения его статической конфигурации. --no-hosting пропускает 1Panel, не выключая уже работающий хостинг. edge-stop.sh останавливает только общий вход, прерывая публичный доступ к сайтам всех проектов.

На Linux сначала скопировать start/edge/web/.env.example в start/edge/web/.env.production вручную либо один раз выполнить ./prepare-production.sh: он создаёт все три production env и разные случайные ключи, сохраняет пароль Traefik отдельно в data/edge/traefik-admin-password.txt, в edge env записывает хеш. Файлы имеют права 600, исключены из Git и не перезаписываются при повторном вызове. Готовые env не заменять примерами. Остаются реальные параметры сервера: email сертификатов, порт панели, шлюз docker_gwbridge, имя OpenResty/PHP и ключ API, выданный 1Panel. В шаблонах указаны команды для их получения; фиктивный API-ключ не генерируется. Запустить ./edge-start.sh 1 --no-hosting. Для переноса ранее работавшего infrastructure_traefik добавить --migrate: он передаёт порты новому стеку, сохраняя базы и том infrastructure_certs, с коротким перерывом входящих запросов. Новый swarm-stop.sh отказывается останавливать инфраструктуру со старым Traefik до такой миграции.

1Panel v2 устанавливается на Linux через sudo bash start/hosting/install.sh. На macOS используется локальный edge с --no-hosting. В панели установить OpenResty с HTTP-портом 18080 и необходимую PHP-среду. Сайты работают по HTTP на внутреннем порту; HTTPS завершает Traefik. Создание сайта, каталога и загрузка файлов выполняются в веб-интерфейсе панели.

В start/hosting/.env из примера задать локальный /api/v2 URL панели, файл API-ключа (права 600), публичный домен панели, точные имена контейнеров OpenResty/PHP и внутренние HTTP-адреса этих служб, доступные из Traefik. Для upstream нельзя использовать localhost контейнера. Внешний доступ к служебным портам закрыть; доверенные forwarded headers (переданные сведения о клиенте и протоколе) ограничить Traefik. Конкретные адреса зависят от VDS.

sudo ./edge-start.sh 1 проверяет существующий edge, запускает настроенный хостинг и включает retentionplay-hosting-sync.timer. Он читает сайты и все их доменные алиасы из API 1Panel каждые 30 секунд и атомарно обновляет только hosting.yml в динамическом каталоге Traefik. Изменение домена не требует перезапуска Traefik. При сбое API или неверном ответе сохраняются последние рабочие маршруты. Остановленные сайты исключаются при успешной синхронизации.

Домены продукта, dashboard и панели защищены от конфликта. Домены других Swarm-проектов читаются из их Host(...) меток; явные имена пользовательских динамических файлов также учитываются. sudo ./hosting-check.sh blog.l2c1.com проверяет имя до создания сайта: код 0 — свободно, 2 — занято, 1 — ошибка проверки. DNS и доступность HTTP не проверяются. Сайты 1Panel, включая остановленные и алиасы, считаются занятыми. После создания проверка повторяется перед публикацией маршрута; одинаковый домен у разных сайтов блокирует обновление с сохранением последних маршрутов. Форма 1Panel не изменена, причина видна в журнале службы синхронизации. Домены проектов добавить в HOSTING_RESERVED_DOMAINS. Маршруты хостинга имеют приоритет 1; маршруты приложений должны иметь больший приоритет. Wildcard в этой конфигурации не поддерживается. secure-chain с запретом индексации к хостингу не применяется. API-ключ отсутствует в маршрутах и журнале.

Перед эксплуатацией проверить на Linux: HTML/PHP-сайт и алиас, действительный сертификат, правильный HTTPS и клиентский IP, затем остановку/обновление RetentionPlay без прерывания сайтов. Локальные unit-тесты не подтверждают установку 1Panel или выпуск сертификата на VDS.

Сквозная проверка ​

Для полной проверки запускайте две реплики worker той же переменной, которую читает Swarm-конфигурация:

bash
./edge-start.sh 2 --no-hosting
./build.sh all --env 2
WORKER_REPLICAS=2 ./swarm-start.sh 2
API_BASE=http://api.localhost bash scripts/wait-for-api.sh
SMOKE_RUN_RELEASE_GATE=1 bash scripts/e2e-stack-smoke.sh
bash scripts/load-play-path.sh
./swarm-stop.sh

Расширенный smoke (короткий сквозной тест) проверяет уже запущенные API и Postgres. Он не создаёт второй API-контейнер и не меняет число worker. Временные настройки demo-проектов восстанавливаются автоматически даже при ошибке.

CI запускает быстрые тесты на каждый pull request (запрос на слияние). Полный Swarm-прогон выполняется для изменений backend, widget, games, сборочных файлов, Swarm-конфигурации и проверочных скриптов, а также для основной ветки и ручного запуска. Изменения только документации не пересобирают все образы.

Наблюдаемость фоновых заданий (задача 13) ​

До задачи 13 живость фоновых заданий хранилась в счётчиках процесса (engine_metrics) — API и worker всегда были разными сервисами Swarm, поэтому значение, записанное worker'ом, было не видно процессу API: snapshot_last_success в /v1/status был всегда пуст, а scripts/pilot-health-check.sh без SKIP_SNAPSHOT_CHECK=1 не мог пройти никогда. Отставание синхронизации считалось как возраст самой старой строки во всей таблице прогресса — через пять минут после любого неактивного игрока алерт «Hot→cold sync is lagging» горел навсегда, независимо от реальной очереди.

Теперь:

  • Живость каждого фонового задания — строка в Postgres-таблице job_heartbeats (services/job_heartbeat.rs): last_started_at, last_success_at, обезличенная last_error без секретов и сырых тел запросов. /v1/status's metrics.jobs агрегирует по имени задания без разбивки по инстансам (без карточки на реплику).
  • Отставание синхронизации (metrics.sync_lag_seconds) — реальный возраст самого старого ключа в очереди sync:pending:zset (Dragonfly ZSET, score — epoch-секунда постановки в очередь), а не самой старой строки всей таблицы. Пустая очередь честно даёт 0, а не null.
  • Каждый цикл задания в jobs/*_queue.rs обёрнут перезапуском с ограниченным числом попыток и растущей паузой (jobs::supervise, задача 13 шаг 3): паника одного цикла больше не завершает весь процесс worker кодом 0 — после исчерпания попыток процесс завершается ненулевым кодом, и Swarm's restart_policy: condition: on-failure его перезапускает.
  • У playflow-worker теперь свой HTTP-эндпойнт живости/готовности (WORKER_HEALTH_BIND, по умолчанию 0.0.0.0:8081; /health, /ready) и собственный healthcheck в start/*/application-stack.yml — раньше он был единственным сервисом без него. /ready также падает, если какое-то задание не подавало признаков жизни дольше job_heartbeat::STALE_AFTER_SECONDS (900с), с дополнительными 3600с для часовых billing и fraud_retention.
  • Стартовая сверка горячих ключей (reconcile::rehydrate_missing_hot_keys) читает холодную таблицу прогресса постранично (list_cold_progress_page, курсор по первичному ключу), а не целиком одним запросом — прежняя версия не укладывалась в лимит 512 МБ на процесс при таблице пилотного масштаба и выше.
  • Игровой путь (services/game_runs) получил счётчики стартов партий, команд, отказов по стабильному коду ошибки (тому же, что уходит в HTTP-ответ) и последней латентности вызова механики — раньше в этом пути не было ни одной метрики. Нарушение инварианта результата механики теперь пишет tracing::error! с кодом нарушения и идентификатором партии перед отказом, а не молча превращается в HTTP 500.

Проверка: cargo test -p playflow-api --test platform_13_jobs -- --ignored --test-threads=1 на поднятом стеке.

Переменные окружения и масштабирование ​

Локальный запуск читает корневой .env, а при его отсутствии — .env.example. Production использует start/web/.env.production. Секреты нельзя добавлять в Git или выводить в журналы.

Количество реплик задаётся до запуска:

bash
API_REPLICAS=3 WORKER_REPLICAS=2 ./swarm-start.sh 2

Резервные копии, деплой и секреты (задача 14) ​

До задачи 14 единственный Postgres писал свои же копии на тот же диск, ни одна не выгружалась вовне и ни одна не была запланирована — потеря диска означала потерю базы и всех копий разом; повторный деплой под тем же тегом образа молча ничего не обновлял; секреты шли обычными переменными окружения.

Резервные копии ​

Служебные сценарии не выводят DB_PASS (пароль базы). backup-postgres.sh берёт дамп через docker exec уже запущенного infrastructure_postgres (не поднимает второй контейнер ради разовой команды), в custom-формате pg_dump -Fc (сжат сам по себе, поддерживает выборочное и параллельное восстановление — pg_restore -j, чего не умели ни простой SQL, ни gzip). Итог — три файла на одну копию: retentionplay-<UTC-штамп>.dump (сам дамп), .dump.sha256 (контрольная сумма) и .dump.manifest.json (время, имя базы, формат, размер, тот же хэш). Имя финального файла появляется только после успешной записи и проверки сигнатуры PGDMP — недописанный или повреждённый дамп никогда не получает финальное имя.

bash
./scripts/backup-postgres.sh
TARGET_DB=retentionplay_restore_tmp BACKUP_FILE=… ./scripts/restore-postgres.sh
./scripts/restore-drill.sh
API_BASE=https://api.example.com POSTGRES_BACKUP_DIR=… ./scripts/pilot-health-check.sh

restore-postgres.sh проверяет контрольную сумму рядом лежащего .sha256 (если он есть) и сигнатуру PGDMP до создания целевой базы; сам дамп копируется в контейнер (docker cp) и восстанавливается pg_restore -j (RESTORE_JOBS, по умолчанию 2) — параллельные джобы pg_restore не умеют читать архив из stdin, только по имени файла со случайным доступом. Восстановление в TARGET_DB, совпадающий с рабочим DB_NAME, отказывается без явного ALLOW_PRODUCTION_RESTORE=YES.

Внешняя выгрузка — необязательный хук BACKUP_OFFSITE_CMD (команда получает пути к дампу/манифесту/чек-сумме как $1/$2/$3, например aws s3 cp "$1" s3://bucket/); без реквизитов офсайт-хранилища эта часть проверена только на уровне «команда вызывается, а её отказ не роняет локально готовую копию» — приёмка реального офсайт-получателя остаётся открытым пунктом до появления настоящих реквизитов.

Цели RPO/RTO (документированы отдельно от измеренных результатов — задача 14 их не измеряла на реальном офсайт-получателе):

ПоказательЦель
RPO (потеря данных при потере узла)≤ 24 ч (ежедневная копия) плюс возраст самой свежей офсайт-выгрузки
RTO (время восстановления БД)минуты на копии пилотного масштаба благодаря параллельному pg_restore -j; растёт линейно с размером базы

Деплой и реестр образов ​

build.sh --env 1 (продакшен) по умолчанию тегирует образы коротким хэшем коммита (playflow-api:<sha12>, -dirty при незакоммиченных изменениях) вместо фиксированной строки версии — второй деплой с другого коммита больше не притворяется тем же артефактом. DOCKER_REGISTRY=registry.example.com/retentionplay включает docker push после каждой сборки (api/admin/landing/docs; demo — локальная тестовая фикстура, никогда не пушится); без реквизитов реестра переменная остаётся не задана, и сборка ведёт себя как раньше — только локальный docker build. Отказ docker push не роняет сборку — образ всё равно собран и пригоден локально.

swarm-start.sh принудительно обновляет (docker service update --force) уже существующие сервисы стека playflow при повторном деплое — раньше это относилось только к локальному режиму (--env 2), и повторный продовый деплой под тем же тегом образа мог не перезапустить ни одной задачи.

Секреты ​

JWT_SECRET, WEBHOOK_SIGNING_MASTER_KEY/WEBHOOK_SIGNING_SECRET, TWO_FACTOR_ENCRYPTION_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, DATABASE_URL и DRAGONFLY_URL можно задать файлом: <ИМЯ>_FILE=/путь имеет приоритет над обычной переменной <ИМЯ> — это конвенция секретов Docker/Swarm (секрет монтируется файлом в /run/secrets/<имя>, а не переменной окружения, которую видно в docker service inspect/ps -eo args). Нечитаемый или пустой файл — явная ошибка с именем пути, никогда тихий откат на переменную окружения или значение по умолчанию. Размер пула соединений Postgres — DATABASE_POOL_SIZE (по умолчанию 10, было зашито в коде).

Проверка продовой конфигурации в CI (задача 14, шаг 5/14.7) ​

Отдельный CI-job production-config-smoke рендерит и поднимает продовый стек (start/web/) со случайными секретами и APP_ENV=production (scripts/ci-production-config-smoke.sh) — именно такой класс ошибок («первый запуск с продовыми настройками — сразу на бою») однажды уже стоил задачи 2 отдельного разбора. Локальная маршрутизация сохраняется намеренно: настоящих DNS/TLS для Traefik'а websecure в CI нет и не должно быть — готовность проверяется напрямую через docker exec .../v1/ready, то же самое, что уже делает собственный healthcheck контейнера.

Данные фикстур вне миграций ​

docs-site/internal/backend/migrations.md, раздел «Convention» — с задачи 14 тестовые/демо-фикстуры (аккаунты, проекты, API-ключи только для CI/ локального smoke) не добавляются новыми миграциями; миграции 025/027/032 это старое исключение (уже применено везде, включая прод, не переписывается задним числом), а миграция 038 безусловно отзывает оба скомпрометированных ключа. Любая новая фикстура — отдельный скрипт стенда вне продакшена.

Исторический чек-лист незапущенного пилота находится в docs/archive/runbooks/pilot-production.md и не задаёт текущую очередь работ. Заготовки Kubernetes в deploy/kubernetes/ также оставлены только на будущее.

Ретеншен, индексы и размер таблиц (задача 15) ​

До задачи 15 ни одна операционная таблица не имела срока хранения: job_heartbeats растёт на каждый рестарт процесса (новый случайный instance_id — старая строка никогда не перезаписывается); свёртка template_analytics_daily писалась каждый час нечестным условием created_at::date = $1::date (без права на индекс) в таблицу, которую дашборд не читает вовсе (он считает по сырым events); сам events не имел индекса под фактические запросы дашборда (project_id + event_type + created_at, template_id + event_type + created_at).

Найденные читатели — оба ушли под ноль ​

Шаг 1/15.2 явно требует зафиксировать список читателей до начала работы. Сплошной поиск по кодовой базе (не только текущим файлам задачи) дал:

  • progress_snapshots (миграция 024, писалась jobs/snapshot_queue.rs раз в 5 минут и services::sync::sync_progress_key на каждом реальном hot→cold sync) — ни одного SELECT нигде в проекте, только INSERT и GDPR-DELETE. Решение по шагу 1: продюсер отключён (файл jobs/snapshot_queue.rs удалён), таблица оставлена (нужна для стирания по GDPR-запросу), старые строки теперь стареют через data_retention_queue.
  • template_analytics_daily (миграция 006, писалась jobs/analytics_queue.rs раз в час) — тоже ни одного SELECT: project_analytics_overview и template_stats считают DAU/WAU/completion rate напрямую по events с собственным временным окном, не по этой свёртке. Решение по шагу 3/15.5: свёртка удалена вместе с заданием (jobs/analytics_queue.rs, rollup_day/rollup_yesterday), таблица оставлена по той же причине, что и progress_snapshots (сохранение старых данных и возможности их стирания), стареет тем же новым заданием. Сохранённая таблица сама по себе не разрешает запуск любого предыдущего образа на обновлённой БД. Нужны совместимая схема и migrator с ignore_missing: текущий образ использует эту настройку, исторический без неё может получить VersionMissing; изменение применённой миграции по-прежнему вызывает VersionMismatch. Совместимость конкретного образа проверяют заранее, а восстановление БД выполняют из согласованной резервной копии, если откат требует её прежнего состояния. Порядок описан в документации миграций.

Оба сигнала о живости hot→cold sync не потеряны, только переименованы честно: метрика cold_sync_last_success читает heartbeat задания sync (это по-прежнему момент последней успешной записи в player_template_progress, а не в удалённую таблицу снимков), алерт progress_snapshot_stale → cold_sync_stale, метрика progress_snapshots_synced → cold_sync_completed_total, snapshot_last_success → cold_sync_last_success.

jobs/data_retention_queue.rs ​

Новое задание (каждые 5 минут) чистит три таблицы пакетами по 1000 строк (до 20 пакетов за тик — следующий тик продолжает с того же места, отдельный курсор не нужен: WHERE каждый раз заново находит «ещё просроченные»): job_heartbeats (30 дней), progress_snapshots (30 дней), template_analytics_daily (30 дней). Не трогает fraud_flags, reward_claims или webhook_deliveries — те уже под fraud_retention_queue (задача 9) или являются активной очередью доставки/идемпотентности наград, которую шаг 3/15.3 прямо запрещает трогать здесь. dry_run_counts() даёт те же счётчики без удаления — шаг 4/15.4's «начать с dry-run».

Индексы (047_analytics_indexes_and_retention.sql) ​

  • events (project_id, event_type, created_at) и events (template_id, event_type, created_at) — под фактические запросы дашборда/template_stats, ни один из которых раньше не мог сузиться дальше диапазона по created_at.
  • DROP INDEX job_heartbeats_job_name_idx — этот индекс (задача 13, 046_job_heartbeats.sql) дублирует ведущий префикс индекса собственного PRIMARY KEY (job_name, instance_id) той же таблицы; Postgres никогда не выбрал бы его вместо индекса первичного ключа для запроса по одному job_name (в том числе job_health's GROUP BY job_name) — чистые накладные расходы на каждую запись heartbeat без единого запроса, которому он бы понадобился.

Размер таблиц в /v1/status ​

metrics.table_sizes — pg_total_relation_size (таблица + индексы + TOAST) по списку из services::cold_storage::WATCHED_TABLES, включая events и game_run_*: это ровно то измерение, которого TODO.md требует для решения об очистке партий — задача 15 сама это решение сознательно не принимает.

Ограничения ​

  • Индексы и ретеншен не проверены живым EXPLAIN (ANALYZE, BUFFERS) в этой сессии (нет Postgres) — platform_15_retention.rs печатает план запроса в стандартный вывод при прогоне на stack-e2e, план явно не проверяется на конкретный тип скана (маленькая тестовая таблица честно может выбрать seq scan — это не провал индекса).
  • Когортные запросы возврата (d1_return_rate/d7_return_rate) и воронка экспозиций эксперимента сохраняют собственные created_at::date-условия внутри EXISTS/join — та же несравнимая форма, что была у удалённой свёртки, но эти запросы живые (дашборд их действительно читает), и их переписывание — отдельная, не запрошенная этим шагом работа с более высоким риском регрессии в аналитике экспериментов.

Internal & integration documentation