Merge origin/main into fix/2656-anchor-ratio-freshness — конфликты: метки #2656→#2661 (main), импорт-union, baseline unrecorded=0 (перезахваченная фикстура)
This commit is contained in:
commit
3d0541b2e2
544 changed files with 47368 additions and 3270 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 → НЕ триггерит деплой
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 — тоже, а каталог
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
184
.forgejo/workflows/deploy-infra.yml
Normal file
184
.forgejo/workflows/deploy-infra.yml
Normal 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)"
|
||||
335
.forgejo/workflows/deploy-metrics.yml
Normal file
335
.forgejo/workflows/deploy-metrics.yml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "✓ деплой прошёл успешно"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*.sh text eol=lf
|
||||
91
.github/workflows/ci.yml
vendored
91
.github/workflows/ci.yml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
376
Caddyfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
backend/.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
.coverage
|
||||
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
|
||||
coverage.xml
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
175
backend/app/observability/metrics.py
Normal file
175
backend/app/observability/metrics.py
Normal 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)
|
||||
|
|
@ -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)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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% (несопоставимые окна)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"]`, то есть АДМИН-имя: для половины районов
|
||||
выборка пустая, для остальных — заметно урезанная. Резолв admin→micros
|
||||
(как в `_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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# ── Общие хелперы фигуры ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
|
||||
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
|
||||
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
|
||||
покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
|
||||
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
|
||||
покрытие domrf↔objective 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) — high≥3 / medium≥2 / 1 → low
|
||||
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример).
|
||||
• domrf_coverage — главный риск проекта (domrf↔objective ~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`), а не покрытие маппинга
|
||||
domrf↔objective. Продьюсера для второго нет и не было (#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, см. комментарий у констант): короткий
|
||||
ряд → связь rate↔sales / тренды не установлены.
|
||||
• 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-пример «покрытие domrf↔objective 2.5%»). None → low.
|
||||
#2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие
|
||||
маппинга domrf↔objective, но такого продьюсера НЕТ и не было: слот
|
||||
`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% покрытия domrf↔objective», как было написано здесь
|
||||
раньше, — другая величина другого порядка.
|
||||
|
||||
Ключ фактора НЕ переименован намеренно: его читает фронт
|
||||
(`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: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||
domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1]. Имя ключа
|
||||
историческое — про маппинг domrf↔objective, продьюсера для которого
|
||||
нет и не было (#2464-H, см. _coverage_factor).
|
||||
history_months: глубина ряда (мес).
|
||||
confounded: True, если окно ряда пересекает шок-период (PR2).
|
||||
advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда).
|
||||
|
|
|
|||
|
|
@ -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 не попадают.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 здесь НЕ выводим — нет
|
||||
дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и
|
||||
величина у неё другая: не «покрытие маппинга domrf↔objective ~2.5%», как
|
||||
было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective —
|
||||
замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%.
|
||||
|
||||
Порядок веток оставлен: если продьюсер появится, приоритет у него.
|
||||
"""
|
||||
if supply_layers is not None:
|
||||
coverage = supply_layers.get("domrf_coverage")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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. "
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>\bне\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-текстов, дальше не ищем
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"участка — застройка МКД невозможна"
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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 для
|
||||
|
|
|
|||
|
|
@ -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": "...",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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: сбойный район
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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-индекс
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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): сбой одного источника
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue