Merge origin/main into fix/2656-anchor-ratio-freshness — конфликты: метки #2656→#2661 (main), импорт-union, baseline unrecorded=0 (перезахваченная фикстура)

This commit is contained in:
bot-backend 2026-08-26 18:22:54 +05:00
commit 3d0541b2e2
544 changed files with 47368 additions and 3270 deletions

View file

@ -20,6 +20,9 @@
Эмпирика 2026-06-27: агент-аудитор на 186k tok / 33 calls упал на StructuredOutput; 5 мелких параллельных прошли.
- Поверхность больше бюджета → **дели на N узких сабагентов** (parallel при непересекающихся файлах, sequential при зависимостях). НЕ один большой.
- Промпт сабагенту: конкретный deliverable + формат ответа + границы («что НЕ делать»). Расплывчатый scope = дубли и мусор.
- **Бюджет живёт В ПРОМПТЕ, а не в голове оркестратора.** Знать лимит недостаточно — агент его не видит. Пиши в промпт явно: потолок вызовов (~20-25) и времени, список «строго запрещено» (типично: не читать исходники приложения, не ходить в git-историю, не диффать смежное), правило деградации «бюджет кончается → отдай что есть, допиши в notes что не успел».
Эмпирика 2026-08-24: два разведчика без потолка ушли на 157 и 178 ходов вместо инвентаризации — один вместо списка веток диффал SQL-миграции и разбирал Caddyfile.
- **`schema:` требует потолка РАЗМЕРА ответа, отдельно от токенов.** Payload `StructuredOutput` >~10k символов не парсится (`InputValidationError`) → повтор → вся работа агента теряется. В промпт: максимум N items, лимит символов на поле, весь ответ ≤~6000 символов, и прямым текстом «неполный ответ несравнимо лучше потерянного». Схему проектируй под краткость: длинные `detail`-поля провоцируют ровно этот отказ.
- Windows: очень длинный промпт субагенту может упасть на лимите командной строки (~8191 символ) — ещё один довод за компактность.
## Эскалация oversized-задачи (worker)
@ -28,6 +31,13 @@ Issue/задача выглядит больше одного захода (эв
- bot-pipeline: комментарий с планом сплита + label `status/needs-analysis`, снять claim
- interactive: вернуть main-сессии план сплита вместо результата
## Целость результата workflow
- **Завершившийся прогон ≠ успешный.** Читай `<failures>` в уведомлении и `journal.jsonl` (по строке `result` на агента). Упавшие агенты возвращают `null`, `parallel()` их молча проглатывает, а стадия синтеза всё равно выдаёт уверенный текст с числами. Прежде чем показывать такой вердикт пользователю — проверь его несущие числа сам.
- **Восстановление:** `TaskStop``Workflow({scriptPath, resumeFromRunId})`. Готовые агенты реплеятся из кэша бесплатно, перезапускаются только упавшие. Правка промпта перезапускает ЭТОТ агент и все последующие (правило префикса) — правь точечно, не переписывай скрипт целиком.
- **Диагностика зависшего агента:** возраст последней записи в `agent-*.jsonl` + тип последнего события. `assistant/tool_use` без ответа при неподвижном журнале = завис. Несколько агентов замолчали одновременно = обрыв соединения, обычно лечится сам повтором — не спеши убивать.
- **Windows: скрипт workflow писать только в LF.** Перезапись через python даёт CRLF → запуск отбивается `script contains control characters`. `io.open(..., 'w', newline='\n')`.
## Единые пороги дробления (analyst / main)
- Estimate S(<2h) / M(2-8h) / **L(>8h) → обязан дробиться дальше** (до S/M)

View file

@ -25,7 +25,8 @@ Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod
## Path triggers (Forgejo Actions, `.forgejo/workflows/`)
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `ops/db-bootstrap/**`, `ops/*.sh`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- `ops/*.sh` (#2203) — любой скрипт непосредственно в `ops/` уезжает на VM автоматически, дополнять `paths:` вручную для нового `ops/<name>.sh` не нужно. ⚠️ Одиночная звёздочка не пересекает `/`**новый подкаталог** внутри `ops/` (по образцу `ops/db-bootstrap/`, `ops/glitchtip-auth-forwarder/`) под этот глоб не попадает и требует своей отдельной строки в `paths:`, иначе не доедет до `/opt/gendesign` и будет молча исполняться в старой версии
- trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all)
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md``.forgejo/workflows/deploy-obsidian.yml`
- `docs/**` alone → НЕ триггерит деплой

View file

@ -41,9 +41,21 @@ In-app scheduler (`scrape_schedules`, tick 60s, `python -m app.scheduler_main`,
`tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations`
в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична —
деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2,
`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename,
не доверяй `tail`.
деструктивный DDL хитит прод на деплое.
**Номер новой миграции сверяй с `origin/main`, не с локальным `ls`** — локальное дерево не видит
миграций, смерженных после ветвления (так разъехались 212 в #2682 и 234 в #2754):
```bash
git fetch origin main
git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail
```
`-r` обязателен — без него `ls-tree` печатает сам каталог одной строкой, а не файлы.
Правило целиком — в докстринге `tradein-mvp/backend/tests/test_migration_numbering.py` (единственная
формулировка контракта, #2683); он же гейтит его в CI. Дописывать имя в какой-либо список НЕ надо:
`_manifest_applied.txt` удалён — он отставал и по построению не мог покраснеть.
## Rapid-merge trap

View file

@ -51,9 +51,25 @@ jobs:
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
- 'auth/**'
# Реестр городов — фронтовый файл, но его читает БЭКЕНДОВЫЙ тест
# (tests/test_public_mera_api.py сверяет то, что мы предлагаем
# выбрать, с тем, на что умеет отвечать проба покрытия). Без этой
# строки правка одного лишь дропдауна не гоняла бы сверку — а
# разошлись списки ровно так: город добавили на фронте, в пороги
# покрытия не внесли, и житель Серова получал «вы вне области».
- 'tradein-mvp/frontend/src/lib/city-registry.ts'
- '.forgejo/workflows/ci-tradein.yml'
frontend:
- 'tradein-mvp/frontend/**'
# Caddyfile — по той же причине, что auth/** у бэкенда: он лежит в
# КОРНЕ репы, но его читает фронтовый тест
# (mera-public/__tests__/public-perimeter.test.ts) — тот сверяет,
# что каждый маршрут публичного сайта действительно раздаётся на
# meraocenka.ru. Без этой строки правка одного лишь Caddyfile не
# запускала бы НИ ОДИН гейт, и удаление короткого адреса из
# allowlist уехало бы на main зелёным — а на сайте кнопка «Проверить»
# стала бы ссылкой в 404.
- 'Caddyfile'
- '.forgejo/workflows/ci-tradein.yml'
browser:
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
@ -85,6 +101,21 @@ jobs:
CI_PG: ci-pg-tradein-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
with:
# ПОЛНАЯ история, а не дефолтный depth=1 (#2683).
# tests/test_migration_numbering.py сверяет номер новой миграции с
# origin/main и с точкой ветвления. Ровно этот флаг их и даёт: при
# depth=0 checkout идёт refspec'ом `+refs/heads/*:refs/remotes/origin/*`
# (видно в логе прогона), при depth=1 — только `+<sha>:refs/remotes/
# pull/N/head`, то есть ни ветки main, ни общего предка в клоне нет.
# Дотянуть main отдельным `git fetch` НЕЛЬЗЯ: из job-контейнера
# git.gendsgn.ru:443 недостижим (проверено, run 6977 — connection
# refused), сеть есть только у самого checkout.
#
# Гейт при отсутствии эталона краснеет, а не пропускается: молча
# пропущенная проверка и есть тот зелёный, который ничего не проверяет.
# Пак репозитория ~33 MiB — полный fetch дешевле разбора коллизии на проде.
fetch-depth: 0
- name: Поднять Postgres и собрать схему tradein
working-directory: .
@ -116,7 +147,7 @@ jobs:
# бы, а тесты всё равно скипались.
run: |
set -u
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \
postgis/postgis:16-3.4
@ -148,16 +179,13 @@ jobs:
"CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE ROLE gendesign_reader;"
# Исключений НЕТ (#2990). Раньше здесь пропускалась 077 — единственная
# миграция, читающая foreign table через postgres_fdw, которой в CI нет.
# Пропуск означал, что гейт не проверял ровно тот файл, который потом
# ронял чистый старт на реальном железе. Теперь 077 сама выходит раньше
# обращения к FDW, если мигрировать нечего, и в CI проходит честно.
for sql_file in $(ls -1 tradein-mvp/backend/data/sql/*.sql | sort); do
fname=$(basename "$sql_file")
# ЕДИНСТВЕННОЕ исключение, и оно названо вслух: 077 — не DDL, а
# backfill, читающий foreign table gendesign_rosreestr_deals из БД
# ДРУГОГО стека через postgres_fdw. В CI второй БД нет, USER MAPPING
# создать не из чего. На пустых таблицах backfill всё равно no-op.
if [ "$fname" = "077_dedup_hash_plain_key_backfill.sql" ]; then
echo "⚠ пропускаю $fname — postgres_fdw к БД gendesign, которой в CI нет"
continue
fi
docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q < "$sql_file" \
|| { echo "::error::миграция $fname не применилась"; docker logs --tail 20 "$CI_PG" 2>&1 || true; exit 1; }
done
@ -183,13 +211,22 @@ jobs:
restore-keys: |
uv-tradein-${{ runner.os }}-
- name: Sync deps (incl. dev group — pytest)
- name: Sync deps (incl. dev group — pytest, ruff)
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
# Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root
# вверх от cwd.
run: uv sync --frozen
- name: Lint (ruff check)
# Правила выбраны в tradein-mvp/backend/pyproject.toml ([tool.ruff.lint]
# select = E F I B UP N RUF), но до этого шага их никто не гонял в CI —
# "дерево чистое" было непроверенным утверждением, а не гарантией.
# Версия ruff — та же, что в tradein-mvp/uv.lock (--frozen из шага выше),
# т.е. ровно то, что видит `uv sync --frozen` в Dockerfile.
# Blocking: любое нарушение → job RED (не декоративно).
run: uv run ruff check .
- name: Run pytest (tradein-mvp/backend)
# БЕЗ deselect'ов — сьют гоняется целиком (#2722).
#
@ -221,7 +258,7 @@ jobs:
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
# Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ:
# ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог

View file

@ -65,6 +65,62 @@ jobs:
python3 scripts/check-workflow-ports.py --selftest
python3 scripts/check-workflow-ports.py
- name: "Guard: Caddy import покрыт volume-маунтом (#3102)"
# Тем же шагом-соседом и по той же причине: дёшево, на каждом PR,
# падение блокирует merge.
#
# ЗАЧЕМ. 2026-08-26 сюда доехал PR, который завёл `import
# ../metrics-*.caddy.snippet` в caddy/sites/infra.caddy, но не добавил
# bind-mount этих файлов в docker-compose.prod.yml. `caddy validate`
# ниже эту дыру НЕ ловит: он копирует ВЕСЬ каталог caddy/ как есть
# (`docker cp caddy ...`), а на проде смонтированы только отдельные
# файлы и два каталога — расхождение между "что лежит в репозитории" и
# "что реально видит контейнер" видно только на реальных маунтах.
# Итог того PR: Caddy на проде не смог адаптировать конфиг, ушёл в
# restart-loop и уронил ВСЕ сайты хоста на ~30 минут.
run: |
python3 scripts/check-caddy-snippet-mounts.py --selftest
python3 scripts/check-caddy-snippet-mounts.py
- name: "Guard: Caddyfile синтаксически валиден"
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
#
# ЗАЧЕМ. До 16.08.2026 конфиг прокси не проверял НИКТО — ни один
# workflow не звал `caddy validate`/`adapt` (grep по .forgejo/). При
# этом deploy.yml применяет его не через `reload` (тот отказался бы
# принять битый конфиг и оставил бы старый работать), а через
# `up -d --force-recreate caddy`: синтаксическая ошибка уводит контейнер
# в crash-loop, и ложатся ВСЕ домены сразу — gendsgn.ru, meraocenka.ru,
# obsidian, status. То есть цена опечатки в этом файле — полный
# даунтайм, а гейта на неё не было.
#
# `docker cp`, а НЕ `-v "$PWD:/etc/caddy"`. Job сам исполняется внутри
# контейнера, и `docker run` создаёт КОНТЕЙНЕР-БРАТ на том же демоне:
# путь в `-v` резолвится на ХОСТЕ, а `$PWD` — это путь внутри job-
# контейнера, которого на хосте нет. Первая версия этого шага так и
# упала: `open /etc/caddy/Caddyfile: no such file or directory`.
# Копирование не зависит от того, как смонтирован workspace.
#
# Образ тот же `caddy:2`, что в docker-compose.prod.yml — проверяем ровно
# тем парсером, который будет читать конфиг на проде.
#
# Копируем и `caddy/` — Caddyfile делает `import caddy/users.caddy.snippet`,
# и без него validate упадёт на импорте (файл в репозитории есть).
#
# Плейсхолдеры окружения ({env.*}) при validate резолвятся в пустую
# строку — это нормально, синтаксис от их значений не зависит.
run: |
set -euo pipefail
cid=$(docker create -w /work caddy:2 \
caddy validate --config /work/Caddyfile --adapter caddyfile)
docker cp Caddyfile "$cid:/work/Caddyfile"
docker cp caddy "$cid:/work/caddy"
rc=0
docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit "$rc"
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
@ -74,6 +130,36 @@ jobs:
python3 scripts/check-migration-lock-timeout.py --selftest
python3 scripts/check-migration-lock-timeout.py
- name: "Guard: shell-скрипты синтаксически валидны (#2917)"
# Соседям по этому job'у (caddy validate, lock_timeout) — тот же довод:
# дёшево, на каждом PR, ловит опечатку до прода.
#
# ЗАЧЕМ ИМЕННО ЭТО. scripts/smoke-mera-perimeter.sh — единственная
# проверка, которая видит публичный периметр МЕРЫ целиком, и до этого
# PR она запускалась только ночным cron'ом. Опечатка в ней обнаружилась
# бы следующим утром — и выглядела бы как регресс периметра, а не как
# сломанный скрипт. Ни один линтер шелла в репозитории не стоит
# (shellcheck нет), поэтому берём то, что есть в каждом образе: `bash -n`
# разбирает файл, не исполняя его.
#
# ГРАНИЦА: `bash -n` ловит СИНТАКСИС, а не смысл — неверный URL или
# перепутанный ожидаемый код он не увидит. Это не замена прогона,
# а защита от того, что скрипт вообще не запустится.
run: |
set -euo pipefail
found=0
for f in $(git ls-files 'scripts/*.sh' 'ops/*.sh' 'ops/**/*.sh'); do
found=$((found + 1))
bash -n "$f" || { echo "::error file=$f::синтаксическая ошибка в shell-скрипте"; exit 1; }
done
# Ноль файлов означал бы, что гейт молча ничего не проверяет —
# ровно тот случай, когда зелёный шаг не значит ничего (#2871).
if [ "$found" -eq 0 ]; then
echo "::error::не найдено ни одного .sh — гейт бы прошёл впустую, проверь маску"
exit 1
fi
echo "✓ синтаксис проверен у $found shell-скриптов"
- uses: dorny/paths-filter@v3
id: filter
with:
@ -89,6 +175,15 @@ jobs:
# переведён в expired, test_get_role_known_users стал красным и
# доехал до main незамеченным (починен в PR #2587).
- 'auth/**'
# Тот же класс, что и с auth/** выше (#2950). В backend/tests/ops/
# лежат гейты на сами workflow-файлы — например «оба прод-деплоя
# обязаны быть в одной группе concurrency». Правка, разводящая
# группы обратно, не трогает 'backend/**' → без этих строк
# backend-tests пропускался бы, гейт не исполнялся, и регрессия
# уезжала в main зелёной. Гейт, который не запускается на той самой
# правке, от которой стережёт, — украшение.
- '.forgejo/workflows/deploy.yml'
- '.forgejo/workflows/deploy-tradein.yml'
- '.forgejo/workflows/ci.yml'
frontend:
- 'frontend/**'
@ -142,7 +237,7 @@ jobs:
# здесь не нужен вовсе, в отличие от tradein-лэйна.
run: |
set -u
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
postgres:16
@ -233,11 +328,18 @@ jobs:
# coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge).
# term-missing → видно непокрытые строки прямо в job-логе.
run: |
# #2871: код возврата печатаем ЯВНО. Сводка pytest («4647 passed») уходит
# в лог ДО выхода, поэтому зелёная сводка при ненулевом коде выглядит как
# «job упал неизвестно где» — а падал именно этот шаг. Гейт сохраняется:
# ниже `exit $rc`.
rc=0
uv run pytest -q -rs --ignore=tests/smoke \
--cov=app \
--cov-report=term-missing:skip-covered \
--cov-report=xml:coverage.xml \
--cov-fail-under=65
--cov-fail-under=65 || rc=$?
echo "### pytest вернул код $rc"
exit $rc
- name: Coverage summary → job output
# Дешёвый human-readable итог. Бежит даже если gate упал (if: always) —
@ -246,20 +348,34 @@ jobs:
# если переменная пустая/файла нет, печатаем в обычный лог (fallback).
if: always()
run: |
echo "### шаг «Coverage summary» начался"
[ -f coverage.xml ] || { echo "coverage.xml отсутствует — пропускаю summary"; exit 0; }
report="$(uv run coverage report --skip-covered --sort=cover | tail -40)"
# NB (#2871): `coverage report` уважает fail_under из pyproject и выходит с
# кодом 2, когда порог не набран, а `run:` идёт под `bash -eo pipefail` —
# то есть падение ЭТОГО шага гасит зелёный pytest и выглядит как «job упал
# неизвестно где». Разделяем вычисление и вывод, чтобы код возврата был виден.
# `|| cov_rc=$?`, а не отдельная строка: под `set -e` присваивание после
# упавшей команды просто не выполнится, и код возврата снова потеряется.
cov_rc=0
uv run coverage report --skip-covered --sort=cover > /tmp/cov_report.txt || cov_rc=$?
echo "### coverage report вернул код $cov_rc"
report="$(tail -40 /tmp/cov_report.txt)"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{ echo '```'; echo "$report"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
else
echo "$report"
fi
echo "### шаг «Coverage summary» закончился успешно"
- name: Снести тестовый Postgres
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
run: |
echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})"
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
echo "### шаг «Снести тестовый Postgres» закончился успешно"
frontend-tests:
runs-on: ubuntu-latest

View file

@ -0,0 +1,184 @@
name: Deploy Infra Host
# Синхронизация /opt/gendesign на ХОСТЕ, КОТОРЫЙ ОСТАЁТСЯ (#3059, #3057).
#
# ЗАЧЕМ. Сегодня Beget — и прод, и инфраструктура одновременно, поэтому его
# рабочее дерево обновляет обычный `deploy.yml` (шаг `git reset --hard
# origin/main` по SSH на `secrets.DEPLOY_HOST`). После переезда 30.08
# `DEPLOY_HOST` станет указывать на Selectel — и /opt/gendesign на Beget
# перестанет обновляться СОВСЕМ. Молча.
#
# А из этого каталога на Beget продолжат работать:
# - cron-скрипты бэкапов: ops/backup.sh, ops/backup-forgejo.sh,
# ops/check-backup-staleness.sh, ops/lib-backup.sh, ops/docker-prune.sh
# - docker-compose.prod.yml для Forgejo / GlitchTip / CouchDB
# - Caddyfile + caddy/sites/infra.caddy — единственный публичный вход для
# git.gendsgn.ru, errors.gendsgn.ru, obsidian.gendsgn.ru (#3062)
#
# То есть любая будущая правка этих файлов легла бы в main и никогда не доехала
# до машины, которая их исполняет. Это ровно класс #2887 («скрипт запускается по
# cron из /opt/gendesign, куда попадает только через git reset --hard шага
# деплоя»), но не на уровне одного файла, а на уровне целого хоста.
#
# ПОЧЕМУ ОТДЕЛЬНЫЙ WORKFLOW, А НЕ JOB В deploy.yml. Разные адресаты и разные
# вердикты: «выкатили приложение на Selectel» и «синхронизировали инфраструктуру
# на Beget» — два независимых факта, и падение второго не должно читаться как
# неудавшийся деплой продукта. Плюс триггеры разные: инфра-хосту не нужны
# пересборки backend/frontend.
#
# ИНЕРТЕН, ПОКА НЕ ЗАДАН INFRA_DEPLOY_HOST. Сейчас, до переезда, Beget и есть
# DEPLOY_HOST — второй проход по тому же хосту был бы лишним и мог бы состязаться
# с основным деплоем за докер-демон (#2950). Поэтому job не делает ничего, пока
# секрет пуст: включается ОДНОЙ настройкой в момент, когда хосты разъедутся.
# ── ПОДЛИННОСТЬ ХОСТА (#3029) ────────────────────────────────────────────────
# Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а раннеры оставляет на
# Beget — SSH становится междоузловым, через интернет. Поэтому у вызова
# appleboy/ssh-action ниже появился вход `fingerprint`.
# ЧТО ЗАДАТЬ: секрет INFRA_DEPLOY_SSH_FINGERPRINT =
# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}'
# (значение с префиксом `SHA256:`; именно ecdsa — см. разбор в deploy.yml).
# ПОБАЙТОВО: значение сравнивается как есть, без trim — лишний пробел/перевод
# строки при копипасте включает проверку и роняет ssh-шаг с `host key
# fingerprint mismatch`.
# ПОКА СЕКРЕТ НЕ ЗАДАН — поведение прежнее: пустой fingerprint у easyssh-proxy
# v1.5.0 означает ssh.InsecureIgnoreHostKey(), то есть ровно как до этого PR.
# Включается одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у
# TRADEIN_INTERNAL_AUTH_SECRET (#2989).
# ─────────────────────────────────────────────────────────────────────────────
on:
push:
branches: [main]
paths:
# Ровно то, что исполняется НА ОСТАЮЩЕМСЯ хосте. Намеренно НЕ включены
# backend/** и frontend/** — их образы туда не едут.
- "ops/*.sh"
# ops/*.cron — эталоны crontab. Деплой их не исполняет, но после
# разъезда хостов (#3057) правка crontab-beget.cron иначе доезжала бы
# только до продового хоста: строка ops/*.cron есть лишь в deploy.yml.
# Одиночная звёздочка не пересекает `/`, поэтому это именно файлы в
# корне ops/, как и ops/*.sh рядом.
- "ops/*.cron"
- "Caddyfile"
- "caddy/**"
- "docker-compose.prod.yml"
- "docker-compose.obsidian.yml"
- ".forgejo/workflows/deploy-infra.yml"
workflow_dispatch:
jobs:
sync-infra-host:
runs-on: ubuntu-latest
steps:
- name: Проверить, разъехались ли хосты
id: gate
env:
INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }}
run: |
set -euo pipefail
if [ -z "${INFRA_HOST:-}" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "INFRA_DEPLOY_HOST не задан — хосты ещё не разъехались."
echo "Инфраструктуру обновляет обычный deploy.yml. Ничего не делаю."
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "INFRA_DEPLOY_HOST задан — синхронизирую остающийся хост."
fi
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
# предупреждение исчезнет само.
- name: Подлинность хоста — статус проверки (#3029)
if: steps.gate.outputs.enabled == 'true'
env:
HOST_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
run: |
set -euo pipefail
if [ -n "${HOST_FINGERPRINT:-}" ]; then
echo "Подлинность хоста: сверяется по INFRA_DEPLOY_SSH_FINGERPRINT."
else
echo '::warning title=SSH без проверки подлинности хоста::INFRA_DEPLOY_SSH_FINGERPRINT не задан — ключ остающегося хоста НЕ проверяется (#3029). Фолбэка на DEPLOY_SSH_FINGERPRINT здесь нет и быть не должно: это другая машина. По каналу едет INFRA_DEPLOY_SSH_KEY и выполняется git reset на /opt/gendesign. Как снять отпечаток — см. шапку этого файла.'
echo '###############################################################'
echo '# ВНИМАНИЕ (#3029): INFRA_DEPLOY_SSH_FINGERPRINT не задан.'
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
echo '# Как снять отпечаток — см. шапку этого файла.'
echo '###############################################################'
fi
- name: Синхронизировать /opt/gendesign на остающемся хосте
if: steps.gate.outputs.enabled == 'true'
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.INFRA_DEPLOY_HOST }}
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT }}
# #3029: БЕЗ фолбэка на DEPLOY_SSH_FINGERPRINT — в отличие от user/key/port
# выше. Те у двух хостов совпадают, а отпечаток — это идентичность
# КОНКРЕТНОЙ машины: после переезда INFRA_DEPLOY_HOST=Beget, а
# DEPLOY_HOST=Selectel, и фолбэк означал бы сверку ключа Beget'а с
# отпечатком Selectel'а — гарантированный отказ ровно у того workflow,
# который чинит остающийся хост. Пусто → проверка пропускается.
fingerprint: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
script: |
set -euo pipefail
cd /opt/gendesign
git fetch origin main
git reset --hard origin/main
# Тот же chmod, что и в deploy.yml: cron зовёт скрипты через `bash`,
# но restore-drill и ручные запуски рассчитывают на +x.
chmod +x ops/*.sh 2>/dev/null || true
# Caddy здесь несёт ТОЛЬКО остающиеся домены (CADDY_SITES=infra).
# reload, а не recreate: конфиг примонтирован read-only, контейнер
# читает тот же файл, который только что обновил git. Если конфиг
# невалиден — reload откажет ГРОМКО, а старый останется работать,
# то есть падение здесь не роняет git/errors/obsidian.
if docker ps --format '{{.Names}}' | grep -q '^gendesign-caddy-1$'; then
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "✓ конфиг прокси перезагружен"
else
echo "⚠ контейнер caddy не найден — пропускаю reload"
fi
# infra-postgres (#3061): применить изменения конфигурации сервиса.
# После разъезда хостов этот workflow — ЕДИНСТВЕННОЕ, что доставляет
# docker-compose.prod.yml на Beget, а лёгкий кластер БД описан именно
# там. Без этой строки любой будущий бамп postgres:16-alpine, правка
# mem_limit или healthcheck'а легли бы в main, workflow отрапортовал
# бы «дерево синхронизировано», а контейнер продолжил бы жить со
# старой конфигурацией по `restart: unless-stopped` — молча, ровно
# класс #2887, ради которого этот workflow и написан.
#
# `up -d` с ЯВНЫМ именем сервиса, а не общий: трогается только
# infra-postgres, до Forgejo / GlitchTip / CouchDB дела нет. Явное
# имя заодно активирует профиль `infra` само по себе, без оглядки на
# COMPOSE_PROFILES. Если конфигурация не менялась — compose ничего не
# пересоздаёт, шаг стоит доли секунды.
#
# СОЗДАВАТЬ кластер отсюда мы НЕ хотим — отсюда проверка на
# существующий контейнер. Первый старт — осознанный ручной шаг окна
# переезда (шаг 2 в docker-compose.prod.yml), и делается он с уже
# заполненными INFRA_PG_PASSWORD / FORGEJO_DB_PASS. Стартуй мы вслепую
# — пустой пароль дал бы отравленный том, который потом не
# переинициализировать.
if docker ps -a --format '{{.Names}}' | grep -qx 'gendesign-infra-postgres'; then
docker compose -p gendesign -f docker-compose.prod.yml up -d infra-postgres
echo "✓ infra-postgres приведён к конфигурации из main"
else
echo "⚠ контейнер gendesign-infra-postgres не найден — кластер ещё не поднят вручную, пропускаю"
fi
# НАМЕРЕННО НЕ ДЕЛАЕТСЯ:
# - docker image prune: конкурирует с деплоем продукта за leases
# докер-демона (#2950). Прун на этом хосте остаётся за
# еженедельным ops/docker-prune.sh.
# - перезапуск Forgejo / GlitchTip / CouchDB: правка ops-скрипта
# не повод ронять git. Их обновление — осознанное действие.
echo "✓ рабочее дерево синхронизировано: $(git rev-parse --short HEAD)"

View file

@ -0,0 +1,335 @@
name: Deploy Metrics
# Деплой стека наблюдаемости (#3078). Двухсторонний, и это существенно:
#
# server — на ИНФРАСТРУКТУРНЫЙ хост (Beget): Prometheus, Loki, Grafana,
# Alertmanager. Наблюдатель намеренно живёт у другого провайдера,
# чем наблюдаемое.
# agent — на ОБА хоста: node-exporter, cAdvisor, postgres-exporter, Alloy.
# Агент на продуктовом хосте шлёт push'ем, поэтому там не открывается
# ни одного входящего порта.
#
# Продуктовый стек не трогается вовсе: другой project-name, другие compose-файлы,
# deploy.yml остаётся в стороне.
on:
push:
branches: [main]
paths:
- "docker-compose.metrics.yml"
- "docker-compose.metrics-agent.yml"
- "ops/metrics/**"
- "caddy/sites/infra.caddy"
- "caddy/metrics-ui.caddy.snippet"
- "caddy/metrics-ingest.caddy.snippet"
# Глоб, а не точечный `setup-metrics-secrets.sh` (#2203: класс бага, а не
# один файл). Деплой запускает ТРИ setup-скрипта — secrets, grafana-role и
# exporter-dsn, — а в триггере стоял только первый: правка двух остальных
# не заводила выкат, и на хосте продолжала исполняться старая версия молча.
- "scripts/setup-metrics-*.sh"
- ".forgejo/workflows/deploy-metrics.yml"
workflow_dispatch:
concurrency:
group: deploy-metrics
cancel-in-progress: false
jobs:
# ═══ СЕРВЕРНАЯ СТОРОНА — инфраструктурный хост ════════════════════════════
server:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
# Тот же приём, что в deploy-obsidian.yml (#3062, #3029): адресат и отпечаток
# берутся В ПАРЕ, без перекрёстного фолбэка. Сверять ключ Beget'а с отпечатком
# Poincare — гарантированный отказ.
- name: Адресат и подлинность инфраструктурного хоста
id: target
env:
INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }}
INFRA_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
MAIN_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
run: |
set -euo pipefail
if [ -n "${INFRA_HOST:-}" ]; then
HOST_FINGERPRINT="${INFRA_FINGERPRINT:-}"
SRC="INFRA_DEPLOY_SSH_FINGERPRINT"
else
HOST_FINGERPRINT="${MAIN_FINGERPRINT:-}"
SRC="DEPLOY_SSH_FINGERPRINT"
fi
case "${HOST_FINGERPRINT}" in
*[![:print:]]*)
echo "ОШИБКА: ${SRC} содержит перевод строки или непечатный символ." >&2
exit 1
;;
esac
echo "fingerprint=${HOST_FINGERPRINT}" >> "$GITHUB_OUTPUT"
if [ -z "${HOST_FINGERPRINT:-}" ]; then
echo "::warning title=SSH без проверки подлинности хоста::${SRC} не задан — ключ хоста НЕ проверяется (#3029)."
fi
- name: Поднять серверный стек
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }}
fingerprint: ${{ steps.target.outputs.fingerprint }}
command_timeout: 15m
script: |
set -euo pipefail
cd /opt/gendesign
git fetch origin main
git reset --hard origin/main
docker network inspect gendesign_shared >/dev/null 2>&1 \
|| docker network create gendesign_shared
# Окружение нужно в shell, а не только в env_file: проверки вида
# ${VAR:?} у compose работают по переменным ОКРУЖЕНИЯ ПРОЦЕССА.
if [ -f backend/.env.runtime ]; then
set -a; . backend/.env.runtime; set +a
fi
# ── Проверка ДО подъёма, а не после ────────────────────────────
# Пустой токен даёт Alertmanager, который стартует зелёным и молча
# ничего не шлёт. Это ровно тот класс тихого отказа, ради которого
# весь стек и заводится, — ловим на пороге.
missing=""
for v in GRAFANA_ADMIN_PASSWORD GLITCHTIP_RO_PASSWORD \
METRICS_INGEST_USER METRICS_INGEST_PASSWORD; do
eval "val=\${$v:-}"
[ -z "$val" ] && missing="$missing $v"
done
if [ -n "$missing" ]; then
echo "ОШИБКА: в окружении хоста не заданы:$missing"
echo "Запусти один раз: bash scripts/setup-metrics-secrets.sh"
exit 1
fi
# Алерты включаются, только когда канал доставки реально задан.
# Поднимать Alertmanager с пустым токеном нельзя: он стартует
# зелёным и молча ничего не шлёт — ровно тот тихий отказ, ради
# которого весь стек и заводится.
PROFILES=""
if [ -n "${METRICS_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${METRICS_TELEGRAM_CHAT_ID:-}" ]; then
PROFILES="alerts"
mkdir -p ops/metrics/alertmanager
# Топик форумной группы (#3078). Необязателен: без него алерты
# уходят в общую тему. Подставляем ЦЕЛОЙ СТРОКОЙ, а не значением,
# потому что envsubst не умеет условий — при пустом
# METRICS_TELEGRAM_TOPIC_ID в конфиг попал бы `message_thread_id:`
# без значения, и Alertmanager не стартовал бы вовсе.
if [ -n "${METRICS_TELEGRAM_TOPIC_ID:-}" ]; then
METRICS_TELEGRAM_TOPIC_LINE=" message_thread_id: ${METRICS_TELEGRAM_TOPIC_ID}"
echo "Алерты: адресуются в топик ${METRICS_TELEGRAM_TOPIC_ID}."
else
METRICS_TELEGRAM_TOPIC_LINE=""
echo "Алерты: топик не задан — уйдут в общую тему чата."
fi
METRICS_TELEGRAM_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
METRICS_TELEGRAM_TOPIC_LINE="$METRICS_TELEGRAM_TOPIC_LINE" \
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_TOPIC_LINE}' \
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
> ops/metrics/alertmanager/alertmanager.yml
chmod 600 ops/metrics/alertmanager/alertmanager.yml
# Проверяем ДО подъёма, как и Caddyfile ниже. Битый конфиг
# Alertmanager не «деградирует» — контейнер не стартует вовсе, и
# алертинг молча исчезает целиком. amtool берём из того же образа,
# что и сам Alertmanager, иначе проверяли бы не ту версию схемы.
if ! docker run --rm \
-v "$PWD/ops/metrics/alertmanager/alertmanager.yml:/tmp/am.yml:ro" \
--entrypoint amtool "$(grep -oE 'prom/alertmanager:[^ ]+' docker-compose.metrics.yml | head -1)" \
check-config /tmp/am.yml; then
echo "ОШИБКА: конфиг Alertmanager не проходит проверку — стек не поднимаем."
exit 1
fi
echo "Алерты: канал задан, конфиг проверен, Alertmanager поднимается."
else
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
fi
# ── read-only роль для датасорса GlitchTip ─────────────────────
# Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
# обязан быть безопасен даже при полном доступе к дашбордам.
bash scripts/setup-metrics-grafana-role.sh
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml pull --quiet
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
# сам Forgejo, из которого идёт деплой. Поэтому validate — обязателен,
# и reload делается только после успешной проверки.
if docker compose -p gendesign -f docker-compose.prod.yml ps caddy --quiet | grep -q .; then
if docker compose -p gendesign -f docker-compose.prod.yml \
exec -T caddy caddy validate --config /etc/caddy/Caddyfile; then
docker compose -p gendesign -f docker-compose.prod.yml \
exec -T caddy caddy reload --config /etc/caddy/Caddyfile
echo "Caddy: конфиг проверен и перезагружен."
else
echo "ОШИБКА: Caddyfile не проходит проверку — reload НЕ выполнен."
echo "Работающий Caddy не тронут, домены живы. Чинить конфиг и повторять."
exit 1
fi
fi
# ── Приёмка ────────────────────────────────────────────────────
for i in $(seq 1 30); do
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
break
fi
sleep 3
done
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
agent-apps:
runs-on: ubuntu-latest
needs: server
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Агент на продуктовом хосте
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT || 22 }}
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
command_timeout: 15m
script: |
set -euo pipefail
cd /opt/gendesign
git fetch origin main
git reset --hard origin/main
docker network inspect gendesign_shared >/dev/null 2>&1 \
|| docker network create gendesign_shared
if [ -f backend/.env.runtime ]; then
set -a; . backend/.env.runtime; set +a
fi
if [ -z "${METRICS_INGEST_PASSWORD:-}" ]; then
echo "ОШИБКА: METRICS_INGEST_PASSWORD не задан — агенту нечем авторизоваться."
echo "Запусти на инфраструктурном хосте: bash scripts/setup-metrics-secrets.sh"
exit 1
fi
# DSN экспортеров собираются из уже имеющихся паролей БД, если их
# ещё нет. Отдельных секретов не заводим — лишняя копия пароля это
# лишнее место, откуда он может утечь.
bash scripts/setup-metrics-exporter-dsn.sh
set -a; . backend/.env.runtime; set +a
# Профиль экспортеров БД включаем, только если DSN реально собрались.
# Раньше их обязательность стояла в compose (`${VAR:?}`), но compose
# интерполирует ВЕСЬ файл до фильтрации по профилям — и продуктовый
# агент падал на INFRA_EXPORTER_DSN, переменной сервиса, который тут
# не поднимается вовсе. Проверка переехала сюда, где роль известна.
#
# Не падаем, а предупреждаем: alloy / node-exporter / cadvisor и сбор
# логов не должны отваливаться из-за одного ненастроенного экспортера.
# Тот же приём, что у Alertmanager в джобе server выше.
EXPORTER_PROFILE=""
missing_dsn=""
[ -n "${GENDESIGN_EXPORTER_DSN:-}" ] || missing_dsn="$missing_dsn GENDESIGN_EXPORTER_DSN"
[ -n "${TRADEIN_EXPORTER_DSN:-}" ] || missing_dsn="$missing_dsn TRADEIN_EXPORTER_DSN"
if [ -z "$missing_dsn" ]; then
EXPORTER_PROFILE="apps"
else
echo "::warning title=Метрики БД не собираются::не заполнены:$missing_dsn. Хостовые метрики и логи поедут, метрик Postgres не будет. Проверь, что scripts/setup-metrics-exporter-dsn.sh нашёл DATABASE_URL/TRADEIN_DATABASE_URL."
fi
METRICS_ROLE=apps \
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml pull --quiet
METRICS_ROLE=apps \
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d
sleep 10
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml ps
agent-infra:
runs-on: ubuntu-latest
needs: server
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Агент на инфраструктурном хосте
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }}
fingerprint: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
command_timeout: 15m
script: |
set -euo pipefail
cd /opt/gendesign
if [ -f backend/.env.runtime ]; then
set -a; . backend/.env.runtime; set +a
fi
# Симметрично продуктовому агенту: профиль экспортера включаем,
# только если DSN есть. Без этого гарда экспортер поднялся бы с
# ПУСТЫМ DATA_SOURCE_NAME (в compose теперь `:-`, а не `:?`) и
# молча не отдавал бы метрик — ровно тот тихий отказ, ради которого
# весь стек и заводится.
#
# NB: INFRA_EXPORTER_DSN сейчас не собирает никто —
# scripts/setup-metrics-exporter-dsn.sh знает только про
# GENDESIGN_/TRADEIN_ и работает на продуктовом хосте. Пока это так,
# ветка ниже всегда даёт предупреждение, и это честно: метрик
# инфраструктурной БД действительно нет.
EXPORTER_PROFILE=""
if [ -n "${INFRA_EXPORTER_DSN:-}" ]; then
EXPORTER_PROFILE="infra"
else
echo "::warning title=Метрики инфраструктурной БД не собираются::INFRA_EXPORTER_DSN не задан. Хостовые метрики и логи поедут, метрик Postgres инфры не будет."
fi
METRICS_ROLE=infra \
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml pull --quiet
METRICS_ROLE=infra \
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d
sleep 10
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml ps

View file

@ -18,6 +18,36 @@ name: Deploy Obsidian
# единственная директория, которую реально исполняет этот инстанс.
# См. issue #2416.
# ── ПОДЛИННОСТЬ ХОСТА (#3029) ────────────────────────────────────────────────
# Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а раннеры оставляет на
# Beget — SSH становится междоузловым, через интернет. Поэтому у вызова
# appleboy/ssh-action ниже появился вход `fingerprint`.
# ЧТО ЗАДАТЬ: секрет DEPLOY_SSH_FINGERPRINT =
# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}'
# (значение с префиксом `SHA256:`; именно ecdsa — см. разбор в deploy.yml).
# ПОБАЙТОВО: значение сравнивается как есть, без trim — лишний пробел/перевод
# строки при копипасте включает проверку и роняет ssh-шаг с `host key
# fingerprint mismatch`.
# ПОКА СЕКРЕТ НЕ ЗАДАН — поведение прежнее: пустой fingerprint у easyssh-proxy
# v1.5.0 означает ssh.InsecureIgnoreHostKey(), то есть ровно как до этого PR.
# Включается одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у
# TRADEIN_INTERNAL_AUTH_SECRET (#2989).
# АДРЕСАТ (#3062). CouchDB/Obsidian ОСТАЁТСЯ на Beget вместе с Forgejo и
# GlitchTip, а DEPLOY_HOST после 30.08 будет указывать на Selectel. Раньше этот
# workflow ходил на DEPLOY_HOST безусловно — то есть в день переезда молча начал
# бы разворачивать стек CouchDB не на той машине: git reset на /opt/gendesign
# продуктового хоста, а волт на Beget тем временем перестал бы обновляться.
# Отказа при этом не было бы — деплой зелёный, адресат другой.
#
# Теперь адресат берётся как INFRA_DEPLOY_HOST, а если он не задан — DEPLOY_HOST.
# До переезда это одна и та же машина, поэтому поведение не меняется; после —
# workflow сам остаётся на инфраструктурном хосте, без правки этого файла.
#
# Отпечаток идёт В ПАРЕ с адресатом и БЕЗ перекрёстного фолбэка: сверять ключ
# Beget'а с отпечатком Selectel'а — гарантированный отказ. Задан INFRA_DEPLOY_HOST
# → берётся INFRA_DEPLOY_SSH_FINGERPRINT; не задан → DEPLOY_SSH_FINGERPRINT.
# Пусто в выбранной ветке → проверка подлинности пропускается, как и раньше.
# ─────────────────────────────────────────────────────────────────────────────
on:
push:
branches: [main]
@ -39,13 +69,74 @@ jobs:
steps:
- uses: actions/checkout@v4
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
# предупреждение исчезнет само.
- name: Адресат и подлинность хоста (#3062, #3029)
id: target
env:
INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }}
INFRA_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
MAIN_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
run: |
set -euo pipefail
# Отпечаток — публичный хеш ключа хоста, не секрет: его можно
# передать через output. Приватный ключ так передавать нельзя,
# поэтому он остаётся прямой ссылкой на секрет в шаге ниже.
if [ -n "${INFRA_HOST:-}" ]; then
echo "Адресат: INFRA_DEPLOY_HOST — хосты разъехались, стек CouchDB едет на инфраструктурный хост."
HOST_FINGERPRINT="${INFRA_FINGERPRINT:-}"
FINGERPRINT_SOURCE="INFRA_DEPLOY_SSH_FINGERPRINT"
else
echo "Адресат: DEPLOY_HOST — INFRA_DEPLOY_HOST не задан, хосты ещё одна машина."
HOST_FINGERPRINT="${MAIN_FINGERPRINT:-}"
FINGERPRINT_SOURCE="DEPLOY_SSH_FINGERPRINT"
fi
# $GITHUB_OUTPUT — формат «ключ=значение» построчно, поэтому перевод
# строки внутри значения означает инъекцию произвольного output'а.
# Отпечаток однострочный по определению (SHA256:...), а вот копипаста
# в поле секрета лишний \n добавляет легко — шапка этого файла об этом
# прямо предупреждает. Не вычищаем молча: сверка побайтовая, тихий trim
# изменил бы результат проверки. Падаем с внятным текстом.
case "${HOST_FINGERPRINT}" in
*[![:print:]]*)
echo "ОШИБКА: ${FINGERPRINT_SOURCE} содержит перевод строки или непечатный символ." >&2
echo "ОШИБКА: значение должно быть одной строкой вида SHA256:xxxx — перезадай секрет без лишних символов." >&2
exit 1
;;
esac
echo "fingerprint=${HOST_FINGERPRINT}" >> "$GITHUB_OUTPUT"
if [ -n "${HOST_FINGERPRINT:-}" ]; then
echo "Подлинность хоста: сверяется по ${FINGERPRINT_SOURCE}."
else
echo "::warning title=SSH без проверки подлинности хоста::${FINGERPRINT_SOURCE} не задан — ключ хоста НЕ проверяется (#3029). По каналу едет ssh-ключ и разворачивается стек CouchDB/Obsidian. После разъезда хостов (#3057) соединение идёт через интернет. Как снять отпечаток — см. шапку этого файла."
echo '###############################################################'
echo "# ВНИМАНИЕ (#3029): ${FINGERPRINT_SOURCE} не задан."
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
echo '# Как снять отпечаток — см. шапку этого файла.'
echo '###############################################################'
fi
- name: Deploy obsidian stack via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT || 22 }}
# #3062: адресат — инфраструктурный хост, если хосты уже разъехались.
# До этого INFRA_DEPLOY_HOST пуст и всё идёт на DEPLOY_HOST, как раньше.
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
# user/key/port с фолбэком: у двух хостов они совпадают, а отдельные
# INFRA_*-секреты может и не завести — тогда работают общие.
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }}
# #3029: подлинность хоста. Отпечаток выбран шагом выше В ПАРЕ с
# адресатом — перекрёстного фолбэка здесь быть не должно, иначе после
# переезда ключ Beget'а сверялся бы с отпечатком Selectel'а.
# Пусто → easyssh-proxy оставляет ssh.InsecureIgnoreHostKey(), как сегодня.
fingerprint: ${{ steps.target.outputs.fingerprint }}
script: |
set -euo pipefail
cd /opt/gendesign

View file

@ -3,6 +3,30 @@ name: Deploy Trade-In
# Forgejo Actions — отдельный pipeline для подпроекта tradein-mvp/.
# Триггерится только на изменения внутри tradein-mvp/ (или этого workflow),
# не пересекается с основным deploy.yml.
# ── ПОДЛИННОСТЬ ХОСТА (#3029) ────────────────────────────────────────────────
# Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а Forgejo и раннеры
# оставляет на Beget — SSH перестаёт быть петлёй и идёт через интернет. В этом
# workflow ДВА разных SSH-канала, и закрываются они по-разному:
# 1) шаг "Deploy via SSH" (appleboy/ssh-action) → вход `fingerprint`,
# секрет DEPLOY_SSH_FINGERPRINT;
# 2) шаг "Resolve deployed base SHA" — обычный openssh-клиент, ему нужен
# known_hosts, а не SHA256-строка → секрет DEPLOY_KNOWN_HOSTS.
# Как снять значения:
# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}'
# → DEPLOY_SSH_FINGERPRINT (с префиксом `SHA256:`; почему именно ecdsa —
# см. разбор в deploy.yml: дефолт x/crypto ставит ecdsa выше ed25519)
# ssh-keyscan -p <порт> <хост>
# → DEPLOY_KNOWN_HOSTS (все типы ключей сразу, без -t)
# ПОБАЙТОВО: fingerprint сравнивается как есть, без trim — лишний пробел или
# перевод строки при копипасте включает проверку и роняет ssh-шаг с `host key
# fingerprint mismatch`.
# ПОКА СЕКРЕТЫ НЕ ЗАДАНЫ — поведение прежнее в обоих каналах: пустой fingerprint
# у easyssh-proxy v1.5.0 это ssh.InsecureIgnoreHostKey(), а второй шаг остаётся
# на StrictHostKeyChecking=no, но печатает громкое предупреждение. Включается
# одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у
# TRADEIN_INTERNAL_AUTH_SECRET (#2989). Ничего не удаляем — только добавляем.
# ─────────────────────────────────────────────────────────────────────────────
on:
push:
branches: [main]
@ -11,8 +35,23 @@ on:
- ".forgejo/workflows/deploy-tradein.yml"
workflow_dispatch:
# #2950: ОБЩАЯ группа с deploy-tradein.yml — не опечатка и не копипаста.
# Оба деплоя ходят по SSH в ОДИН докер-демон (стеки gendesign-* и tradein-*
# плюс сам forgejo-runner живут на одной VM), и `docker image prune -af` одного
# сносит leases ещё не доехавшего `compose pull` другого:
# unable to lease content: lease does not exist: not found
# 20.08 так и вышло: run 8083 упал за 5с — прун соседнего деплоя отработал через
# 0.4с после обрыва пула. Прод остался на старом коде, при том что голова main
# показывала success (зелёным был чужой, Trade-In'овый деплой той же головы).
# Разные группы + cancel-in-progress: false не спасают: false сериализует раны
# ВНУТРИ группы, а гонка была МЕЖДУ группами.
# Цена: деплои ждут друг друга целиком, вместе с билдами (~6 мин). Осознанно:
# host-lock (flock) сериализовал бы только докер-секцию, но у него своя отказная
# мода — дочерний процесс наследует fd лока и при аварийной смерти job'а лок
# залипает (проверено на хосте: после kill -9 лок остался занят). Сериализацию
# гарантирует планировщик Forgejo, залипать там нечему.
concurrency:
group: deploy-tradein-prod
group: deploy-prod
cancel-in-progress: false
env:
@ -61,24 +100,72 @@ jobs:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# #3029: строки known_hosts прод-хоста. DEPLOY_SSH_FINGERPRINT здесь НЕ
# подходит: ниже обычный openssh-клиент, а не Go-клиент ssh-action'а, и
# SHA256-отпечаток он на вход не принимает — ему нужен known_hosts.
# Получить: ssh-keyscan -p <порт> <хост> (без -t: пусть в секрете лежат
# все типы ключей сразу, тогда выбор алгоритма клиентом ничего не ломает).
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
# Write SSH key to a temp file
SSH_KEY_FILE=$(mktemp)
echo "$DEPLOY_SSH_KEY" > "$SSH_KEY_FILE"
chmod 600 "$SSH_KEY_FILE"
# #3029: подлинность хоста для ЭТОГО канала. Раньше здесь стояло
# безусловное -o StrictHostKeyChecking=no, то есть ключ хоста не
# проверялся никогда. После переезда (#3057) соединение идёт через
# интернет, поэтому: секрет задан → пишем known_hosts и требуем
# StrictHostKeyChecking=yes; не задан → оставляем ровно сегодняшнее
# поведение, но ГРОМКО об этом сообщаем. Инертно по умолчанию: пустой
# секрет = поведение до этого PR бит в бит.
KNOWN_HOSTS_FILE=$(mktemp)
if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$KNOWN_HOSTS_FILE"
chmod 600 "$KNOWN_HOSTS_FILE"
SSH_HOST_OPTS=(-o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$KNOWN_HOSTS_FILE")
echo "Подлинность хоста: сверяется по DEPLOY_KNOWN_HOSTS."
else
SSH_HOST_OPTS=(-o StrictHostKeyChecking=no)
echo "::warning title=SSH без проверки подлинности хоста::DEPLOY_KNOWN_HOSTS не задан — ключ прод-хоста НЕ проверяется (#3029). После переезда на Selectel (#3057) этот SSH идёт через интернет: задайте секрет через ssh-keyscan -p <порт> <хост>."
echo "################################################################"
echo "# ВНИМАНИЕ (#3029): DEPLOY_KNOWN_HOSTS не задан. #"
echo "# Подлинность прод-хоста НЕ проверяется — канал уязвим к MITM. #"
echo "# Задать секрет: ssh-keyscan -p <порт> <хост> #"
echo "################################################################"
fi
# Try to read the marker file from the VPS. Suppress errors — if host is
# unreachable or file missing, RAW_SHA will be empty.
# #3029: сюда же попадает и расхождение ключа хоста. Шаг fail-safe по
# построению — пустой RAW_SHA уводит в build-all ниже, — поэтому цена
# ошибки в known_hosts здесь максимум лишняя полная пересборка, а не
# сорванный деплой. Это и делает включение проверки безопасным.
SSH_ERR_FILE=$(mktemp)
RAW_SHA=$(ssh -i "$SSH_KEY_FILE" \
-o StrictHostKeyChecking=no \
"${SSH_HOST_OPTS[@]}" \
-o ConnectTimeout=10 \
-p "${DEPLOY_PORT:-22}" \
"${DEPLOY_USER}@${DEPLOY_HOST}" \
"cat /opt/gendesign/.tradein-deployed-sha 2>/dev/null || true" \
2>/dev/null || true)
2>"$SSH_ERR_FILE" || true)
RAW_SHA=$(echo "$RAW_SHA" | tr -d '[:space:]')
rm -f "$SSH_KEY_FILE"
# #3029: раньше stderr уходил в /dev/null, и «Host key verification
# failed» был неотличим от недоступного хоста — неверный known_hosts
# молча читался как штатный фолбэк на полную пересборку. Теперь эта
# причина называется отдельно. Fail-safe шага не меняется: RAW_SHA всё
# равно пуст, ветка build-all включается ровно как прежде.
if grep -qiE 'host key verification failed|remote host identification has changed|no matching host key' "$SSH_ERR_FILE"; then
echo '::warning title=Ключ хоста не сошёлся с DEPLOY_KNOWN_HOSTS::Проверка подлинности хоста НЕ прошла (#3029) — это не «хост недоступен», а расхождение known_hosts: сменился ключ хоста либо переехал адрес (#3057). Обновите секрет DEPLOY_KNOWN_HOSTS через ssh-keyscan -p <порт> <хост>. Шаг fail-safe: сейчас включится полная пересборка.'
echo "Причина пустого RAW_SHA: проверка ключа хоста, а не недоступность."
sed 's/^/ ssh: /' "$SSH_ERR_FILE"
elif [ -s "$SSH_ERR_FILE" ]; then
echo "ssh stderr (не про ключ хоста — хост недоступен либо иная ошибка):"
sed 's/^/ ssh: /' "$SSH_ERR_FILE"
fi
rm -f "$SSH_KEY_FILE" "$KNOWN_HOSTS_FILE" "$SSH_ERR_FILE"
# Validate: non-empty, looks like a git SHA, and is an ancestor of HEAD.
DEPLOYED_SHA=""
@ -176,6 +263,11 @@ jobs:
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
steps:
- uses: actions/checkout@v4
with:
# Как в ci-tradein.yml: tests/test_migration_numbering.py (#2683) требует
# origin/main и общего предка с HEAD, а даёт их именно depth=0 — при
# depth=1 checkout тянет один sha и ветки main в клоне нет.
fetch-depth: 0
- name: Install uv
# Официальный standalone-инсталлер: системный `pip install uv` на
@ -227,10 +319,49 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push tradein-backend
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
# валит весь шаг ДО push нового образа — деплой тогда молча
# пропускается (#2841), хотя собрать образ можно и без кеша. Ретрай
# без cache-from — ниже.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
@ -238,6 +369,8 @@ jobs:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
@ -251,6 +384,54 @@ jobs:
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Retry build & push tradein-backend без кеша (битый buildcache, #2841)
# cache-from опущен (источник падения), cache-to ОСТАВЛЕН (ревью #2841 R2,
# issue #2): успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max) — это и есть самолечение. Без cache-to здесь порча
# оставалась навсегда, следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Проверить, что tradein-backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
# проверяет РЕАЛЬНОЕ состояние registry через buildx (уже настроен выше).
# Если act_runner не заполняет outcome, ретрай выше молча НЕ побежит при
# упавшем build — этот шаг единственный это заметит: манифеста с этим SHA
# не будет → шаг падает БЕЗ continue-on-error → job честно FAILURE → deploy
# ниже пропускается вместо накатки старого :latest на прод.
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-frontend:
runs-on: ubuntu-latest
needs: changes
@ -267,8 +448,41 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
@ -279,10 +493,16 @@ jobs:
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
- name: Build & push tradein-frontend
# id + continue-on-error — см. tradein-backend (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
# basePath=/trade-in baked-in во время build (Next.js)
# NB (#2205): НЕ передаём NEXT_PUBLIC_ENABLE_PREVIEW — preview-роут
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
@ -303,6 +523,49 @@ jobs:
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841)
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max), это и есть самолечение.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Проверить, что tradein-frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-browser:
runs-on: ubuntu-latest
needs: changes
@ -321,20 +584,96 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push tradein-browser
# id + continue-on-error — см. tradein-backend выше (#2841): битый blob
# в удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/browser
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BROWSER }}:latest
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
- name: Retry build & push tradein-browser без кеша (битый buildcache, #2841)
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max), это и есть самолечение.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/browser
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BROWSER }}:latest
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
- name: Проверить, что tradein-browser:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
run: docker buildx imagetools inspect ${{ env.IMAGE_BROWSER }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
deploy:
runs-on: ubuntu-latest
needs: [changes, test, build-backend, build-frontend, build-browser]
@ -348,6 +687,46 @@ jobs:
needs.build-frontend.result != 'failure' &&
needs.build-browser.result != 'failure'
steps:
# ── #2950: :latest не старше последнего коммита по компоненту ─────────────
# См. комментарий к тому же шагу в deploy.yml и scripts/check-latest-image-revision.sh.
# Пути = фильтры job'а changes (backend/frontend/browser + infra), которые
# приводят к сборке соответствующего образа.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Login to GHCR — для imagetools inspect гарда (#2950)
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Гард свежести :latest (#2950)
run: |
INFRA="tradein-mvp/docker-compose.prod.yml tradein-mvp/deploy .forgejo/workflows/deploy-tradein.yml"
scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- tradein-mvp/backend tradein-mvp/packages/scraper-kit tradein-mvp/VERSION $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- tradein-mvp/frontend tradein-mvp/VERSION tradein-mvp/CHANGELOG.md $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_BROWSER" 900 -- tradein-mvp/browser $INFRA
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
# предупреждение исчезнет само.
- name: Подлинность хоста — статус проверки (#3029)
env:
HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
run: |
set -euo pipefail
if [ -n "${HOST_FINGERPRINT:-}" ]; then
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
else
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029): при пустом отпечатке easyssh-proxy молча оставляет InsecureIgnoreHostKey. По этой же SSH-сессии едут GHCR_PAT и секреты Trade-In вместе с DEPLOY_SSH_KEY. После переезда на Selectel (#3057) канал идёт через интернет. Как снять отпечаток — см. шапку этого файла.'
echo '###############################################################'
echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.'
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
echo '# Как снять отпечаток — см. шапку этого файла.'
echo '###############################################################'
fi
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
env:
@ -377,9 +756,48 @@ jobs:
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT }}
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA
script: |
set -euo pipefail
# #2950: взаимное исключение докер-секции двух прод-деплоев.
# Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в ОДИН докер-демон —
# стеки gendesign-*, tradein-* и сам forgejo-runner живут на этой VM.
# Каждый в конце делает `docker image prune -af`, и прун одного сносит
# leases ещё не доехавшего `compose pull` другого:
# unable to lease content: lease does not exist: not found
# 20.08 так и вышло: run 8083 упал за 5с (прун соседа отработал через
# 0.4с после обрыва пула), прод остался на старом коде.
#
# Секция `concurrency: deploy-prod` в шапке обоих workflow этого НЕ
# обеспечивает: на Forgejo 10.0.3 (gitea-1.22) workflow-level
# concurrency не исполняется — проверено, обе цепочки стартовали на
# одном коммите одновременно. Она оставлена как декларация, которая
# заработает после обновления Forgejo; сегодня работает вот этот лок.
#
# Лок держит живой потомок этого скрипта. Если ssh-сессия оборвётся,
# докер-команды на хосте продолжат работу — и лок продолжит их
# прикрывать, что и требуется. Ожидание ограничено: не дождались за
# 900с — падаем с внятным сообщением, а не молча ждём вечно.
exec 9>/var/lock/gendesign-docker-deploy.lock
# Сначала неблокирующая попытка — чтобы ОЖИДАНИЕ оставляло след в логе.
# Без этого работающий лок ненаблюдаем: flock при успехе молчит, и отличить
# «второй деплой дождался первого» от «они просто разошлись по времени»
# нельзя — а именно это и есть критерий приёмки #2950.
if flock -n 9; then
echo "→ докер-лок свободен, взят сразу"
else
echo "→ докер-лок занят соседним деплоем, жду (до 900с)…"
lock_wait_started=$(date +%s)
if ! flock -w 900 9; then
echo "ERROR: не дождался лока докер-деплоя за 900с."
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
exit 1
fi
echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания"
fi
cd /opt/gendesign
# repo уже clone'ен — origin = Forgejo. Подтягиваем последний main.
@ -398,6 +816,11 @@ jobs:
chmod 600 .env.runtime
set -a; source .env.runtime; set +a
# Re-assert +x на deploy-скриптах (#3005, по образцу deploy.yml ops/*.sh из #71).
# Cron зовёт backup-tradein-db.sh через `bash`, так что бит ему не нужен —
# но любой другой вызов сырым путём не должен зависеть от git-режима файла.
chmod +x deploy/*.sh 2>/dev/null || true
# External network для Caddy (он в основном gendesign-стеке)
docker network inspect gendesign_shared >/dev/null 2>&1 \
|| docker network create gendesign_shared
@ -405,8 +828,47 @@ jobs:
# Re-login to GHCR (PAT может быть rotated)
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
# ── Набор compose-файлов (#3059) ──────────────────────────────────
# docker-compose.selectel.yml закрепляет рабочий IP api.telegram.org
# через extra_hosts у backend и tgbot. Файл существовал с 23.08, но НИ
# ОДИН вызов ниже его не подключал — то есть первый же деплой на новом
# хосте поднял бы МЕРУ без закрепления, и бот с пересылкой алертов
# умерли бы молча: у api.telegram.org семь адресов, а с Selectel
# отвечает РОВНО ОДИН (149.154.167.220), и штатный резолвер отдаёт
# мёртвый.
#
# Подключается БЕЗУСЛОВНО, на обоих хостах. Замер 25.08 с Beget:
# 149.154.167.220 -> 302 за 0.18 с
# реальный Bot API /getMe -> {"ok":false,"error_code":401} — то есть
# отвечает именно Telegram, а не заглушка
# Условная логика «оверрайд только на Selectel» была бы лишней машинерией
# ради хоста, который через переезд перестанет быть продовым.
#
# NB: файл — оверрайд стека МЕРЫ (проект gendesign-tradein). Подмешивать
# его к стеку ПТИЦЫ нельзя: там нет сервиса tgbot, и compose отвергает
# весь проект ("has neither an image nor a build context"), а сервис
# backend есть в обоих — закрепление молча легло бы на бэкенд Птицы.
COMPOSE_FILES="-f docker-compose.prod.yml"
if [ -f docker-compose.selectel.yml ]; then
COMPOSE_FILES="$COMPOSE_FILES -f docker-compose.selectel.yml"
echo "→ compose-оверрайд: docker-compose.selectel.yml подключён (пин api.telegram.org)"
else
# ПАДАЕМ, а не предупреждаем. Файл git-tracked, а шагом выше сделан
# `git reset --hard origin/main` — значит его отсутствие означает не
# штатный сценарий, а поломку (удалили/переименовали, не поправив
# это место). Предупреждение в зелёном логе здесь было бы ровно тем
# классом тихого отказа, от которого защищает сама правка: деплой
# «успешен», а бот и пересылка алертов мертвы. Тот же принцип, что у
# health-check'ов #2214 ниже по файлу.
echo "ERROR: docker-compose.selectel.yml не найден в $(pwd)."
echo "ERROR: без него api.telegram.org не закреплён → tgbot и пересылка"
echo "ERROR: алертов умрут МОЛЧА (с Selectel отвечает 1 адрес из 7)."
echo "ERROR: если файл убран намеренно — снять и эту проверку тем же PR."
exit 1
fi
export IMAGE_TAG="$IMAGE_TAG"
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
docker compose -p gendesign-tradein $COMPOSE_FILES pull
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
@ -419,14 +881,32 @@ jobs:
# код на старой схеме». Откат = просто ничего не поднимали.
# (1) Только БД — чтобы прогнать миграции до нового app-кода.
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres
docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps postgres
# (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep).
#
# `-h 127.0.0.1` ОБЯЗАТЕЛЕН (#2990) — по тому же образцу, что уже в
# ci-tradein.yml:157. На пустом томе образ postgres поднимает
# ВРЕМЕННЫЙ сервер с listen_addresses='' на время прогона
# docker-entrypoint-initdb.d (сюда смонтирован весь
# backend/data/sql/*.sql, см. docker-compose.prod.yml). Этот временный
# сервер отвечает "accepting connections" по unix-сокету уже через
# пару секунд — а pg_isready БЕЗ -h ходит именно по сокету через
# `docker compose exec`. Проба зеленела посреди initdb, до того как
# цепочка миграций реально доехала до конца, и код ниже (детект
# baseline vs пустая БД) видел частично накаченную схему. TCP-порт
# 5432 открывается только когда initdb.d полностью отработал и
# postgres перезапустился как настоящий сервер — проба по 127.0.0.1
# зеленеет ровно тогда, когда БД реально готова.
#
# 90 попыток × 2с = до 3 минут: на пустом томе postgres прогоняет
# ВСЮ цепочку миграций (270+ файлов) внутри initdb, это медленнее,
# чем ожидание живого сервера на непустом томе (обычный деплой).
echo "→ Ожидание готовности postgres..."
pg_ready=""
for i in $(seq 1 30); do
for i in $(seq 1 90); do
if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
pg_isready -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then
pg_isready -h 127.0.0.1 -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then
pg_ready="yes"; break
fi
sleep 2
@ -444,8 +924,9 @@ jobs:
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
# #2216) glob'ом не подхватывается.
# ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало
# до main, здесь и применяется, а имя закрепляется в _schema_migrations.
# На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683).
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
# первый deploy после внедрения tracking на уже-наполненной prod-БД
@ -456,6 +937,35 @@ jobs:
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT to_regclass('public._schema_migrations') IS NOT NULL;" | tr -d '[:space:]')
# Sentinel (#2990): отсутствия _schema_migrations НЕДОСТАТОЧНО, чтобы
# заключить «схема уже накачена». Ровно два разных состояния дают одно
# и то же отсутствие таблицы:
# 1) наполненный прод до внедрения tracking → baseline корректен;
# 2) ПУСТАЯ БД на новом сервере → baseline пометил бы все миграции
# применёнными, ни одной не прогнав, и деплой уехал бы зелёным
# на пустой схеме. Отказ тихий и обнаружился бы уже под нагрузкой.
#
# Раньше различали одной живой таблицей listings — она создаётся
# миграцией 002, то есть почти в САМОМ НАЧАЛЕ цепочки. Этого мало: на
# пустом томе до фикса ожидания готовности (см. выше, -h 127.0.0.1)
# проба зеленела ПОСРЕДИ initdb, когда listings уже создан, а хвост
# цепочки — ещё нет; результат — тихий baseline недокачанной схемы.
# Фикс готовности эту гонку убирает (TCP открывается только после
# полного прохода initdb.d), но сентинел всё равно проверяем по ОБОИМ
# концам цепочки как defense-in-depth: если голова и хвост когда-нибудь
# разъедутся — это тот самый гоночный симптом, и его надо ловить явно,
# а не гадать.
#
# Хвост — houses_geog_gist_idx, индекс из миграции 270 (#2997, самая
# свежая на момент правки #2990). При добавлении новых миграций после
# 270 обнови этот сентинел на объект из новой последней миграции.
schema_head_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT to_regclass('public.listings') IS NOT NULL;" | tr -d '[:space:]')
schema_tail_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT to_regclass('public.houses_geog_gist_idx') IS NOT NULL;" | tr -d '[:space:]')
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on -c "
CREATE TABLE IF NOT EXISTS _schema_migrations (
@ -464,12 +974,23 @@ jobs:
);
"
if [ "$migrations_table_existed" != "t" ]; then
# BASELINE: таблицы не было → seed ВСЕ текущие миграции как applied
# БЕЗ их прогона. prod уже работает на этой схеме; помечаем её
# текущим состоянием, чтобы под строгий gate попадали только НОВЫЕ
# (077+) миграции. INSERT ... ON CONFLICT DO NOTHING — идемпотентно.
echo "→ _schema_migrations отсутствовала — baseline существующих миграций (без прогона)"
if [ "$migrations_table_existed" != "t" ] && [ "$schema_head_present" != "t" ] && [ "$schema_tail_present" != "t" ]; then
# ПУСТАЯ БД: ни головы, ни хвоста цепочки — baseline пропускаем
# намеренно. Цикл ниже применит всю цепочку с нуля под
# ON_ERROR_STOP — это и есть штатный путь чистого старта на новом
# сервере (initdb.d уже должен был всё применить сам; этот цикл —
# подстраховка на случай, если монтирование почему-то не сработало).
echo "→ БД пуста (нет ни _schema_migrations, ни listings, ни хвоста цепочки) — baseline ПРОПУЩЕН,"
echo " вся цепочка миграций будет применена циклом ниже."
elif [ "$migrations_table_existed" != "t" ] && [ "$schema_head_present" = "t" ] && [ "$schema_tail_present" = "t" ]; then
# BASELINE: таблицы не было, но и голова, и хвост цепочки на месте →
# seed ВСЕ текущие миграции как applied БЕЗ их прогона. Это либо
# наполненный прод до внедрения tracking, либо чистый старт, где
# initdb.d уже честно доехал до конца сам (ожидание готовности это
# теперь гарантирует). В обоих случаях повторный прогон не нужен —
# помечаем текущим состоянием, чтобы под строгий gate попадали
# только НОВЫЕ миграции. INSERT ... ON CONFLICT DO NOTHING — идемпотентно.
echo "→ _schema_migrations отсутствовала, но схема на месте целиком (голова + хвост) — baseline существующих миграций (без прогона)"
for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do
fname=$(basename "$sql_file")
echo " baseline: $fname"
@ -478,6 +999,21 @@ jobs:
"INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
done
echo "Baseline complete — existing schema marked as applied."
elif [ "$migrations_table_existed" != "t" ]; then
# НЕОДНОЗНАЧНО: ровно один из концов цепочки на месте, второго нет,
# а _schema_migrations отсутствует. Это и есть симптом гонки
# готовности (см. комментарий выше) — молча баселайнить тут нельзя:
# либо схема реально недокачана (baseline пометил бы недостающий
# хвост как применённый без прогона), либо и то и другое пусто, но
# тогда tail-check не должен был сработать. Падаем громко, а не
# гадаем — новый app-код НЕ поднят, старые контейнеры не тронуты.
echo "ERROR: неоднозначное состояние схемы — _schema_migrations нет,"
echo " listings присутствует=${schema_head_present}, houses_geog_gist_idx присутствует=${schema_tail_present}."
echo " Похоже на недокачанную схему (гонка готовности postgres) —"
echo " baseline пропущен намеренно, чтобы не пометить недостающие"
echo " миграции применёнными без прогона. Прерываю деплой; нужен"
echo " ручной разбор состояния тома перед повторным запуском."
exit 1
fi
for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do
@ -662,7 +1198,7 @@ jobs:
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
fi
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps $SERVICES
if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
@ -869,3 +1405,88 @@ jobs:
# The changes job reads this file on the next run to compute cumulative diff.
echo "$GITHUB_SHA" > /opt/gendesign/.tradein-deployed-sha
echo "→ Deployed SHA marker updated: $GITHUB_SHA"
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда `test` или один из build-* падает (например,
# битый blob в buildcache роняет `docker/build-push-action` — до ретрая
# выше, #2841). skipped-job не красит прогон явным «FAILED» так, чтобы это
# было видно на первый взгляд — итог выглядит зелёным/нейтральным, хотя
# tradein-стек на проде не обновился. Эта job бежит ВСЕГДА (`if: always()`,
# кроме отмены прогона) и сама падает, если deploy не завершился success —
# неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/
# health-check/сверка образов #2679). Красная точка встаёт именно там, где
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
#
# ЗАЧЕМ ЗДЕСЬ. scripts/smoke-mera-perimeter.sh — единственная проверка, которая
# видит периметр целиком (короткие адреса, 301 с длинных, публичный API,
# закрытость B2B-путей на публичном домене). До этого PR он запускался только
# по cron'у 06:17 UTC, то есть регресс жил до суток и находил его либо ночной
# прогон, либо владелец. Для правки, чья логика живёт в конфиге прокси, это
# единственный настоящий гейт — и он был асинхронным.
#
# ПОЧЕМУ ОТДЕЛЬНЫЙ JOB, А НЕ ШАГ В deploy. Вердикты разные: «выкатили» и
# «периметр цел» — два разных факта, и красный смоук не должен читаться как
# неудавшийся деплой. Деплой к этому моменту уже прошёл; смоук говорит, что
# именно получилось.
#
# ПОЧЕМУ ДУБЛИРУЕТСЯ В ДВУХ ПАЙПЛАЙНАХ. Конфиг прокси (deploy.yml) и фронт
# МЕРЫ (deploy-tradein.yml) едут раздельно, и сломать периметр может каждый.
# `workflow_call` под act_runner не гарантирован, поэтому 20 строк повторены
# осознанно вместо зависимости, которая может молча не сработать.
perimeter-smoke:
runs-on: ubuntu-latest
needs: deploy
# Только после РЕАЛЬНОЙ выкатки: при skipped/failed проверять нечего, а
# красный смоук поверх несостоявшегося деплоя увёл бы разбор не туда.
if: always() && needs.deploy.result == 'success'
timeout-minutes: 6
steps:
- uses: actions/checkout@v4
- name: Дождаться, пока периметр отвечает после пересоздания контейнеров
# `up -d --force-recreate` возвращает управление раньше, чем бэкенд
# начинает отвечать. Без ожидания смоук ловил бы не регресс, а гонку.
# Ждём ДВА признака: лэндинг (Caddy + фронт) и API (бэкенд поднялся) —
# одного мало, Caddy отвечает раньше апстрима.
run: |
set -uo pipefail
for i in $(seq 1 30); do
page=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://meraocenka.ru/ || true)
api=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://gendsgn.ru/trade-in/api/v1/me || true)
if [ "$page" = "200" ] && [ "$api" = "401" ]; then
echo "периметр отвечает (попытка $i): лэндинг $page, API $api"
exit 0
fi
echo "ждём готовности, попытка $i/30: лэндинг '${page:-нет ответа}', API '${api:-нет ответа}'"
sleep 5
done
# НЕ падаем здесь: вердикт должен вынести смоук, а не таймаут ожидания.
# Иначе «не успел подняться» и «периметр сломан» слились бы в один
# красный шаг без разбора.
echo "::warning::за 150 с периметр так и не ответил ожидаемо — запускаем смоук, его вывод и будет диагнозом"
- name: Смоук периметра
run: |
chmod +x scripts/smoke-mera-perimeter.sh
./scripts/smoke-mera-perimeter.sh
deploy-status:
runs-on: ubuntu-latest
needs: [test, build-backend, build-frontend, build-browser, deploy]
if: always() && !cancelled()
steps:
- name: Итог прогона — деплой обязан быть success, не skipped/failure
run: |
echo "test: ${{ needs.test.result }}"
echo "build-backend: ${{ needs.build-backend.result }}"
echo "build-frontend: ${{ needs.build-frontend.result }}"
echo "build-browser: ${{ needs.build-browser.result }}"
echo "deploy: ${{ needs.deploy.result }}"
if [ "${{ needs.deploy.result }}" != "success" ]; then
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
"Смотри логи test/build-backend/build-frontend/build-browser/deploy выше."
exit 1
fi
echo "✓ деплой прошёл успешно"

View file

@ -4,6 +4,49 @@ name: Deploy
# Migration 2026-05-16: GitHub → Forgejo (git.gendsgn.ru)
# Builds images on Forgejo runner, pushes to ghcr.io (GitHub Container Registry),
# SSH-deploys to Beget VPS (46.173.16.127).
# ── ПОДЛИННОСТЬ ХОСТА В ДЕПЛОЕ (#3029) ───────────────────────────────────────
#
# ЗАЧЕМ ИМЕННО СЕЙЧАС. Пока раннер и цель деплоя — одна и та же машина (Beget,
# 46.173.16.127), SSH фактически не покидает петлю, и цена непроверенного ключа
# хоста была низкой. После переезда 30.08 (#3057) цель уезжает на Selectel
# (188.246.224.93), а Forgejo и раннеры ОСТАЮТСЯ на Beget — тот же самый SSH
# становится междоузловым и идёт через интернет. По этой сессии через `envs:`
# едут GHCR_PAT, OPENAI_API_KEY, OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN и сам
# DEPLOY_SSH_KEY: без проверки ключа хоста MITM на маршруте забирает их разом,
# причём молча — деплой при этом выглядит зелёным.
#
# ЧТО ЗАДАТЬ: секрет DEPLOY_SSH_FINGERPRINT — SHA256-отпечаток ХОСТОВОГО ключа
# (не деплой-ключа!). Снять с любой машины:
# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}'
# Значение кладётся ЦЕЛИКОМ, вместе с префиксом: `SHA256:xxxxxxxx…`.
# СРАВНЕНИЕ ПОБАЙТОВОЕ и без trim. Лишний перевод строки или пробел, прилипший
# при копипасте в UI секретов, делает значение непустым — проверка ВКЛЮЧАЕТСЯ и
# все ssh-шаги падают с `host key fingerprint mismatch`. Вставлять без хвостов.
#
# ПОЧЕМУ ecdsa, А НЕ ed25519 — это грабли, на которые легко наступить.
# appleboy/ssh-action@v1.0.3 = drone-ssh 1.7.3 на easyssh-proxy v1.5.0 поверх
# golang.org/x/crypto v0.17.0. HostKeyAlgorithms клиент не задаёт, значит берётся
# дефолт x/crypto, а там (ssh/common.go, supportedHostKeyAlgos) ecdsa-sha2-nistp256
# стоит ВЫШЕ ssh-ed25519 и rsa. Со стоковым OpenSSH согласуется ECDSA — отпечаток
# ed25519 просто не совпадёт, и деплой встанет с `host key fingerprint mismatch`.
#
# ПОЧЕМУ ЭТО НЕ ЛОМАЕТ СЕГОДНЯШНИЙ ДЕПЛОЙ. Незаданный секрет разворачивается в
# пустую строку, а easyssh-proxy v1.5.0 (easyssh.go:178) делает буквально:
# hostKeyCallback := ssh.InsecureIgnoreHostKey()
# if config.Fingerprint != "" { …сверять отпечаток… }
# То есть пустой fingerprint = поведение до этого PR бит в бит; сам drone-ssh
# описывает флаг как "default is to skip verification". Проверка включается ОДНОЙ
# настройкой — заведением секрета. Тот же приём, что уже применён в репо:
# deploy-infra.yml инертен, пока пуст INFRA_DEPLOY_HOST (#3059); CADDY_SITES;
# fail-open у TRADEIN_INTERNAL_AUTH_SECRET (#2989). Ничего не удаляем и не
# срезаем — только добавляем, пока конвейер не проехал на новый хост.
#
# ВНИМАНИЕ ПРИ ПЕРЕЕЗДЕ: сменится хост — сменится и отпечаток. Секрет надо
# обновить В ТОТ ЖЕ МОМЕНТ, когда DEPLOY_HOST начнёт указывать на Selectel,
# иначе деплой встанет. Это осознанный размен: лучше громкий отказ, чем тихий
# коннект не туда.
# ─────────────────────────────────────────────────────────────────────────────
on:
push:
branches: [main]
@ -20,8 +63,46 @@ on:
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
# до прода до следующего чужого коммита в backend/.
- "ops/db-bootstrap/**"
# RBAC roles config (auth/roles.yaml, bind-mounted read-only ТОЛЬКО в backend —
# см. docker-compose.prod.yml; worker монтирует лишь ./data и ./reports).
# app.core.auth кэширует парсинг на весь lifetime процесса (@lru_cache) — без
# этого триггера правка ролей вступала бы в силу в случайный момент, только на
# следующий чужой деплой (`up -d --force-recreate --no-deps backend worker beat`
# ниже сбрасывает кэш перезапуском процесса; сам файл в образ не запекается,
# ребилда картинок для этого не нужно).
- "auth/**"
# То же самое, ровно тот же класс бага (#2887): скрипт запускается на VM
# по cron из /opt/gendesign/ops/, куда попадает только через `git reset --hard`
# шага деплоя. Без этой строки правка скрипта лежала бы в main, а cron месяцами
# исполнял бы старую версию — молча и без единого сигнала.
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
# backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это
# снова забыли бы (см. как этот самый комментарий выше был точечным про
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.
- "ops/*.sh"
# Эталонные crontab'ы двух хостов (#3059). Деплоем не исполняются, но
# должны физически лежать в /opt/gendesign — иначе их нечем будет
# установить в окне: `crontab /opt/gendesign/ops/crontab-<хост>.cron`.
- "ops/*.cron"
workflow_dispatch:
# #2950: ОБЩАЯ группа с deploy-tradein.yml — не опечатка и не копипаста.
# Оба деплоя ходят по SSH в ОДИН докер-демон (стеки gendesign-* и tradein-*
# плюс сам forgejo-runner живут на одной VM), и `docker image prune -af` одного
# сносит leases ещё не доехавшего `compose pull` другого:
# unable to lease content: lease does not exist: not found
# 20.08 так и вышло: run 8083 упал за 5с — прун соседнего деплоя отработал через
# 0.4с после обрыва пула. Прод остался на старом коде, при том что голова main
# показывала success (зелёным был чужой, Trade-In'овый деплой той же головы).
# Разные группы + cancel-in-progress: false не спасают: false сериализует раны
# ВНУТРИ группы, а гонка была МЕЖДУ группами.
# Цена: деплои ждут друг друга целиком, вместе с билдами (~6 мин). Осознанно:
# host-lock (flock) сериализовал бы только докер-секцию, но у него своя отказная
# мода — дочерний процесс наследует fd лока и при аварийной смерти job'а лок
# залипает (проверено на хосте: после kill -9 лок остался занят). Сериализацию
# гарантирует планировщик Forgejo, залипать там нечему.
concurrency:
group: deploy-prod
cancel-in-progress: false
@ -38,6 +119,12 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
infra: ${{ steps.filter.outputs.infra }}
# #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он
# включает и compose, и сам workflow, где полный деплой обязателен.
# `github.event_name == 'push'` первым множителем НАМЕРЕННО: на
# workflow_dispatch у paths-filter нет диффа, и любой его ответ не должен
# уметь отключить сборку — ручной прогон обязан оставаться полным.
caddy_only: ${{ github.event_name == 'push' && steps.filter.outputs.caddy == 'true' && steps.filter.outputs.non_caddy == 'false' }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@ -54,14 +141,28 @@ jobs:
- 'Caddyfile'
- 'caddy/**'
- '.forgejo/workflows/deploy.yml'
# Пара фильтров для «правка ТОЛЬКО прокси» (#2916). Одного `caddy`
# мало: он true и когда вместе с конфигом приехал бэкенд — тогда
# нужен обычный полный деплой. `non_caddy` матчит ВСЁ остальное,
# и быстрый путь включается лишь когда он false.
caddy:
- 'Caddyfile'
- 'caddy/**'
non_caddy:
- '**'
- '!Caddyfile'
- '!caddy/**'
build-backend:
runs-on: ubuntu-latest
needs: changes
if: |
needs.changes.outputs.backend == 'true' ||
needs.changes.outputs.infra == 'true' ||
github.event_name == 'workflow_dispatch'
needs.changes.outputs.caddy_only != 'true' &&
(
needs.changes.outputs.backend == 'true' ||
needs.changes.outputs.infra == 'true' ||
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v4
@ -71,28 +172,121 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push backend (lean — без Chromium)
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
# (registry cache, не local) валит весь шаг ДО push нового образа —
# деплой тогда молча пропускается (#2841), хотя код собрать можно, просто
# без кеша. cache-from нефатален: при падении ретраим БЕЗ него ниже.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Retry build & push backend без кеша (битый buildcache, #2841)
# cache-from опущен (источник падения), а cache-to ОСТАВЛЕН: успешный
# ретрай пушит свежие слои в buildcache-тег и тем самым сам перезаписывает
# битый blob (mode=max — полная перезапись манифеста). Раньше cache-to был
# опущен и здесь тоже — но следующий обычный прогон опять получает cache-from
# на детерминированно битый тег и падает СНОВА: самолечения не было НИКОГДА
# (ревью #2841 R2, issue #2). Если и retry упадёт — шаг красный БЕЗ
# continue-on-error, job честно FAILURE, и deploy ниже корректно
# пропускается (уже настоящая причина, не кеш).
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Проверить, что backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
# проверяет РЕАЛЬНОЕ состояние registry напрямую через buildx (уже настроен
# выше). Если act_runner не заполняет outcome (не проверено живым прогоном,
# см. ревью), ретрай выше молча НЕ побежит при упавшем build, а этот шаг —
# единственный, кто это заметит: манифеста с этим SHA не будет → шаг падает
# БЕЗ continue-on-error → job честно FAILURE → deploy ниже пропускается
# вместо накатки старого :latest на прод.
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-worker:
runs-on: ubuntu-latest
needs: changes
if: |
needs.changes.outputs.backend == 'true' ||
needs.changes.outputs.infra == 'true' ||
github.event_name == 'workflow_dispatch'
needs.changes.outputs.caddy_only != 'true' &&
(
needs.changes.outputs.backend == 'true' ||
needs.changes.outputs.infra == 'true' ||
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v4
@ -102,28 +296,111 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push worker (с Chromium для Playwright)
# id + continue-on-error — см. build-backend выше (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner-with-chromium
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_WORKER }}:latest
${{ env.IMAGE_WORKER }}:${{ github.sha }}
- name: Retry build & push worker без кеша (битый buildcache, #2841)
# См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН —
# успешный ретрай перезаписывает битый buildcache-тег своими слоями
# (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась
# навсегда — следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner-with-chromium
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_WORKER }}:latest
${{ env.IMAGE_WORKER }}:${{ github.sha }}
- name: Проверить, что worker:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. backend выше — не полагается на steps.build.outcome раннера, проверяет
# реальное состояние registry, чтобы молча пропущенный ретрай (если outcome
# не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом.
run: docker buildx imagetools inspect ${{ env.IMAGE_WORKER }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-frontend:
runs-on: ubuntu-latest
needs: changes
if: |
needs.changes.outputs.frontend == 'true' ||
needs.changes.outputs.infra == 'true' ||
github.event_name == 'workflow_dispatch'
needs.changes.outputs.caddy_only != 'true' &&
(
needs.changes.outputs.frontend == 'true' ||
needs.changes.outputs.infra == 'true' ||
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v4
@ -133,14 +410,53 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push frontend
# id + continue-on-error — см. build-backend выше (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }}
NEXT_PUBLIC_ENVIRONMENT=production
@ -150,16 +466,105 @@ jobs:
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Retry build & push frontend без кеша (битый buildcache, #2841)
# См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН —
# успешный ретрай перезаписывает битый buildcache-тег своими слоями
# (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась
# навсегда — следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }}
NEXT_PUBLIC_ENVIRONMENT=production
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Проверить, что frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. backend выше — не полагается на steps.build.outcome раннера, проверяет
# реальное состояние registry, чтобы молча пропущенный ретрай (если outcome
# не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом.
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
deploy:
runs-on: ubuntu-latest
needs: [changes, build-backend, build-worker, build-frontend]
if: |
always() &&
!cancelled() &&
needs.changes.outputs.caddy_only != 'true' &&
needs.build-backend.result != 'failure' &&
needs.build-worker.result != 'failure' &&
needs.build-frontend.result != 'failure'
steps:
# ── #2950: :latest не старше последнего коммита по компоненту ─────────────
# Forgejo отменяет ещё не стартовавший deploy предыдущего run'а этой группы,
# а следующий run (например ops-only, билды пропущены) катит :latest как есть.
# 21.08.2026 10:35 прод получил новый код только потому, что билды
# предшественника успели за 70 с до pull'а. Гард читает метку ревизии из
# образа в registry (labels на build-push выше), ждёт билд предшественника
# до 15 мин и иначе падает громко — вместо тихого отката при зелёной голове.
# Пути = фильтры job'а changes, которые приводят к сборке (caddy_only не
# собирает — Caddyfile/caddy/** намеренно не в списке).
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Login to GHCR — для imagetools inspect гарда (#2950)
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Гард свежести :latest (#2950)
run: |
INFRA="docker-compose.prod.yml .forgejo/workflows/deploy.yml"
scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- backend data/sql $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_WORKER" 900 -- backend data/sql $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- frontend $INFRA
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
# предупреждение исчезнет само.
- name: Подлинность хоста — статус проверки (#3029)
env:
HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
run: |
set -euo pipefail
if [ -n "${HOST_FINGERPRINT:-}" ]; then
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
else
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029): при пустом отпечатке easyssh-proxy молча оставляет InsecureIgnoreHostKey. По этой же SSH-сессии едут GHCR_PAT, OPENAI_API_KEY, OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN и сам DEPLOY_SSH_KEY. После переезда на Selectel (#3057) канал идёт через интернет — MITM забирает их разом, а деплой остаётся зелёным. Как снять отпечаток — см. шапку deploy.yml.'
echo '###############################################################'
echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.'
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
echo '# Как снять отпечаток — см. шапку этого файла.'
echo '###############################################################'
fi
- name: Deploy to VM via SSH
uses: appleboy/ssh-action@v1.0.3
env:
@ -178,14 +583,58 @@ jobs:
# own-portfolio каннибализации. Non-sensitive (публичные id) → actions
# variable. UNSET → каннибализация отдаёт proxy (фича дормант).
OWN_DEVELOPER_IDS: ${{ vars.OWN_DEVELOPER_IDS }}
# #3029: guard на пересоздание worker'а во время активного скрап-прогона
# (временная заплатка до чекпоинтов #3074). Откат — переменная репозитория
# в 'off', без коммита. Non-sensitive → actions variable, не secret.
WORKER_RECREATE_GUARD: ${{ vars.WORKER_RECREATE_GUARD }}
WORKER_GUARD_MAX_SKIP_H: ${{ vars.WORKER_GUARD_MAX_SKIP_H }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT }}
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS,WORKER_RECREATE_GUARD,WORKER_GUARD_MAX_SKIP_H
script: |
set -euo pipefail
# #2950: взаимное исключение докер-секции двух прод-деплоев.
# Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в ОДИН докер-демон —
# стеки gendesign-*, tradein-* и сам forgejo-runner живут на этой VM.
# Каждый в конце делает `docker image prune -af`, и прун одного сносит
# leases ещё не доехавшего `compose pull` другого:
# unable to lease content: lease does not exist: not found
# 20.08 так и вышло: run 8083 упал за 5с (прун соседа отработал через
# 0.4с после обрыва пула), прод остался на старом коде.
#
# Секция `concurrency: deploy-prod` в шапке обоих workflow этого НЕ
# обеспечивает: на Forgejo 10.0.3 (gitea-1.22) workflow-level
# concurrency не исполняется — проверено, обе цепочки стартовали на
# одном коммите одновременно. Она оставлена как декларация, которая
# заработает после обновления Forgejo; сегодня работает вот этот лок.
#
# Лок держит живой потомок этого скрипта. Если ssh-сессия оборвётся,
# докер-команды на хосте продолжат работу — и лок продолжит их
# прикрывать, что и требуется. Ожидание ограничено: не дождались за
# 900с — падаем с внятным сообщением, а не молча ждём вечно.
exec 9>/var/lock/gendesign-docker-deploy.lock
# Сначала неблокирующая попытка — чтобы ОЖИДАНИЕ оставляло след в логе.
# Без этого работающий лок ненаблюдаем: flock при успехе молчит, и отличить
# «второй деплой дождался первого» от «они просто разошлись по времени»
# нельзя — а именно это и есть критерий приёмки #2950.
if flock -n 9; then
echo "→ докер-лок свободен, взят сразу"
else
echo "→ докер-лок занят соседним деплоем, жду (до 900с)…"
lock_wait_started=$(date +%s)
if ! flock -w 900 9; then
echo "ERROR: не дождался лока докер-деплоя за 900с."
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
exit 1
fi
echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания"
fi
cd /opt/gendesign
# Sync compose / Caddyfile / init scripts from the repo.
@ -281,6 +730,19 @@ jobs:
# Apply pending SQL migrations
set -a; source .env; set +a
# #3029: checkpoint по часам БД ДО миграций — worker-guard ниже (после
# force-recreate блока) сверяет его с applied_at, чтобы честно
# предупредить «worker на старом коде + новая схема», если recreate
# worker'а был пропущен именно в деплое, где данные схемы поменялись.
# `tr -d '[:space:]'` здесь СЛОМАН бы CAST ниже: NOW() отдаёт
# "2026-08-24 09:12:33+00" с пробелом ВНУТРИ значения (дата/время),
# который [:space:] тоже вырезает → "2026-08-2409:12:33+00" не
# парсится как timestamptz. Убираем только CR/LF (psql -tA не
# добавляет ведущих/хвостовых пробелов, только trailing \n).
DEPLOY_MIGRATIONS_START_TS="$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT NOW();" 2>/dev/null | tr -d '\r\n')" \
|| DEPLOY_MIGRATIONS_START_TS=""
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=on -c "
CREATE TABLE IF NOT EXISTS _schema_migrations (
@ -418,7 +880,17 @@ jobs:
# Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись.
docker compose -p gendesign -f docker-compose.prod.yml build glitchtip-auth-forwarder
docker compose -p gendesign -f docker-compose.prod.yml up -d
# #3029 fail-safe review fix: голый `up -d` без списка сервисов сам
# пересоздаёт ЛЮБОЙ сервис с изменившимся image — включая worker,
# ДО того как guard ниже (~896) успевает сравнить pulled vs running.
# К моменту проверки они уже совпадают (worker только что
# пересоздан этим самым up -d) → guard видит "не изменился" и
# печатает no-op, хотя worker уже убит и прогон уже потерян.
# Фикс: явно исключаем worker из этого bulk up -d — его recreate
# решается ТОЛЬКО guard-блоком ниже (строка ~977), который видит
# ещё не тронутый running_worker_image.
UP_SERVICES="$(docker compose -p gendesign -f docker-compose.prod.yml config --services | grep -v '^worker$')"
docker compose -p gendesign -f docker-compose.prod.yml up -d $UP_SERVICES
# Defense: ensure postgres is in gendesign_shared network for tradein FDW.
# `compose up -d` should detect networks: shared addition and recreate
@ -439,8 +911,86 @@ jobs:
# требуют --force-recreate — обычный `up -d` не перечитывает env_file
# если только image не сменился. На deploy где меняется только runtime
# без backend image change — без этого backend остаётся со старым DSN.
#
# #3029: worker исключён из безусловного recreate. Временная заплатка
# до чекпоинтов (#3074) — стиль (digest-сверка pulled vs running,
# $SERVICES) как SCRAPER_RECREATE в deploy-tradein.yml, но здесь
# расхождение digest после skip уходит в WARNING, не в exit 1 (там
# scraper не имеет второго потребителя схемы; здесь пропуск recreate
# worker'а может оставить его читать старую версию схемы — риск, а не
# ошибка деплоя). backend/beat пересоздаются безусловно, как раньше.
WORKER_SERVICES="backend beat"
if [ "${WORKER_RECREATE_GUARD:-on}" != "on" ]; then
echo "→ WORKER_RECREATE_GUARD=off — безусловное пересоздание worker'а (fallback на старое поведение)"
WORKER_SERVICES="$WORKER_SERVICES worker"
else
pulled_worker_image=$(docker image inspect -f '{{.Id}}' "ghcr.io/lekss361/gendesign-worker:$IMAGE_TAG" 2>/dev/null || echo "")
running_worker_image=$(docker inspect -f '{{.Image}}' "$(docker compose -p gendesign -f docker-compose.prod.yml ps -q worker)" 2>/dev/null || echo "")
if [ -z "$pulled_worker_image" ] || [ -z "$running_worker_image" ]; then
echo "WARNING (#3029): не удалось прочитать worker image id (pulled='$pulled_worker_image' running='$running_worker_image') — детект не отработал, fail-safe = пересоздаём worker как обычно."
WORKER_SERVICES="$WORKER_SERVICES worker"
elif [ "$pulled_worker_image" = "$running_worker_image" ]; then
echo "→ образ worker'а не изменился ($pulled_worker_image) — пересоздание и так no-op, worker в recreate"
WORKER_SERVICES="$WORKER_SERVICES worker"
else
# Оба трекера прогонов — строки в БД (lifecycle.py:108 kn_scrape_runs,
# lifecycle.py:267 objective_scrape_runs), литералы статуса сверены с
# кодом: 'running' в обеих таблицах. Анти-зомби: считаем только
# прогоны свежее WORKER_GUARD_MAX_SKIP_H часов (started_at /
# heartbeat_at) — иначе зависший навечно 'running' блокировал бы
# recreate worker'а бесконечно.
# NB: `assignment="$(...)" && next=...` (не отдельная строка) — под
# `set -euo pipefail` (шапка скрипта) присвоение, упавшее КАК
# ПОСЛЕДНЯЯ команда своего стейтмента, роняет весь деплой. Внутри
# AND-списка (не последним звеном) — нет, ошибка молча даёт пустой
# $kn_count/$obj_count, что и проверяем ниже. Тот же приём —
# deploy-tradein.yml psql_out/running_count.
guard_max_h="${WORKER_GUARD_MAX_SKIP_H:-6}"
kn_count=""
kn_out="$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
"SELECT COUNT(*) FROM kn_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval);" 2>/dev/null)" \
&& kn_count="$(printf '%s' "$kn_out" | tr -d '[:space:]')"
obj_count=""
obj_out="$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
"SELECT COUNT(*) FROM objective_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval);" 2>/dev/null)" \
&& obj_count="$(printf '%s' "$obj_out" | tr -d '[:space:]')"
if ! printf '%s' "$kn_count" | grep -qE '^[0-9]+$' \
|| ! printf '%s' "$obj_count" | grep -qE '^[0-9]+$'; then
echo "WARNING (#3029): не удалось прочитать running-прогоны (psql молчит/пусто/не число) — детект не отработал, fail-safe = пересоздаём worker как обычно."
WORKER_SERVICES="$WORKER_SERVICES worker"
else
total_running=$((kn_count + obj_count))
if [ "$total_running" -gt 0 ]; then
echo "!!! WORKER RECREATE SKIPPED (#3029) — ${total_running} running runs, worker остаётся на образе ${running_worker_image} !!!"
echo "WARNING (#3029): worker digest разошёлся с pulled — running=${running_worker_image} pulled=${pulled_worker_image}"
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
"SELECT 'kn run_id=' || run_id || ' started_at=' || started_at FROM kn_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval) ORDER BY started_at ASC;" 2>/dev/null || true
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
"SELECT 'objective run_id=' || run_id || ' started_at=' || started_at FROM objective_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval) ORDER BY started_at ASC;" 2>/dev/null || true
if [ -n "$DEPLOY_MIGRATIONS_START_TS" ]; then
migrations_since=""
migrations_since=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
"SELECT COUNT(*) FROM _schema_migrations WHERE applied_at > CAST('${DEPLOY_MIGRATIONS_START_TS}' AS timestamptz);" 2>/dev/null | tr -d '[:space:]') \
|| migrations_since=""
if printf '%s' "$migrations_since" | grep -qE '^[0-9]+$' && [ "$migrations_since" -gt 0 ]; then
echo "WARNING (#3029): WORKER НА СТАРОМ КОДЕ + НОВАЯ СХЕМА — в этом деплое применились ${migrations_since} data/sql/** миграций, а worker остался на старом образе. Нужен ручной recreate после прогона: docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps worker"
fi
fi
else
echo "→ активных прогонов (kn/objective) нет — worker пересоздаётся вместе с backend/beat"
WORKER_SERVICES="$WORKER_SERVICES worker"
fi
fi
fi
fi
docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps backend worker beat
--force-recreate --no-deps $WORKER_SERVICES
# Caddy: force-recreate чтобы подхватить изменения в Caddyfile
# И в особенности новые volume mounts из docker-compose.prod.yml
@ -467,8 +1017,195 @@ jobs:
docker image prune -af || true
docker builder prune -af || true
# Health check
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (см. #2214,
# уже сделано так в deploy-tradein.yml; ревью #2841 R2 issue #3).
# `curl ... && break` под set -e НЕ мог провалить скрипт: curl — не
# последняя команда &&-списка, а POSIX прямо освобождает от errexit
# все команды AND/OR-списка кроме последней. После 30 неуспешных
# попыток цикл завершался кодом последнего sleep (0) — скрипт тихо
# продолжался, деплой уходил success с мёртвым бэкендом.
healthy=""
for i in $(seq 1 30); do
curl -fsS http://localhost:8000/health && break
if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
healthy="yes"; break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ backend healthy на /health."
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда build падает (например, битый blob в
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).
# skipped-job НЕ красит прогон явным «FAILED» так, чтобы это было видно на
# первый взгляд — итог выглядит зелёным/нейтральным, хотя прод не обновился.
# Эта job бежит ВСЕГДА (`if: always()`, кроме отмены прогона) и сама падает,
# если deploy не завершился success — неважно, пропущен он (build упал) или
# упал сам (SSH/миграция/health-check). Красная точка встаёт именно там, где
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
# ── Быстрый путь: правка ТОЛЬКО конфига прокси (#2916) ────────────────────
#
# ЗАЧЕМ. `Caddyfile` лежит в фильтре `infra`, поэтому правка одной строки
# allowlist'а ради meraocenka.ru запускала полный деплой Site Finder:
# пересборку трёх образов, `git reset --hard` на боевой VM, применение ВСЕХ
# pending `data/sql/*.sql` в боевой БД gendsgn и `--force-recreate` бэкенда,
# воркера, beat и caddy. То есть радиус поражения правки, относящейся к
# чужому домену, — gendsgn.ru целиком, включая миграции продукта, который
# никто в этот момент катить не собирался.
#
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
# страница = новая строка allowlist'а.
#
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой
# осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы
# принять битый конфиг и оставил бы работать старый — на общем деплое это
# скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ
# применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой
# код → job краснеет, а домены продолжают обслуживаться старым конфигом.
# Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop
# и роняет ВСЕ домены сразу.
#
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
# вторая, уже против боевого файла после `git reset`.
deploy-caddy:
runs-on: ubuntu-latest
needs: changes
# Только push: на workflow_dispatch человек просит полный деплой, и
# подменять его перезагрузкой конфига нельзя.
if: github.event_name == 'push' && needs.changes.outputs.caddy_only == 'true'
steps:
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
# предупреждение исчезнет само.
- name: Подлинность хоста — статус проверки (#3029)
env:
HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
run: |
set -euo pipefail
if [ -n "${HOST_FINGERPRINT:-}" ]; then
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
else
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029). По этому каналу едет DEPLOY_SSH_KEY и выполняется git reset + перезагрузка Caddy на проде: MITM здесь переписывает конфиг прокси всех доменов. После переезда на Selectel (#3057) соединение идёт через интернет. Как снять отпечаток — см. шапку deploy.yml.'
echo '###############################################################'
echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.'
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
echo '# Как снять отпечаток — см. шапку этого файла.'
echo '###############################################################'
fi
- name: Синхронизировать конфиг и перезагрузить прокси
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT }}
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
script: |
set -euo pipefail
cd /opt/gendesign
git fetch origin main
git reset --hard origin/main
# Конфиг примонтирован read-only с хоста, пересборка не нужна —
# контейнер читает тот же файл, что только что обновил git.
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "✓ конфиг прокси перезагружен без пересборки и без миграций"
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
#
# ЗАЧЕМ ЗДЕСЬ. scripts/smoke-mera-perimeter.sh — единственная проверка, которая
# видит периметр целиком (короткие адреса, 301 с длинных, публичный API,
# закрытость B2B-путей на публичном домене). До этого PR он запускался только
# по cron'у 06:17 UTC, то есть регресс жил до суток и находил его либо ночной
# прогон, либо владелец. Для правки, чья логика живёт в конфиге прокси, это
# единственный настоящий гейт — и он был асинхронным.
#
# ПОЧЕМУ ОТДЕЛЬНЫЙ JOB, А НЕ ШАГ В deploy. Вердикты разные: «выкатили» и
# «периметр цел» — два разных факта, и красный смоук не должен читаться как
# неудавшийся деплой. Деплой к этому моменту уже прошёл; смоук говорит, что
# именно получилось.
#
# ПОЧЕМУ ДУБЛИРУЕТСЯ В ДВУХ ПАЙПЛАЙНАХ. Конфиг прокси (deploy.yml) и фронт
# МЕРЫ (deploy-tradein.yml) едут раздельно, и сломать периметр может каждый.
# `workflow_call` под act_runner не гарантирован, поэтому 20 строк повторены
# осознанно вместо зависимости, которая может молча не сработать.
perimeter-smoke:
runs-on: ubuntu-latest
needs: [deploy, deploy-caddy]
# Только после РЕАЛЬНОЙ выкатки: при skipped/failed проверять нечего, а
# красный смоук поверх несостоявшегося деплоя увёл бы разбор не туда.
# ЛЮБОЙ из двух путей (#2916): быстрый путь трогает как раз конфиг прокси,
# то есть ровно то, что смоук и проверяет — пропустить его там было бы
# хуже всего.
if: |
always() &&
(needs.deploy.result == 'success' || needs.deploy-caddy.result == 'success')
timeout-minutes: 6
steps:
- uses: actions/checkout@v4
- name: Дождаться, пока периметр отвечает после пересоздания контейнеров
# `up -d --force-recreate` возвращает управление раньше, чем бэкенд
# начинает отвечать. Без ожидания смоук ловил бы не регресс, а гонку.
# Ждём ДВА признака: лэндинг (Caddy + фронт) и API (бэкенд поднялся) —
# одного мало, Caddy отвечает раньше апстрима.
run: |
set -uo pipefail
for i in $(seq 1 30); do
page=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://meraocenka.ru/ || true)
api=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://gendsgn.ru/trade-in/api/v1/me || true)
if [ "$page" = "200" ] && [ "$api" = "401" ]; then
echo "периметр отвечает (попытка $i): лэндинг $page, API $api"
exit 0
fi
echo "ждём готовности, попытка $i/30: лэндинг '${page:-нет ответа}', API '${api:-нет ответа}'"
sleep 5
done
# НЕ падаем здесь: вердикт должен вынести смоук, а не таймаут ожидания.
# Иначе «не успел подняться» и «периметр сломан» слились бы в один
# красный шаг без разбора.
echo "::warning::за 150 с периметр так и не ответил ожидаемо — запускаем смоук, его вывод и будет диагнозом"
- name: Смоук периметра
run: |
chmod +x scripts/smoke-mera-perimeter.sh
./scripts/smoke-mera-perimeter.sh
deploy-status:
runs-on: ubuntu-latest
needs: [build-backend, build-worker, build-frontend, deploy, deploy-caddy]
if: always() && !cancelled()
steps:
- name: Итог прогона — выкатка обязана быть success, не skipped/failure
# #2916: путей выкатки теперь ДВА — полный деплой и быстрая перезагрузка
# конфига прокси. Успешен прогон, если сработал ЛЮБОЙ из них; ошибка —
# когда не сработал ни один. Требовать `deploy == success` как раньше
# значило бы красить каждую правку прокси, которая как раз прошла.
run: |
echo "build-backend: ${{ needs.build-backend.result }}"
echo "build-worker: ${{ needs.build-worker.result }}"
echo "build-frontend: ${{ needs.build-frontend.result }}"
echo "deploy: ${{ needs.deploy.result }}"
echo "deploy-caddy: ${{ needs.deploy-caddy.result }}"
if [ "${{ needs.deploy.result }}" = "success" ]; then
echo "✓ полный деплой прошёл успешно"
elif [ "${{ needs.deploy-caddy.result }}" = "success" ]; then
echo "✓ конфиг прокси перезагружен (быстрый путь, без пересборки и миграций)"
else
echo "::error::выкатка НЕ прошла ни одним путём" \
"(deploy=${{ needs.deploy.result }}," \
"deploy-caddy=${{ needs.deploy-caddy.result }})." \
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
"Смотри логи build-* / deploy / deploy-caddy выше."
exit 1
fi

View file

@ -18,6 +18,15 @@ on:
schedule:
# Раз в сутки, 06:17 UTC — вне пиков, время произвольное.
- cron: '17 6 * * *'
# #2917: правка самого смоука должна проверяться сразу, а не следующим утром.
# Проверки read-only (curl по публичным адресам), поэтому прогонять их на
# push в main безопасно и дёшево. Синтаксис скрипта отдельно гейтится в
# ci.yml на каждом PR — здесь проверяется уже поведение против прода.
push:
branches: [main]
paths:
- 'scripts/smoke-mera-perimeter.sh'
- '.forgejo/workflows/perimeter-smoke.yml'
concurrency:
group: perimeter-smoke-mera

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.sh text eol=lf

View file

@ -1,91 +0,0 @@
name: CI
on:
push:
branches:
- main
- 'feat/**'
- 'fix/**'
- 'refactor/**'
- 'chore/**'
- 'docs/**'
- 'perf/**'
- 'test/**'
- 'hotfix/**'
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_DB: gendesign
POSTGRES_USER: gendesign
POSTGRES_PASSWORD: gendesign
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U gendesign"
--health-interval 5s
--health-timeout 5s
--health-retries 10
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Set up Python
run: uv python install 3.12
- name: Install system deps for geo + WeasyPrint
run: |
sudo apt-get update
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0
- name: Install Python deps
run: uv sync
- name: Lint (ruff)
run: uv run ruff check .
- name: Type check (mypy strict on core)
run: |
uv run mypy \
app/services/generative \
app/services/site_finder/scorer.py
- name: Test (pytest)
run: uv run pytest -q
env:
DATABASE_URL: postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: npm ci || npm install
- run: npm run lint
- run: npm run type-check
- run: npm run build

4
.gitignore vendored
View file

@ -98,3 +98,7 @@ ds-bundle/
.design-sync/.cache/
.design-sync/learnings/
.design-sync/node_modules
# Боевой конфиг Alertmanager собирается на хосте из .tmpl (deploy-metrics.yml):
# содержит токен бота и идентификатор чата, поэтому в репозиторий не попадает.
ops/metrics/alertmanager/alertmanager.yml

View file

@ -26,8 +26,12 @@ repos:
- id: detect-private-key
# Python — ruff (lint + format) on backend/ + tradein-mvp/backend/
# #2864: rev ОБЯЗАН совпадать с версией ruff в backend/uv.lock и tradein-mvp/uv.lock
# (гейт backend/tests/test_2864_ruff_version_alignment.py). Иначе хук и
# `uv run ruff format` форматируют по-разному и играют в пинг-понг на каждом коммите.
# Бампить втроём: rev здесь + `ruff==X` в обоих pyproject.toml + `uv lock` в backend/ и tradein-mvp/.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.4
rev: v0.15.20
hooks:
- id: ruff
args: [--fix]

376
Caddyfile
View file

@ -31,354 +31,24 @@
# (тег деградирует в "(none)" — forwarder это уже обрабатывает gracefully, не падает).
# Событие basic_auth 401 (remote_ip / uri / method) по-прежнему уходит в GlitchTip.
gendsgn.ru {
encode zstd gzip
log {
output file /var/log/caddy/gendsgn.ru.log {
roll_size 50MiB
roll_keep 5
roll_keep_for 720h
}
format json
}
# Отдельный лог только для auth-событий.
# Forwarder (ops/glitchtip-auth-forwarder) читает именно этот файл.
# Retention 7 дней (меньше чем main log) — содержит plain Base64 credentials.
log auth_audit {
output file /var/log/caddy/auth_audit.log {
roll_size 10MiB
roll_keep 3
roll_keep_for 168h
}
format json
}
route {
# /health и /preview/* — public, без auth, short-circuit.
handle /health {
reverse_proxy backend:8000
}
# Static HTML mockups для review (audit alternatives).
# Public access — без auth (по запросу 2026-05-17).
handle_path /preview/* {
root * /srv/preview
file_server browse
}
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
# «денежного экрана» без бэкенда → axe/lighthouse гоняются без креды.
# Реальных клиентских данных нет (статичная фикстура) → безопасно публично.
# ДО auth-import: route матчит сверху вниз, handle short-circuit'ит.
# Без strip — Next.js basePath=/trade-in ждёт префикс в URL (как @tradein).
# ui-preview + его статика (_next/static — CSS/JS бандлы, без секретов).
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
handle @uipreview {
reverse_proxy tradein-frontend:3000 {
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
# (страница статична, у tradein-frontend нет секрета для
# X-Internal-Auth-Secret), но убираем ради единообразия периметра,
# а не полагаясь на то, что downstream ничего не делает с заголовком.
header_up -X-Authenticated-User
}
}
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
# ей больше не нужен и не должен применяться (short-circuit сверху вниз,
# как /health и /preview/* выше).
#
# X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`),
# НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот
# блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}`
# никогда не резолвится авторизованным юзером на этом пути.
# Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`):
# старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`)
# НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле
# пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ
# строку (`ReplaceKnown`), т.е. upstream получил бы буквально
# `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode=
# "dual", `app/core/config.py`) это НЕ подмена личности — legacy path
# (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не
# найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не
# security-дырой, а fail-closed-but-сломанной (все trade-in запросы без
# session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин).
# `-Field` остаётся правильным выбором не потому что Set был бы дырой, а
# потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
# basic_auth), это единственное, что теперь отсекает подделку заголовков
# изнутри gendesign_shared network для legacy dual-mode пути.
handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
# префикса basePath /trade-in (Next.js basePath leak).
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
# gendsgn.ru/sale-share — короткий адрес standalone-продукта «Поиск домов».
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
# #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на
# /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и
# для остального /trade-in). Это НЕ делает страницу публичной: она всё
# ещё за собственной авторизацией trade-in — `RouteGuard` во фронте
# (`app/layout.tsx`) и сессия для `/api/v1/buildings/sale-share*` на
# бэке; без валидной сессии юзер получит редирект на /login, а не
# контент. Смысл переноса — не открыть страницу всем, а убрать
# несогласованность: короткий URL не должен быть строже (Caddy
# basic_auth) целевого адреса, к которому и так уже нет
# basic_auth-барьера (только собственный login trade-in).
#
# ОБНОВЛЕНО 2026-07-31: доступ к разделу сузился с «pilot + admin» до
# ТОЛЬКО admin — «Поиск домов» признан тестовым продуктом, клиентам не
# показывается (deny в auth/roles.yaml для pilot и analyst + в
# DB_ROLE_PATHS для employee/manager). Сам редирект не трогаем: он ведёт
# на страницу, а гейт стоит на роли — для всех, кроме admin, короткий
# адрес приведёт на NoAccessScreen.
@saleshare path /sale-share /sale-share/
handle @saleshare {
redir /trade-in/sale-share permanent
}
# Matcher `path /trade-in /trade-in/*` ловит И /trade-in (без слеша),
# И /trade-in/ + /trade-in/anything. Без обоих случаев `handle /trade-in/*`
# пропускал /trade-in без слеша → попадал в общий frontend → пустой ответ.
@tradein path /trade-in /trade-in/*
handle @tradein {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
# См. комментарий над /trade-in/api/* выше — та же логика (явное
# удаление вместо Set с пустым {http.auth.user.id}).
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
# Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и
# handle {} ниже). Trade-In уже отработал и short-circuit'нул выше.
import caddy/users.caddy.snippet
handle /api/* {
reverse_proxy backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}
}
}
handle {
reverse_proxy frontend:3000 {
header_up X-Authenticated-User {http.auth.user.id}
}
}
}
}
www.gendsgn.ru {
redir https://gendsgn.ru{uri} permanent
}
# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала).
# ── Site-блоки вынесены по хостам (#3059, переезд 30.08) ────────────────────
# Раньше все восемь доменов жили прямо здесь. После разделения продуктов между
# двумя хостами это стало опасно: деплой синхронизирует рабочее дерево с
# origin/main и перечитывает конфиг, поэтому на Selectel приезжал бы файл
# целиком — и Caddy начинал бы выпускать сертификаты для obsidian/errors/git,
# чей DNS указывает на Beget. ACME падал бы на HTTP-01, с риском упереться в
# rate limit Let's Encrypt.
#
# Архитектурное решение: отдельный домен, а НЕ дырка в блоке gendsgn.ru
# выше. На gendsgn.ru модель "запрещено всё, кроме дырок ВЫШЕ auth-import" —
# порядко-зависимая и общая для B2B (trade-in v2, admin, scrapers, /api/*).
# Здесь, наоборот, allowlist-by-default: basic_auth НЕТ ВООБЩЕ (не импортируем
# caddy/users.caddy.snippet), потому что на этом site-блоке B2B-маршрутов
# физически не объявлено — их нечего "открывать". Явно перечислены РОВНО два
# handle (корень "/" + статика Next _next/*), всё остальное — финальный
# catch-all `handle { respond 404 }`. Регресс-тест на эту модель:
# scripts/smoke-mera-perimeter.sh (проверяет, что B2B-путь здесь = 404, а не
# 200/401 — т.е. не был случайно проброшен).
# caddy/sites/apps.caddy gendsgn.ru, www, meraocenka, merahome, meraotsenka
# -> уезжают на Selectel
# caddy/sites/infra.caddy obsidian, errors, git
# -> остаются на Beget (Forgejo, GlitchTip, CouchDB)
#
# Next.js basePath=/trade-in запечён в prod-образ tradein-frontend (тот же
# контейнер, что обслуживает и gendsgn.ru/trade-in/*, см. build-args в
# .forgejo/workflows/deploy-tradein.yml) — поэтому корень домена rewrite'ится
# на internal-путь /trade-in/mera-public (страница-заглушка,
# tradein-mvp/frontend/src/app/mera-public/). Пользователь префикс /trade-in
# никогда не видит — rewrite меняет путь ТОЛЬКО для Caddy→backend запроса,
# это не HTTP-редирект браузера.
#
# DNS: A-record meraocenka.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт
# реальный трафик. Если записи ещё нет на момент деплоя этого блока: `caddy
# reload`/`up -d --force-recreate caddy` в deploy.yml НЕ падает (конфиг
# синтаксически валиден, ошибка сертификата асинхронна и per-hostname) — Caddy
# просто залогирует неудачную попытку ACME-выпуска для meraocenka.ru (DNS не
# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит
# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же
# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются —
# автоматический HTTPS в Caddy изолирован per-hostname (тот же принцип, что
# уже описан для status.gendsgn.ru ниже). Повторные неудачные попытки ДО
# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed
# validations/hostname/hour) — не критично, просто подождать; `docker volume
# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval).
meraocenka.ru {
encode zstd gzip
log {
output file /var/log/caddy/meraocenka.ru.log
}
# Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на
# полноценную страницу). rewrite добавляет basePath-префикс только для
# Caddy→backend хопа, пользователь /trade-in никогда не видит.
handle / {
rewrite * /trade-in/mera-public
reverse_proxy tradein-frontend:3000 {
# Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже.
# Этот блок вообще не под basic_auth, поэтому анонимный клиент
# тем более может прислать свой X-Authenticated-User. Сейчас
# инертно (лэндинг статичен, backend-вызовов нет), но снимаем
# ради единообразия периметра, а не полагаясь на то, что
# downstream ничего не делает с заголовком — иначе на этапе 5,
# когда откроется публичный /estimate, это станет дырой.
header_up -X-Authenticated-User
}
}
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
# документ был бы недоступен с публичной страницы.
#
# Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*.
# B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history)
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
# в scripts/smoke-mera-perimeter.sh.
handle /trade-in/mera-public/* {
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
# basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS
# чанки), сами по себе не содержат ни B2B-данных, ни секретов.
#
# Именно `static/*`, а не весь `_next/*` — тот же матчер, что у @uipreview
# (:78), который в проде доказал, что этого хватает для рендера. Широкий
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
handle /trade-in/_next/static/* {
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# #2631: favicon — единственный корневой статик, который браузер запрашивает
# сам; без явного handle падал в allowlist-404. app/favicon.ico отдаёт Next
# по корневому пути через basePath /trade-in.
handle /favicon.ico {
rewrite * /trade-in/favicon.ico
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
handle {
respond 404
}
}
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.
# Решение 2026-07-31: канонический адрес ровно один, остальные две регистрации
# ловят (а) альтернативный транслит «оценка» — ocenka/otsenka, на слух
# неразличимы, (б) прежний рабочий вариант merahome. Отдельные site-блоки, а не
# matcher внутри основного: Caddy матчит по hostname и выпускает свой
# сертификат на каждый, поэтому DNS A-record нужен для КАЖДОГО из них — иначе
# ACME для этого хоста будет ретраиться (безвредно, см. комментарий выше, но
# лучше завести записи сразу).
# `{uri}` сохраняет путь и query — короткая ссылка с визитки не теряет ?id=.
merahome.ru {
redir https://meraocenka.ru{uri} permanent
}
meraotsenka.ru {
redir https://meraocenka.ru{uri} permanent
}
# Obsidian Self-hosted LiveSync (CouchDB backend).
# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap
# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user).
#
# DNS: A-record obsidian.gendsgn.ru → IP VPS.
# Клиенты Obsidian + Self-hosted LiveSync plugin указывают на этот URL.
obsidian.gendsgn.ru {
encode zstd gzip
reverse_proxy couchdb:5984 {
# Большие документы (vault attachments / images) — увеличиваем timeout
transport http {
response_header_timeout 120s
}
}
}
# GlitchTip — self-hosted error tracking (Sentry-compatible).
# DNS: A-record errors.gendsgn.ru → IP VPS.
errors.gendsgn.ru {
encode zstd gzip
reverse_proxy glitchtip-web:8080
log {
output file /var/log/caddy/errors.gendsgn.ru.log
}
}
# Uptime Kuma — self-hosted uptime monitoring + public status page (#75 B6-1).
# DNS: A-record status.gendsgn.ru → IP VPS (добавить перед деплоем стека).
# Контейнер из docker-compose.uptime.yml (project gendesign-uptime) на shared
# gendesign_shared network. Если стек не запущен — Caddy отдаёт 502 ТОЛЬКО на
# этом домене, main-сайт не страдает (как obsidian.gendsgn.ru).
#
# ВНИМАНИЕ: status-page НАМЕРЕННО публичен (trust-building для пилотов, issue #75).
# Admin-панель Kuma (/dashboard, /manage-*) защищена собственным логином Kuma —
# НЕ кладём её за caddy/users.caddy.snippet, иначе double-auth сломает setup.
status.gendsgn.ru {
encode zstd gzip
reverse_proxy uptime-kuma:3001
log {
output file /var/log/caddy/status.gendsgn.ru.log
}
}
# Forgejo — self-hosted git (migration 2026-05-16).
# DNS: A-record git.gendsgn.ru → IP VPS.
# Forgejo container из forgejo-migration/docker-compose.yml на shared
# gendesign_default network. HTTP port 3000 (default Forgejo).
# Был добавлен вручную при migration, потерян при первом auto-deploy после
# изменения Caddyfile (deploy.yml делает git reset --hard). См. fix issue.
git.gendsgn.ru {
encode zstd gzip
reverse_proxy forgejo:3000
log {
output file /var/log/caddy/git.gendsgn.ru.log
}
}
# CADDY_SITES выбирает подмножество. Дефолт `*` = оба файла = ТЕКУЩЕЕ поведение
# Beget, где сейчас обслуживаются все восемь доменов — то есть до переезда
# ничего не меняется. В окне: на Selectel CADDY_SITES=apps, на Beget=infra.
import caddy/sites/{$CADDY_SITES:*}.caddy
# Plain HTTP by IP — closed by same auth gate (prevent bypass via direct IP / SSH tunnel).
# Caddy issues no TLS here (no hostname). /health remains public.
@ -408,3 +78,19 @@ git.gendsgn.ru {
}
}
# Test deploy flow 2026-05-15T21:43:32Z
# ─────────────────────────────────────────────────────────────────────────────
# Локальные site-блоки, которых не может быть в git.
#
# Мотив: garmin.gendsgn.ru (личный remote MCP на этом же VPS). Апстрим-сервер
# аутентификации не имеет вовсе, а claude.ai custom connector ходит на голый URL
# без кастомных заголовков — единственный доступный рубеж это секрет в пути.
# Секрет в git класть нельзя, а блок, вписанный руками прямо сюда, сносится
# первым же деплоем (`git reset --hard origin/main`; 2026-08-16 так и вышло —
# контейнер остался жив, но хост пропал вместе со своим сертификатом).
#
# Поэтому: сам блок лежит на VPS как untracked `caddy/local/*.caddy` (reset
# --hard untracked не трогает), а в репозитории живёт только этот import.
# Пустой glob для Caddy не ошибка — `caddy validate` проходит, на машинах без
# локальных блоков строка просто ничего не делает.
import caddy/local/*.caddy

View file

@ -85,12 +85,10 @@ docker-compose.prod.yml main стек (backend, frontend, postgres, redis, work
docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно
docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru) — отдельный стек, запуск вручную
.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026)
├── ci.yml lint (ruff) + mypy + pytest на PR
├── ci.yml lint (ruff) + pytest на PR
├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy
├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн + свой _schema_migrations)
└── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне
.github/workflows/ (остаточные — только obsidian-стек на GitHub)
└── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap)
```
---
@ -158,7 +156,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru
**Forgejo Actions deploys** (self-hosted `git.gendsgn.ru`, мигрировано с GitHub Actions 16.05.2026):
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале.
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + pytest (coverage gate ≥65%). mypy strict в гейте не гоняется (доступен вручную — `uv run mypy app/services/generative app/services/site_finder/scorer.py`). Блокирует merge при провале.
- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
- [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)*

2
backend/.gitignore vendored
View file

@ -1 +1,3 @@
.coverage
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
coverage.xml

View file

@ -81,12 +81,18 @@ def _resolve_quarters(
) -> list[str]:
"""Собрать список кварталов согласно scope."""
if scope == "manual_list":
if not quarters:
# Сначала чистим, потом проверяем (#2464). Раньше порядок был обратным, и
# список из одних пробелов проходил проверку `not quarters` как непустой,
# а после strip превращался в []. Дальше по коду это молча создавало job
# с нулём кварталов, ставило его в очередь и возвращало targets_total=0 —
# пустышку, неотличимую в списке заданий от настоящей.
cleaned = [q.strip() for q in (quarters or []) if q.strip()]
if not cleaned:
raise HTTPException(
status_code=400,
detail="scope=manual_list требует непустой список quarters",
)
return [q.strip() for q in quarters if q.strip()]
return cleaned
cap = limit or (PILOT_LIMIT if scope == "pilot" else 100000)

View file

@ -130,7 +130,16 @@ def leads_stats(
db: Annotated[Session, Depends(get_db)],
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> dict[str, Any]:
"""KPI summary за последние N месяцев."""
"""KPI summary за последние N месяцев.
Суффикс `_window` за окно `months`, `_total` за всё время.
"""
# Почему это важно и почему поля переименованы (#2464): revenue_total и
# deals_total считались по CTE window_leads, то есть за окно, а суффиксом
# обещали итог за всё время — рядом с честными leads_total/sources_total.
# Админка из-за этого показывала карточку «Revenue (всего)» с 12-месячной
# цифрой. Рационал держим комментарием, а не docstring'ом: docstring уходит
# в OpenAPI description и дальше в сгенерированные типы фронта.
row = (
db.execute(
text(
@ -155,14 +164,14 @@ def leads_stats(
WHERE d.deal_id IN (
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
)
) AS revenue_total,
) AS revenue_window,
(
SELECT COUNT(*)
FROM prinzip_deals d
WHERE d.deal_id IN (
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
)
) AS deals_total
) AS deals_window
FROM window_leads
"""
),
@ -178,8 +187,16 @@ def leads_stats(
"converted_window": 0,
"conv_pct_window": None,
"sources_total": 0,
"revenue_total": None,
"deals_total": 0,
"revenue_window": None,
"deals_window": 0,
# window_months раньше отдавался ТОЛЬКО в непустой ветке — формы ответа
# различались. Оговорка про достижимость: этот `if not row` СЕГОДНЯ не
# срабатывает — запрос агрегатный и всегда возвращает ровно одну строку
# (проверено на пустых таблицах: leads_total=0, leads_window=0, строка
# truthy). То есть правка здесь — согласованность, а не наблюдаемая
# починка; ветка остаётся защитой на случай смены формы запроса, и
# расходиться с основной ей нельзя — именно так пропажа поля и возникла.
"window_months": months,
}
return {
"leads_total": row["leads_total"] or 0,
@ -189,10 +206,10 @@ def leads_stats(
float(row["conv_pct_window"]) if row["conv_pct_window"] is not None else None
),
"sources_total": row["sources_total"] or 0,
"revenue_total": (
float(row["revenue_total"]) if row["revenue_total"] is not None else None
"revenue_window": (
float(row["revenue_window"]) if row["revenue_window"] is not None else None
),
"deals_total": row["deals_total"] or 0,
"deals_window": row["deals_window"] or 0,
"window_months": months,
}

View file

@ -191,9 +191,35 @@ def queue_status(
return None
deadline = time.monotonic() + 0.8
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
# #2464-C: НЕ `with ThreadPoolExecutor(...)`. Его __exit__ зовёт
# shutdown(wait=True), поэтому обещанные ~600 мс худшего случая не выполнялись:
# result(timeout=...) переставал ждать значение, а выход из блока всё равно
# ждал, пока celery inspect отвиснет сам. Для UI-поллинга это ровно та ручка,
# которая обязана возвращаться быстро при недоступном брокере.
#
# ЧЕСТНАЯ ЦЕНА: shutdown(wait=False) оставляет зависший поток дорабатывать в
# фоне. Ограничиваем ЗАПРОС, не процесс — потоки пула не-демоны и джойнятся в
# atexit. Размен осознанный: висящий поллинг-эндпоинт хуже висящего потока.
def _probe_queue_depth() -> int | None:
with celery_app.connection_or_acquire() as conn:
with conn.channel() as channel:
return channel.client.llen("celery")
ex = concurrent.futures.ThreadPoolExecutor(max_workers=3)
try:
f_reserved = ex.submit(_safe, inspect.reserved)
f_ping = ex.submit(_safe, inspect.ping)
# #2464: проба глубины очереди раньше шла СИНХРОННО и без таймаута вовсе —
# `connection_or_acquire()` + `llen` по висящему сокету не возвращаются
# никогда. Дедлайн 0.8 с выше ограничивал только inspect, а ручка всё равно
# висела столько, сколько висел брокер: обещание докстроки не выполнялось на
# последнем шаге. Отправляем в тот же пул под тот же дедлайн.
#
# Отправляем ДО чтения результатов, а не после: иначе к моменту старта пробы
# бюджет уже израсходован inspect'ами и ей досталась бы только нижняя
# граница max(0.1, ...).
f_queue = ex.submit(_safe, _probe_queue_depth)
try:
reserved_raw = f_reserved.result(timeout=max(0.1, deadline - time.monotonic()))
except concurrent.futures.TimeoutError:
@ -202,22 +228,20 @@ def queue_status(
ping_resp = f_ping.result(timeout=max(0.1, deadline - time.monotonic()))
except concurrent.futures.TimeoutError:
ping_resp = None
# 3) Pending in broker queue (not yet picked up by any worker).
# Деградация до None намеренна (см. _safe): недоступный брокер не должен
# ронять UI-поллинг, но обязан быть виден в логах.
try:
queue_depth: int | None = f_queue.result(timeout=max(0.1, deadline - time.monotonic()))
except concurrent.futures.TimeoutError:
logger.warning("queue_status: broker queue_depth probe timed out")
queue_depth = None
finally:
ex.shutdown(wait=False, cancel_futures=True)
reserved = _flatten(reserved_raw)
workers = list((ping_resp or {}).keys())
# 3) Pending in broker queue (not yet picked up by any worker).
queue_depth: int | None = None
try:
with celery_app.connection_or_acquire() as conn:
with conn.channel() as channel:
queue_depth = channel.client.llen("celery")
except Exception:
# Намеренная деградация для UI-poll; логируем чтобы недоступный broker
# не был невидим в логах (см. .claude/rules/backend.md).
logger.warning("queue_status: broker queue_depth probe failed", exc_info=True)
queue_depth = None
return {
"workers": workers,
"queue_depth": queue_depth,
@ -1099,18 +1123,48 @@ def cancel_geo_job(
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Пометить job как cancelled. Worker увидит при следующей итерации."""
db.execute(
text(
"""
UPDATE nspd_geo_jobs SET status = 'cancelled', finished_at = NOW(),
error = COALESCE(error, 'cancelled by admin')
WHERE job_id = :id AND status IN ('queued','running','paused')
"""
),
{"id": job_id},
# #2464: фильтр статуса здесь был всегда (в отличие от resume ниже), но ответ
# возвращал cancelled=True независимо от того, задел ли UPDATE хоть одну строку.
# Несуществующий job_id и уже завершённая задача давали тот же ответ, что
# настоящая отмена — оператор и админ-UI получали подтверждение действия,
# которого не было.
#
# Обоснование держим в КОММЕНТАРИИ, а не в докстринге: FastAPI кладёт докстринг
# в OpenAPI-description, откуда он попадает в опубликованный контракт и в
# сгенерированные типы фронта (frontend/src/lib/api-types.ts). Внутренние замеры
# там не нужны, а gate openapi-codegen-check честно ловит такое расхождение.
row = (
db.execute(
text(
"""
UPDATE nspd_geo_jobs SET status = 'cancelled', finished_at = NOW(),
error = COALESCE(error, 'cancelled by admin')
WHERE job_id = :id AND status IN ('queued','running','paused')
RETURNING job_id
"""
),
{"id": job_id},
)
.mappings()
.first()
)
if row is None:
current = db.execute(
text("SELECT status FROM nspd_geo_jobs WHERE job_id = :id"),
{"id": job_id},
).scalar()
db.commit()
return {
"job_id": job_id,
"cancelled": False,
"status": current,
"reason": (
"задача не найдена" if current is None else f"статус {current!r} уже терминальный"
),
}
db.commit()
return {"job_id": job_id, "cancelled": True}
return {"job_id": job_id, "cancelled": True, "status": "cancelled"}
@router.post("/geo/jobs/{job_id}/resume")
@ -1118,18 +1172,61 @@ def resume_geo_job(
job_id: int,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Re-enqueue paused/failed job. Resume idempotent через pending targets."""
"""Re-enqueue задачу из НЕзавершённого состояния (paused / failed / cancelled)."""
# #2464: UPDATE шёл БЕЗ фильтра статуса — в отличие от соседнего cancel_geo_job,
# который фильтрует явно. Из-за этого «возобновить» можно было завершённую задачу
# (done → снова queued и повторный прогон, затирая результат) и уже бегущую
# (второй worker на тот же job_id — лишние запросы к НСПД, у которого WAF).
#
# Замер на проде 19.08: все 66 задач в терминальных статусах — 61 done, 5
# cancelled. То есть resume на ЛЮБУЮ существующую делал ровно то, чего не должен.
#
# Второе: ручка возвращала resumed=True всегда, независимо от того, изменилось ли
# что-нибудь. Теперь ответ отражает факт — статус и причина в ответе, задача НЕ
# ставится в очередь.
#
# 'cancelled' оставлен возобновляемым намеренно: cancel — ручное действие
# оператора, и без этого отменённая по ошибке задача не восстанавливалась бы.
from app.services.job_settings import get_setting_value
from app.workers.tasks.nspd_geo import process_nspd_geo_job
db.execute(
text("UPDATE nspd_geo_jobs SET status='queued', error=NULL WHERE job_id=:id"),
{"id": job_id},
row = (
db.execute(
text(
"""
UPDATE nspd_geo_jobs SET status='queued', error=NULL
WHERE job_id = :id AND status IN ('paused','failed','cancelled')
RETURNING job_id
"""
),
{"id": job_id},
)
.mappings()
.first()
)
if row is None:
# Ничего не обновили — либо задачи нет, либо статус неподходящий. Читаем
# текущий статус ДО commit'а, чтобы ответ объяснял отказ, а не молчал.
current = db.execute(
text("SELECT status FROM nspd_geo_jobs WHERE job_id = :id"),
{"id": job_id},
).scalar()
db.commit()
return {
"job_id": job_id,
"resumed": False,
"status": current,
"reason": (
"задача не найдена"
if current is None
else f"статус {current!r} не подлежит возобновлению"
),
}
db.commit()
geo_queue = get_setting_value("nspd_geo", "queue_name", "geo")
process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue)
return {"job_id": job_id, "resumed": True}
return {"job_id": job_id, "resumed": True, "status": "queued"}
# ── Newbuilding cross-load ETL (#976) ────────────────────────────────────────
@ -1317,6 +1414,15 @@ class FreshnessSource(BaseModel):
# внутри окна и не ложно-срабатывает (#1947 fix). Default 1 → флагует только если
# суммарный выход цикла = 0 (безопасный минимальный catch).
min_output_rows: int = 1
# Data-table режим: условие «строка означает УСПЕХ». Без него свежесть считается по
# факту записи строки, а не по факту получения данных — и провалившийся загрузчик,
# исправно пишущий строку с ошибкой, вечно выглядит свежим. Ровно это и случилось с
# nspd: последний успешный дамп 27.07.2026, а монитор молчал 24 суток, потому что
# каждый упавший harvest обновлял fetched_at_utc (#2956).
# Run-ledger режиму не нужно: там успех уже отделён через FILTER (WHERE status='done').
# Значение — статическая SQL-строка ИЗ КОДА (не из пользовательского ввода), она
# подставляется в FILTER (WHERE ...) как есть.
success_where: str | None = None
# Реестр источников. Run-ledger таблицы (kn/objective/nspd_geo/cadastre) проверены на
@ -1406,6 +1512,12 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
# defunct nspd_scrape_runs (manual WAF-ban 2026-04-30) больше НЕ источник истины.
table="nspd_quarter_dumps",
timestamp_col="fetched_at_utc",
# Свежесть — по УСПЕШНЫМ дампам. Упавший harvest всё равно пишет строку
# (fetched_at_utc проставлен, harvest_error заполнен, счётчики нулевые), и без
# этого условия каждый провал обновлял часы свежести. С 03.08.2026 провалились
# все 61 дамп подряд, последний успешный — 27.07, а источник числился fresh
# (#2956).
success_where="harvest_error IS NULL",
# В timestamp-режиме не используется — оставляем валидное имя колонки.
work_col="total_features",
# Медленный кадастровый + lazy-refresh источник: дампы освежаются по мере
@ -1494,7 +1606,9 @@ def compute_freshness(db: Session) -> dict[str, Any]:
Для data-table источников (src.timestamp_col задан, напр. nspd nspd_quarter_dumps)
нет run-ledger семантики (status/started/finished отсутствуют), поэтому:
- last_success_at = last_attempt_at = MAX(timestamp_col)
- last_attempt_at = MAX(timestamp_col); last_success_at то же, но по
строкам, прошедшим success_where (у источника с колонкой ошибки это
отделяет «строку записали» от «данные получили», #2956)
- objects_updated_24h / _7d = COUNT(*) строк, обновлённых в окне
- last_status = NULL (косметика только для run-ledger'ов)
Остальной downstream (age_days / _classify_freshness / status-маппинг) общий.
@ -1511,16 +1625,23 @@ def compute_freshness(db: Session) -> dict[str, Any]:
# все временные границы передаются параметрами (:d1/:d7).
if src.timestamp_col is not None:
# Data-table режим: плоская контент-таблица без run-ledger семантики
# (нет status/started/finished). Свежесть = MAX(timestamp_col),
# upd_24h/_7d = COUNT(*) строк, обновлённых в окне. last_status=NULL
# (косметика только для run-ledger'ов).
# (нет status/started/finished). Свежесть = MAX(timestamp_col) по строкам,
# прошедшим success_where (если задан; иначе по всем), upd_24h/_7d =
# COUNT(*) строк, обновлённых в окне. last_status=NULL (косметика только
# для run-ledger'ов).
ts = src.timestamp_col
# Успех vs попытка. last_attempt_at — всегда MAX(ts) (строка записана),
# last_success_at — только по строкам, прошедшим success_where. Это тот же
# раздел, что в run-ledger ветке ниже (FILTER (WHERE status = 'done')):
# без него упавший загрузчик, который исправно пишет строку с ошибкой,
# выглядит свежим вечно (#2956).
success_filter = f" FILTER (WHERE {src.success_where})" if src.success_where else ""
row = (
db.execute(
text(
f"""
SELECT
MAX({ts}) AS last_success_at,
MAX({ts}){success_filter} AS last_success_at,
MAX({ts}) AS last_attempt_at,
COALESCE(COUNT(*) FILTER (
WHERE {ts} > NOW() - CAST(:d1 AS interval)

View file

@ -925,14 +925,17 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
"""
try:
row = (
db.execute(
_NEIGHBORS_SUMMARY_SQL,
{"wkt": geom_wkt, "our_cad": our_cad_num},
# #2464: SAVEPOINT — сессия общая с analyze_parcel, ошибку глотаем ниже. Без него
# aborted-транзакция дошла бы до persist_analysis_run, и анализ не сохранился бы.
with db.begin_nested():
row = (
db.execute(
_NEIGHBORS_SUMMARY_SQL,
{"wkt": geom_wkt, "our_cad": our_cad_num},
)
.mappings()
.first()
)
.mappings()
.first()
)
neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else []
# #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) —
@ -1084,11 +1087,12 @@ def _compute_confidence(
poi_rows: list[dict[str, Any]],
district_row: dict[str, Any] | None,
competitor_rows: list[dict[str, Any]],
noise_sources_count: int,
noise_map_rows_nearby: int,
air_q: dict[str, Any] | None,
weather: dict[str, Any] | None,
market_trend: dict[str, Any] | None,
zoning: dict[str, Any],
nspd_zoning: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""X2 (#48) — composite confidence score 0..1 + caveats для site-finder analyze.
@ -1164,15 +1168,37 @@ def _compute_confidence(
caveats.append("Нет конкурентов-ЖК в 3км — низкая урбанизация / окраина")
# 6) Environmental data freshness
env_ok = sum([bool(noise_sources_count > 0), bool(air_q), bool(weather)])
# #2464-G: считаем строки шумовой КАРТЫ в радиусе (любого типа, включая
# water/utility), а не отфильтрованные источники для скоринга. Вопрос здесь —
# «есть ли у нас данные по этой точке», и ноль означает непокрытие карты.
# Отфильтрованный список дал бы 0 у трети участков, где рядом просто тихо, и
# оговорка ниже утверждала бы неправду.
env_ok = sum([bool(noise_map_rows_nearby > 0), bool(air_q), bool(weather)])
subscores["environment"] = env_ok / 3.0
if noise_sources_count == 0:
if noise_map_rows_nearby == 0:
caveats.append("Шумовая карта не загружена — noise score = stub")
if not air_q:
caveats.append("Air Quality API недоступен — exposure unknown")
# 7) ПЗЗ coverage — placeholder до G1
# Зона ПЗЗ приходит ДВУМЯ путями, и признак обязан учитывать оба.
#
# `zoning` — старый per-parcel слой из таблицы `pzz_zones_ekb`. На проде она
# ПУСТА (0 строк, замер 19.08), поэтому `data_available` там всегда False.
# Настоящая зона живёт в `nspd_zoning`: из территориальных зон дампа НСПД, а
# для участков в зазорах между зонами — синтезируется резолвером геопортала
# (см. комментарий PR-A #financial-zoning-decouple выше по файлу).
#
# Пока сюда передавали только `zoning`, подскор был 0.2 у КАЖДОГО участка, а
# оговорка ниже утверждала неправду. Прогон analyze на проде, участок
# 66:41:0402029:25: `nspd_zoning.zone_code = 'Ж-5'`, при этом
# `confidence = 0.61` и оговорка «ПЗЗ zone_code не известен». Отчёт в одном и
# том же ответе показывал зону и заявлял, что зона неизвестна. Подскоров семь,
# значит цена ошибки в композите — (1.0 0.2) / 7 = 0.114: 0.61 вместо 0.72.
_nspd = nspd_zoning or {}
has_zoning = bool(zoning.get("data_available")) if zoning else False
if not has_zoning:
has_zoning = bool(_nspd.get("zone_code") or _nspd.get("regulation_zone_index"))
subscores["zoning"] = 1.0 if has_zoning else 0.2
if not has_zoning:
caveats.append(
@ -2319,12 +2345,31 @@ def analyze_parcel(
-- (303 строки = 303 distinct) COUNT(*) по дедуп-физлотам корректен.
SELECT
np.domrf_obj_id,
ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
-- #2464-D: границы правдоподобия, как в двух соседних запросах
-- по этой же таблице (BETWEEN 30000 AND 600000) здесь их не было.
-- Замер 13.08 по проду ЧЕРЕЗ ЭТОТ ЖЕ ПУТЬ (physflat-дедуп +
-- маппинг на domrf_obj_id): вне диапазона 204 лота из 2 279 827,
-- из них 118 в 10 замапленных проектах и 86 в незамапленных.
-- Эффект сегодня МАЛЫЙ: меняются 6 проектов из 308, худший на
-- 2.4%, market_avg_price (среднее средних) 138 056 138 008;
-- NULL не появляется нигде. Ставим границы не ради этих 48 ,
-- а потому что среднее считается ПО ПРОЕКТУ и один лот держит
-- группу без ограничения сверху: максимум в таблице
-- 19 198 429 /м² (ЖК «Дебют»), и он вне экрана только потому,
-- что проект пока не замаплен (замаплено 308 имён из 881, список
-- растёт). Одна строка маппинга и это число на экране.
-- FILTER, а не WHERE: строки нужны целиком, иначе поедут
-- units_sold / units_available, считающие ВСЕ лоты.
ROUND(AVG(oll.price_per_m2_rub) FILTER (
WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000
)::numeric, 0) AS avg_price_per_m2_rub,
ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd,
COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold,
COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available,
-- Считаем ТУ ЖЕ популяцию, что кормит среднее: иначе счётчик
-- обещал бы выборку шире, чем на самом деле участвовала.
COUNT(*) FILTER (
WHERE oll.price_per_m2_rub IS NOT NULL
WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000
) AS lots_with_price
FROM nearby_projects np
JOIN obj_lots_latest oll
@ -2512,7 +2557,24 @@ def analyze_parcel(
}
)
# 7) Noise score — шумовые источники в радиусе 2 км
# 7) Noise score — шумовые источники в радиусе 2 км.
#
# #2464-G: фильтр по source_type обязателен. Таблица osm_noise_sources_ekb
# держит и НЕшумовые слои — 'water' (870 строк) и 'utility' (1 487), их
# отдельно читает гидрология в 9c ниже. Для скорера они мусор: ключа в
# NOISE_L_BASE у них нет, поэтому `.get(key, 50.0)` выдавал им ровно 50 дБ —
# значение, совпадающее с порогом попадания в список источников.
#
# Главное — `LIMIT 30` берётся ПО БЛИЗОСТИ, поэтому вода вытесняла настоящие
# источники. Замер 19.08 на 1 000 участков (детерминированная выборка по
# cad_num): 19 755 занятых слотов, из них 8 797 (44.5%) — вода и коммуникации;
# 562 участка теряли хотя бы один настоящий источник.
#
# Честно про эффект: сегодня пользователь этого не видит — все вытесненные
# источники оказались тише порога 50 дБ (участков, теряющих ВИДИМЫЙ источник:
# 0 из 729), и максимум дБ не меняется ни у одного. Правка убирает не видимую
# поломку, а скрытый потолок: почти половина бюджета LIMIT уходила на строки,
# которые скорер не умеет оценивать.
noise_rows = (
db.execute(
text("""
@ -2522,7 +2584,8 @@ def analyze_parcel(
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM osm_noise_sources_ekb n
WHERE ST_DWithin(
WHERE n.source_type IN ('highway', 'railway', 'industrial', 'aerodrome')
AND ST_DWithin(
n.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
2000
@ -2536,6 +2599,30 @@ def analyze_parcel(
.all()
)
# Покрытие шумовой карты — ОТДЕЛЬНО от списка источников, и это не педантизм.
# _compute_confidence спрашивает «загружена ли шумовая карта», а не «шумно ли
# тут»: при нуле она пишет «Шумовая карта не загружена — noise score = stub».
# У 345 участков из 1 000 в радиусе 2 км нет НИ ОДНОГО шумового источника, но
# вода/коммуникации есть. Передай туда len(noise_rows) после фильтра — и треть
# участков получит утверждение о незагруженной карте, которое неверно: карта
# загружена, просто рядом тихо. До этой правки верный ответ получался
# случайно — ровно потому, что в счёт шли и нешумовые строки.
noise_map_rows_nearby: int = (
db.execute(
text("""
SELECT COUNT(*)
FROM osm_noise_sources_ekb n
WHERE ST_DWithin(
n.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
2000
)
"""),
{"wkt": geom_wkt},
).scalar()
or 0
)
noise_db_max = 0.0
nearby_noise_sources: list[dict[str, Any]] = []
for nr in noise_rows:
@ -2604,6 +2691,10 @@ def analyze_parcel(
.mappings()
.all()
)
_flood_proximity = any(
float(r["distance_m"]) < 200 and r["road_class"] in ("river", "canal")
for r in hydro_rows
)
hydrology = {
"nearest": [
{
@ -2613,14 +2704,23 @@ def analyze_parcel(
}
for r in hydro_rows[:5]
],
"flood_risk_flag": any(
float(r["distance_m"]) < 200 and r["road_class"] in ("river", "canal")
for r in hydro_rows
),
"flood_risk_flag": _flood_proximity,
# #2934: оговорка была написана в расчёте ТОЛЬКО на случай «пойма есть» —
# при flood_risk_flag=false фронт всё равно печатал «Пойма реки (<200м) —
# повышенный риск подтопления», то есть текст противоречил значению рядом.
# Вторая половина («официальные зоны — в Росреестре») верна всегда и
# существенна: этот флаг — близость водного объекта по OSM, а НЕ проверка
# зон затопления. Ни cad_risk_zones (пуста), ни слои risk_* НСПД в него
# не входят.
"note": (
"Пойма реки (<200м) — повышенный риск подтопления. Точные данные о "
"зонах затопления — в Росреестре (ЗОУИТ типа 33: 'Зона затопления, "
"подтопления') через ФГИС ТП."
(
"Пойма реки или канала ближе 200 м — повышенный риск подтопления. "
if _flood_proximity
else "Рек и каналов ближе 200 м не найдено. "
)
+ "Это близость водного объекта по OSM, а НЕ проверка зон затопления: "
"официальные зоны — ЗОУИТ типа 33 «Зона затопления, подтопления» "
"(Росреестр, ФГИС ТП)."
),
}
except Exception as e:
@ -3056,11 +3156,23 @@ def analyze_parcel(
except Exception as e:
logger.warning("district_price_block query failed for %s: %s", cad_num, e)
# B5-6) Risk indicators — flood_zone из cad_risk_zones + noise_score + geology proxy (SF-B5)
# B5-6) Risk indicators — flood_zone + noise_score (SF-B5).
#
# `geology_risk_label` УБРАН (#2934). Он назывался геологическим риском, а
# вычислялся из подтопления и ШУМА: «high» при подтоплении, «medium» при шуме
# ≥65 дБ, иначе «low». Геологии в нём не было ни одного бита. При этом
# `cad_risk_zones` пуста (0 строк, писателя нет — см. #2934 п.6), поэтому
# подтопление приходило только из OSM-прокси «река ближе 200 м», и на
# тихом участке без реки поле всегда говорило «low» — зелёный вердикт,
# ни разу не подкреплённый проверкой геологии.
#
# Замена не нужна: соседний блок `geology` честно отдаёт
# `data_available: false`, когда данных нет. Потребителей у поля не было —
# ни фронт, ни экспортёры, ни §19-allowlist чата его не читали, а в схеме
# `risks: dict[str, Any]`, поэтому OpenAPI не меняется.
risks_block: dict[str, Any] = {
"flood_zone": False,
"noise_score": round(noise_score, 2),
"geology_risk_label": None,
}
try:
with db.begin_nested():
@ -3082,20 +3194,13 @@ def analyze_parcel(
.first()
)
_flood = bool(flood_row and int(flood_row["cnt"]) > 0)
# Geology proxy через hydrology flood_risk_flag (уже посчитан выше)
# OSM-прокси «река или канал ближе 200 м» (посчитан выше в hydrology).
# На сегодня это ЕДИНСТВЕННЫЙ работающий источник этого признака:
# cad_risk_zones пуста, поэтому _flood всегда False (#2934 п.6).
_geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False
_has_flood = _flood or _geo_flood
# geology_risk_label: high если flooding, medium если шум > 65дБ, иначе low
if _has_flood:
_geo_label: str | None = "high"
elif noise_db_max >= 65.0:
_geo_label = "medium"
else:
_geo_label = "low"
risks_block = {
"flood_zone": _has_flood,
"flood_zone": _flood or _geo_flood,
"noise_score": round(noise_score, 2),
"geology_risk_label": _geo_label,
}
except Exception as e:
logger.warning("risks_block query failed for %s: %s", cad_num, e)
@ -3653,7 +3758,14 @@ def analyze_parcel(
"source": "gisogd66",
}
try:
permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500)
# #2464: SAVEPOINT, как у соседних блоков этой же функции (ближайший — разрешения
# 10d-pre2 шестьюдесятью строками выше, где приём применён явно). get_permits_nearby
# делает db.execute на ЭТОЙ сессии и своей защиты не имеет; ошибку глотаем здесь.
# Без savepoint'а упавший запрос оставляет транзакцию в aborted-состоянии, и дальше
# по обработчику падают _geotech_risk (:4141), _neighbors_summary (:4145) и запись
# прогона — то есть теряется весь анализ, а не блок разрешений.
with db.begin_nested():
permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500)
except Exception as e:
logger.warning("gisogd permits_nearby query failed for %s: %s", cad_num, e)
@ -3783,11 +3895,12 @@ def analyze_parcel(
poi_rows=[dict(p) for p in poi_rows],
district_row=dict(district_row) if district_row else None,
competitor_rows=[dict(c) for c in competitor_rows],
noise_sources_count=len(noise_rows),
noise_map_rows_nearby=noise_map_rows_nearby,
air_q=air_q,
weather=weather,
market_trend=market_trend,
zoning=zoning,
nspd_zoning=nspd_dump_data.get("nspd_zoning"),
)
# D4 (#36): aggregate pipeline_24mo

View file

@ -85,6 +85,24 @@ def get_photo(
upstream = row["photo_url"]
photo_name = row["photo_name"]
# #2464-C: отпускаем соединение ДО любой медленной работы — внешнего фетча
# (до 8 с) и генерации миниатюры. SELECT выше открыл транзакцию (SQLAlchemy
# начинает её на первом запросе), и без этого она висела бы idle-in-transaction
# всё это время, занимая соединение пула.
#
# Почему это важно именно здесь: закешировано локально 1 889 фотографий из
# 165 208 (замер 19.08.2026), то есть 98.9% запросов идут «ленивым» путём с
# походом наружу. Пул дефолтный — `create_engine` в app/core/db.py без
# pool_size, значит 5 + 10 overflow = 15 соединений на весь бэкенд. Страница
# отчёта тянет картинки пачкой, и пятнадцать таких запросов занимают пул
# целиком, а за ними встают ВСЕ остальные ручки.
#
# `close()` не делает сессию непригодной: следующий `db.execute` ниже
# прозрачно возьмёт новое соединение и откроет свою транзакцию. Значения из
# `row` уже разложены по локальным переменным выше — после закрытия они
# остаются доступны.
db.close()
headers = {"Cache-Control": "public, max-age=604800, immutable"}
# ── size=thumb ──────────────────────────────────────────────────────────

View file

@ -48,6 +48,7 @@ from app.core import auth_db
from app.core.audit_middleware import audit_log_middleware
from app.core.auth import get_role
from app.core.config import settings
from app.observability import metrics as app_metrics
from app.observability.sentry_scrub import scrub_event
from app.services.auth_session import resolve_session_token
@ -180,7 +181,15 @@ app.middleware("http")(audit_log_middleware)
# `users:` в roles.yaml и решить — применять `paths`/`deny` на бэкенде или убрать
# `expired` как вводящий в заблуждение.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
# `/metrics` публичен здесь и НЕ публичен снаружи — это два разных периметра, и
# путать их нельзя. Снимает его агент Alloy изнутри docker-сети, где заголовка
# `X-Authenticated-User` нет ни у кого, так что без записи в этом множестве
# скрейп получал бы 401 и метрик не было бы вовсе. Наружу путь при этом не
# открывается: `caddy/sites/apps.caddy` отдаёт бэкенду «Птицы» только `/health`
# и `/api/*`, а `/metrics` там дополнительно закрыт явным `respond 404`.
_PUBLIC_PATHS = frozenset(
{"/health", "/metrics", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"}
)
def _propagate_authenticated_user(request: Request, username: str) -> None:
@ -465,6 +474,15 @@ app.add_middleware(
allow_headers=["*"],
)
# Метрики — СЛЕДОМ ЗА CORS и, значит, самым внешним слоем: `add_middleware`
# вставляет в начало списка, поэтому зарегистрированный последним оказывается
# снаружи всех. Порядок здесь несущий, а не вкусовой. Изнутри RBAC-гварда не
# видно ни отказов авторизации (401/403 — их отдаёт сам гвард), ни времени,
# которое он тратит на резолв сессии в БД `auth`; а именно этот путь уже давал
# инцидент (#1202, блокирующий I/O в middleware). Снаружи видно и то и другое.
app.add_middleware(app_metrics.MetricsMiddleware)
app.include_router(app_metrics.router, tags=["observability"])
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
app.include_router(chat.router, prefix="/api/v1/chat", tags=["chat"])
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
@ -508,3 +526,24 @@ async def health() -> dict[str, str]:
"environment": settings.environment,
"version": app.version,
}
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
# отдаёт 405. Это боевой прод-эндпоинт: Caddyfile:60 `handle /health {
# reverse_proxy backend:8000 }` — именно ЭТОТ хендлер отвечает на
# `HEAD https://gendsgn.ru/health`, которым бьёт внешний uptime-monitor
# (GlitchTip PING-тип шлёт HEAD, не GET) и не мог отличить "жив" от "мёртв" по
# статусу. media_type="application/json" — Content-Type совпадает с GET;
# Content-Length сознательно НЕ вычисляем под байт GET-ответа (пришлось бы
# дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload-
# заголовки (Content-Length) для HEAD, требует совпадения только заголовков
# представления (Content-Type).
# include_in_schema=False: HEAD-проба — инфраструктура (uptime-monitor), а не часть
# контракта, по которому фронт генерирует типы. Без этого флага операция попадает в
# app.openapi(), и job `openapi-codegen-check` краснеет, требуя перегенерации
# frontend/src/types/api-types.ts — правки в сгенерированном файле ради маршрута,
# который фронт никогда не вызывает.
@app.head("/health", include_in_schema=False)
async def health_head() -> Response:
return Response(status_code=200, media_type="application/json")

View file

@ -0,0 +1,175 @@
"""Метрики Prometheus для API «Птицы»: счётчики, гистограмма задержки, `/metrics`.
Часть 3 задачи #3078. До неё числовых рядов у приложения не было вовсе — только
логи и исключения в GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт
4xx потоком» в такой картине невидим: исключения нет, строка в логе выглядит
обычной, а пользователь видит неработающий продукт.
ЧТО ИМЕННО СЧИТАЕМ И ПОЧЕМУ ТАК
`route` это ШАБЛОН маршрута (`/api/v1/parcels/{cad_num}`), а не путь запроса.
Разница принципиальная, а не косметическая: кадастровый номер в метке дал бы
новый временной ряд на каждый участок. У Prometheus ряд стоит памяти постоянно,
а не в момент запроса, и такая метка кладёт приёмник за сутки это самый
известный способ уронить мониторинг тем самым мониторингом.
Незаматченные пути (404, сканеры, чужие боты) сведены в одну метку
``__unmatched__``. Иначе достаточно одного бота, перебирающего адреса, чтобы
получить тот же взрыв рядов через чёрный ход.
Ошибка внутри приложения фиксируется как 500 в `finally`: исключение проходит
сквозь этот слой наружу, к `ServerErrorMiddleware`, и без `finally` такие
запросы просто не попали бы в счётчик то есть отсутствовали бы ровно в тот
момент, когда метрики нужнее всего.
ОДИН ПРОЦЕСС ОДИН РЕЕСТР
`Dockerfile:75` запускает `uvicorn` без `--workers`, то есть процесс один и
значения счётчиков целостны. Появится `--workers` или gunicorn счётчики
станут per-process, и каждый скрейп будет попадать в случайный воркер: график
начнёт пилить вверх-вниз без всякой связи с нагрузкой. Лечится штатным
многопроцессным режимом `prometheus_client` (`PROMETHEUS_MULTIPROC_DIR` +
`MultiProcessCollector`), но это отдельная работа, и делать её заранее «на
всякий случай» не стоит. Здесь оставлена явная отметка, чтобы связь между
`--workers` и сломанными графиками не пришлось искать заново.
ДОСТУП
`/metrics` снимает только агент Alloy изнутри docker-сети. Снаружи путь
недостижим: `caddy/sites/apps.caddy` проксирует на бэкенд «Птицы» лишь
`/health` и `/api/*`, а `/metrics` там вдобавок закрыт явным `respond 404`
чтобы это осталось решением, а не побочным следствием текущего порядка
директив.
"""
from __future__ import annotations
import os
import time
from collections.abc import Awaitable, Callable, MutableMapping
from typing import Any
from fastapi import APIRouter, Response
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest
Scope = MutableMapping[str, Any]
Message = MutableMapping[str, Any]
Receive = Callable[[], Awaitable[Message]]
Send = Callable[[Message], Awaitable[None]]
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
# Метка для всего, что не совпало ни с одним маршрутом. Явная строка, а не
# пустое значение: пустая метка в PromQL неотличима от отсутствующей.
UNMATCHED = "__unmatched__"
# Границы гистограммы подобраны под «Птицу», а не взяты из примера в документации.
# Быстрые ручки (`/health`, справочники) укладываются в десятки миллисекунд;
# `POST /api/v1/parcels/{cad_num}/analyze` уходит в десятки секунд, потому что
# внутри поход в OSRM и подсчёт геометрии. Без верхних корзин весь тяжёлый хвост
# слипся бы в `+Inf`, и «стало вдвое медленнее» было бы не увидеть.
_DURATION_BUCKETS = (0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, float("inf"))
REQUESTS = Counter(
"http_requests_total",
"Запросов обслужено",
labelnames=("method", "route", "status"),
)
DURATION = Histogram(
"http_request_duration_seconds",
"Время ответа целиком, включая авторизацию и middleware",
labelnames=("method", "route"),
buckets=_DURATION_BUCKETS,
)
# Без меток намеренно. Gauge с меткой маршрута не возвращается в ноль сам:
# после единственного запроса ряд остаётся навсегда, и получается тот же рост
# кардинальности, только медленный и незаметный.
IN_PROGRESS = Gauge(
"http_requests_in_progress",
"Запросов обрабатывается прямо сейчас",
)
BUILD_INFO = Gauge(
"app_build_info",
"Всегда 1; полезны метки — по ним видно, какая версия отвечала в момент сбоя",
labelnames=("app", "release"),
)
BUILD_INFO.labels(
app="sitefinder",
release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown",
).set(1)
def route_label(scope: Scope) -> str:
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
`scope["route"]` проставляет роутер Starlette в момент матчинга. Наш слой
внешний, поэтому к моменту, когда управление возвращается сюда, поле уже
заполнено scope это один и тот же dict на весь стек, он не копируется
между слоями.
"""
route = scope.get("route")
path = getattr(route, "path", None)
if isinstance(path, str) and path:
return path
return UNMATCHED
class MetricsMiddleware:
"""Чистый ASGI-слой, без `BaseHTTPMiddleware`.
`BaseHTTPMiddleware` заворачивает ответ в собственный поток и на потоковых
ответах ведёт себя иначе, чем голый ASGI. В «Птице» такие ответы есть
выгрузки PDF/DXF/XLSX идут телом ответа, и ставить ради подсчёта запросов
слой, который меняет их обработку, не стоит.
Регистрировать ПОСЛЕДНИМ: `add_middleware` вставляет в начало списка, то
есть последний зарегистрированный оказывается самым внешним. Именно это и
нужно иначе 401 от RBAC-гварда не попадёт в счётчик, а поток отказов
авторизации это ровно то, что нужно видеть.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
method = scope.get("method", "UNKNOWN")
# 500 по умолчанию: если приложение упадёт исключением, `http.response.start`
# мы не увидим, и запрос обязан быть посчитан как ошибка, а не пропасть.
status = 500
async def send_wrapper(message: Message) -> None:
nonlocal status
if message["type"] == "http.response.start":
status = message["status"]
await send(message)
IN_PROGRESS.inc()
started = time.perf_counter()
try:
await self.app(scope, receive, send_wrapper)
finally:
IN_PROGRESS.dec()
route = route_label(scope)
DURATION.labels(method, route).observe(time.perf_counter() - started)
REQUESTS.labels(method, route, str(status)).inc()
router = APIRouter()
@router.get("/metrics", include_in_schema=False)
def metrics() -> Response:
"""Выгрузка в текстовом формате Prometheus.
Реестр по умолчанию, а не свой: вместе с нашими метриками он отдаёт
`process_resident_memory_bytes`, `process_open_fds` и счётчики сборщика
мусора. Утечка памяти и исчерпание файловых дескрипторов видны по ним
напрямую, доплачивать за это ничем не нужно.
"""
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)

View file

@ -49,9 +49,7 @@ class OwnPlannedProjectCreate(BaseModel):
planned_release_month: date | None = Field(
None, description="Планируемый месяц выхода в продажу (нормализуется к 1-му числу)"
)
price_min_per_m2: float | None = Field(
None, ge=0, description="Нижняя граница цены, ₽/м² (≥0)"
)
price_min_per_m2: float | None = Field(None, ge=0, description="Нижняя граница цены, ₽/м² (≥0)")
price_max_per_m2: float | None = Field(
None, ge=0, description="Верхняя граница цены, ₽/м² (≥0)"
)

View file

@ -598,7 +598,8 @@ class TopLayoutRow(BaseModel):
total_sold_in_window: int
velocity_per_month: float
avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj
avg_area_m2: float
# #2867: NULL если сделок за окно нет — средней площади нет; раньше отдавался 0 м².
avg_area_m2: float | None
supply_units_in_radius: int
sold_pct_of_supply: float | None # NULL если supply=0; clamped at 100.0
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)

View file

@ -316,9 +316,7 @@ def refresh_ddu_price_indicator(db: Session, *, concurrently: bool = True) -> in
db.commit()
except OperationalError as e:
if concurrently and "cannot refresh materialized view" in str(e).lower():
logger.warning(
"ddu_indicator CONCURRENTLY failed (MV not populated), falling back"
)
logger.warning("ddu_indicator CONCURRENTLY failed (MV not populated), falling back")
db.rollback()
db.execute(text("REFRESH MATERIALIZED VIEW mv_ddu_price_indicator"))
db.commit()

View file

@ -665,8 +665,7 @@ def prinzip_insights() -> dict[str, Any]:
{
"district": "Чкаловский / Железнодорожный",
"why": (
"Растущие районы, 0% PRINZIP, низкая конкуренция. "
"Тест 60-80 м² без премиума."
"Растущие районы, 0% PRINZIP, низкая конкуренция. Тест 60-80 м² без премиума."
),
},
],
@ -688,7 +687,7 @@ def prinzip_insights() -> dict[str, Any]:
{
"name": "Холдинг Форум-групп",
"model": (
"113 тыс м² × sold 54% × Δ +21пп лидер velocity. " "3-к доля 21.5%, ср. 61 м²."
"113 тыс м² × sold 54% × Δ +21пп лидер velocity. 3-к доля 21.5%, ср. 61 м²."
),
},
],
@ -1436,9 +1435,27 @@ def _velocity_baseline(
Migrated from domrf_kn_sale_graph (stale since 2026-01) to
objective_corpus_room_month (updated weekly via Objective API).
objective_corpus_room_month.district matches domrf_kn_objects.district_name.
class filter uses 'class' column (Комфорт/Бизнес/Стандарт).
ВНИМАНИЕ ПРО СЛОВАРЬ РАЙОНОВ. Прежняя редакция утверждала, что
`objective_corpus_room_month.district` совпадает с
`domrf_kn_objects.district_name`. Это неверно, и docstring `_elasticity_coef`
ниже описывает ту же колонку правильно: там МИКРО-вокабуляр ЕКБ.
Замер прода 20.08.2026:
district (микро) Академический, ВИЗ, Юго-Западный, Уктус, Втузгородок,
Широкая Речка, Центр, Эльмаш,
district_name (админ) Академический, Чкаловский, Верх-Исетский, Ленинский,
Орджоникидзевский, Кировский,
Пересечение частичное: из 8 админ-имён в микро-колонке встречаются 4, и с
сильно меньшим объёмом (Ленинский 55 точек против 621 у Академического;
Чкаловский и Верх-Исетский ноль). Вызывающий передаёт сюда
`district_row["district_name"]`, то есть АДМИН-имя: для половины районов
выборка пустая, для остальных заметно урезанная. Резолв adminmicros
(как в `_elasticity_coef`, #1211) здесь НЕ сделан — это отдельная задача,
docstring лишь перестаёт утверждать обратное (#2464).
Returns dict {realised_per_month_median, realised_per_month_avg,
objects_count, observations}. All-None means no data caller falls back.
"""
@ -1856,7 +1873,7 @@ def _active_competitors_count(
# #38: реальный obj_class в приоритете, иначе obj_class_fallback.
if target_class:
n = _q(
"AND district_name = :dn" " AND COALESCE(obj_class, obj_class_fallback) = :cls",
"AND district_name = :dn AND COALESCE(obj_class, obj_class_fallback) = :cls",
{"rc": region_code, "dn": district_name, "cls": target_class},
)
if n >= 2:

View file

@ -30,7 +30,12 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkServerError
from app.scrapers.nspd_bulk_client import (
NSPDBulkClient,
NspdBulkRateLimitError,
NspdBulkServerError,
NspdBulkWafError,
)
from app.services.cadastre.grid_geometry import generate_grid_click_points, quarter_bbox_3857
logger = logging.getLogger(__name__)
@ -182,6 +187,13 @@ async def harvest_quarter(
try:
cat_snapshot = await client.search_by_quarter(quarter, category_id=cat_id)
result.snapshot_requests += 1
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: бан IP / исчерпанные ретраи — НЕ «этот cat не дошёл».
# Контракт harvest_quarter (Raises:) обещает пробросить их наверх,
# а голый except ниже их глотал: прогон доходил до status='done'
# с частичными данными. Прод-замер 13.08: 23 job'а, 50 WAF-блоков,
# 0 упавших — то есть бан ни разу не остановил сбор.
raise
except Exception as e:
logger.warning(
"harvest_quarter: per-cat probe failed cat=%d quarter=%s: %s",
@ -279,6 +291,9 @@ async def harvest_quarter(
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст».
raise
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
@ -399,6 +414,18 @@ async def _grid_walk_category(
requests += 1
server_errors += 1
continue
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: 403 WAF — бан IP, а не «этот cell не дошёл». Продолжать
# обход значит углублять бан и дописать в БД ложный нулевой слой.
# Зеркало уже исправленных nspd_bulk_client.get_features_in_bbox_grid
# и nspd_client.get_features_in_bbox_grid (#2464-G).
logger.warning(
"_grid_walk_category: WAF/rate-limit layer=%d quarter=%s cell=%d — прерываем",
layer_id,
quarter,
idx,
)
raise
except Exception as e:
# Прочие (сетевые / parse) ошибки одного cell — тоже не валим квартал,
# но это НЕ server-side 500 → не учитываем в server_errors (иначе сеть
@ -551,10 +578,20 @@ async def backfill_parcel_geom(
)
result.grid_walk_requests += n_requests
db.commit()
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464: бан IP / исчерпанные ретраи — НЕ «сбойный квартал». Голый
# except ниже их глотал, хотя его же комментарий обещал обратное:
# «WAF 403 пробросится из client и прервёт прогон». Прервать он не мог —
# ловил сам себя, и цикл шёл дальше по всем оставшимся кварталам, долбя
# уже блокирующий WAF и углубляя бан. Замер прода 20.08: limit=500
# участков раскладывается на 174 квартала, каждый — grid-walk по 49
# запросов, то есть до ~8500 обращений вместо остановки на первом.
# Тот же фикс, что в harvest_quarter выше (#2464-A) — там это место
# уже чинили, а это пропустили.
db.rollback()
raise
except Exception as e:
# Один сбойный квартал не валит весь backfill — лог + продолжаем.
# (WAF 403 пробросится из client и прервёт прогон — это ожидаемо,
# caller-task ловит и не ретраит, как в bulk_harvest.)
logger.warning("backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e)
db.rollback()
continue

View file

@ -251,9 +251,7 @@ def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]:
if summary:
lines.append(str(summary))
if not any(
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
):
if not any(section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")):
lines.append("Раздел рекомендации продукта в отчёте пуст.")
return _assemble(lines), sections_used

View file

@ -182,7 +182,24 @@ def _suggest_geocode(address: str, token: str) -> tuple[float, float] | None:
logger.info("dadata_client: suggest пусто для %r", address[:60])
return None
data = suggestions[0].get("data") or {}
# #2464: `or {}` ловит только falsy. Если DaData отдаст в `data` список или
# строку (дрейф контракта), `.get` ниже поднимет AttributeError — а он летит
# НАРУЖУ: сюда попадают из clean_address по фолбэку 401/403 (строка 112), то
# есть уже ЗА пределами её try/except, и у вызывающего гео-прохода
# (objective_backfill._geocode) обёртки тоже нет. Один такой ответ уронил бы
# весь проход целиком, а не один адрес.
#
# Соседние уровни в этом же файле проверяются через isinstance — `payload`,
# `suggestions[0]`, `item` в clean_address. Защита пропала ровно на один
# уровень глубже.
data = suggestions[0].get("data")
if not isinstance(data, dict):
logger.warning(
"dadata_client: suggest data не dict (%s) для %r",
type(data).__name__,
address[:60],
)
return None
lat = _coerce_float(data.get("geo_lat"))
lon = _coerce_float(data.get("geo_lon"))
if lat is None or lon is None:

View file

@ -229,8 +229,7 @@ def run_crossload(db: Session | None = None) -> dict[str, Any]:
except Exception as exc:
skipped += 1
logger.warning(
"etl_newbuilding_crossload: upsert failed "
"source=%s ext_id=%s: %s",
"etl_newbuilding_crossload: upsert failed source=%s ext_id=%s: %s",
params.get("source"),
params.get("ext_house_id"),
exc,

View file

@ -364,12 +364,15 @@ class CoreMatchReport:
ambiguous >1 objective-кандидатов по core в отчёт, разрешение вручную/гео.
skipped_taken objective_complex_name уже занят в mapping (UNIQUE-констрейнт;
его domrf-группа уже покрыта дубли не нужны).
taken_names сами занятые имена. Нужны гео-проходу (#2464): он разбирает
ambiguous по ВСЕМ кандидатам ядра, а занятого записать нельзя.
"""
tier_a: list[CoreMatch] = field(default_factory=list)
tier_b: list[CoreMatch] = field(default_factory=list)
ambiguous: list[CoreMatch] = field(default_factory=list)
skipped_taken: list[CoreMatch] = field(default_factory=list)
taken_names: set[str] = field(default_factory=set)
def counts(self) -> dict[str, int]:
return {
@ -456,6 +459,7 @@ def find_core_matches(db: Session) -> CoreMatchReport:
taken_names: set[str] = {
str(r[0]) for r in db.execute(_TAKEN_NAMES_SQL, {"group": OBJECTIVE_GROUP}).all()
}
report.taken_names = taken_names
# domrf-сторона: несопоставленные ЕКБ, latest snapshot per obj_id
for row in db.execute(_DOMRF_UNMAPPED_SQL).all():
@ -696,7 +700,8 @@ class GeoMatch:
@dataclass
class GeoReject:
"""Отклонённый гео-кандидат (для отчёта). reason: 'no_address' |
'no_geocode' | 'too_far' | 'ambiguous_multi' | 'call_limit'.
'no_geocode' | 'too_far' | 'ambiguous_multi' | 'partial_geocode' |
'all_candidates_taken' | 'call_limit'.
distance_m None когда дистанцию посчитать не удалось (нет адреса/геокода/
координат domrf).
@ -820,6 +825,7 @@ def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M)
tier_b = core_report.tier_b
ambiguous = core_report.ambiguous
taken_names = core_report.taken_names
if not tier_b and not ambiguous:
logger.info("find_geo_matches: нет tier_b/ambiguous кандидатов — nothing to do")
return report
@ -890,7 +896,19 @@ def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M)
if domrf_pt is None:
report.rejected.append(_geo_reject(m, "ambiguous", "no_geocode"))
continue
candidates = objective_by_core.get(m.core, [])
# Занятые objective-имена отсеиваем ДО геокода. Записать такое имя
# нельзя в принципе: apply_geo_matches вставляет с
# ON CONFLICT (objective_complex_name, objective_group) DO NOTHING, а
# _TAKEN_NAMES_SQL выбирает ровно по этому ключу. Раньше занятый кандидат
# мог оказаться единственным в радиусе и уходил в confirmed — прогон
# рапортовал подтверждение, которого запись затем молча не делала.
# Замер на проде 19.08: 14 неоднозначных строк (из 930 несопоставленных),
# 28 слотов кандидатов, из них 14 занятых; 3 адреса из 6 к геокоду —
# занятых. После отсева у всех 14 остаётся ровно один кандидат.
candidates = [c for c in objective_by_core.get(m.core, []) if c[0] not in taken_names]
if not candidates:
report.rejected.append(_geo_reject(m, "ambiguous", "all_candidates_taken"))
continue
in_radius: list[tuple[str, int | None, str, float]] = []
any_geocoded = False
geocoded_count = 0
@ -934,9 +952,12 @@ def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M)
reason = "call_limit" if report.call_limit_hit else "no_geocode"
report.rejected.append(_geo_reject(m, "ambiguous", reason))
else:
# 0 в радиусе, или >1 в радиусе → остаётся ambiguous
# Отделяем «никто не близко» от «близко несколько». После отсева
# занятых кандидат часто остаётся один, и метка ambiguous_multi при
# пустом in_radius была бы прямой неправдой в отчёте оператору.
nearest = min((d for *_, d in in_radius), default=None)
report.rejected.append(_geo_reject(m, "ambiguous", "ambiguous_multi", nearest))
reason = "ambiguous_multi" if in_radius else "too_far"
report.rejected.append(_geo_reject(m, "ambiguous", reason, nearest))
logger.info(
"find_geo_matches: %s call_limit_hit=%s",

View file

@ -41,6 +41,7 @@ from typing import TYPE_CHECKING, Any
# `_fc_*`-хелперы (нормализация forecast-словаря) — реэкспорт из report_pdf через
# full_report_html, тянем оттуда же (одна точка импорта).
from app.services.exporters.full_report_html import (
FLOOD_PROXIMITY_LABEL,
_as_dict,
_as_list,
_development_type_ru,
@ -297,7 +298,13 @@ def _build_zouit(doc: _DocxDocument, result: dict[str, Any]) -> None:
summary_pairs: list[tuple[str, Any]] = [
("Есть ЗОУИТ", has_zouit),
("Кол-во типов ЗОУИТ", zouit_count),
# Подпись именно «Кол-во ЗОУИТ», а не «типов»: значение приходит из
# encumbrance.zouit_count, а там `len(zouit_rows)` — число ЗАПИСЕЙ cad_zouit,
# пересёкших участок (parcels.py). Типы лежат отдельно, в zouit_types, и
# показаны строкой ниже. Прежняя подпись «Кол-во типов ЗОУИТ» расходилась со
# значением в 717 разборах из 1637 с ЗОУИТ — 43.8%, в среднем завышая «типы»
# в 1.35 раза (#2464).
("Кол-во ЗОУИТ", zouit_count),
]
if zouit_types:
summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types)))
@ -404,10 +411,16 @@ def _build_geotech_hydro(doc: _DocxDocument, result: dict[str, Any]) -> None:
("Балльность", geotech.get("seismic_intensity_balls")),
("Многолетняя мерзлота", geotech.get("permafrost")),
("Промобъектов в 500 м", geotech.get("industrial_within_500m")),
("Риск подтопления", hydro.get("flood_risk_flag")),
(FLOOD_PROXIMITY_LABEL, hydro.get("flood_risk_flag")),
]
pairs = [(k, v) for k, v in pairs if v not in (None, "")]
_add_kv_table(doc, pairs)
# #2934: та же оговорка, что в HTML-двойнике. Метка — общая константа оттуда же:
# обе таблицы собираются одинаковыми списками пар, и правка в одном файле молча
# разошлась бы с другим.
_hydro_note = hydro.get("note")
if _hydro_note:
doc.add_paragraph(str(_hydro_note))
water_rows = [
[w.get("name") or w.get("subtype"), _fmt_int_ru(w.get("distance_m"))]

View file

@ -402,6 +402,19 @@ def _kv_row(label: str, value: Any) -> str:
return f'<tr><td class="k">{html.escape(label)}</td><td class="v">{_esc(value)}</td></tr>'
# #2934: метка строки о подтоплении. Прежняя — «Риск подтопления» — утверждала
# результат проверки зон затопления, которой не было: значение берётся из
# hydrology.flood_risk_flag, а это близость реки или канала ближе 200 м по OSM.
# Ни cad_risk_zones (0 строк на проде), ни 11 слоёв risk_* НСПД (0 объектов на 669
# дампов) в него не входят. `_fmt(False)` печатал «нет», и читатель экспортированного
# документа получал «Риск подтопления — нет» как заключение.
#
# Константа общая с DOCX (`full_report_docx` импортирует хелперы отсюда): строка
# собирается в двух файлах одинаковыми списками пар, и разъезд формулировок был бы
# незаметен до чьей-нибудь жалобы.
FLOOD_PROXIMITY_LABEL = "Река или канал ближе 200 м (OSM)"
def _kv_table(pairs: list[tuple[str, Any]]) -> str:
"""Таблица «метка → значение» из списка пар. Пустой список → «нет данных». PURE."""
if not pairs:
@ -469,6 +482,12 @@ def _build_zoning(result: dict[str, Any]) -> str:
note = zoning.get("note")
note_html = f'<p class="alt-meta">{_esc(note)}</p>' if note else ""
return _no_data() + note_html
# Без этой строки годное легаси-зонирование признавалось пригодным выше и тут же
# терялось: ниже всё читается из nspd_zoning, а он в этой ветке пустой — все пары
# выходили None, отбрасывались фильтром, и §1 печатал «нет данных» ПОВЕРХ
# имеющихся данных. Соседний full_report_docx._build_zoning делает ровно это же
# присваивание (#2464).
nspd_zoning = zoning
zone_code = nspd_zoning.get("zone_code") or nspd_zoning.get("regulation_zone_index")
pairs: list[tuple[str, Any]] = [
@ -519,7 +538,13 @@ def _build_zouit(result: dict[str, Any]) -> str:
summary_pairs: list[tuple[str, Any]] = [
("Есть ЗОУИТ", has_zouit),
("Кол-во типов ЗОУИТ", zouit_count),
# Подпись именно «Кол-во ЗОУИТ», а не «типов»: значение приходит из
# encumbrance.zouit_count, а там `len(zouit_rows)` — число ЗАПИСЕЙ cad_zouit,
# пересёкших участок (parcels.py). Типы лежат отдельно, в zouit_types, и
# показаны строкой ниже. Прежняя подпись «Кол-во типов ЗОУИТ» расходилась со
# значением в 717 разборах из 1637 с ЗОУИТ — 43.8%, в среднем завышая «типы»
# в 1.35 раза (#2464).
("Кол-во ЗОУИТ", zouit_count),
]
if zouit_types:
summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types)))
@ -643,10 +668,16 @@ def _build_geotech_hydro(result: dict[str, Any]) -> str:
("Балльность", geotech.get("seismic_intensity_balls")),
("Многолетняя мерзлота", geotech.get("permafrost")),
("Промобъектов в 500 м", geotech.get("industrial_within_500m")),
("Риск подтопления", hydro.get("flood_risk_flag")),
(FLOOD_PROXIMITY_LABEL, hydro.get("flood_risk_flag")),
]
pairs = [(k, v) for k, v in pairs if v not in (None, "")]
geotech_table = _kv_table(pairs)
# Оговорка payload'а существовала и терялась ровно здесь, на границе экспортёра:
# фронт её печатает (HydrologyBlock.tsx), а PDF и DOCX — нет. Именно она говорит,
# что официальные зоны затопления живут в ЗОУИТ типа 33, а не в этой строке.
hydro_note = hydro.get("note")
if hydro_note:
geotech_table += f'<p class="alt-meta">{_esc(str(hydro_note))}</p>'
water_rows = [
[w.get("name") or w.get("subtype"), _fmt_int_ru(w.get("distance_m"))]

View file

@ -50,6 +50,12 @@ def build_layout_tz_html(
return "<td>—</td>"
return f"<td>{val:,.0f}".replace(",", " ") + " ₽</td>"
def _area_cell(val: float | None) -> str:
"""#2867: средняя площадь — None, если сделок за окно нет → «—», а не «0.0»."""
if val is None:
return "<td>—</td>"
return f"<td>{val:.1f}</td>"
def _price_m2_cell(val: float | None) -> str:
"""Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful)."""
if val is None:
@ -69,7 +75,7 @@ def build_layout_tz_html(
f"<td>{_html.escape(r.room_bucket)}</td>"
f"<td>{_html.escape(r.area_bin)}</td>"
f"<td>{r.velocity_per_month:.1f}</td>"
f"<td>{r.avg_area_m2:.1f}</td>"
f"{_area_cell(r.avg_area_m2)}"
f"{_price_cell(r.avg_price_per_m2_rub)}"
f"<td>{r.total_sold_in_window}</td>"
"</tr>"

View file

@ -270,9 +270,7 @@ def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None:
for name, payload in by_scenario.items():
data = _as_dict(payload)
rate_path = _as_dict(data.get("rate_path"))
rate_str = (
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
)
rate_str = ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
headers = [

View file

@ -85,8 +85,7 @@ _CONCEPT_FOOTPRINT_STYLE = {
}
_MAP_UNAVAILABLE_HTML = (
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют '
"в отчёте</div>"
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют в отчёте</div>'
)
@ -145,9 +144,22 @@ def _add_basemap(ax: Any) -> bool:
def _fetch() -> None:
cx.add_basemap(ax, crs=_WEB_MERCATOR, source=cx.providers.OpenStreetMap.Mapnik)
# #2464-C: НЕ `with ThreadPoolExecutor(...)`. Его __exit__ зовёт
# shutdown(wait=True) и ждёт, пока рабочий поток реально закончит — то есть
# result(timeout=...) ограничивал момент, когда мы перестаём ждать ЗНАЧЕНИЕ,
# а функция всё равно не возвращалась, пока висел tile-сервер. Заявленный
# «таймаут N секунд» не выполнялся: экспорт стоял столько, сколько стояло
# зависание.
#
# ЧЕСТНАЯ ЦЕНА: shutdown(wait=False) оставляет зависший поток жить до конца
# его собственного вызова. Это ограничивает ЗАПРОС, но не процесс —
# ThreadPoolExecutor держит потоки не-демонами и джойнит их в atexit, так что
# остановка воркера всё ещё может подождать зависший фетч. Меняем «висит
# генерация отчёта» на «висит один поток в фоне» — это осознанный размен,
# а не полное устранение.
pool = ThreadPoolExecutor(max_workers=1)
try:
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S)
pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S)
return True
except FuturesTimeoutError:
logger.warning(
@ -157,6 +169,10 @@ def _add_basemap(ax: Any) -> bool:
except Exception as exc: # тайлы недоступны: graceful fallback на белый фон, не валим экспорт
logger.warning("report_maps: OSM basemap недоступен (%s) — fallback белый фон", exc)
return False
finally:
# cancel_futures=True снимает ещё не начатые задачи; начатую — не отменит
# (Python не умеет прерывать поток), она просто доработает в фоне.
pool.shutdown(wait=False, cancel_futures=True)
# ── Общие хелперы фигуры ───────────────────────────────────────────────────────

View file

@ -348,9 +348,7 @@ def compute_affordability(
# Иначе сценарный платёж считался бы по «голой» key_rate (≈ на 4.5 п.п.
# ниже базовой ставки) и был бы НЕсопоставим с monthly_payment_rub (#1639).
market_scenario_rate = (
scenario_rate + _KEY_RATE_MARKET_SPREAD_PP
if scenario_rate is not None
else None
scenario_rate + _KEY_RATE_MARKET_SPREAD_PP if scenario_rate is not None else None
)
payment = _annuity(principal, market_scenario_rate, _ANNUITY_TERM_MONTHS)
if payment is not None:

View file

@ -3,10 +3,10 @@
#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
покрытие domrfobjective, глубина истории, шок-окно) в ОДИН отчётный уровень
покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
покрытие domrfobjective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987.
цена известна у 12% ближних ЖК»). Наполняет слот `ReportConfidence` отчёта #987.
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x
движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только
@ -24,13 +24,17 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ,
и причина это ПРОГОВАРИВАЕТ. Честность важнее оптимистичной метки.
ПОРОГИ (align с per-service gate'ами, которые читает движок):
deal_count зеркало market_metrics._confidence (n_lots/n_sold) + §9.6 _MIN_OBS:
deal_count зеркало market_metrics._confidence (n_lots/n_sold) + порог
rate_sensitivity._MIN_OBS:
мало сделок скоростные метрики статистически ненадёжны.
analog_count (ЖК-аналоги, = market_metrics.obj_count) high3 / medium2 / 1 low
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» ТЗ §15-пример).
domrf_coverage главный риск проекта (domrfobjective ~2.5%, см. market_metrics
docstring): низкое покрытие скрытый/будущий слой §9.3 недооценён.
history_months зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (2 года) / _MIN_OBS=8: короткий
domrf_coverage имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой
из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга
domrfobjective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08:
медиана 40%, среднее 31.7%. Низкое покрытие рынок и конкуренция оценены хуже.
history_months созвучно rate_sensitivity._CONF_HIGH_MIN_OBS=24 (2 года) /
_MIN_OBS=8 (НЕ §9.6: там свой _MIN_OBS=30, см. комментарий у констант): короткий
ряд связь ratesales / тренды не установлены.
confounded шок-окно (is_confounded_window, PR2): ряд пересекает структурный
разрыв оценки смещены (НИКОГДА не 'high').
@ -82,7 +86,8 @@ _SERVICE_RU_DEFAULT: str = "Компонент"
# deal_count: число сделок (продаж) за окно. high — длинная плотная выборка,
# medium — рабочий минимум, low — статистически ненадёжно (зеркало духа
# market_metrics: n_sold>0 обязателен; §9.6 _MIN_OBS=8 — пол для регрессии).
# market_metrics: n_sold>0 обязателен; rate_sensitivity._MIN_OBS=8 — пол для оценки
# чувствительности. НЕ §9.6: у регрессии §9.6 порог свой, _MIN_OBS=30.)
_DEAL_COUNT_HIGH: int = 50
_DEAL_COUNT_LOW: int = 15
@ -91,14 +96,22 @@ _DEAL_COUNT_LOW: int = 15
_ANALOG_COUNT_HIGH: int = 3
_ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low
# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%).
# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог
# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства).
# domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1] (имя ключа историческое,
# см. _coverage_factor). high — покрытие плотное; low — рынок оценён по меньшинству ЖК.
# medium-порог созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6.
# NB: пороги подбирались под ожидавшиеся ~2.5% покрытия маппинга, а реальная величина
# другого порядка (медиана 40%) — их стоит пересмотреть отдельно, замером, а не на глаз.
_DOMRF_COVERAGE_HIGH: float = 0.6
_DOMRF_COVERAGE_LOW: float = 0.2
# history_months: глубина ряда (мес). Зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) /
# _MIN_OBS=8 (пол): короткий ряд → тренды/чувствительность не установлены.
# history_months: глубина ряда (мес). Пороги созвучны rate_sensitivity:
# _CONF_HIGH_MIN_OBS=24 (≥2 года Δln-наблюдений) и _MIN_OBS=8 (пол, ниже которого
# чувствительность не считаем). Короткий ряд → тренды/чувствительность не установлены.
#
# #2464 кластер H: раньше обе константы приписывались «§9.6». Это неверный адрес —
# §9.6 (forecasting/regression.py) держит СВОЙ _MIN_OBS=30 (gate-порог для claim) и
# _MIN_FIT_OBS=8 (можно ли вообще фитить). Совпадение цифры 8 в двух модулях и сбило
# ссылку. Значения 24/8 верны, неверна была атрибуция.
_HISTORY_MONTHS_HIGH: int = 24
_HISTORY_MONTHS_LOW: int = 12
@ -252,23 +265,36 @@ _QUALITY_WORD: dict[Confidence, str] = {
def _coverage_factor(coverage: float | None) -> ConfidenceFactor:
"""domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
"""Покрытие рынка ценами Objective ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ
(структурный §15-пример «покрытие domrfobjective 2.5%»). None low.
#2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие
маппинга domrfobjective, но такого продьюсера НЕТ и не было: слот
`supply_layers.domrf_coverage` никто не заполняет (см. явную оговорку в
`orchestrator._summarize_supply_layers`), и значение ВСЕГДА приходит из
`analyze.market_data_coverage_pct` = `competitors_priced / competitors_total`,
то есть доля БЛИЖНИХ ЖК (3 км), у которых есть цена из Objective.
Замер на проде 13.08: 2074 анализа, min 0% · медиана 40% · среднее 31.7% ·
max 70%. Это не «~2.5% покрытия domrfobjective», как было написано здесь
раньше, другая величина другого порядка.
Ключ фактора НЕ переименован намеренно: его читает фронт
(`ForecastConfidenceBlock`, `ConfidencePanel`) как стабильный контракт.
Порог и значение не меняются правится только то, что читает человек.
None low.
"""
level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
if coverage is None:
note = (
"Доля будущих проектов с известными планировками и площадями неизвестна — "
"оценка будущего предложения и конкуренции менее надёжна"
"Доля ближних ЖК с известной ценой из Objective неизвестна — "
"оценка рынка и конкуренции менее надёжна"
)
else:
pct = round(float(coverage) * 100.0, 1)
note = (
f"Известные планировки и площади есть у {pct}% будущих проектов "
f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза "
"будущего предложения и конкуренции"
f"Цена из Objective известна у {pct}% ближних ЖК "
f"({_QUALITY_WORD[level]}) — от этого зависит точность оценки "
"рынка и конкуренции"
)
return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note)
@ -294,9 +320,7 @@ def _history_factor(history_months: int | None) -> ConfidenceFactor:
"ряде тренды и чувствительность спроса к ставке оцениваются хуже "
"(поэтому в 6.2 может остаться один сценарий вместо трёх)"
)
return ConfidenceFactor(
name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note
)
return ConfidenceFactor(name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note)
def _confounded_factor(confounded: bool) -> ConfidenceFactor:
@ -479,7 +503,9 @@ def compute_report_confidence(
deal_count_months: окно наблюдения для deal_count (мес) добавляет «за N мес»
в ноту фактора («7 сделок за 6 мес мало»). None нота без периода.
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
domrf_coverage: доля domrfobjective [0,1] (главный sparse-риск проекта).
domrf_coverage: доля ближних ЖК с ценой из Objective [0,1]. Имя ключа
историческое про маппинг domrfobjective, продьюсера для которого
нет и не было (#2464-H, см. _coverage_factor).
history_months: глубина ряда (мес).
confounded: True, если окно ряда пересекает шок-период (PR2).
advisory: весь стек советующий cap 'medium' (по умолчанию True; почти всегда).

View file

@ -96,10 +96,12 @@ _MACRO_COEF_NEUTRAL: float = 1.0
# режима (зеркалит дух лагов §9.6, где полугодовой лаг ловит ипотечный эффект).
_TREND_WINDOW_MONTHS: int = 6
# ── Named-константы: веса sub-factors (СУММА backed-весов = 0.45) ──────────────
# ── Named-константы: веса sub-factors (СУММА backed-весов = 0.53) ──────────────
# Веса — экспертная оценка вклада каждого канала в макрорежим спроса (НЕ фит).
# Заданы в ИСХОДНОМ (полном) наборе из 8 каналов; renorm делит на сумму ДОСТУПНЫХ.
# Backed-каналы (rate/mortgage_rate/issuance/overdue) несут основную массу: ставка и
# Backed-каналы (rate/mortgage_rate/issuance/overdue/inflation) несут основную массу:
# 0.18+0.12+0.10+0.05+0.08 = 0.53. Прежде здесь стояло 0.45 — цифра до #946, где
# inflation стал backed-каналом с весом 0.08; сумму тогда не обновили (#2464). Ставка и
# стоимость/доступность ипотеки — доминирующий драйвер первичного спроса в РФ.
# Degraded-каналы (gov/income/confidence) имеют НЕнулевые веса в схеме (резерв
# под будущие ряды), но СЕЙЧАС всегда None → в renorm не попадают.

View file

@ -301,16 +301,19 @@ def get_monthly_macro(
ЛЮБЫХ данных всё равно присутствует (все поля None для него кроме carry key_rate).
Graceful: при сбое БД или пустой таблице key_rate сетка месяцев всё равно
возвращается, но с None-полями (НЕ crash). Пустой список [] только если
сама сетка пуста (months_back < 0).
возвращается, но с None-полями (НЕ crash).
Пустой список [] недостижим: months_back клампится через max(0, ...), поэтому
даже при отрицательном вводе сетка содержит текущий месяц. Прежняя редакция
обещала [] «при months_back < 0» это описывало поведение, которого нет (#2464).
Args:
db: SQLAlchemy sync Session.
months_back: глубина ряда в месяцах (по умолчанию _DEFAULT_MONTHS_BACK).
Returns:
Список MonthlyMacro по возрастанию month (по непрерывной сетке);
[] только при пустой сетке (months_back < 0).
Список MonthlyMacro по возрастанию month (по непрерывной сетке).
Пустым не бывает: см. про клампинг выше.
"""
# month-bucketing в локальной tz сервера (single-region, как и весь codebase)
today = date.today()

View file

@ -203,15 +203,23 @@ def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None
def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | None) -> float | None:
"""Покрытие domrf↔objective ∈ [0,1] — для domrf_coverage #990. PURE.
"""Покрытие рынка ценами Objective ∈ [0,1] — для фактора domrf_coverage. PURE.
Главный sparse-риск проекта (~2.5%). Источники по приоритету (единица ЯВНАЯ
per-branch НЕ угадываем по величине, иначе настоящий sub-1% процент типа 0.8%
спутался бы с долей 0.8 = 80% и инфлировал бы confidence в exactly near-zero кейсе,
который §15 призван флагать):
`supply_layers.domrf_coverage` уже ДОЛЯ [0,1] (0.025) берём как есть.
`analyze.market_data_coverage_pct` всегда ПРОЦЕНТ (2.5 == 2.5%) /100 доля.
Нет сигнала None (#990 → тянет в low: слой §9.3 недооценён).
Источники по приоритету (единица ЯВНАЯ per-branch НЕ угадываем по величине,
иначе настоящий sub-1% процент типа 0.8% спутался бы с долей 0.8 = 80%):
`supply_layers.domrf_coverage` ДОЛЯ [0,1] берём как есть.
`analyze.market_data_coverage_pct` ПРОЦЕНТ (40 == 40%) /100 доля.
Нет сигнала None.
#2464-H, важно для читающего: **первая ветка не исполнялась ни разу**. Слот
`supply_layers.domrf_coverage` никто не заполняет `_summarize_supply_layers`
в orchestrator это прямо оговаривает («domrf_coverage здесь НЕ выводим нет
дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и
величина у неё другая: не «покрытие маппинга domrfobjective ~2.5%», как
было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective
замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%.
Порядок веток оставлен: если продьюсер появится, приоритет у него.
"""
if supply_layers is not None:
coverage = supply_layers.get("domrf_coverage")

View file

@ -479,8 +479,12 @@ def build_sales_series(
bias на старых месяцах каведат в module docstring).
Graceful: при сбое БД / пустых данных возвращается ряд по сетке с units=0,
area/price=None, confidence='low' (НЕ crash). Пустой ряд (months=[]) только
если сетка пуста (months_back < 0).
area/price=None, confidence='low' (НЕ crash).
Пустой ряд (months=[]) недостижим: months_back клампится через max(0, ...),
поэтому даже при отрицательном вводе сетка содержит текущий месяц. Прежняя
редакция обещала пустой ряд «при months_back < 0» это описывало поведение,
которого нет (#2464).
Args:
db: SQLAlchemy sync Session.

View file

@ -588,8 +588,13 @@ def _timing_overlap(
) -> float | None:
"""Ось тайминга: временна́я близость окон запуска. PURE.
exp(|Δмесяцев| / half_life): одновременный выход 1.0, расхождение в half_life мес
0.5, дальше затухает. Чем ближе наши запуски, тем сильнее пересекаются окна продаж
0.5 ** (|Δмесяцев| / half_life): одновременный выход 1.0, расхождение в half_life
мес ровно 0.5, дальше затухает.
Формула в докстринге раньше была записана как exp(Δ/half_life) она даёт при
Δ=half_life не 0.5, а exp(1) 0.368, то есть противоречила соседнему же
утверждению « 0.5». Верен КОД (строка ниже несёт то же пояснение); расходился
докстринг (#2464 кластер H). Чем ближе наши запуски, тем сильнее пересекаются окна продаж
= выше каннибализация. Любая дата None None (ось НЕДОСТУПНА НЕ фабрикуем). PURE.
"""
if candidate_month is None or own_month is None:

View file

@ -123,7 +123,7 @@ def get_house_type(section_type: str) -> HouseType:
return _BY_KEY[section_type]
except KeyError as exc:
raise KeyError(
f"unknown house type {section_type!r}; " f"available: {', '.join(sorted(_BY_KEY))}"
f"unknown house type {section_type!r}; available: {', '.join(sorted(_BY_KEY))}"
) from exc

View file

@ -163,6 +163,29 @@ def _financial_table(variants: Sequence[ConceptVariant]) -> str:
)
def _sales_phrase(financial: FinancialModel) -> str:
"""Фраза о сроке распродажи для методической сноски. PURE.
#2464: срок был зашит числом «30 мес» — при том, что ставка дисконта в той же
строке берётся из расчёта. 30 это ФОЛБЭК (`financial._SALES_DURATION_MONTHS`),
применяемый только когда рыночная скорость абсорбции не передана. Иначе окно
считается как площадь/скорость и клампится в [6, 120] мес, то есть сноска обещала
читателю не тот срок, по которому посчитан NPV.
Оба нужных поля уже есть в схеме: `sales_duration_months` (реализованное окно) и
`schedule_is_default` (честный флаг «норматив, а не рынок»). Отчёт Site Finder флаг
уже читает full_report_html.py:1335 и full_report_docx.py:855; игнорировал его
только этот экспортёр.
getattr с дефолтом тот же оборонительный приём, что у соседних полей: старый
сериализованный вариант без новых ключей не должен ронять экспорт.
"""
months = getattr(financial, "sales_duration_months", None)
if getattr(financial, "schedule_is_default", True) or months is None:
return "распродажа 30 мес (нормативный темп)"
return f"распродажа {months:.0f} мес (по рыночной абсорбции)"
def _build_html(variants: Sequence[ConceptVariant]) -> str:
if not variants:
return (
@ -172,6 +195,7 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
f"<p>{_DASH} нет вариантов для отображения</p></body></html>"
)
disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%"
sales_phrase = _sales_phrase(variants[0].financial)
return (
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head><body>"
f"<h1>{html.escape(_TITLE)}</h1>"
@ -179,7 +203,7 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
f"{_teap_table(variants)}"
f"{_financial_table(variants)}"
"<p class='sub'>NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз "
f"(ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт {disc_pct} годовых). "
f"(ПИР 6 мес → СМР по типу застройки → {sales_phrase}, дисконт {disc_pct} годовых). "
"График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; "
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "

View file

@ -316,8 +316,7 @@ def parse_parcel(
raise ParcelGeometryError("buildable area degenerated after setback")
if buildable.area < MIN_BUILDABLE_AREA_SQM:
raise ParcelGeometryError(
f"buildable area {buildable.area:.1f} sqm below minimum "
f"{MIN_BUILDABLE_AREA_SQM} sqm"
f"buildable area {buildable.area:.1f} sqm below minimum {MIN_BUILDABLE_AREA_SQM} sqm"
)
effective_step = _coarsen_step_for_budget(buildable, grid_step_m)

View file

@ -295,13 +295,17 @@ def place_program(
)
placed_for_item += 1
if placed_for_item < item.count:
# Печатаем ФАКТИЧЕСКИ использованные размеры fp_w/fp_d, а не каталожные
# house.footprint_* (#2464): если элемент программы переопределил габарит,
# прежнее сообщение называло размер, которым никто не пытался ставить, —
# диагностика уводила от причины «участок мал».
logger.warning(
"program: type=%s placed %d of %d sections (%.0fx%.0f m) — участок мал",
item.section_type,
placed_for_item,
item.count,
house.footprint_w_m,
house.footprint_d_m,
fp_w,
fp_d,
)
result = PlacedProgram(

View file

@ -124,22 +124,30 @@ def _fallback(job_type: str) -> dict[str, Any]:
def get_all(db) -> list[dict[str, Any]]:
"""Вернуть все строки job_settings. При ошибке БД — fallback на _DEFAULTS."""
try:
rows = (
db.execute(
text(
"""
SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
max_retries, max_concurrency, extra_config,
updated_at, updated_by, description
FROM job_settings
ORDER BY job_type
"""
with db.begin_nested():
rows = (
db.execute(
text(
"""
SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
max_retries, max_concurrency, extra_config,
updated_at, updated_by, description
FROM job_settings
ORDER BY job_type
"""
)
)
.mappings()
.all()
)
.mappings()
.all()
)
except Exception as e:
# #2464 cluster A: сессия ЧУЖАЯ — её отдаёт вызывающий (admin-ручка,
# beat_schedule, get_setting_value из cadastre_fetch/nspd_geo). Ошибка
# db.execute на Postgres оставляет транзакцию в aborted-состоянии, и все
# последующие запросы этой же сессии падают с «current transaction is
# aborted». Голый db.rollback() здесь НЕЛЬЗЯ: он снёс бы незакоммиченную
# работу вызывающего. Поэтому SAVEPOINT вокруг самого execute (см.
# developer_attribution.py:152, тот же кластер) — откатывается только он.
logger.warning("get_all job_settings: БД недоступна — fallback. %s", e)
return [_fallback(jt) for jt in _DEFAULTS]
@ -153,23 +161,25 @@ def get_all(db) -> list[dict[str, Any]]:
def get_one(job_type: str, db) -> dict[str, Any]:
"""Вернуть одну строку по job_type. При отсутствии — fallback с warning."""
try:
row = (
db.execute(
text(
"""
SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
max_retries, max_concurrency, extra_config,
updated_at, updated_by, description
FROM job_settings
WHERE job_type = :jt
"""
),
{"jt": job_type},
with db.begin_nested():
row = (
db.execute(
text(
"""
SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
max_retries, max_concurrency, extra_config,
updated_at, updated_by, description
FROM job_settings
WHERE job_type = :jt
"""
),
{"jt": job_type},
)
.mappings()
.first()
)
.mappings()
.first()
)
except Exception as e:
# См. get_all выше: SAVEPOINT, а не rollback — сессия принадлежит вызывающему.
logger.warning("get_one job_settings '%s': БД недоступна — fallback. %s", job_type, e)
return _fallback(job_type)

View file

@ -231,9 +231,7 @@ def _call_with_retries(
# #1209: cap И серверное Retry-After (раньше min(...,30) применялся
# только к exp.backoff). _MAX_BACKOFF_S — единый потолок для обеих
# веток, защищает anyio-threadpool от blocking на часы.
raw_wait = float(
e.retry_after if e.retry_after is not None else 2**attempt
)
raw_wait = float(e.retry_after if e.retry_after is not None else 2**attempt)
wait = min(raw_wait, _MAX_BACKOFF_S)
logger.warning(
"llm: HTTP %s (attempt %d/%d), backing off %.1fs (raw=%.1fs)",

View file

@ -463,7 +463,15 @@ def get_sqlite_info(sqlite_path: str | Path) -> dict[str, Any]:
}
if not p.exists():
return info
st = p.stat()
# stat() под защитой (#2464): между exists() и stat() файл может исчезнуть —
# его переписывает выгрузка Объектива. Раньше try/except покрывал только
# sqlite3.connect ниже, и OSError отсюда улетал наружу, превращая
# диагностическую функцию в источник отказа. Отдаём то, что успели узнать.
try:
st = p.stat()
except OSError as e:
info["stat_error"] = f"{type(e).__name__}: {e}"
return info
info["size_bytes"] = st.st_size
info["modified_at"] = st.st_mtime # epoch seconds
try:

View file

@ -77,8 +77,16 @@ STATUS_RESERVED = "reserved"
# Паттерны для извлечения статуса (issue #1609).
# Все морфоварианты: продан/продана/продано, забронирован[аоы]?, реализован[аоы]?.
#
# Группа `neg` (#2464): без неё отрицательные формы давали ОБРАТНЫЙ статус —
# «нереализована» содержит «реализована» и классифицировалась как sold,
# «не продана» → sold, «не забронирована» → reserved, «не в продаже» → free.
# `\s*` покрывает и слитную приставку («нереализована»), и раздельное «не
# продана»; `\b` перед «не» не даёт зацепиться за хвост другого слова («в цене
# продажи» — «не» внутри «цене» не на границе слова).
_STATUS_KW_RE = re.compile(
r"\s*продаже|свободн[аоы]?|free"
r"(?P<neg>\е\s*)?"
r"(?P<kw>в\s*продаже|свободн[аоы]?|free"
r"|продан[аоы]?|реализован[аоы]?|sold"
r"|забронирован[аоы]?|бронь|reserved)",
re.IGNORECASE | re.UNICODE,
@ -153,6 +161,26 @@ def _classify_status_kw(matched_text: str) -> str | None:
return None
def _status_in_text(text_: str) -> str | None:
"""Первый НЕотрицаемый статус-токен в тексте, иначе None.
Отрицание не переворачивается в противоположный статус, а гасит токен:
«не забронирована» не означает ни sold, ни free, а «не продана»
вывод, а не факт со страницы. Лучше отсутствие статуса, чем неверный.
Перебираем ВСЕ вхождения, а не только первое: блок «Квартира не продана.
Статус: в продаже» на первом совпадении дал бы None и был бы пропущен
целиком, хотя настоящий статус в нём есть.
"""
for m in _STATUS_KW_RE.finditer(text_):
if m.group("neg"):
continue
classified = _classify_status_kw(m.group("kw"))
if classified:
return classified
return None
# ── HTML fetching ─────────────────────────────────────────────────────────────
@ -372,7 +400,11 @@ def _extract_plan_from_next_data(html: str) -> str | None:
page_props = blob.get("props", {}).get("pageProps")
root: Any = page_props if isinstance(page_props, dict) else blob
# BFS по вложенному dict/list; ключ+значение проверяем на plan-hint.
# DFS по вложенному dict/list (stack.pop() — LIFO); ключ+значение проверяем на
# plan-hint. Раньше здесь стояло «BFS» — неверно, и это не косметика: функция
# возвращает ПЕРВОЕ найденное совпадение, а при упоре в cap (20 000 узлов) обход
# успевает посмотреть разные подмножества дерева. То есть порядок влияет и на то,
# какой план найдётся, и на то, найдётся ли (#2464 кластер H).
stack: list[Any] = [root]
seen = 0
while stack and seen < 20_000: # cap: защита от патологически глубокого JSON
@ -538,11 +570,9 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
status_from_badge: str | None = None
for cls, block_text in blocks:
if _STATUS_BADGE_CLS_RE.search(cls):
m = _STATUS_KW_RE.search(block_text)
if m:
status_from_badge = _classify_status_kw(m.group(1))
if status_from_badge:
break
status_from_badge = _status_in_text(block_text)
if status_from_badge:
break
if status_from_badge:
result["status"] = status_from_badge
@ -551,9 +581,7 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
status_label_value = _find_text_near(blocks, r"^статус$")
status_from_label: str | None = None
if status_label_value:
m2 = _STATUS_KW_RE.search(status_label_value)
if m2:
status_from_label = _classify_status_kw(m2.group(1))
status_from_label = _status_in_text(status_label_value)
if status_from_label:
result["status"] = status_from_label
@ -564,10 +592,9 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
free_candidate: bool = False
sold_reserved_found: str | None = None
for _cls, block_text in blocks:
m3 = _STATUS_KW_RE.search(block_text)
if not m3:
classified = _status_in_text(block_text)
if not classified:
continue
classified = _classify_status_kw(m3.group(1))
if classified in (STATUS_SOLD, STATUS_RESERVED):
sold_reserved_found = classified
break # точнее nav-текстов, дальше не ищем

View file

@ -29,6 +29,10 @@ from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedEr
logger = logging.getLogger(__name__)
# Сколько WAF-блоков ПОДРЯД прерывают батч (#2464). Одиночный блок бывает
# переходным (сессия перегреет cookies и восстановится), три подряд — стена.
_WAF_BREAKER_THRESHOLD = 3
# URL шаблон страницы объекта в каталоге DOM.РФ.
# Человекочитаемый вид: https://наш.дом.рф/сервисы/каталог-новостроек/объект/{obj_id}
CATALOG_OBJECT_PATH = "/сервисы/каталог-новостроек/объект/{obj_id}"
@ -340,22 +344,35 @@ async def scrape_catalog_object(
session: BrowserSession,
obj_id: int,
snapshot_date: date,
) -> bool:
) -> bool | None:
"""Scrape одного объекта: fetch HTML → extract __NEXT_DATA__ → parse → UPDATE.
Использует SAVEPOINT (begin_nested) для изоляции per-row ошибок.
Логирует результат через logger.info.
Returns:
True если UPDATE затронул строку, False при ошибке или 0 rows.
True UPDATE затронул строку;
None ПРОПУСК: строки (obj_id, snapshot_date) в БД нет. Это не сбой:
obj_id берутся из БД, но снимок мог смениться между выборкой и
UPDATE'ом. Раньше этот случай возвращал False и попадал в
счётчик failed вместе с настоящими ошибками, а объявленный в
контракте счётчик skipped всегда оставался нулём (#2464);
False сбой: не скачалось, не распарсилось, упал UPDATE.
Третье состояние сделано через None, а не через новый Literal, намеренно:
прежние True/False сохраняют смысл, поэтому вызывающие и тесты, полагающиеся
на них, не меняются.
"""
logger.info("catalog_object scrape start obj_id=%d snapshot_date=%s", obj_id, snapshot_date)
try:
html = await fetch_catalog_object_html(session, obj_id)
except WafBlockedError as exc:
logger.warning("catalog_object WAF blocked obj_id=%d: %s", obj_id, exc)
return False
except WafBlockedError:
# #2464: WAF-блок — не «этот объект не дошёл», а закрытая дверь. Раньше он
# гасился здесь и возвращался как обычная неудача, поэтому батч-цикл шёл
# дальше и слал ЖИВОЙ запрос на каждый оставшийся obj_id в уже забаненную
# сессию. Пробрасываем: решение принимает предохранитель в батче.
raise
except Exception as exc:
logger.warning("catalog_object fetch failed obj_id=%d: %s", obj_id, exc)
return False
@ -394,7 +411,7 @@ async def scrape_catalog_object(
obj_id,
snapshot_date,
)
return False
return None
logger.info(
"catalog_object scraped obj_id=%d fields=%d rows_updated=%d",
@ -457,17 +474,67 @@ async def scrape_catalog_objects(
# Idempotent — один вызов покрывает весь batch через этот BrowserSession.
await session.warm_up()
# #2464: предохранитель на серию WAF-блоков. Замер 20.08: в очереди 13200
# объектов из 13801, а DOM.РФ отдаёт страницу «Доступ заблокирован [403]»
# с капчей (#2443). Без предохранителя один прогон «Загрузить все» выдал бы
# 13200 живых запросов в забаненную сессию — ровно то, что углубляет бан
# (анти-бан-комментарий к BrowserSession выше про тот же path family).
# Порог не единица: одиночный блок бывает переходным, три подряд — стена.
consecutive_waf = 0
for obj_id in obj_ids:
stats["processed"] += 1
ok = await scrape_catalog_object(db, session, obj_id, snapshot_date)
try:
ok = await scrape_catalog_object(db, session, obj_id, snapshot_date)
except WafBlockedError as exc:
consecutive_waf += 1
stats["failed"] += 1
logger.warning(
"catalog_object WAF blocked obj_id=%d (подряд %d/%d): %s",
obj_id,
consecutive_waf,
_WAF_BREAKER_THRESHOLD,
exc,
)
if consecutive_waf >= _WAF_BREAKER_THRESHOLD:
stats["aborted_on_waf"] = 1
logger.error(
"scrape_catalog_objects: %d WAF-блока подряд — прерываю батч,"
" обработано %d из %d",
consecutive_waf,
stats["processed"],
len(obj_ids),
)
break
continue
consecutive_waf = 0
if ok is None:
# Строки в БД нет — это пропуск, а не сбой (см. контракт выше).
stats["skipped"] += 1
continue
if ok:
stats["succeeded"] += 1
# Фиксируем сразу, а не одним commit'ом в конце (#2464). Раньше весь
# батч жил в одной незакоммиченной транзакции, и любой отказ ПОСЛЕ
# цикла — исключение в BrowserSession.__aexit__, снятие Celery-таски,
# перезапуск контейнера — обнулял все уже успешные UPDATE'ы.
# Это не теория: беговой режим здесь force=True («Загрузить все»),
# то есть SQL без LIMIT. На 20.08.2026 в очереди 13200 объектов из
# 13801 — многочасовой прогон, где отказ в конце стоил бы всего.
# SAVEPOINT внутри scrape_catalog_object к этому моменту уже снят,
# поэтому commit здесь корректен.
try:
db.commit()
except Exception:
db.rollback()
raise
else:
stats["failed"] += 1
# Commit outer transaction: SAVEPOINT (`begin_nested`) releases внутри loop,
# но outer tx остаётся autobegin'd — без commit() все UPDATE'ы откатятся
# при db.close() в Celery task.
# Финальный commit. Успешные строки зафиксированы по ходу цикла (см. выше), но
# этот вызов остаётся: он закрывает транзакцию, которую могли autobegin'ить
# неудачные итерации (их SAVEPOINT откатился, а внешняя транзакция открыта),
# и сохраняет прежнее поведение для вызывающих, которые на него полагались.
try:
db.commit()
except Exception:

View file

@ -70,10 +70,32 @@ _TABLE_CAPTION_RE = {
def _page_contains_table(text: str, table_no: int) -> bool:
"""Возвращает True если текст страницы содержит заголовок таблицы N.
"""Возвращает True если на странице встречается строка «Таблица N».
Требуем явный caption «Таблица N» hint-только режим (оглавление, перекрёстные ссылки)
даёт false positive и подавляется. Если caption присутствует достаточно.
Это поиск подстроки, без разбора контекста. Строка ОГЛАВЛЕНИЯ
«Таблица 11 Баланс территории ....... 34» от настоящей подписи не отличается
падеж тот же, именительный, и вызывающий код ставит found_start=True прямо на
странице содержания, начиная выдирать таблицы оттуда.
Прежняя редакция этой докстроки утверждала обратное будто ложные срабатывания
оглавления и перекрёстных ссылок здесь отсеиваются. Такого кода никогда не было
(#2464). Обещание защиты, которой нет, опаснее её отсутствия: читающий не станет
её добавлять. (Старая формулировка тут намеренно пересказана, а не процитирована:
гейт test_2464_tep_docstring_truth ищет обещание по тексту и не отличил бы цитату
от утверждения.)
Замер 20.08.2026 уточняет и границы проблемы: перекрёстные ссылки в косвенных
падежах регекс НЕ ловит он требует именительное «Таблица N», поэтому
«приведены в таблице 12», «см. Таблицу 12», «табл. 12» дают False. Опасно
ровно оглавление, а не любое упоминание.
Почему подавление не реализовано здесь и сейчас: таблица `ekb_ppt_tep` на проде
пуста (0 строк) URL в `_SEED_DOCS` заглушка, живых PDF нет, эвристику отсева
оглавления не на чем откалибровать. Правило, придуманное без образцов, ловит
ровно те случаи, которые придумали вместе с ним. Условие для реализации хотя
бы один настоящий документ (#1136); целиться следует в признаки оглавления
(точки-выноски, номер страницы в конце строки, несколько подписей на одной
странице), а не в падежи.
"""
cap = _TABLE_CAPTION_RE[table_no]
return bool(cap.search(text))
@ -303,9 +325,7 @@ def _parse_table13(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
seen: set[tuple[str, str, str, str]] = set()
result: list[dict[str, Any]] = []
for rec in rows_clean:
area_key = (
f"{rec['area_ha_num']:.4f}" if rec["area_ha_num"] is not None else rec["area_ha"]
)
area_key = f"{rec['area_ha_num']:.4f}" if rec["area_ha_num"] is not None else rec["area_ha"]
key = (rec["phase"], rec["composition"], rec["zone"], area_key)
if key in seen:
continue

View file

@ -60,8 +60,11 @@ _SECTION = "razdel13"
# гоняем его после того, как регион уже покрыл основную массу (см. circuit breaker ниже).
SCHEMAS: tuple[str, ...] = ("agate_sverdregion", "agate_ekbgo")
# Группа источника (doc group key) → наш doc_group-код в БД (CHECK IN ('RS','RV')).
GROUP_CODE: dict[str, str] = {"DocRS": "RS", "DocRV": "RV"}
# Группа источника (doc group key) → наш doc_group-код в БД (CHECK IN ('RS','RV','IZ')).
#
# DocIZ («Изменение в Разрешение на строительство») есть на портале с самого начала,
# но в этом словаре его не было — 548 документов не грузились вовсе (#2986).
GROUP_CODE: dict[str, str] = {"DocRS": "RS", "DocRV": "RV", "DocIZ": "IZ"}
_HTTP_TIMEOUT = 20.0
_MAX_CONNECTIONS = 5
@ -259,11 +262,18 @@ def _existing_reg_dates(db: Session, schema: str) -> dict[str, date | None]:
def _upsert_permit(db: Session, rec: dict[str, Any]) -> str:
"""UPSERT одной записи в gisogd_permits по (doc_group, doc_num). Per-row SAVEPOINT.
"""UPSERT одной записи в gisogd_permits по source_key. Per-row SAVEPOINT.
Конфликт-резолв: при коллизии бизнес-ключа обновляем ТОЛЬКО если у новой записи
date_reg НЕ старше сохранённой (EXCLUDED.date_reg >= existing) предпочитаем
более позднюю регистрацию (или запись без даты не затирает датированную).
Ключ идентификатор документа на портале (#2986). Раньше ключом был
(doc_group, doc_num), но docNum у ГИСОГД НЕ уникален: разрешение и изменения к
нему носят один номер, и UPSERT оставлял только одно из них. Замер 20.08.2026:
так схлопывалось 2243 документа, а межсхемных дублей ради которых ключ и
вводился всего 7, и у них key ОБЩИЙ, то есть новый ключ их тоже склеивает.
Конфликт-резолв: при коллизии обновляем ТОЛЬКО если у новой записи date_reg НЕ
старше сохранённой (EXCLUDED.date_reg >= existing) предпочитаем более позднюю
регистрацию (или запись без даты не затирает датированную). Осмыслен ровно для
тех 7 межсхемных совпадений.
Returns: 'inserted' | 'updated' | 'skipped_unchanged'.
"""
@ -285,13 +295,14 @@ def _upsert_permit(db: Session, rec: dict[str, Any]) -> str:
ST_GeomFromGeoJSON(CAST(:geojson AS text)), 4326)) END,
NOW(), NOW()
)
ON CONFLICT (doc_group, doc_num) DO UPDATE
SET doc_name = EXCLUDED.doc_name,
ON CONFLICT (source_key) DO UPDATE
SET doc_group = EXCLUDED.doc_group,
doc_num = EXCLUDED.doc_num,
doc_name = EXCLUDED.doc_name,
date_doc = EXCLUDED.date_doc,
date_reg = EXCLUDED.date_reg,
approved_organization = EXCLUDED.approved_organization,
source_schema = EXCLUDED.source_schema,
source_key = EXCLUDED.source_key,
cad_nums = EXCLUDED.cad_nums,
geom = EXCLUDED.geom,
updated_at = NOW()

View file

@ -37,6 +37,33 @@ _RE_ACT_NUMBER = re.compile(
)
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
# ссылки на другой документ (#2464). «Сообщение о планируемом изъятии»
# открывается списком оснований, где первой строкой почти всегда стоит
# «Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об
# утверждении Генерального плана города»» — Генплан, а не акт об изъятии.
# Брать первую дату подряд означало ставить всем участкам дату Генплана.
#
# Действующее основание изъятия — постановление (Администрации города об
# утверждении проекта планировки/межевания либо Правительства области),
# поэтому дата принимается, только если слово стоит в предшествующем контексте.
# Ссылки-помехи в этих документах — «Решение … Думы» и «Приказ Министерства»,
# и ни одна из них слова «постановление» не содержит.
#
# Пробовал требовать ещё и «администраци»: на пяти прод-документах результат
# тот же 5 из 5, но правило ломает законный случай «Постановление № 509-ПП»
# (областное постановление без слова «администрация») — он уже закреплён
# тестом test_act_date_extracted_from_text. Взято более широкое условие:
# на живых данных оно не хуже, а лишнего не отсекает.
_ACT_CONTEXT_WORDS = ("постановлени",)
# Ширина окна контекста. OCR перемешивает колонки таблицы, и между словами
# «Постановление Администрации города» и «от DD.MM.YYYY» вклинивается текст
# соседней колонки («…Администрации города документами) Екатеринбурга от
# 19.04.2019…»), поэтому окно шире самой фразы (~50 символов). Откалибровано
# на пяти прод-документах land_reservation: 80, 120 и 160 дают одинаковые
# 5 из 5, выбрана середина.
_ACT_CONTEXT_WINDOW = 120
# Паттерн цели: «в целях…», «для …», «под строительство …» — best-effort.
_RE_PURPOSE = re.compile(
r"(?:для|в целях?|под)\s+([^.;,\n]{10,120})",
@ -207,7 +234,9 @@ def extract_izyatie_records(
# Реквизиты акта из заголовка или текста.
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
act_date = _extract_act_date(doc_title) or _extract_act_date(normalized)
act_date = _extract_act_date(doc_title) or _extract_act_date(
normalized, require_act_context=True
)
purpose = _extract_purpose(doc_title) or _extract_purpose(normalized)
# Поиск кад-номеров.
@ -268,19 +297,35 @@ def _extract_act_number(text: str) -> str | None:
return re.sub(r"\s+", "", m.group(1))
def _extract_act_date(text: str) -> str | None:
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE."""
m = _RE_ACT_DATE.search(text)
if not m:
return None
day, month, year = m.group(1), m.group(2), m.group(3)
try:
# Валидируем диапазоны.
d, mo, y = int(day), int(month), int(year)
def _act_context_matches(text: str, pos: int) -> bool:
"""Стоит ли перед датой упоминание постановления — акта-основания."""
ctx = text[max(0, pos - _ACT_CONTEXT_WINDOW) : pos].lower()
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
require_act_context=True брать только дату, перед которой стоит
упоминание постановления (#2464). Нужен для ТЕЛА
документа, где первой датой почти всегда идёт ссылка на Генплан-2004.
Для заголовка не нужен: там ссылок на посторонние акты нет.
Если подходящей даты нет, возвращается None. Это сознательно: отсутствие
даты честнее, чем дата чужого документа по ней нельзя ни отфильтровать
актуальные изъятия, ни сверить срок.
"""
for m in _RE_ACT_DATE.finditer(text):
if require_act_context and not _act_context_matches(text, m.start()):
continue
day, month, year = m.group(1), m.group(2), m.group(3)
try:
# Валидируем диапазоны.
d, mo, y = int(day), int(month), int(year)
except ValueError:
continue
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
return f"{y:04d}-{mo:02d}-{d:02d}"
except ValueError:
pass
return None

View file

@ -260,7 +260,19 @@ class QuarterDump:
- core: parcels + buildings + territorial_zones + red_lines + engineering
- zouit: 5 ЗОУИТ layers (G3)
- risks: 11 risk-zone layers (TIER 3)
Default = только core, чтобы не сжигать rate-limit на 17 запросов.
По умолчанию берутся core + zouit: `search_by_quarter(include_zouit=True,
include_risks=False)`. Прежняя редакция утверждала обратное будто по умолчанию
берётся один core ради экономии полутора десятков запросов (#2464). Неверно
вдвойне. Во-первых, `include_zouit` по умолчанию True, и 5 ЗОУИТ-слоёв входят в
дефолтный вызов; докстрока самого метода это говорит правильно. Во-вторых, порядок
величины не тот: territorial_zones/red_lines/engineering и все ЗОУИТ идут через
grid-walk при grid_n=7, то есть по 49 запросов КАЖДЫЙ дефолтный дамп это сотни
запросов. Экономит rate-limit только `include_risks=False`.
(Старая формулировка здесь пересказана, а не процитирована: гейт
test_2464_docstring_matches_code ищет обещание по тексту и не отличил бы
цитату от утверждения.)
"""
quarter_cad: str
@ -559,7 +571,11 @@ class NSPDClient:
"""
# Импортируем здесь чтобы избежать circular import:
# nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain)
from app.scrapers.nspd_bulk_client import NSPDBulkClient
from app.scrapers.nspd_bulk_client import (
NSPDBulkClient,
NspdBulkServerError,
NspdBulkWafError,
)
xmin, ymin, xmax, ymax = bbox
width_m = xmax - xmin
@ -607,10 +623,45 @@ class NSPDClient:
results = await asyncio.gather(*tasks, return_exceptions=True)
features: list[NSPDFeature] = []
for r in results:
if isinstance(r, Exception):
logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r)
# #2464-G: раньше ЛЮБОЕ исключение ячейки глушилось warning'ом и обход
# возвращал []. Отказ слоя (WAF-бан IP, 5xx на всех ячейках) становился
# неотличим от честного «здесь зон нет» — на проде это 124 дампа из 669
# с territorial_zones_count=0, из них у 50 legacy-слой данные нашёл.
# Ниже — зеркало уже исправленного близнеца
# nspd_bulk_client.get_features_in_bbox_grid (Issue #252-mirror).
server_errors = 0
ok_cells = 0
first_server_error: NspdBulkServerError | None = None
for idx, r in enumerate(results):
if isinstance(r, NspdBulkWafError):
# 403 WAF — бан IP. Пробрасываем немедленно: продолжать обход
# бессмысленно, а пустой результат соврал бы про отсутствие зон.
logger.warning(
"get_features_in_bbox_grid layer=%d cell=%d WAF 403 — прерываем обход: %s",
layer_id,
idx,
r,
)
raise r
if isinstance(r, NspdBulkServerError):
server_errors += 1
if first_server_error is None:
first_server_error = r
logger.debug(
"get_features_in_bbox_grid layer=%d cell=%d server error: %s",
layer_id,
idx,
r,
)
continue
if isinstance(r, Exception):
# Сетевые / parse-ошибки одной ячейки: обход не валим и НЕ
# считаем server-side, иначе сеть ложно поднимет layer_failed.
logger.warning(
"get_features_in_bbox_grid layer=%d cell=%d error: %s", layer_id, idx, r
)
continue
ok_cells += 1
for bulk_feat in r:
raw = {
"id": bulk_feat.id,
@ -618,6 +669,20 @@ class NSPDClient:
"properties": bulk_feat.properties,
}
features.append(NSPDFeature.from_raw(raw))
# Были server-side отказы И ни одна ячейка не прошла — лёг слой или
# весь NSPD. Возврат [] здесь означал бы «зон нет», хотя мы просто
# ничего не узнали. Пробрасываем, чтобы caller отличил одно от другого.
if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
logger.warning(
"get_features_in_bbox_grid layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
"(%d server errors, 0 успешных ячеек) — бросаем вместо ложного пустого",
layer_id,
effective_n,
effective_n,
server_errors,
)
raise first_server_error
return features
raw_features = asyncio.run(_run_grid())
@ -679,6 +744,10 @@ class NSPDClient:
dict[layerId, list[NSPDFeature]]. Ключи все запрошенные layerId
(пустой list если слой пуст / упал). Стабильная форма для caller'а.
"""
# Локальный импорт по той же причине, что в get_features_in_bbox_grid:
# nspd_client ← nspd_bulk_client дало бы circular import на top-level.
from app.scrapers.nspd_bulk_client import NspdBulkServerError
layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys())
result: dict[int, list[NSPDFeature]] = {}
for layer_id in layer_ids:
@ -686,7 +755,15 @@ class NSPDClient:
feats = self.get_features_in_bbox_grid(
layer_id, bbox_3857, grid_n=grid_n, step_m=step_m
)
except (NspdLiteError, NspdLiteWafError) as exc:
except (NspdLiteError, NspdLiteWafError, NspdBulkServerError) as exc:
# #2464-G: с этой правки grid-walk умеет бросать NspdBulkServerError
# («слой лёг целиком»). Здесь ловим его И оставляем прежнее поведение —
# пустой список на слой, — потому что именно это обещает докстрока
# («пустой list если слой пуст / упал») и на это опирается вызывающий.
# NspdBulkWafError НЕ ловим намеренно: 403 — это бан IP, продолжать
# обход остальных слоёв значит углублять бан.
# Ограничение честно: наружу отсюда «упал» и «пусто» по-прежнему
# неразличимы — у функции нет канала для флага. Отдельным заходом.
logger.warning(
"get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем",
layer_id,
@ -811,15 +888,24 @@ class NSPDClient:
Шаги:
1. `search_by_cad(quarter_cad, thematic_id=2)` получить полигон квартала
2. Compute bbox в EPSG:3857 из quarter geometry (или None если NSPD пуст)
3. Для каждого core layer `get_features_in_bbox(layer_id, bbox)`
4. Если include_zouit то же для 5 ЗОУИТ layers
5. Если include_risks то же для 11 risk layers
3. Core layers: parcels/buildings legacy `get_features_in_bbox`
(1 запрос); territorial_zones/red_lines/engineering_structures
`get_features_in_bbox_grid` при grid_n=7, то есть 49 запросов КАЖДЫЙ
(см. _GRID_WALK_LAYERS и docstring get_features_in_bbox_grid)
4. Если include_zouit 5 ЗОУИТ layers, все через grid-walk
5. Если include_risks 11 risk layers, все через grid-walk
Стоимость HTTP:
- core only: 1 (search) + 5 (core layers) = 6 запросов
- +zouit: +5 = 11 запросов
- +risks: +11 = 22 запроса
При rate_ms=600 один dump = ~3.6с (core) / ~6.6с (+zouit) / ~13с (всё).
- core only: 1 (search) + 2*1 (legacy) + 3*49 (grid) = 150 запросов
- +zouit: +5*49 = 395 запросов
- +risks: +11*49 = 934 запроса
При rate_ms=600 один dump = ~90с (core) / ~237с (+zouit) / ~560с (всё).
Прежняя редакция обещала 6/11/22 запроса и ~3.6с/~6.6с/~13с цифры для
мира, где все слои идут legacy-путём. Занижение в 25-42 раза, и это не
безобидно: по такой оценке слои включают не задумываясь, а объём запросов
здесь прямой фактор WAF-риска (#2464; ср. #2956, где НСПД сейчас отдаёт
403 на IP VPS).
Args:
quarter_cad: 3-сегментный cad-номер квартала, e.g. '66:41:0204016'.
@ -840,9 +926,19 @@ class NSPDClient:
`layers_fetched` в этом случае содержит только `('search',)`.
Raises:
NspdLiteWafError при 403/429 на любом из layer запросов caller
должен делать backoff. Partial-success НЕ возвращается; вся
операция атомарна (failure exception).
NspdLiteWafError при 403/429 на legacy-запросах (parcels/buildings)
caller должен делать backoff.
NspdBulkWafError при 403 на любой ячейке grid-walk-слоя (#2464-G) —
бан IP, обход прерывается сразу.
NspdBulkServerError когда grid-walk-слой сбойный ЦЕЛИКОМ (были 5xx и
ни одна ячейка не прошла) иначе вернулся бы пустой список,
неотличимый от честного «здесь ничего нет».
До #2464-G это место обещало атомарность, которой не было: grid-walk
глушил любое исключение ячейки и отдавал []. Теперь обещание верно
для отказа слоя и бана, но partial-success внутри слоя ВОЗМОЖЕН:
если часть ячеек упала по сети, а часть прошла, вернётся то, что
собралось, с warning'ом в лог на каждую упавшую ячейку.
Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors,
E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity.

View file

@ -324,7 +324,11 @@ def denorm_dump(
одной строки не откатывает весь batch.
Args:
db: SQLAlchemy Session. Caller отвечает за commit/close после вызова.
db: SQLAlchemy Session. Функция САМА делает commit в конце (см. ниже);
на вызывающем остаётся только close. Прежняя редакция обещала
обратное «caller отвечает за commit/close», и вызывающий,
понадеявшийся обернуть это в свою транзакцию, получил бы уже
зафиксированные строки (#2464).
quarter_cad: 3-сегментный кадастровый квартал.
features: плоский list из features_json JSONB (уже декодированный Python list).

View file

@ -127,10 +127,24 @@ def _parse_act_date(text: str) -> date | None:
def _detect_kind(text: str, default_kind: str) -> str:
"""Определяет тип операции: 'резервирование' | 'изъятие' | default_kind."""
if _RE_REZERV.search(text):
"""Определяет тип операции: 'резервирование' | 'изъятие' | default_kind.
Побеждает то слово, что встретилось РАНЬШЕ, а не то, что стоит выше в
коде (#2464). Прежний безусловный приоритет «резервир» переклассифицировал
ВЕСЬ документ все участки разом, если постановление об изъятии хоть
раз ссылалось на резервирование (типовая формулировка «ранее
зарезервированных земель», ссылка на утративший силу акт). Тема документа
стоит в заголовке, поэтому позиция первого упоминания сигнал сильнее
порядка проверок, и он симметричен: заголовок «О резервировании» так же
выигрывает у «изъятия» в теле.
"""
m_rez = _RE_REZERV.search(text)
m_izy = _RE_IZYAT.search(text)
if m_rez and m_izy:
return "резервирование" if m_rez.start() < m_izy.start() else "изъятие"
if m_rez:
return "резервирование"
if _RE_IZYAT.search(text):
if m_izy:
return "изъятие"
return default_kind

View file

@ -288,17 +288,47 @@ class BrowserSession:
Uses Playwright APIRequest which goes through the browser context same
cookies, same TLS fingerprint as the page itself.
Ретраи с backoff на транзиентных ответах (429 / 5xx / 0) так же, как в
get_json выше (#2464). Раньше их не было: один 429 под тем же WAF, под
которым get_json переживает до пяти попыток, ронял загрузку картинки
насовсем, и вызывающий (download_plan_image, download_photos) записывал
это в лог как «не удалось» неотличимо от «файла нет».
Непереходные коды (403, 404) поднимаются сразу, без ожидания: повтор их
не изменит, а под WAF лишний стук вредит.
"""
if self._context is None:
raise RuntimeError("BrowserSession not bootstrapped")
async with self._sem:
await jitter_sleep(200, 500) # Lighter throttle for static assets.
self._request_count += 1
resp = await self._context.request.get(
url,
headers={"Authorization": self.auth} if self.auth else {},
)
if resp.status != 200:
last_err: Exception | None = None
for attempt in range(5):
async with self._sem:
await jitter_sleep(200, 500) # Lighter throttle for static assets.
self._request_count += 1
try:
resp = await self._context.request.get(
url,
headers={"Authorization": self.auth} if self.auth else {},
)
except Exception as e:
last_err = e
logger.warning("download_binary err attempt=%d url=%s: %r", attempt, url, e)
await asyncio.sleep(2**attempt)
continue
status = resp.status
if status == 200:
return await resp.body()
body = await resp.text()
raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
return await resp.body()
# Разбор статуса — ВНЕ семафора: sleep не должен держать слот.
if status in (429,) or status >= 500:
last_err = RuntimeError(f"binary transient status={status}")
logger.warning(
"download_binary transient status=%d attempt=%d url=%s, backing off",
status,
attempt,
url,
)
await asyncio.sleep(2**attempt)
continue
raise RuntimeError(f"binary http {status}: {body[:200]}")
raise RuntimeError(f"binary max retries exhausted: {last_err!r}")

View file

@ -192,15 +192,30 @@ _INLINE_VELOCITY_SQL = text("""
SELECT
a.room_bucket,
SUM(a.deals_window) AS deals_window,
COALESCE(
-- #2867: БЕЗ COALESCE(...,0), как у avg_price_per_m2_rub ниже (#2464-B).
-- Сделок за окно нет делитель NULL средней площади нет, и это NULL,
-- а не «0 м²». Замер прода 13.08: 635 пустых пар (проект × комнатность)
-- из 2083, у 80 проектов пусты ВСЕ комнатности ноль выдумывался ровно
-- там, где окрестность беднее замапленными проектами. Схема объявлена
-- float | None, фронт и PDF печатают «».
(
SUM(a.area_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
/ NULLIF(SUM(a.deals_window), 0)
)::numeric(10, 2) AS avg_area_m2,
COALESCE(
-- #2464-B: БЕЗ COALESCE(...,0). Сделок за окно нет → делитель NULL →
-- средней цены нет, и это NULL, а не «0 /м²». Схема так и объявлена
-- (TopLayoutRow.avg_price_per_m2_rub: float | None), и Python ниже уже
-- умеет None (пропускает строку во взвешенном роллапе) но COALESCE
-- делал эту ветку недостижимой.
-- Замер 13.08 по проду, окно 6 месяцев. Сработает ноль или нет зависит
-- от того, сколько замапленных проектов попало в радиус, поэтому цифры
-- по слоям: у 616 проектов 2083 пары (проект × комнатность), пустых 635;
-- 323 проекта имеют хотя бы одну пустую комнатность, 80 пустые ВСЕ.
-- При объединении по два пустых остаётся 255 из 1267, по всему городу
-- ноль. То есть чем беднее окрестность участка, тем чаще выдумывался 0.
(
SUM(a.price_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
/ NULLIF(SUM(a.deals_window), 0)
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub,
array_agg(DISTINCT a.project_name) AS matched_project_names,
MIN(a.window_start) AS window_start,
@ -398,8 +413,18 @@ _SUPPLY_ONLY_LOTS_SQL = text("""
WHEN rooms_int IN (1, 2, 3) THEN rooms_int::text
ELSE '4+'
END AS rb,
-- #2464: площадь неизвестна — это ОТДЕЛЬНАЯ корзина, а не «<25».
-- Прежде NULL сваливался к настоящим студиям: на проде 20.08.2026
-- в продаже 11 557 квартир без area_pd против 7 013 реально
-- меньших 25 м², то есть корзина «<25» на 62 % состояла из
-- неизвестного и завышала долю мелких лотов в структуре остатков.
-- Зеркала у этого отображения не было: layout_signature.area_bin
-- принимает float и NULL-ветки не имеет вовсе.
-- Исключать такие лоты нельзя они реально в продаже, и без них
-- предложение занижалось бы на 6.4 %. Медиана площади у этой
-- корзины выйдет NULL (PERCENTILE_CONT игнорирует NULL) честно.
CASE
WHEN area_pd IS NULL THEN '<25'
WHEN area_pd IS NULL THEN 'н/д'
WHEN area_pd < 25 THEN '<25'
WHEN area_pd < 40 THEN '25-40'
WHEN area_pd < 60 THEN '40-60'
@ -1259,7 +1284,8 @@ def get_best_layouts(
for r in vel_rows:
room_bucket = str(r["room_bucket"])
deals_window = float(r["deals_window"]) if r["deals_window"] is not None else 0.0
avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else 0.0
# #2867: None остаётся None — «сделок нет» ≠ «0 м²».
avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else None
price_rub = (
float(r["avg_price_per_m2_rub"]) if r["avg_price_per_m2_rub"] is not None else None
)
@ -1342,7 +1368,10 @@ def get_best_layouts(
total_sold_in_window=int(row["sum_deals"]),
velocity_per_month=row["velocity_per_month"],
avg_price_per_m2_rub=row["avg_price_per_m2_rub"],
avg_area_m2=round(row["avg_area_m2"], 1),
# #2867: None (сделок нет) остаётся None — round(None) ронял бы сборку.
avg_area_m2=(
round(row["avg_area_m2"], 1) if row["avg_area_m2"] is not None else None
),
supply_units_in_radius=row["supply_units_in_radius"],
sold_pct_of_supply=row["sold_pct_of_supply"],
is_oversold=row["is_oversold"],
@ -1474,6 +1503,7 @@ def _build_recommendation(
# Группировка по room_bucket (строки уже могут быть per-bucket из MV GROUP BY)
rb_deals: dict[str, float] = {}
rb_area_weighted: dict[str, float] = {}
rb_area_total_deals: dict[str, float] = {} # #2867: знаменатель только по рядам с площадью
rb_price_weighted: dict[str, float] = {}
rb_price_total_deals: dict[str, float] = {}
all_competitor_ids: set[int] = set()
@ -1482,7 +1512,12 @@ def _build_recommendation(
rb = row["room_bucket"]
sd = float(row["sum_deals"])
rb_deals[rb] = rb_deals.get(rb, 0.0) + sd
rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd
# #2867: ряд без средней площади (сделок за окно нет) не участвует ни в числителе,
# ни в знаменателе взвешенной площади — как у цены ниже. Иначе его sd считался бы
# сделками «с площадью 0» и занижал среднее.
if row["avg_area_m2"] is not None:
rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd
rb_area_total_deals[rb] = rb_area_total_deals.get(rb, 0.0) + sd
all_competitor_ids.update(row["competitor_obj_ids"])
if row["avg_price_per_m2_rub"] is not None:
rb_price_weighted[rb] = rb_price_weighted.get(rb, 0.0) + (
@ -1496,8 +1531,12 @@ def _build_recommendation(
mix: list[LayoutTzMixRow] = []
for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]):
# #2867: делим на сделки рядов С площадью, а не на все — иначе ряды без площади
# занижали бы среднее; нет ни одного ряда с площадью → None, не 0.
avg_area = (
round(rb_area_weighted[rb] / rb_deals[rb], 1) if rb_deals.get(rb, 0) > 0 else None
round(rb_area_weighted[rb] / rb_area_total_deals[rb], 1)
if rb_area_total_deals.get(rb, 0) > 0
else None
)
abs_units: int | None = None
if target_total_flats is not None:

View file

@ -98,10 +98,17 @@ def cad_exists_in_db(db: Session, cad_num: str) -> bool:
def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
"""Найти существующий on-demand job (queued/running/paused) для этого cad.
Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60
секунд тоже None (чтобы повторно пробовать). Если есть DONE job, но cad
отсутствует в БД (на NSPD не нашлось) тоже None, но caller через
`fetch_status` отличит этот случай как `not_in_nspd`.
Возвращает job_id или None.
Неуспешные джобы не возвращаются НИКОГДА, независимо от давности: запрос отбирает
только `status IN ('queued','running','paused')`, и других статусов в нём нет.
Прежняя редакция обещала минутное окно давности для неуспешных (#2464) — такой
логики здесь никогда не было, временного фильтра в SQL нет вовсе. Обещание было
вдвойне вредным: оно подразумевало, что неуспешная джоба ПОСТАРШЕ вернётся как
активная (не вернётся), и отправляло отлаживающего искать окно, которого нет.
Если есть DONE job, но cad отсутствует в БД (на NSPD не нашлось) тоже None,
но caller через `fetch_status` отличит этот случай как `not_in_nspd`.
NB (issue #1356): 'paused' тоже считается active. Job переходит в 'paused'
при WAF (consecutive>=8) или Celery soft_time_limit (6h) нетронутые targets

View file

@ -113,32 +113,39 @@ def get_connection_capacity(
def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]:
"""Центры питания в радиусе от центроида участка (ST_DWithin geography)."""
rows = (
db.execute(
text("""
SELECT sc_name, dzo_name, voltage_class, load_index,
installed_capacity_mva, current_load_mva, reserve_mva,
reserve_unit, district, reserve_asof,
ST_Distance(
# #2464: SAVEPOINT вокруг запроса. Сессия ОБЩАЯ с вызывающим — при сбое без
# savepoint транзакция остаётся aborted, а исключение всплывает до
# _get_connection_capacity (full_report_pdf.py), где оно ГЛОТАЕТСЯ ради
# деградации §3. Дальше падает уже следующий запрос — рыночная цена в
# _generate_concept_result — и концепция молча уходит в class_norm-фолбэк.
# Четыре соседних _query_* в этом же файле savepoint имеют.
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT sc_name, dzo_name, voltage_class, load_index,
installed_capacity_mva, current_load_mva, reserve_mva,
reserve_unit, district, reserve_asof,
ST_Distance(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM power_supply_centers
WHERE geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM power_supply_centers
WHERE geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
"""),
{"wkt": parcel_wkt, "radius_m": radius_m},
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
"""),
{"wkt": parcel_wkt, "radius_m": radius_m},
)
.mappings()
.all()
)
.mappings()
.all()
)
points: list[dict] = []
for r in rows:
@ -209,24 +216,31 @@ def _query_water_latest(db: Session) -> list[dict]:
опубликовать водоотведение на квартал позже водоснабжения глобальный MAX
молча выкинул бы отстающий вид целиком.
"""
rows = (
db.execute(
text("""
SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day,
w.note, w.period
FROM water_supply_reserves w
WHERE w.period IS NOT NULL
AND w.period = (
SELECT MAX(w2.period) FROM water_supply_reserves w2
WHERE w2.period IS NOT NULL
AND w2.system_kind = w.system_kind
)
ORDER BY w.system_kind, w.system_name
""")
# #2464: SAVEPOINT вокруг запроса. Сессия ОБЩАЯ с вызывающим — при сбое без
# savepoint транзакция остаётся aborted, а исключение всплывает до
# _get_connection_capacity (full_report_pdf.py), где оно ГЛОТАЕТСЯ ради
# деградации §3. Дальше падает уже следующий запрос — рыночная цена в
# _generate_concept_result — и концепция молча уходит в class_norm-фолбэк.
# Четыре соседних _query_* в этом же файле savepoint имеют.
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day,
w.note, w.period
FROM water_supply_reserves w
WHERE w.period IS NOT NULL
AND w.period = (
SELECT MAX(w2.period) FROM water_supply_reserves w2
WHERE w2.period IS NOT NULL
AND w2.system_kind = w.system_kind
)
ORDER BY w.system_kind, w.system_name
""")
)
.mappings()
.all()
)
.mappings()
.all()
)
return [
{
"system_kind": r["system_kind"],

View file

@ -169,7 +169,16 @@ def _cell(row: tuple, idx: int) -> object:
def _pct_share_to_percent(value: object) -> float | None:
"""Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем.
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах.
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1).
#2464-B: продакшен-вызывающих у функции СЕЙЧАС НЕТ. Значение колонки E
раньше писалось в `load_index`, но это категориальная колонка
('open'|'limited'|'closed'|NULL) число в ней фронт отбрасывает в
«неизвестно» и плодит мусорный бакет в `power_summary.by_load_index`.
Функцию оставляю с тестами: она описывает формат листа, и она понадобится
в тот момент, когда под процент загрузки заведут числовую колонку.
Если такого решения не будет удалить вместе с тестом, а не держать молча.
None/мусор None.
"""
num = parse_reserve_number(value)
@ -214,7 +223,9 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
rows_seen += 1
district = _cell(row, 1) # B
load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %)
# Колонку E (степень загрузки ЦП долей) НЕ читаем и не храним: места
# под неё в power_supply_centers нет — load_index категориальный,
# current_load_mva в мегавольт-амперах (#2464-B, см. UPDATE ниже).
reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт)
name_norm = normalize_sc_name(str(sc_name))
@ -223,7 +234,6 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
"reserve": reserve,
"asof": reserve_asof,
"district": str(district).strip() if district else None,
"load_pct": load_pct,
"name_norm": name_norm,
}
@ -236,10 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
reserve_unit = 'МВт',
installed_capacity_mva = :installed,
district = :district,
load_index = COALESCE(
load_index,
CAST(:load_pct AS text)
),
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки.
-- load_index категориальная колонка
-- ('open'|'limited'|'closed'|NULL, см.
-- data/sql/180_connection_capacity.sql:35), её
-- заполняет rosseti_wfs_loader._map_load_index.
-- Раньше тут стоял COALESCE(load_index,
-- CAST(:load_pct AS text)) при пустой ячейке
-- в колонку легло бы число строкой ("72.5"),
-- а фронтовый classifyLoadIndex такое значение
-- отбрасывает в null («неизвестно»), и в
-- power_summary.by_load_index появился бы
-- бакет с именем "72.5".
-- Сегодня не стреляло только потому, что у всех
-- 3416 строк load_index уже заполнен
-- (open 2741 / limited 346 / closed 329, NULL 0)
-- и COALESCE не проваливался.
capacity_source = 'eesk_35_220',
reserve_asof = :asof
WHERE sc_name_norm = :name_norm

View file

@ -156,9 +156,7 @@ def _quarter_from_text(row_text: str) -> tuple[int, int] | None:
def build_card_url(org_id: int) -> str:
"""URL карточки организации в реестре ФАС (грид публикаций форм 14 / 4_6)."""
return (
f"{_CARD_URL}?reg={_REG}&orgId={org_id}" f"&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}"
)
return f"{_CARD_URL}?reg={_REG}&orgId={org_id}&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}"
def build_template_url(guid: str, pub_id: str) -> str:
@ -516,6 +514,23 @@ def load_heat_reserves(db: Session | None = None) -> dict[str, dict]:
except Exception as e:
logger.exception("load_heat_reserves: org %s failed: %s", org, e)
out[org] = {"error": str(e)}
if owns_session:
# Сбойная организация не должна тащить свои частичные записи
# в общий коммит следующих.
db.rollback()
continue
if owns_session:
# #2464: фиксируем ПОСЛЕ КАЖДОЙ организации, а не одним коммитом в
# конце. Раньше одна транзакция оставалась открытой на весь батч —
# восемь организаций, у каждой несколько HTTP-раундов к медленному
# внешнему реестру с таймаутом _HTTP_TIMEOUT=60с. Открытая транзакция
# столько времени держит соединение и тормозит vacuum, а падение в
# конце обнуляло бы всё уже собранное.
#
# ТОЛЬКО на своей сессии: при db, переданном вызывающим, транзакцией
# распоряжается он — коммитить её здесь значило бы зафиксировать
# чужую работу (то же правило, что для плоского rollback).
db.commit()
db.commit()
except Exception as e:
db.rollback()

View file

@ -46,7 +46,13 @@ def parcel_functional_zones(db: Session, parcel_wkt: str | None) -> list[dict[st
if not parcel_wkt:
return []
try:
rows = db.execute(_ZONE_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
rows = db.execute(_ZONE_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена или ошибка БД — graceful degrade (analyze не падает).
logger.warning(

View file

@ -327,8 +327,15 @@ def compute_gate_verdict(
sub17_overlaps: list[dict[str, Any]] = []
# cad_zouit path: сетевое обременение + keyword-blocker (утилитарная охранная зона).
cad_utility_overlaps: list[dict[str, Any]] = []
# Подпись вида сети для cad-detail (первый встреченный network_kind).
cad_utility_label: str | None = None
# Подписи видов сетей для cad-detail. Копим ВСЕ различённые виды, а не первый
# (#2464): покрытие ниже агрегируется по всем overlap'ам bucket'а, поэтому подпись
# от одного вида приписывала бы конкретную причину чужой площади. На проде
# 20.08.2026 это не редкость: 316 пересечений охранных зон РАЗНЫХ видов, 155
# зон вовлечено (чаще всего «тепловых сетей» × «инженерных коммуникаций»).
# Порядок в списке — по появлению, но наружу отдаём отсортированным: порядок
# overlap'ов задан `ORDER BY reg_numb_border, id`, а он к покрытию отношения
# не имеет, и делать подпись зависящей от него незачем.
cad_utility_labels: list[str] = []
for overlap in nspd_zouit_overlaps or []:
src = overlap.get("source", "nspd-quarter-dump")
if src == "cad_zouit":
@ -350,9 +357,7 @@ def compute_gate_verdict(
warnings.append(
Warning(
code="ZOUIT_CAD_SZZ",
detail=(
f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}"
),
detail=(f"СЗЗ ({overlap.get('type_zone', '')}): {overlap.get('name', '')}"),
)
)
elif net_kind is not None or any(
@ -360,17 +365,16 @@ def compute_gate_verdict(
):
# Утилитарная охранная зона — копим для area-gate (см. ниже).
cad_utility_overlaps.append(overlap)
if cad_utility_label is None and net_kind is not None:
cad_utility_label = overlap.get("network_kind_label") or network_kind_label(
net_kind
)
if net_kind is not None:
_lbl = overlap.get("network_kind_label") or network_kind_label(net_kind)
if _lbl and _lbl not in cad_utility_labels:
cad_utility_labels.append(_lbl)
else:
warnings.append(
Warning(
code="ZOUIT_CAD_OTHER",
detail=(
f"ЗОУИТ cad ({overlap.get('type_zone', '')}): "
f"{overlap.get('name', '')}"
f"ЗОУИТ cad ({overlap.get('type_zone', '')}): {overlap.get('name', '')}"
),
)
)
@ -438,14 +442,20 @@ def compute_gate_verdict(
pct = _coverage_pct_label(coverage)
# Код-различение сетевого обременения (#1070) vs общего охранного keyword-blocker:
# blocker'у с network_kind отдаём ZOUIT_NETWORK_OBREMENENIE, иначе ZOUIT_CAD_BLOCKER.
is_network = cad_utility_label is not None
is_network = bool(cad_utility_labels)
# Все различённые виды через запятую: покрытие — их объединение, и подпись
# обязана это отражать. Множественное число, когда видов больше одного.
cad_utility_label = ", ".join(sorted(cad_utility_labels))
_обременение = (
"Сетевые обременения" if len(cad_utility_labels) > 1 else "Сетевое обременение"
)
if coverage > threshold:
if is_network:
blockers.append(
Blocker(
code="ZOUIT_NETWORK_OBREMENENIE",
detail=(
f"Сетевое обременение ({cad_utility_label}) покрывает {pct}% "
f"{_обременение} ({cad_utility_label}) покрывает {pct}% "
f"участка — застройка МКД невозможна"
),
)

View file

@ -88,7 +88,13 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
if not parcel_wkt:
return []
try:
rows = db.execute(_GRANDDOC_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
rows = db.execute(_GRANDDOC_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_granddoc: planning_projects недоступна, skip: %s", exc)
return []

View file

@ -58,7 +58,13 @@ def parcel_ird_overlaps(db: Session, parcel_wkt: str | None) -> dict[str, Any]:
if not parcel_wkt:
return {"ird_overlaps": [], "ird_by_kind": {}}
try:
rows = db.execute(_IRD_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
rows = db.execute(_IRD_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError, DataError) as exc:
# OperationalError — БД недоступна / connection drop.
# ProgrammingError — ird_overlays ещё не задеплоена (UndefinedTable, pre-migration).

View file

@ -943,8 +943,7 @@ def compute_offer_price_trend(
delta_pct = (last_median - first_median) / first_median * 100.0
logger.info(
"offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d "
"lots_latest=%s delta_pct=%s",
"offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d lots_latest=%s delta_pct=%s",
center_lat,
center_lon,
radius_m,

View file

@ -91,10 +91,10 @@ def _build_overpass_query(key: str, value: str, el_type: str) -> str:
bbox = f"({south},{west},{north},{east})"
if el_type == "nwr":
# node + way: точки подключения бывают и точкой, и площадкой
return f"[out:json][timeout:30];" f'nwr["{key}"="{value}"]{bbox};' f"out geom;"
return f'[out:json][timeout:30];nwr["{key}"="{value}"]{bbox};out geom;'
if el_type == "way":
return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;"
return f"[out:json][timeout:30];" f'node["{key}"="{value}"]{bbox};' f"out body;"
return f'[out:json][timeout:30];way["{key}"="{value}"]{bbox};out geom;'
return f'[out:json][timeout:30];node["{key}"="{value}"]{bbox};out body;'
async def fetch_overpass_noise() -> list[dict]:

View file

@ -24,9 +24,7 @@ logger = logging.getLogger(__name__)
_ORS_MATRIX_BASE = "https://api.openrouteservice.org/v2/matrix"
# Профили ORS-routing. foot-walking — пеший радиус (метро/школа/магазин),
# driving-car — авто (для будущих авто-категорий).
VALID_PROFILES: frozenset[str] = frozenset(
{"foot-walking", "cycling-regular", "driving-car"}
)
VALID_PROFILES: frozenset[str] = frozenset({"foot-walking", "cycling-regular", "driving-car"})
# ORS /matrix ограничивает foot-walking ~2000 пар (sources×destinations) на free tier.
MAX_MATRIX_DESTINATIONS = 1000
_DEFAULT_TIMEOUT_S = 12.0
@ -128,7 +126,16 @@ def matrix_durations_min(
if sec is None:
out.append(None) # ORS не построил маршрут до этой точки
else:
out.append(float(sec) / 60.0)
# #2464: тот же довод, что у проверки длины ниже — нечисловое значение
# дало бы ValueError/TypeError мимо OrsUnavailableError, а вызывающий
# (poi_score.py:348) ловит только её. Принцип в этом файле уже
# сформулирован, просто не применён к самой конверсии.
try:
out.append(float(sec) / 60.0)
except (TypeError, ValueError) as exc:
raise OrsUnavailableError(
f"ORS matrix: нечисловая длительность {sec!r} в durations"
) from exc
# Длина durations должна совпадать с числом destinations — иначе zip(strict=True)
# у вызывающего бросит ValueError (не OrsUnavailableError) → 500. Закрываем как ORS-сбой.
if len(out) != len(dests):

View file

@ -96,8 +96,7 @@ def get_road_distances_m(
# Координаты `;`-joined: origin первой (→ sources=0), затем POI по порядку.
coords = ";".join(
[_fmt_coord(origin_lon, origin_lat)]
+ [_fmt_coord(lon, lat) for lon, lat in destinations]
[_fmt_coord(origin_lon, origin_lat)] + [_fmt_coord(lon, lat) for lon, lat in destinations]
)
base = (base_url if base_url is not None else settings.osrm_local_url).rstrip("/")
url = f"{base}/table/v1/{profile}/{coords}"
@ -134,7 +133,17 @@ def get_road_distances_m(
if d is None:
out.append(None) # OSRM не построил маршрут до этой точки
else:
out.append(float(d))
# #2464: конверсия обязана падать в ДОМЕННУЮ ошибку. Весь файл переводит
# любую кривизну ответа в OsrmLocalUnavailableError (строки выше), потому
# что вызывающий (parcels.py:399) ловит ТОЛЬКО её и уходит на прямолинейный
# fallback. Голый float() на нечисловом значении поднял бы ValueError или
# TypeError — они пролетят мимо и дадут 500 на /analyze вместо деградации.
try:
out.append(float(d))
except (TypeError, ValueError) as exc:
raise OsrmLocalUnavailableError(
f"OSRM table: нечисловое расстояние {d!r} в distances"
) from exc
# Длина должна совпадать с числом destinations — иначе zip у вызывающего
# рассинхронит POI↔distance. Закрываем как OSRM-сбой → straight-line fallback.

View file

@ -118,6 +118,37 @@ def select_calibrated_price(
return None, "class_norm"
# Потолки правдоподобия для параметров градрегламента (#2464). Не нормативные
# лимиты, а сито против порчи разбора: самый плотный жилой КСИТ в РФ — единицы,
# самый высокий жилой дом — меньше 100 этажей. Прод 20.08.2026: far 1..4,
# floors 0..5 — запас больше чем семикратный.
_MAX_PLAUSIBLE_FAR: float = 30.0
_MAX_PLAUSIBLE_FLOORS: int = 100
def _sane(value: float | None, low: float, high: float, name: str) -> float | None:
"""Вернуть значение, если оно в (low, high]; иначе None с предупреждением.
Ноль и отрицательные отбрасываются молча их отсутствие уже штатно
обрабатывается ветвями ниже, и логировать «в регламенте нет параметра»
незачем. Предупреждаем только о значениях ВНЕ верхней границы: это признак
порчи разбора, и его нужно видеть.
"""
if value is None or value <= low:
return None
if value > high:
logger.warning(
"synthesize_teap: %s=%s вне правдоподобного диапазона (%s, %s] — "
"параметр отброшен, расчёт продолжен по остальным",
name,
value,
low,
high,
)
return None
return float(value)
def synthesize_teap_from_buildability(
*,
area_m2: float | None,
@ -147,6 +178,30 @@ def synthesize_teap_from_buildability(
if area_m2 is None or area_m2 <= 0:
return None
# ── Санитария входа (#2464) ────────────────────────────────────────────────
# Параметры приходят из ПЗЗ-регламента (zone_regulation_cache) — это внешние
# разобранные данные, а не наши вычисления. Проверялось только `> 0`, поэтому
# процент застройки 150 дал бы пятно БОЛЬШЕ участка, а дальше — жилую площадь,
# число квартир и выручку, физически невозможные, но поданные как обычные
# цифры финмодели.
#
# Невозможное значение ОТБРАСЫВАЕМ, а не роняем расчёт: если рядом есть КСИТ,
# GFA считается по нему и остаётся верной. Лучше отсутствие параметра, чем
# неверный — тот же принцип, что в остальных правках этого эпика.
#
# Границы взяты с запасом к реальным данным прода 20.08.2026
# (33 строки zone_regulation_cache: pct 0..100, far 1..4, floors 0..5),
# чтобы ловить порчу разбора, а не отсекать законные значения.
max_building_pct = _sane(max_building_pct, 0.0, 100.0, "max_building_pct")
max_far = _sane(max_far, 0.0, _MAX_PLAUSIBLE_FAR, "max_far")
max_floors_f = _sane(
float(max_floors) if max_floors is not None else None,
0.0,
float(_MAX_PLAUSIBLE_FLOORS),
"max_floors",
)
max_floors = int(max_floors_f) if max_floors_f is not None else None
# ── GFA: предпочитаем КСИТ/max_far; иначе % застройки × этажность ───────────
gfa: float
if max_far is not None and max_far > 0:
@ -169,8 +224,25 @@ def synthesize_teap_from_buildability(
# Нет %застройки → пятно ≈ GFA / этажность.
built_area = gfa / max_floors
else:
# Нет ни процента, ни этажности — пятно оцениваем как GFA (неявно «один этаж»).
built_area = gfa
# Пятно застройки физически не может превышать участок (#2464). Это не эвристика,
# а геометрия. Ветка выше (`built_area = gfa`) нарушала её при КСИТ > 1: участок
# 10 000 м² с far=2 давал пятно 20 000 м². Ограничение вводится ЗДЕСЬ, а не в
# каждой ветке, чтобы инвариант держался и для будущих способов оценки пятна.
if built_area > area_m2:
logger.warning(
"synthesize_teap: пятно %.0f м² превысило участок %.0f м² — ограничено "
"площадью участка (far=%s, pct=%s, floors=%s)",
built_area,
area_m2,
max_far,
max_building_pct,
max_floors,
)
built_area = area_m2
# Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA до расчёта жилой — точно
# как compute_teap: жилая считается по ОСТАВШЕЙСЯ GFA, total (gfa) не меняется.
office_share = _OFFICE_SHARE_OF_GFA[housing_class]

View file

@ -50,7 +50,13 @@ _PERMITS_NEARBY_SQL = text("""
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM gisogd_permits
WHERE geom IS NOT NULL
-- Только РНС/РВЭ: агрегат обещает total_count = rs_count + rv_count, а с
-- #2986 в таблице появилась третья группа 'IZ' (изменения в разрешение).
-- Она попадала бы в total и не попадала ни в один из счётчиков молчаливое
-- расхождение. Показывать ли изменения отдельной строкой в §6 вопрос
-- продуктовый (см. #2986); до его решения выборка сужена явно, а не молча.
WHERE doc_group IN ('RS', 'RV')
AND geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,

View file

@ -45,7 +45,13 @@ def parcel_planning_overlaps(db: Session, parcel_wkt: str | None) -> list[dict[s
if not parcel_wkt:
return []
try:
rows = db.execute(_PLANNING_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
rows = db.execute(_PLANNING_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning("parcel_planning_overlaps: planning_projects недоступна, skip: %s", exc)

View file

@ -11,7 +11,7 @@ from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
PKK6_URL = "https://pkk.rosreestr.ru/arcgis/rest/services/PKK6/ZONES/" "MapServer/5/query"
PKK6_URL = "https://pkk.rosreestr.ru/arcgis/rest/services/PKK6/ZONES/MapServer/5/query"
# bbox ЕКБ: (xmin, ymin, xmax, ymax) в WGS84
EKB_BBOX = (60.5, 56.7, 60.75, 56.95)

View file

@ -160,6 +160,9 @@ def make_empty_result(
"harvest_triggered": harvest_triggered,
"total_features": total_features,
"harvest_eta_seconds": harvest_eta_seconds,
# Дампа нет — про риск-слои не известно ничего. None, а не 0:
# ноль означал бы «спросили и не нашли».
"risks_count": None,
},
}
@ -297,6 +300,20 @@ def get_quarter_dump_data(
"harvest_triggered": False,
"total_features": total_features,
"harvest_eta_seconds": None,
# #2934: сколько объектов риск-слоёв лежит в дампе КВАРТАЛА. Нужен, чтобы
# отличить «слой опрошен, на участке чисто» от «слой не дал ничего вообще».
# Пустой `nspd_risk_zones` сам по себе этих случаев не различает, и фронт
# рисовал по нему зелёное «Риски не обнаружены».
#
# Замер 19.08: risks_count = 0 у ВСЕХ 669 дампов (для сравнения zouit_count > 0
# у 581), то есть сегодня признак всегда говорит «не подтверждено». Это и есть
# правда: одиннадцать слоёв природного риска ни разу не вернули ни одного
# объекта.
#
# Намеренно НЕ отдаём layers_fetched: `layers_fetched.append(...)` в
# nspd_client.py:956 стоит ДО запроса, поэтому как признак «слой опрошен» он
# лжёт. Пока append не перенесён после успешного ответа, наружу его нельзя.
"risks_count": risks_count,
}
if parcel_wkt is None:

View file

@ -45,7 +45,13 @@ def parcel_reservations(db: Session, cad_num: str | None) -> list[dict[str, obje
if not cad_num:
return []
try:
rows = db.execute(_LOOKUP_SQL, {"cad_num": cad_num}).mappings().all()
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
rows = db.execute(_LOOKUP_SQL, {"cad_num": cad_num}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning("parcel_reservations: land_reservation недоступна, skip: %s", exc)

View file

@ -36,6 +36,48 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Конкуренты в радиусе — модульная константа (а не inline f-string), чтобы
# integration-тест мог прогнать EXPLAIN по обеим подстановкам `{class_filter}`.
# Ветка с фильтром до #2464-G не парсилась вообще: ссылалась на алиас `o`,
# которого внутри CTE нет (`missing FROM-clause entry for table "o"`).
_COMPETITORS_SQL_TMPL = """
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
-- #38: эффективный класс — реальный, иначе fallback
COALESCE(obj_class, obj_class_fallback) AS obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
# Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median).
# Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на
# один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка:
@ -173,9 +215,17 @@ def compute_velocity(
# только если явно передан. #38: при NULL реального класса используем
# obj_class_fallback (yandex_match / price_inference) — реальный obj_class
# в приоритете (COALESCE), поведение для размеченных ЖК не меняется.
class_filter = (
"AND COALESCE(o.obj_class, o.obj_class_fallback) = :obj_class" if obj_class else ""
)
# Колонки БЕЗ алиаса: фильтр подставляется ВНУТРЬ latest_obj, где FROM —
# голый domrf_kn_objects. Алиас `o` появляется только во внешнем SELECT,
# и `o.obj_class` здесь давал `missing FROM-clause entry for table "o"`
# (#2464-G, прод-EXPLAIN 13.08). Ошибку глотал except ниже → velocity
# молча выпадал из отчёта. Не срабатывало только потому, что единственный
# вызывающий (parcels.py) obj_class не передаёт.
# NB для первого, кто ветку включит: сравнение точное и регистрозависимое, а
# в проде классы с большой буквы и словарь шире ожидаемого — «Комфорт» 870,
# «Типовой» 224, «Бизнес» 95, «Премиум» 13, «Элит» 12, «Стандарт» 9,
# «Элитный» 4 объекта (замер 13.08). Передавать нужно ровно эти строки.
class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :obj_class" if obj_class else ""
# SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx.
# db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction
# (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup).
@ -183,45 +233,7 @@ def compute_velocity(
with db.begin_nested():
comp_rows = (
db.execute(
text(
f"""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
-- #38: эффективный класс — реальный, иначе fallback
COALESCE(obj_class, obj_class_fallback) AS obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
),
text(_COMPETITORS_SQL_TMPL.format(class_filter=class_filter)),
{
"parcel_wkt": parcel_geom_wkt,
"radius_m": radius_km * 1000.0,

View file

@ -458,11 +458,15 @@ def load_water_reserves_from_docx(
system_kind: str,
docx_bytes: bytes,
source_url: str = "",
) -> dict[str, int]:
) -> dict[str, object]:
"""Парсит docx-байты → UPSERT ЦСВ/ЦСК в water_supply_reserves.
Выделено из load_water_reserves для юнит-теста на синтетическом docx.
Читает word/document.xml из zip, forward-fill vMerge, извлечение записей.
Возвращает счётчики (`records`, `inserted`, `updated`, ) И `period` строку
вида «III кв. 2025» либо None. Раньше тип был `dict[str, int]`, и ради него
период выбрасывался фильтром на выходе, хотя в лог печатался (#2464).
"""
with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf:
document_xml = zf.read("word/document.xml")
@ -485,9 +489,19 @@ def load_water_reserves_from_docx(
logger.exception("load_water_reserves_from_docx: outer tx rolled back: %s", e)
raise
result = {"records": len(records), **counts, "period": period} # type: ignore[dict-item]
# `period` возвращаем вместе с остальным (#2464). Раньше стоял фильтр
# `isinstance(v, int)`, который выбрасывал его ВСЕГДА — период это строка или
# None. В лог при этом печатался полный словарь, поэтому по логам казалось, что
# период отдаётся, а вызывающий его не получал никогда.
#
# Фильтр ничего не защищал: соседняя ветка `load_water_reserves` кладёт в тот же
# словарь `{"error": str(...)}`, то есть «только int» контрактом не было, а
# единственный потребитель (задача sync_water_reserves) результат логирует и
# возвращает как есть. Период при этом полезен: он говорит, за какой квартал
# данные, — без него «загружено 42 записи» не отличить от прошлогодних.
result: dict[str, object] = {"records": len(records), **counts, "period": period}
logger.info("water_reserves[%s] done: %s", system_kind, result)
return {k: v for k, v in result.items() if isinstance(v, int)}
return result
def load_water_reserves(db: Session | None = None) -> dict[str, dict]:

View file

@ -96,11 +96,20 @@ _SELECT_BY_ID = f"""
AND id = :profile_id
"""
# ORDER BY здесь не украшение (#2464): без него LIMIT 1 брал произвольную строку,
# и при двух дефолтах у одного пользователя выбор мог молча перескакивать между
# ними от запроса к запросу. Соседние запросы этого файла тай-брейк по id уже
# имеют (см. ORDER BY is_default DESC, id ASC выше) — приводим к ним.
#
# Сам случай «два дефолта» с миграции 190 невозможен: частичный уникальный индекс
# user_weight_profiles_one_default (user_id) WHERE is_default. ORDER BY остаётся
# вторым рубежом — на случай, если индекс когда-нибудь снимут.
_SELECT_DEFAULT = f"""
SELECT {_SELECT_COLS}
FROM user_weight_profiles
WHERE user_id = :user_id
AND is_default = TRUE
ORDER BY id ASC
LIMIT 1
"""

View file

@ -299,7 +299,13 @@ def get_cached_zone_regulation(
if not zone_index:
return None
try:
row = db.execute(_SELECT_SQL, {"city": city, "zone_index": zone_index}).mappings().first()
# #2464: SAVEPOINT — как у upsert_zone_regulation двадцатью строками выше в
# ЭТОМ ЖЕ файле. Сессия общая с analyze_parcel; без savepoint'а упавший SELECT
# оставляет транзакцию в aborted-состоянии, и падает всё, что идёт следом.
with db.begin_nested():
row = (
db.execute(_SELECT_SQL, {"city": city, "zone_index": zone_index}).mappings().first()
)
except (OperationalError, ProgrammingError) as exc:
logger.warning("get_cached_zone_regulation: cache недоступна, skip: %s", exc)
return None

View file

@ -161,8 +161,15 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
"avg_max_c": round(sum(t_max) / len(t_max), 1) if t_max else None,
"avg_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
},
"precipitation_total_mm": round(sum(precip), 1) if precip else 0,
"precipitation_days": sum(1 for p in precip if p and p > 0.5),
# #2464: было `if precip else 0`. Ноль здесь означал бы «осадков не
# ожидается» — утверждение о погоде. Но пустой `precip` значит, что
# open-meteo не отдал ряд осадков вовсе, то есть мы НЕ ЗНАЕМ. Все шесть
# соседних агрегатов в этом же словаре при пустых данных дают None
# (min_c/max_c/avg_*/uv_index_max/max_speed_m_s) — осадки были
# единственным исключением, и именно они рисуются на фронте как
# измеренная величина (ptica-adapt заворачивает их в `real(...)`).
"precipitation_total_mm": round(sum(precip), 1) if precip else None,
"precipitation_days": (sum(1 for p in precip if p and p > 0.5) if precip else None),
"uv_index_max": round(max(uv), 1) if uv else None,
"wind": {
"dominant_direction_deg": (
@ -251,7 +258,10 @@ def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None:
"avg_precip_per_day_mm": (
round(sum(precip) / len(precip), 1) if precip else None
),
"total_precip_mm": round(sum(precip), 0) if precip else 0,
# #2464: та же правка, что у прогноза выше. Особенно наглядно
# здесь: соседняя строка avg_precip_per_day_mm считается из ЭТОГО ЖЕ
# списка и при пустом даёт None, а сумма давала 0.
"total_precip_mm": round(sum(precip), 0) if precip else None,
"days_observed": len(vals["t_max"]),
}
return {

View file

@ -406,16 +406,17 @@ def build_beat_schedule() -> dict:
# Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц.
# kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д.
# Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления.
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
# за ~5 недель полного обновления.
#
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
# cooldown 24-48h (проверить через targeted test).
# schedule["scrape-kn-catalog-objects-weekly"] = {
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
# "schedule": _parse_cron("0 4 * * 2"), # Tuesday 04:00 UTC
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
# "kwargs": {"region_code": 66, "max_objects": 300},
# "options": {"queue": "celery"},
# }
@ -430,10 +431,10 @@ def build_beat_schedule() -> dict:
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
# напр. четверг 04:00 UTC.
# напр. четверг 04:00 МСК.
# schedule["scrape-kn-catalog-flats-weekly"] = {
# "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
# "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC
# "schedule": _parse_cron("0 4 * * 4"), # четверг 04:00 МСК
# "kwargs": {"region_code": 66, "max_flats": 300},
# "options": {"queue": "celery"},
# }
@ -542,13 +543,20 @@ def build_beat_schedule() -> dict:
}
# Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings.
# Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК).
# 00:30 МСК ежедневно (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
# Комментарий до #2464-H говорил «00:30 UTC = 03:30 МСК» — считал сдвиг дважды,
# оставшись с эпохи UTC-расписания. Факт по логам beat (10-12.08): «Sending due
# task newbuilding-crossload-nightly» в 21:30 UTC = 00:30 МСК, то есть на три
# часа раньше обещанного.
# Расписание НЕ трогаем: на 00:30 МСК ничего не наложено, а сдвиг на 03:30 МСК
# завёл бы задачу прямо в окно tradein-задания newbuilding_enrich (00:00-01:00 UTC
# = 03:00-04:00 МСК), с которым она делит источник — tradein.houses.
# Не в job_settings (технический ETL, не требует конфигурации UI).
# Идемпотентен через ON CONFLICT (source, ext_house_id).
# Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения.
schedule["newbuilding-crossload-nightly"] = {
"task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload",
"schedule": _parse_cron("30 0 * * *"), # 00:30 UTC = 03:30 МСК
"schedule": _parse_cron("30 0 * * *"), # 00:30 МСК
"options": {"queue": "celery"},
}

View file

@ -89,14 +89,24 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
db = SessionLocal()
ids: list[int] = []
try:
# #2464: берём ЛЮБУЮ строку в 'running', без фильтра по objects_snapshot.
# Докстринг этой функции формулирует инвариант прямо: «by definition, on
# worker_ready ANY 'running' row is a zombie because there is no active
# worker». Фильтр ему противоречил: строка без снапшота не попадала в
# выборку и оставалась 'running' НАВСЕГДА — ровно то состояние, ради
# устранения которого функция и заводилась.
#
# Снапшот всё равно нужен — но не для пометки, а для ВОЗОБНОВЛЕНИЯ:
# resume_kn_run восстанавливает обход «using objects_snapshot». Поэтому
# помечаем зомби всех, а resume ставим только тем, кого есть чем
# возобновить. Остальные получают честную причину вместо тишины.
rows = (
db.execute(
text(
"""
SELECT run_id
SELECT run_id, (objects_snapshot IS NOT NULL) AS resumable
FROM kn_scrape_runs
WHERE status = 'running'
AND objects_snapshot IS NOT NULL
ORDER BY started_at ASC
LIMIT 20
"""
@ -106,22 +116,45 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
.all()
)
if rows:
ids = [int(r["run_id"]) for r in rows]
# Помечаем найденные как 'zombie' одним апдейтом — resume создаст новые
# run_id со ссылкой resumed_from_run_id.
db.execute(
text(
"""
UPDATE kn_scrape_runs
SET status = 'zombie',
finished_at = NOW(),
error = COALESCE(error,
'auto-zombie at worker_ready, resume scheduled')
WHERE run_id = ANY(:ids)
"""
),
{"ids": ids},
)
ids = [int(r["run_id"]) for r in rows if r["resumable"]]
orphan_ids = [int(r["run_id"]) for r in rows if not r["resumable"]]
if ids:
# Помечаем как 'zombie' одним апдейтом — resume создаст новые
# run_id со ссылкой resumed_from_run_id.
db.execute(
text(
"""
UPDATE kn_scrape_runs
SET status = 'zombie',
finished_at = NOW(),
error = COALESCE(error,
'auto-zombie at worker_ready, resume scheduled')
WHERE run_id = ANY(:ids)
"""
),
{"ids": ids},
)
if orphan_ids:
db.execute(
text(
"""
UPDATE kn_scrape_runs
SET status = 'zombie',
finished_at = NOW(),
error = COALESCE(error,
'auto-zombie at worker_ready, resume невозможен: '
'нет objects_snapshot')
WHERE run_id = ANY(:orphans)
"""
),
{"orphans": orphan_ids},
)
logger.warning(
"worker_ready: %d kn-прогонов без objects_snapshot помечены zombie"
" без resume — возобновлять нечем: %s",
len(orphan_ids),
orphan_ids,
)
db.commit()
else:
logger.info("worker_ready: нет stale kn runs для resume")
@ -209,6 +242,59 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
logger.warning("worker_ready: failed to enqueue geo resume job=%s: %s", jid, e)
logger.info("worker_ready: resume scan finished (geo_jobs=%d)", len(geo_resume_jobs))
# objective_scrape_runs: тот же инвариант, что у kn — на worker_ready активных
# воркеров нет, значит любая строка в 'running' осиротела. Подметальщика у этой
# таблицы не было вовсе, и на проде 2026-08-20 висело 6 строк со статусом
# 'running' с 17.05 (94 суток), при 71 'done' и НИ ОДНОГО 'failed' — след
# отравления сессии, из-за которого _finish_run(status='failed') не мог
# записаться (починено #2972). Причина устранена, но жёсткое убийство воркера
# (редеплой, OOM) по-прежнему оставляет 'running' навсегда: у Объектива нет
# ни своего cleanup_zombies, ни snapshot'а для resume.
#
# Resume не делаем — возобновлять нечего (снапшота обхода нет), только честно
# закрываем. finished_at ставим НЕ NOW(), а по последнему признаку жизни:
# прогон, умерший 94 дня назад, не должен читаться как «завершён только что».
# Монитору свежести это безразлично в обе стороны — он считает last_success_at
# и recent_output только по status='done', а last_attempt_at/last_status — по
# started_at (см. _FRESHNESS_SOURCES в admin_scrape.py), так что зомби-строки
# в него не попадают ни одним столбцом.
db = SessionLocal()
try:
rows = (
db.execute(
text(
"""
UPDATE objective_scrape_runs
SET status = 'zombie',
finished_at = COALESCE(heartbeat_at, started_at),
error = COALESCE(error,
'auto-zombie at worker_ready: воркер перезапущен '
'во время прогона, возобновление невозможно')
WHERE status = 'running'
RETURNING run_id
"""
)
)
.mappings()
.all()
)
db.commit()
if rows:
logger.info(
"worker_ready: objective_scrape_runs — помечено зомби: %s",
[int(r["run_id"]) for r in rows],
)
else:
logger.info("worker_ready: нет осиротевших objective-прогонов")
except Exception as e:
logger.warning("worker_ready objective zombie sweep failed: %s", e)
try:
db.rollback()
except Exception:
pass
finally:
db.close()
# Sanity check: nspd_quarter_dumps table must exist (migration 88).
# Logs critical error but does NOT crash the worker — table may be absent
# in dev/staging before migration is applied.

View file

@ -110,13 +110,22 @@ def _upsert_inflation(db: Session, rows: list[tuple[date, Decimal]]) -> int:
return upserted
# Ретраев здесь НЕТ намеренно, и параметров, обещающих их, тоже быть не должно
# (#2464). Раньше стояло `bind=True, max_retries=2` — но self не использовался,
# self.retry() не вызывался и autoretry_for задан не был, поэтому конфигурация
# ретраев не имела ни малейшего эффекта: таска падала окончательно с первой ошибки,
# а параметр обещал до двух повторов. Соседи, где ретраи действительно нужны,
# задают их явно: autoretry_for в nspd_sync и scrape_cadastre, self.retry() в
# scrape_kn.
#
# Отсутствие ретраев — это и есть задуманное поведение, оно описано в докстринге
# ниже: «первая возникшая ошибка пробрасывается в конце (surfaces в
# Celery/GlitchTip), не глотается». Ряды тянутся по расписанию, следующий тик
# повторит попытку; молча ретраить внутри тика значило бы прятать отказ источника.
@celery_app.task(
bind=True,
name="tasks.cbr_macro_sync.cbr_macro_sync",
max_retries=2,
)
def cbr_macro_sync(
self: Any,
from_date: str | None = None,
to_date: str | None = None,
) -> dict[str, Any]:

View file

@ -28,12 +28,15 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло
# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и
# autoretry_for задан не был — конфигурация не имела эффекта. Соседи, где ретраи
# нужны, задают их явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry()
# (scrape_kn).
@celery_app.task(
bind=True,
name="tasks.developer_registry_refresh.refresh_developer_registry",
max_retries=2,
)
def refresh_developer_registry(self: Any) -> dict[str, Any]:
def refresh_developer_registry() -> dict[str, Any]:
"""REFRESH MATERIALIZED VIEW CONCURRENTLY developer_registry.
Лёгкая задача (реестр ~1024 застройщика). CONCURRENTLY non-blocking для

View file

@ -22,10 +22,22 @@ logger = logging.getLogger(__name__)
# ── Seed-документы ─────────────────────────────────────────────────────────────
# Образец: ППТ 22823 (2018), пояснительная записка.
#
# URL — placeholder. Реальный URL пояснительной записки лежит на ГИСОГД ЕКБ
# (https://gisogd.ekburg.ru/) под номером проекта планировки, но прямой PDF-линк
# требует ручного поиска через UI (#1136). До тех пор — ingest скипает с
# WARNING и метрики остаются {"docs": 0}.
# URL — placeholder, ingest скипает с WARNING, метрики остаются {"docs": 0}.
# Прод 20.08.2026: в таблице ekb_ppt_tep 0 строк — то есть загрузчик не отработал
# ни разу.
#
# ВАЖНО (#2464): хост `gisogd.ekburg.ru`, названный ниже как место, где «лежит
# реальный URL», НЕ СУЩЕСТВУЕТ — DNS не резолвит его ни с рабочей машины, ни с
# прод-хоста (проверено 20.08.2026). Прежняя редакция этого комментария отправляла
# искать документ вручную на портале, которого нет.
#
# Живой портал ГИСОГД Свердловской области — `gisogd66.midural.ru` (его использует
# загрузчик РНС/РВЭ, см. services/scrapers/gisogd66.py). Раздел 13 там — документы
# по земельному участку; проекты планировки лежат в других разделах, перечисление
# групп доступно через `/api/v1/{schema}/gisogddocgroups/{razdel}`. Перебор
# razdel3/4/5 показал разделы «Генеральный план», «Местные нормативы», «Правила
# землепользования и застройки» — точный раздел ППТ и формат ссылки на PDF
# пояснительной записки ещё предстоит найти (#1136).
#
# Override-пути для прогона:
# 1. Передать в task: ingest_ppt_tep([{"doc_ref": "...", "url": "...",

View file

@ -7,14 +7,18 @@ UPSERT-ит в land_reservation (м.136). Reservation_lookup / analyze-wiring (#
Дедуп-ключ:
ON CONFLICT (cad_num, act_number) унаследован из reservation_ingest.py.
Если act_number IS NULL (не извлечён из сканов) конфликт НЕ возникает при NULL-UPSERT
(NULL != NULL в SQL). Чтобы предотвратить дубли при act_number IS NULL, дедуплицируем
по (cad_num, doc_url) на уровне Python перед UPSERT: один URL = один батч,
повторный запуск с тем же URL обновит существующую строку через source+fetched_at
(где act_number IS NULL используем DO NOTHING вместо DO UPDATE нет stable key).
Решение: для строк с act_number IS NULL добавляем в ON CONFLICT УНИКАЛЬНОСТЬ через
отдельный UPSERT с COALESCE-fallback: если запись с (cad_num, doc_url) уже есть
UPDATE, иначе INSERT. Реализовано через двухшаговый UPSERT ниже.
Уникальность держит констрейнт uq_land_reservation_cad_act; с миграции 189 он
объявлен как UNIQUE NULLS NOT DISTINCT, поэтому записи без номера акта тоже
конфликтуют между собой и ON CONFLICT DO NOTHING реально их ловит.
До м.189 констрейнт был обычным UNIQUE, где NULL != NULL: у записей с
act_number IS NULL конфликт не наступал никогда, и каждый недельный прогон
вставлял копию. Замер прода 20.08.2026 до правки 297 строк, все без номера
акта, 27 групп с дублями, до 11 копий, 270 лишних строк (91% таблицы).
Прежняя редакция этого docstring обещала python-дедуп по (cad_num, doc_url)
перед UPSERT и «двухшаговый UPSERT ниже». Ни того, ни другого в коде не было
описание расходилось с реализацией и скрывало накопление дублей (#2464).
Beat: еженедельно (пятница 07:00 МСК) изъятия выходят редко.
@ -45,10 +49,13 @@ logger = logging.getLogger(__name__)
# Stable key = (cad_num, act_number). Идемпотентно при повторном прогоне.
#
# Вариант B (act_number IS NULL): INSERT ... ON CONFLICT DO NOTHING.
# NULL != NULL → (cad_num, NULL) никогда не конфликтует по индексу.
# Python-дедуп per-batch предотвращает дубли в рамках одного прогона.
# Повторные прогоны добавят дубли если строки нет — acceptable (rare, data audit OK).
# Альтернатива (partial unique index на NULL) — задача database-expert, не здесь.
# Работает с миграции 189: uq_land_reservation_cad_act объявлен как
# UNIQUE NULLS NOT DISTINCT, поэтому (cad_num, NULL) конфликтует с такой же
# строкой и повторный прогон становится no-op.
# Прежний комментарий здесь оценивал накопление дублей как «rare, data audit OK»
# и откладывал уникальный индекс. Оценка не подтвердилась: на 20.08.2026 дубли
# составляли 91% таблицы (270 лишних строк из 297), максимум 11 копий одной
# записи. Отложенный вариант и реализован м.189 (#2464).
_UPSERT_WITH_ACT_SQL = text(
"""

View file

@ -30,12 +30,15 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло
# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и
# autoretry_for задан не был — конфигурация не имела эффекта. Соседи, где ретраи
# нужны, задают их явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry()
# (scrape_kn).
@celery_app.task(
bind=True,
name="tasks.location_refresh.location_refresh",
max_retries=2,
)
def location_refresh(self: Any, region: str | None = None) -> dict[str, Any]:
def location_refresh(region: str | None = None) -> dict[str, Any]:
"""Пересчитать + upsert-нуть district-level индексы по всем районам в `location`.
Идемпотентно (ON CONFLICT по district_name). Graceful: сбойный район

View file

@ -26,12 +26,15 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло
# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и
# autoretry_for задан не был — конфигурация не имела эффекта. Соседи, где ретраи
# нужны, задают их явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry()
# (scrape_kn).
@celery_app.task(
bind=True,
name="tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs",
max_retries=2,
)
def refresh_sales_tracker_mvs_task(self: Any) -> dict[str, Any]:
def refresh_sales_tracker_mvs_task() -> dict[str, Any]:
"""REFRESH both sales-tracker MVs (#61).
Both MVs are refreshed CONCURRENTLY (non-blocking, require their UNIQUE

View file

@ -17,13 +17,16 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло
# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и
# autoretry_for задан не был — конфигурация не имела эффекта. Где ретраи нужны, они
# задаются явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry()
# (scrape_kn.resume_kn_run). Где их сознательно нет — пишется max_retries=0 с
# пояснением (nspd_geo, objective_etl.import_anton_objective).
@celery_app.task(
bind=True,
name="tasks.refresh_analytics.refresh_ekb_districts_medians",
max_retries=2,
)
def refresh_ekb_districts_medians(
self: Any,
window_months: int = 24,
min_deals: int = 50,
) -> dict[str, Any]:

View file

@ -22,12 +22,16 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло
# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и
# autoretry_for задан не был — конфигурация не имела эффекта. Где ретраи нужны, они
# задаются явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry()
# (scrape_kn.resume_kn_run). Где их сознательно нет — пишется max_retries=0 с
# пояснением (nspd_geo, objective_etl.import_anton_objective).
@celery_app.task(
bind=True,
name="tasks.refresh_layout_velocity.refresh_layout_velocity",
max_retries=2,
)
def refresh_layout_velocity_task(self: Any) -> dict[str, Any]:
def refresh_layout_velocity_task() -> dict[str, Any]:
"""REFRESH MATERIALIZED VIEW mv_layout_velocity (best_layouts, #113 / #1666).
MV рефрешится CONCURRENTLY (non-blocking, требует unique-индекс

View file

@ -20,12 +20,16 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло
# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и
# autoretry_for задан не был — конфигурация не имела эффекта. Где ретраи нужны, они
# задаются явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry()
# (scrape_kn.resume_kn_run). Где их сознательно нет — пишется max_retries=0 с
# пояснением (nspd_geo, objective_etl.import_anton_objective).
@celery_app.task(
bind=True,
name="tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain",
max_retries=2,
)
def refresh_quarter_price_index_chain(self: Any) -> dict[str, Any]:
def refresh_quarter_price_index_chain() -> dict[str, Any]:
"""Refresh mv_quarter_price_per_m2 then mv_quarter_price_index in sequence.
Both MVs are refreshed CONCURRENTLY (non-blocking). Falls back to

View file

@ -231,12 +231,22 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
return upserted
# Ретраев здесь НЕТ намеренно, и параметров, обещающих их, тоже быть не должно
# (#2464). Раньше стояло `bind=True, max_retries=2` — но self не использовался,
# self.retry() не вызывался и autoretry_for задан не был, поэтому конфигурация
# ретраев не имела ни малейшего эффекта: таска падала окончательно с первой ошибки,
# а параметр обещал до двух повторов. Соседи, где ретраи действительно нужны,
# задают их явно: autoretry_for в nspd_sync и scrape_cadastre, self.retry() в
# scrape_kn.
#
# Отсутствие ретраев — это и есть задуманное поведение, оно описано в докстринге
# ниже: «первая возникшая ошибка пробрасывается в конце (surfaces в
# Celery/GlitchTip), не глотается». Ряды тянутся по расписанию, следующий тик
# повторит попытку; молча ретраить внутри тика значило бы прятать отказ источника.
@celery_app.task(
bind=True,
name="tasks.rosstat_macro_sync.rosstat_macro_sync",
max_retries=2,
)
def rosstat_macro_sync(self: Any) -> dict[str, Any]:
def rosstat_macro_sync() -> dict[str, Any]:
"""Загрузить ряды Росстата (open-data + ЕМИСС + xlsx-СМР) и апсертить в macro_indicator.
Источники выполняются НЕЗАВИСИМО (per-source try/except): сбой одного источника

View file

@ -40,7 +40,20 @@ _RELEASE_LOCK_LUA = (
def _lock_key(region_code: int, developers: list[str] | None) -> str:
devs_key = ",".join(developers) if developers else "*"
"""Ключ синглтон-лока. Зависит от МНОЖЕСТВА разработчиков, не от их порядка.
#2464: раньше список джойнился как пришёл, а приходит он прямо из тела запроса
(`developers` в admin_scrape). Один и тот же набор, поданный в другом порядке,
давал ДРУГОЙ ключ и синглтон молча переставал быть синглтоном: два свипа шли
параллельно по одним и тем же разработчикам.
Второе следствие того же: `force_release_lock` строит ключ этой же функцией.
Оператор, снимающий залипший лок и перечисливший разработчиков в ином порядке,
молча не снимал ничего.
sorted(set(...)) закрывает оба: порядок и повторы ('A','A' 'A').
"""
devs_key = ",".join(sorted(set(developers))) if developers else "*"
return f"scrape:kn:lock:{region_code}:{devs_key}"
@ -144,7 +157,10 @@ def _region_lock(region_code: int, developers: list[str] | None) -> Iterator[boo
logger.warning("release lock %s failed: %s", key, e)
@celery_app.task(bind=True, name="tasks.scrape_kn.scrape_kn_region", max_retries=2)
# bind=True здесь настоящий: self.request.id пишется в kn_scrape_log. А вот
# max_retries=2 был инертен — self.retry() в этой таске не вызывается и
# autoretry_for не задан (#2464). self.retry() ниже принадлежит resume_kn_run.
@celery_app.task(bind=True, name="tasks.scrape_kn.scrape_kn_region")
def scrape_kn_region(
self: Any,
region_code: int,

View file

@ -146,13 +146,16 @@ def _save_raw(
return int(row)
# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло
# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и
# autoretry_for задан не был — конфигурация не имела эффекта. Где ретраи нужны, они
# задаются явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry()
# (scrape_kn.resume_kn_run). Где их сознательно нет — пишется max_retries=0 с
# пояснением (nspd_geo, objective_etl.import_anton_objective).
@celery_app.task(
bind=True,
name="tasks.scrape_objective.sync_objective_group",
max_retries=2,
)
def sync_objective_group(
self: Any,
group_name: str | None = None,
triggered_by: str = "beat",
use_ddu: bool = True,
@ -349,7 +352,7 @@ def sync_objective_group(
db.rollback()
reports_failed += 1
logger.exception(
"sync_objective_group: parser failed for %s/%s/%s " "raw_id=%s: %s",
"sync_objective_group: parser failed for %s/%s/%s raw_id=%s: %s",
section,
rtype,
rname,
@ -402,10 +405,31 @@ def sync_objective_group(
}
except Exception as e:
if run_id:
# #2464: сессия здесь МОЖЕТ быть отравлена. Исходный сбой бывает
# DB-level (напр. INSERT в _save_raw), и тогда транзакция остаётся в
# aborted-состоянии: следующий execute падает, _finish_run не проходит,
# а голый `except Exception: pass` ниже гасил это молча — строка прогона
# навсегда оставалась в status='running'. Замер прода 20.08: шесть таких
# строк висят с 17.05, то есть 95 суток; уборщика зомби для
# objective_scrape_runs нет.
#
# Сессия здесь СВОЯ (SessionLocal() выше, close в finally), поэтому
# плоский rollback законен: он отбрасывает уже провалившуюся транзакцию
# и ничего чужого не теряет.
try:
db.rollback()
except Exception:
logger.exception("sync_objective_group: rollback перед _finish_run не удался")
try:
_finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}")
except Exception:
pass
# Больше не молча: если и это не прошло, строка останется 'running',
# и знать об этом важнее, чем сохранить тишину в логе.
logger.exception(
"sync_objective_group: не удалось пометить run_id=%s как failed —"
" строка останется в status='running'",
run_id,
)
raise
finally:
db.close()

Some files were not shown because too many files have changed in this diff Show more