Compare commits

..

No commits in common. "main" and "feat/3404-proxy-run-attribution" have entirely different histories.

242 changed files with 1034 additions and 28323 deletions

View file

@ -98,24 +98,6 @@ jobs:
python3 scripts/check-compose-ambiguous-hosts.py --selftest
python3 scripts/check-compose-ambiguous-hosts.py
- name: "Guard: подмена фронта МЕРЫ без окна недоступности (#3274)"
# Тем же шагом-соседом и по той же причине: секунды на PR, падение
# блокирует merge.
#
# ЗАЧЕМ. Публичный лендинг лежал 3090 с на КАЖДОМ деплое МЕРЫ —
# не потому, что подмена контейнера медленная (0,5 с), а потому, что
# `up -d` со списком сервисов делает create всех (старые контейнеры
# УДАЛЯЮТСЯ) и только потом start, дождавшись зависимостей. Лечение —
# две половинки в разных файлах: `frontend` вынесен из общей пачки в
# deploy-tradein.yml + ретрай подключения в caddy/sites/apps.caddy.
# Обе обратимы молча и незаметно (дописать frontend обратно в SERVICES
# «за компанию»; скопировать новый публичный путь с блока без импорта),
# а отказ виден только непрерывной пробой во время деплоя — то есть
# никогда, если её никто не запустил.
run: |
python3 scripts/check-frontend-swap-window.py --selftest
python3 scripts/check-frontend-swap-window.py
- name: "Guard: Caddyfile синтаксически валиден"
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
@ -219,30 +201,6 @@ jobs:
- '.forgejo/workflows/deploy.yml'
- '.forgejo/workflows/deploy-tradein.yml'
- '.forgejo/workflows/ci.yml'
# #3448: тот же класс, ещё раз. Гейт про исключающие `!`-шаблоны
# в paths-filter проверяет ВСЕ воркфлоу, а paths-filter живёт и
# здесь — без этой строки правка ci-tradein.yml с таким шаблоном
# не запустила бы backend-tests, то есть гейт не побежал бы ровно
# на той правке, от которой стережёт.
- '.forgejo/workflows/ci-tradein.yml'
# #3467/#3475: гейт backend/tests/ops/test_3467_prometheus_reload.py
# читает оба файла ниже. Без них правка, трогающая ТОЛЬКО
# deploy-metrics.yml (скажем, дописывающая `|| true` к шагу
# перезагрузки Prometheus), даёт backend=false — джоба
# backend-tests пропускается, гейт не исполняется, регрессия
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
- '.forgejo/workflows/deploy-metrics.yml'
- 'docker-compose.metrics.yml'
# #3443: тот же класс, третий раз. Гейт
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
# все содержательные регрессии живут в самом скрипте, а не в
# deploy.yml. PR, правящий только ops/**, без этой строки давал бы
# backend=false: джоба пропускается, гейт не исполняется, и
# «пересоздавать всегда» (окно 67 с на всех доменах) или
# «не пересоздавать никогда» (правка конфига беззвучно не доезжает)
# уезжает в main зелёным.
- 'ops/**'
frontend:
- 'frontend/**'
- '.forgejo/workflows/ci.yml'

View file

@ -90,13 +90,8 @@ jobs:
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
with:
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL
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 }}
@ -193,14 +188,6 @@ jobs:
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
fi
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
# должно — деплой обязан сказать, что канал не поднялся.
if [ -z "${ALERT_ACK_GLITCHTIP_SECRET:-}" ]; then
echo "::warning title=Резервный канал GlitchTip выключен::ALERT_ACK_GLITCHTIP_SECRET пуст — alert-ack отвечает 503 на /glitchtip, и при падении продуктового бэкенда его ошибки доставлять будет нечем."
fi
if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
else
@ -263,19 +250,6 @@ jobs:
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
fi
# Ретранслятор Telegram Bot API (#3471, PR #3487 сломал прод: сервис
# без profiles уходил в SystemExit на пустом секрете и висел в
# Restarting). Профиль relay включаем НЕЗАВИСИМО от alerts — это
# разные каналы (один шлёт алерты боту, другой ретранслирует
# продуктовый Bot API трафик с Selectel). PROFILES — список через
# запятую, как того требует COMPOSE_PROFILES.
if [ -n "${TG_RELAY_SECRET:-}" ]; then
PROFILES="${PROFILES:+$PROFILES,}relay"
echo "Ретранслятор Telegram: секрет задан, профиль relay включён."
else
echo "::warning title=Резервный ретранслятор Telegram выключен::TG_RELAY_SECRET пуст — tg-relay не поднимается (профиль relay выключен). Продуктовый Telegram-трафик пойдёт напрямую с Selectel, где теряется примерно каждый четвёртый короткий запрос."
fi
# ── Цели file_sd для Prometheus (#3155) ────────────────────────
# Включатель профиля и цель для Prometheus обязаны стоять в ОДНОМ
# условии. Пока они жили порознь, вышло так: 27.08 профиль alerts
@ -294,27 +268,19 @@ jobs:
AM_TARGETS_FILE=ops/metrics/prometheus/alertmanager_targets.gen.yml
: > "$AM_TARGETS_FILE"
echo "# Файл рендерится деплоем (deploy-metrics.yml), правки руками затрутся." >> "$AM_TARGETS_FILE"
# Сравнение через case, а не "=": PROFILES теперь может быть
# комбинацией через запятую ("alerts,relay") с тех пор, как #3471
# завёл независимый профиль relay — точное равенство строке
# "alerts" сломалось бы молча в тот момент, когда оба профиля
# включены разом.
case ",$PROFILES," in
*,alerts,*)
if [ "$PROFILES" = "alerts" ]; then
echo '- targets: ["alertmanager:9093"]' >> "$AM_TARGETS_FILE"
echo " labels:" >> "$AM_TARGETS_FILE"
echo " host: infra" >> "$AM_TARGETS_FILE"
echo "Prometheus: приёмник alertmanager:9093 прописан в целях."
;;
*)
else
# Пустой список, а НЕ отсутствующий файл: одиночный бинд-маунт
# несуществующего пути docker подменяет каталогом, и Prometheus
# не стартует вовсе.
echo "# Профиль alerts выключен — приёмников нет." >> "$AM_TARGETS_FILE"
echo "[]" >> "$AM_TARGETS_FILE"
echo "Prometheus: профиль alerts выключен — целей нет, это штатно."
;;
esac
fi
# ── read-only роль для датасорса GlitchTip ─────────────────────
# Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
@ -326,43 +292,6 @@ jobs:
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans
# ── alert-ack / tg-relay: код монтируется с хоста ────────────────
# Тот же класс бага, что у Alertmanager (см. ниже) и Caddyfile:
# `up -d` сравнивает ОПИСАНИЕ сервиса, а не содержимое бинд-маунта.
# alert-ack и tg-relay получают код именно бинд-маунтом файла
# (./ops/metrics/{alert-ack,tg-relay}/app.py:/app/app.py:ro), а не
# сборкой образа — правка app.py оставляет уже запущенный
# контейнер работать на СТАРОМ коде в памяти интерпретатора сколько
# угодно, и `up -d` этого не видит вовсе.
#
# Пойман на проде 12.09.2026: PR #3490 (фикс alert-ack) слился,
# `git reset --hard` обновил файл на диске (grep по новому
# комментарию находил его), а gendesign-alert-ack, запущенный за
# 25 минут до этого, продолжал отвечать по старой логике —
# зелёный деплой, тихо неверное поведение. Починил только ручной
# `docker restart gendesign-alert-ack`. force-recreate здесь —
# замена этому ручному шагу.
#
# case ",$PROFILES," — пересоздаём только если профиль сервиса
# реально включён в ЭТОМ прогоне, иначе force-recreate ругается на
# несуществующий контейнер (сервис не создан вовсе).
case ",$PROFILES," in
*,alerts,*)
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
up -d --force-recreate alert-ack
echo "alert-ack: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)."
;;
esac
case ",$PROFILES," in
*,relay,*)
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
up -d --force-recreate tg-relay
echo "tg-relay: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)."
;;
esac
# ── Alertmanager: пересоздать, если конфиг перерисовали ─────────
# `up -d` выше СЧИТАЕТ alertmanager неизменившимся: он сравнивает
# описание сервиса, а содержимое бинд-маунта в это сравнение не
@ -415,51 +344,6 @@ jobs:
done
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
# перечитывает ────────────────────────────────────────────────
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
# docker compose сравнивает описание сервиса, а НЕ содержимое
# бинд-маунта, поэтому уже работающий контейнер продолжает жить
# со старым конфигом сколько угодно — на проде дошло до 16 суток
# незамеченными (#3467): lastConfigTime совпадал со startTime
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
#
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
# после `git reset --hard` подхватывается без пересоздания
# контейнера. --web.enable-lifecycle уже включён в compose ради
# этого шага (см. docker-compose.metrics.yml) — просто раньше
# никто не звал сам reload.
#
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
# положить работающий Prometheus молчаливым откатом на дефолты.
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml'; then
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
# lastConfigTime обновляется на КАЖДЫЙ успешный reload, даже
# если содержимое конфига не поменялось — значит сравнение
# "было/стало" надёжно ловит и несостоявшийся reload, и
# изменившиеся правила.
LAST_CONFIG_AFTER=""
for i in $(seq 1 10); do
LAST_CONFIG_AFTER="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
[ -n "$LAST_CONFIG_AFTER" ] && [ "$LAST_CONFIG_AFTER" != "$LAST_CONFIG_BEFORE" ] && break
sleep 1
done
if [ -z "$LAST_CONFIG_AFTER" ] || [ "$LAST_CONFIG_AFTER" = "$LAST_CONFIG_BEFORE" ]; then
echo "ОШИБКА: reload Prometheus не подтверждён — lastConfigTime не изменился ($LAST_CONFIG_BEFORE)."
exit 1
fi
echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)."
else
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге."
exit 1
fi
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
agent-apps:
runs-on: ubuntu-latest

View file

@ -511,24 +511,12 @@ jobs:
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG).
# NEXT_PUBLIC_YM_ID/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION —
# ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и
# мета-теги верификации поисковых консолей. Пустая строка = скрипт
# счётчика НЕ рендерится вообще (контракт фронта, см. тот же
# Dockerfile-комментарий). Когда номера появятся — вписать
# литералом сюда И в retry-блок ниже (оба обязательны, иначе
# ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки
# образа (build-time bake, не runtime-правка на проде).
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 }}
NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
@ -552,10 +540,6 @@ jobs:
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 }}
NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
@ -1137,31 +1121,7 @@ jobs:
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
# с browser/backend/frontend, отдельного graceful-drain не требует.
#
# ── FRONTEND ЗДЕСЬ НЕТ. ЭТО И ЕСТЬ ЛЕЧЕНИЕ #3274 ─────────────────
# Публичный лендинг лежал 3090 с на КАЖДОМ деплое. Причина не в
# том, что подмена контейнера медленная — она занимает полсекунды.
# Причина в том, что `docker compose up -d` со СПИСКОМ сервисов
# работает в две фазы: сначала create (старый контейнер каждого
# сервиса останавливается и УДАЛЯЕТСЯ — иначе занято container_name),
# и только потом start, в порядке зависимостей и с ожиданием их
# условий. Между фазами фронта уже нет, а нового ещё нет.
#
# Замер на проде (docker inspect, 10.09, оба деплоя МЕРЫ):
# пачка сервисов: tradein-backend создан 15:01:40 → запущен
# 15:02:10 = 30 с (и 503 на лендинге в
# 15:01:46/15:01:52/15:02:06 — ровно окно);
# ОДИН сервис: tradein-frontend создан 16:42:17.5 → запущен
# 16:42:18.0 = 0,5 с, 503 в логе нет вообще.
# Тот же двухфазный порядок воспроизведён на стенде по меткам
# .Created/.StartedAt: соседи создаются сразу, стартуют через 41 с.
#
# Поэтому фронт пересоздаётся ОТДЕЛЬНОЙ командой ниже, после этой
# пачки: в его графе один сервис, фазы create и start идут подряд.
# Остаток (~0,5 с) добирает ретрай подключения в Caddy — см.
# снипет (tradein_frontend_retry) в caddy/sites/apps.caddy.
# Гейт на обе половины: scripts/check-frontend-swap-window.py.
SERVICES="browser backend tgbot"
SERVICES="browser backend frontend tgbot"
SCRAPER_STOP_TS=""
scraper_stale=""
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then
@ -1257,21 +1217,6 @@ jobs:
" || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper"
fi
# (4b) Фронт — ОТДЕЛЬНОЙ командой, один сервис в графе (#3274).
# Обоснование и прод-замеры — у SERVICES выше. Здесь важен ПОРЯДОК:
# эта команда идёт ПОСЛЕ пачки (backend уже поднят — новый SSR сразу
# ходит в новый бэкенд) и ДО `caddy reload` ниже, чтобы reload, как
# и раньше, оставался последним касанием прокси.
#
# ЗАЧЕМ ЖДАТЬ ПОСЛЕ КОМАНДЫ. `up -d` возвращает управление, когда
# контейнер ЗАПУЩЕН, а не когда Next начал слушать (на проде между
# ними ~0,1 с, см. journald «Ready in 110ms», но это не гарантия).
# Полноценная проверка фронта — health-check ниже по файлу, он же
# валит деплой при неудаче; здесь только короткая пауза, чтобы
# ретрай Caddy (2 с) не пришёлся на ещё не слушающий порт.
docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps frontend
sleep 1
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с

View file

@ -121,98 +121,37 @@ jobs:
infra: ${{ steps.filter.outputs.infra }}
# #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он
# включает и compose, и сам workflow, где полный деплой обязателен.
caddy_only: ${{ steps.filter.outputs.caddy_only }}
# `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
# ── #3448: список изменённых файлов считаем САМИ ─────────────────────────
#
# ЧТО БЫЛО. Быстрый путь «правка только прокси» (#2916) не отработал НИ
# РАЗУ. Причина — НЕ пустой `event.before`: эта гипотеза опровергнута
# логом задачи 29244 (run 10881, мерж 84920e6c) — `before` там валиден,
# 204e2e09…, и `git diff` вернул ровно один файл. Причина в семантике
# самого фильтра: dorny/paths-filter склеивает шаблоны ОДНОГО фильтра
# через `some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`,
# predicate-quantifier по умолчанию `some`). Список
# non_caddy: ['**', '!Caddyfile', '!caddy/**']
# читается не как «всё, КРОМЕ caddy», а как «подходит под `**` ИЛИ не
# Caddyfile ИЛИ не caddy/**». `**` матчит всё, поэтому non_caddy был true
# ВСЕГДА и caddy_only — false всегда. В логе это видно дословно:
# ##[group]Filter non_caddy = true
# Matching files:
# caddy/sites/apps.caddy [modified]
# Исключённый файл сам себя и «исключил». deploy-caddy при этом
# пропускался, а Forgejo рисует пропущенную джобу зелёной — сигнала не
# было ни одного.
#
# ПОЧЕМУ ШЕЛЛ, А НЕ ЗАПЛАТКА К ФИЛЬТРАМ. Разность множеств тут нужна одна
# («все изменения лежат под caddy»), и выражать её действием, у которого
# ИЛИ по умолчанию, — значит снова повесить решение на незаметное
# умолчание: `predicate-quantifier: every` действует на ВЕСЬ блок и
# сломал бы backend/frontend/infra. Плюс два требования #3448: решение
# обязано быть ВИДНО в логе (иначе «сработало» и «просто не совпало»
# неотличимы), и оно не должно молча зависеть от того, что платформа
# кладёт в `before`.
#
# FAIL-SAFE. База не разрешилась (ручной запуск, пустой/нулевой `before`,
# коммита нет на сервере) → считаем изменённым ВЕСЬ репозиторий: лишний
# полный деплой безопаснее пропущенного. Фолбэка на `HEAD^..HEAD` тут
# намеренно нет: у мерж-коммита он дал бы верный ответ, а у push'а из
# нескольких коммитов — молча урезанный, и быстрый путь включился бы
# там, где приехал бэкенд.
- name: Определить изменённые файлы (#3448)
- uses: dorny/paths-filter@v3
id: filter
env:
BEFORE: ${{ github.event.before }}
EVENT: ${{ github.event_name }}
run: |
set -eu
NULL_SHA=0000000000000000000000000000000000000000
BASE=""
if [ "$EVENT" = "push" ] && [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "$NULL_SHA" ]; then
git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \
|| git fetch --depth=1 --no-tags origin "$BEFORE" >/dev/null 2>&1 \
|| true
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
BASE="$BEFORE"
else
echo "::warning::коммит $BEFORE недоступен в клоне — деплой будет полным"
fi
fi
if [ -n "$BASE" ]; then
FILES=$(git -c core.quotePath=false diff --no-renames --name-only "$BASE" HEAD)
N=$(printf '%s\n' "$FILES" | grep -c . || true)
echo "База: $BASE → $(git rev-parse HEAD); изменённых файлов: $N"
printf '%s\n' "$FILES" | sed 's/^/ /'
else
FILES=$(git -c core.quotePath=false ls-files)
N=$(printf '%s\n' "$FILES" | grep -c . || true)
echo "База не определена (event=$EVENT, before='${BEFORE:-}') — считаем изменённым весь репозиторий ($N файлов), деплой полный"
fi
# Те же наборы путей, что были в фильтрах до #3448.
CADDY_RE='^(Caddyfile$|caddy/)'
has() { printf '%s\n' "$FILES" | grep -qE "$1"; }
backend=false; frontend=false; infra=false; caddy_only=false
has '^(backend/|data/sql/)' && backend=true
has '^frontend/' && frontend=true
has '^(docker-compose\.prod\.yml$|Caddyfile$|caddy/|\.forgejo/workflows/deploy\.yml$)' && infra=true
# Быстрый путь: изменения ЕСТЬ и НИ ОДНО из них не лежит вне caddy.
# Проверка `N -gt 0` обязательна: пустой список иначе прошёл бы как
# «всё под caddy» и отключил бы сборку на ровном месте.
if [ "$N" -gt 0 ] && ! printf '%s\n' "$FILES" | grep -vE "$CADDY_RE" | grep -q .; then
caddy_only=true
fi
echo "Флаги: backend=$backend frontend=$frontend infra=$infra caddy_only=$caddy_only"
{
echo "backend=$backend"
echo "frontend=$frontend"
echo "infra=$infra"
echo "caddy_only=$caddy_only"
} >> "$GITHUB_OUTPUT"
with:
filters: |
backend:
- 'backend/**'
- 'data/sql/**'
frontend:
- 'frontend/**'
infra:
- 'docker-compose.prod.yml'
- '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
@ -1058,17 +997,12 @@ jobs:
docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps $WORKER_SERVICES
# Caddy: пересоздание ТОЛЬКО когда без него правка не доедет (#3443).
# Здесь стоял безусловный `up -d --force-recreate --no-deps caddy` —
# то есть КАЖДЫЙ полный деплой сносил единственный процесс, слушающий
# 80/443, и все домены хоста отдавали `code=000` (замер 05.09: 67 с).
# Довод той правки (17.05, 11e78d73 — «иначе новые volume mounts не
# появляются») не подтвердился: `up -d` БЕЗ флага пересоздаёт
# контейнер сам, как только меняется описание сервиса или образ.
# Разбор и проверки — в шапке ops/caddy-apply.sh; там же сверка
# пофайловых bind-маунтов (Caddyfile + 4 сниппета держат инод) и
# `caddy validate` до применения.
sh ops/caddy-apply.sh
# Caddy: force-recreate чтобы подхватить изменения в Caddyfile
# И в особенности новые volume mounts из docker-compose.prod.yml
# (`reload` не пересоздаёт container, поэтому новые binds не появляются —
# был случай 2026-05-17 с PR #268 preview/ — потребовался manual SSH fix).
docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps caddy
# Forwarder: force-recreate чтобы новый image / новые env подхватывались.
# Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild
@ -1295,13 +1229,14 @@ jobs:
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
# страница = новая строка allowlist'а.
#
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Опечатка в конфиге на
# пересоздании уводит контейнер в crash-loop и роняет ВСЕ домены сразу, а
# `caddy reload` её просто не принимает: job краснеет, домены продолжают
# обслуживаться прежним конфигом. С #3443 ровно тот же порядок действует и на
# полном деплое — оба пути зовут ops/caddy-apply.sh, который сперва проверяет
# конфиг одноразовым контейнером и пересоздаёт Caddy, только если правка иначе
# не доедет (пофайловый bind-маунт держит инод).
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой
# осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы
# принять битый конфиг и оставил бы работать старый — на общем деплое это
# скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ
# применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой
# код → job краснеет, а домены продолжают обслуживаться старым конфигом.
# Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop
# и роняет ВСЕ домены сразу.
#
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
# вторая, уже против боевого файла после `git reset`.
@ -1347,73 +1282,14 @@ jobs:
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
script: |
set -euo pipefail
# #3448: ТОТ ЖЕ ЛОК, что берёт полный деплой (см. job `deploy` выше).
# Эта джоба делает `git reset --hard` в /opt/gendesign, то есть правит
# прод-дерево — ровно то, что полный деплой сериализует локом. Пока
# быстрый путь был мёртв, столкнуться было нечему; теперь есть.
exec 9>/var/lock/gendesign-docker-deploy.lock
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
git fetch origin main
# ── #3448: быстрый путь законен, только если прод отстаёт РОВНО на
# конфиг прокси ────────────────────────────────────────────────────
#
# Джоба `changes` считает дифф between-push (before→HEAD) и не знает,
# что доехало до прода. Пока caddy_only был мёртв, любой push шёл
# полным деплоем и гард свежести :latest (#2950, job `deploy`)
# прикрывал прод по умолчанию. Оживший быстрый путь этот гард
# обходит: при caddy_only=true джоба `deploy` пропускается целиком.
#
# Сценарий отказа: push A правит бэкенд, билды ~6 мин, `deploy` в
# очереди; через 2 мин push B правит только caddy/. Forgejo на
# 10.0.3 отменяет ещё не стартовавший `deploy` предыдущего прогона
# ДАЖЕ при cancel-in-progress: false (наблюдение 21.08.2026 10:35:13,
# см. шапку scripts/check-latest-image-revision.sh). Дифф A..B — один
# caddy-файл, быстрый путь включается, `compose pull` + `up -d` не
# делает никто: прод крутит старый образ при зелёной голове main.
#
# Единственный источник правды о том, что реально на проде, — HEAD
# прод-дерева (у Trade-In для этого заведён отдельный маркер
# /opt/gendesign/.tradein-deployed-sha, см. deploy-tradein.yml:150;
# у ПТИЦЫ маркера нет, но git reset ниже делает HEAD эквивалентом).
# Проверка стоит ДО reset намеренно: при отказе прод-HEAD остаётся
# честным для следующего прогона.
#
# ЧЕГО ЭТА ПРОВЕРКА НЕ ЛОВИТ: `deploy` прогона A, упавшую ПОСЛЕ
# `git reset --hard` (например на миграции). Тогда прод-HEAD уже
# равен A, а контейнеры старые, и caddy-only push пройдёт быстрым
# путём. Это остаётся за настоящим маркером «что задеплоено».
PROD_HEAD=$(git rev-parse HEAD)
OUTSIDE=$(git -c core.quotePath=false diff --name-only "$PROD_HEAD" origin/main | grep -vE '^(Caddyfile$|caddy/)' || true)
if [ -n "$OUTSIDE" ]; then
echo "::error::прод отстаёт не только по конфигу прокси — быстрый путь запрещён:"
printf '%s\n' "$OUTSIDE" | sed 's/^/ /'
echo "Запусти полный деплой через workflow_dispatch."
exit 1
fi
git reset --hard origin/main
# #3443: тот же скрипт, что и в полном деплое. Голый `exec caddy
# reload` здесь был ВЕРЕН только для каталогов (caddy/sites/**,
# caddy/local/**). Caddyfile и четыре сниппета смонтированы
# ПОФАЙЛОВО, а `git reset --hard` выше пишет новый инод — контейнер
# остаётся на прежнем, и reload перечитывает СТАРЫЙ текст. Отказ
# беззвучный: джоба зелёная, конфиг на диске новый, прокси работает
# по старому. Скрипт сверяет, что именно видит контейнер, и
# пересоздаёт его только в этом случае.
sh ops/caddy-apply.sh
echo "✓ быстрый путь завершён: без пересборки образов и без миграций"
# Конфиг примонтирован 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) ──────────────────
#

View file

@ -35,35 +35,7 @@ concurrency:
jobs:
smoke:
runs-on: ubuntu-latest
# 11.09.2026: было 5 минут — теперь мало. В скрипте появились пауза между
# проверками (2 c) и повтор запроса, если ответа не пришло вовсе: обычный
# прогон вырос с ~89 c до ~175 c, а ХУДШИЙ случай — гораздо больше, потому
# что каждая неотвечающая проверка стоит до 3×15 c таймаута плюс паузы
# (~53 c против обычных ~2 c).
#
# 12.09.2026: 10 минут — тоже мало, и мало ровно в том сценарии, ради
# которого повтор писался. АРИФМЕТИКА ХУДШЕГО СЛУЧАЯ. Одна неотвечающая
# проверка сетевого класса = 3×15 c таймаута + 2 c и 4 c пауз ретрая + 2 c
# паузы между проверками = 53 c. Прод не отвечает целиком (DNS не
# резолвится, вход лежит) — мертвы ВСЕ проверки: 43 × 53 = 2279 c ≈ 38 мин.
# Откуда 43 (замер 12.09, зелёный прогон против прода — 43 PASS за 167 c):
# 42 обычные проверки + отдельная загрузка HTML лэндинга; 43-я, производный
# layout-чанк, при мёртвом ответе не запрашивается вовсе — запросов ровно
# столько же.
# В 10 минут помещалось ~8 мёртвых проверок из 43, дальше job убивали ДО
# печати FAIL-строк и итога — то есть лог терялся при полном отказе прода.
#
# Правка «повторяем только сетевой класс» (12.09) худший случай НЕ
# уменьшает: 15-секундный таймаут как раз сетевой (rc=28) и повторяется
# по-прежнему. Она удешевляет ДРУГОЙ сценарий — протухший/чужой сертификат
# (rc=60): отказ приходит сразу и без повторов. Замер 12.09 на
# expired.badssl.com, одна проверка при SMOKE_PAUSE=0 — 23 c на прежней
# голове (3 попытки + 6 c пауз) против <1 c теперь.
#
# 40 минут = 38 мин худшего случая + запас на чекаут и разброс сети.
# Цена промаха несимметрична: занятый раннер стоит дёшево (прогон daily +
# on-push), потерянный лог при полном отказе прода — дорого.
timeout-minutes: 40
timeout-minutes: 5
steps:
- name: Checkout repo

7
.gitignore vendored
View file

@ -109,10 +109,3 @@ ops/metrics/alertmanager/alertmanager.yml
# и Prometheus не видел ни одного приёмника (#3155). Производный файл убирает
# сам зазор — правится только там же, где принимается решение о профиле.
ops/metrics/prometheus/alertmanager_targets.gen.yml
# Временные рабочие копии-worktree вида _wt-<тема>/ живут рядом с репозиторием
# и в него попадать не должны. 09.09 копия _wt-mskcol попала в индекс одним
# файлом сборщика, и три следующих фикса ушли в дубликат мимо канонического
# tradein-mvp/scripts/local-avito-msk/collect.py — дефект нашёлся только при
# сверке page size.
_wt-*/

View file

@ -50,27 +50,30 @@
# ничего не меняется. В окне: на Selectel CADDY_SITES=apps, на Beget=infra.
import caddy/sites/{$CADDY_SITES:*}.caddy
# Plain HTTP by IP. /health остаётся публичным (liveness). Всё остальное —
# РЕДИРЕКТ на канонический HTTPS, а не проксирование под basic_auth.
#
# ЗДЕСЬ СТОЯЛ auth-гейт с проксированием приложения — «закрыть обход через
# голый IP тем же гейтом». Замысел верный, исполнение — дыра: Basic-challenge
# на plain HTTP означает, что браузер отправит пароль пилота ОТКРЫТЫМ ТЕКСТОМ
# любому, кто слушает канал (аудит 02.09.2026: curl http://<IP>/api/v1/me →
# 401 + Www-Authenticate: Basic realm="GenDesign Pilot"). Редирект строже
# гейта: по HTTP не отдаётся ни контент, ни сам запрос пароля, обход через
# IP закрыт тем, что отвечать нечему. Потребителей у IP:80 нет: все
# deploy-смоки ходят docker exec → localhost внутри контейнеров (проверено
# grep-ом по .forgejo/workflows и ops/ 02.09.2026).
# 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.
:80 {
encode zstd gzip
route {
# /health — public, без auth (liveness probe).
# /health — public, без auth (GHA deploy smoke check, liveness probe).
handle /health {
reverse_proxy backend:8000
}
# Auth gate (same snippet as gendsgn.ru).
import caddy/users.caddy.snippet
handle /api/* {
reverse_proxy backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}
}
}
handle {
redir https://gendsgn.ru{uri} permanent
reverse_proxy frontend:3000 {
header_up X-Authenticated-User {http.auth.user.id}
}
}
}
}

View file

@ -17,7 +17,6 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.observability.metrics import REPORTS_EXPORTED
from app.schemas.parcel import (
AnalysisRunDetail,
AnalysisRunListResponse,
@ -1613,11 +1612,6 @@ def export_parcel_forecast(
if run is None:
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
# #3471: считаем выгрузку здесь, а не в каждой format-ветке ниже — рано
# (до самого рендера), зато один раз на весь запрос и без риска разъехаться
# с новой веткой формата, если её когда-нибудь добавят.
REPORTS_EXPORTED.labels(format=format).inc()
# tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
if format == "tg":
return Response(
@ -4917,7 +4911,6 @@ async def get_parcel_best_layouts_pdf(
today = _dt.date.today().strftime("%Y-%m-%d")
cad_safe = cad_num.replace(":", "-")
filename = f"tz-layout-{cad_safe}-{today}.pdf"
REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
return Response(
content=pdf_bytes,
media_type="application/pdf",

View file

@ -100,19 +100,6 @@ BUILD_INFO.labels(
release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown",
).set(1)
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
#
# `format` — фиксированный литерал из сигнатуры эндпоинта (Literal["md", "json",
# "tg", "docx", "pptx", "pdf"] в `export_parcel_forecast` + одно статичное
# значение "best_layouts_pdf" из ТЗ-на-проектирование), НЕ произвольная строка —
# кардинальность ограничена набором форматов экспорта, а не количеством
# участков/пользователей.
REPORTS_EXPORTED = Counter(
"sitefinder_reports_exported_total",
"Экспортов отчётов по участку (§22-форсайт, ТЗ на проектирование), по формату",
labelnames=("format",),
)
def route_label(scope: Scope) -> str:
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.

View file

@ -246,19 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
reserve_unit = 'МВт',
installed_capacity_mva = :installed,
district = :district,
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки:
-- load_index категориальная колонка, её заполняет
-- rosseti_wfs_loader._map_load_index. Историю см. в
-- git log этого файла.
--
-- ВАЖНО: в этом комментарии НЕЛЬЗЯ упоминать
-- бинд-параметры в синтаксисе «двоеточие + имя».
-- SQLAlchemy text() парсит бинды и внутри
-- SQL-комментариев: упоминание снятого параметра
-- «(двоеточие)load_pct» в тексте комментария
-- сделало его ОБЯЗАТЕЛЬНЫМ, все 71 UPDATE падали
-- с 18.08 по 02.09, а per-row except глотал это
-- как «битую строку» задача оставалась зелёной.
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки.
-- load_index категориальная колонка
-- ('open'|'limited'|'closed'|NULL, см.
-- data/sql/180_connection_capacity.sql:35), её
-- заполняет rosseti_wfs_loader._map_load_index.
-- Раньше тут стоял COALESCE(load_index,
-- CAST(:load_pct AS text)) при пустой ячейке
-- в колонку легло бы число строкой ("72.5"),
-- а фронтовый classifyLoadIndex такое значение
-- отбрасывает в null («неизвестно»), и в
-- power_summary.by_load_index появился бы
-- бакет с именем "72.5".
-- Сегодня не стреляло только потому, что у всех
-- 3416 строк load_index уже заполнен
-- (open 2741 / limited 346 / closed 329, NULL 0)
-- и COALESCE не проваливался.
capacity_source = 'eesk_35_220',
reserve_asof = :asof
WHERE sc_name_norm = :name_norm

View file

@ -17,22 +17,12 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
per-test ``patch`` он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
что их ожидаемые значения резолвера сохраняются.
Perf-fix (2026-09-12): в конце ``analyze_parcel`` безусловный best-effort
``forecast_site_finder_report.delay(...)`` (§22-форсайт enqueue, см. app/api/v1/parcels.py).
В песочнице тестов Celery-брокер (Redis) недоступен ``.delay()`` синхронно ждёт
kombu-реконнект с растущим backoff (~69с) ДО того как try/except его проглотит
эта пауза оказалась внутри КАЖДОГО теста, который дергает ``POST /analyze`` и не
мокал форсайт-таску. Авто-фикстура ниже глушит ``.delay`` в no-op-мок для ВСЕХ
тестов каталога (как и с резолвером выше) тесты самого enqueue
(``test_parcels_forecast.py``, ``test_run_history_and_response_contract.py``)
переопределяют тот же target своим per-test ``patch`` поверх авто-фикстуры.
"""
from __future__ import annotations
from collections.abc import Iterator
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
@ -47,34 +37,3 @@ def _stub_zone_regulation_resolver() -> Iterator[None]:
"""
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
yield
@pytest.fixture(autouse=True)
def _stub_forecast_enqueue() -> Iterator[None]:
"""No-op форсайт-enqueue по умолчанию (без реального Celery/Redis round-trip).
``.delay(...)`` в проде fire-and-forget (best-effort, обёрнут в try/except в
``analyze_parcel``), тестам сам форсайт не нужен, а живой брокер в CI/локальной
песочнице недоступен и держит запрос ~69с на реконнект-backoff.
"""
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
yield
@pytest.fixture(autouse=True)
def _fast_inline_fetch_wait(monkeypatch: pytest.MonkeyPatch) -> None:
"""Схлопнуть inline-ожидание NSPD-фетча (#93 graceful fallback) до миллисекунд.
В ``analyze_parcel`` ветка «участка нет в БД» ждёт появления геометрии циклом
``sleep(_INLINE_FETCH_POLL_INTERVAL_S)`` до ``_INLINE_FETCH_WAIT_S`` (15с прод-
значение). В тестах фетч замокан и геометрия не появится никогда каждый такой
тест честно спал 16с (``test_market_price_invalid_cad_returns_404``,
``test_recent_permits_invalid_cad_no_regression``).
Оставляем цикл РАБОЧИМ (несколько итераций по 10мс), а не выключаем его нулём:
тесты, проверяющие сам fast-path «строка появилась на N-м опросе», продолжают
видеть опросы. Тесты с собственным ``patch`` того же имени (напр.
``test_run_history_and_response_contract.py``) переопределяют это поверх.
"""
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0.05)
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_POLL_INTERVAL_S", 0.01)

View file

@ -1,463 +0,0 @@
"""Полный деплой не пересоздаёт Caddy без нужды (#3443).
ЧТО СЛУЧИЛОСЬ. Каждый полный деплой ПТИЦЫ делал `up -d --force-recreate
--no-deps caddy`, то есть сносил единственный процесс, слушающий 80/443.
Замер 05.09 (#3274): 67 с `code=000` на ВСЕХ доменах хоста — gendsgn.ru,
meraocenka.ru и зеркала. Не 502/503: принимающего процесса нет вовсе, поэтому
заглушка окна деплоя бессильна по построению её отдаёт тот же Caddy.
ЧТО УСТАНОВЛЕНО. Безусловный флаг появился 17.05 (11e78d73) ради нового
bind-маунта `./preview`, который «не появлялся в running container». Довод
неверен: `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер
сам, как только меняется описание сервиса или образ (проверено на живом демоне
docker 28.4). Единственное, чего compose не видит, СОДЕРЖИМОЕ пофайлового
bind-маунта: `git reset --hard` пишет новый инод, контейнер держит прежний, и
`caddy reload` перечитывает старый текст. У Caddy так смонтированы Caddyfile и
четыре сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают.
ЗАЧЕМ ЭТОТ ФАЙЛ. У правки нет отрицательного признака: вернуть `--force-recreate`
«на всякий случай» одна строка, все деплои останутся зелёными, а окно в минуту
увидит только тот, кто в этот момент держал непрерывную пробу. Проверки ниже
ИСПОЛНЯЮТ ops/caddy-apply.sh с подставным `docker` и смотрят на СОВЕРШЁННЫЕ
действия (пересоздал / перезагрузил / не тронул), а не на текст скрипта.
Отдельно проверяется проводка в deploy.yml что оба пути деплоя зовут именно
его.
"""
from __future__ import annotations
import re
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
import yaml
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "ops" / "caddy-apply.sh"
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
DEPLOY = WORKFLOWS / "deploy.yml"
CID = "caddy-cid-0001"
# Маунты Caddy ровно как на проде (`docker inspect gendesign-caddy-1`, 12.09):
# пять ПОФАЙЛОВЫХ bind-маунтов и три каталога. Тома (caddy_data и соседи) в
# сверку не входят — их фильтрует `{{if eq .Type "bind"}}`.
FILE_MOUNTS = {
"Caddyfile": "/etc/caddy/Caddyfile",
"caddy/users.caddy.snippet": "/etc/caddy/caddy/users.caddy.snippet",
"caddy/metrics-ui.caddy.snippet": "/etc/caddy/caddy/metrics-ui.caddy.snippet",
"caddy/metrics-ingest.caddy.snippet": "/etc/caddy/caddy/metrics-ingest.caddy.snippet",
"caddy/deploy-window.caddy.snippet": "/etc/caddy/caddy/deploy-window.caddy.snippet",
}
DIR_MOUNTS = {
"caddy/sites": "/etc/caddy/caddy/sites",
"caddy/local": "/etc/caddy/caddy/local",
"preview": "/srv/preview",
}
# Подставной `docker`. Пишет каждый вызов в $FAKE_LOG и отвечает по сценарию:
# run — одноразовый `caddy validate`, код из $FAKE_VALIDATE_RC;
# inspect — список маунтов из $FAKE_MOUNTS, код из $FAKE_INSPECT_RC;
# exec — sha256sum ФАЙЛА, КОТОРЫЙ ВИДИТ КОНТЕЙНЕР ($FAKE_VIEW/<slug>);
# compose … ps — текущий id контейнера из $FAKE_CID_FILE;
# compose … up — при $FAKE_UP_RECREATES=1 подменяет id (compose пересоздал сам).
FAKE_DOCKER = r"""#!/bin/bash
printf '%s\n' "$*" >> "$FAKE_LOG"
cmd="$1"; shift
case "$cmd" in
run) exit "${FAKE_VALIDATE_RC:-0}" ;;
inspect)
if [ "${FAKE_INSPECT_RC:-0}" != "0" ]; then
echo "Error: No such object" >&2
exit "$FAKE_INSPECT_RC"
fi
cat "$FAKE_MOUNTS"
;;
exec)
dst="$3"
view="$FAKE_VIEW/$(printf '%s' "$dst" | tr '/' '_')"
[ -f "$view" ] || exit 1
sha256sum "$view"
;;
compose)
case " $* " in
*" ps "*) cat "$FAKE_CID_FILE" ;;
*--force-recreate*) echo "recreated caddy (forced)" ;;
*" up "*)
if [ "${FAKE_UP_RECREATES:-0}" = "1" ]; then
printf 'caddy-cid-NEW\n' > "$FAKE_CID_FILE"
echo "Container gendesign-caddy-1 Started"
else
echo "Container gendesign-caddy-1 Running"
fi
;;
*) echo "(compose $*)" ;;
esac
;;
esac
exit 0
"""
# macOS несёт shasum вместо sha256sum; на раннере (ubuntu) и на проде утилита
# настоящая. Шим ставится только при её отсутствии — иначе гейт не запускался бы
# локально вовсе.
SHA_SHIM = '#!/bin/sh\nexec shasum -a 256 "$@"\n'
def _write_exec(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@pytest.fixture
def prod_tree(tmp_path: Path) -> Path:
"""Копия боевого дерева: скрипт + конфиги + «взгляд контейнера»."""
tree = tmp_path / "opt" / "gendesign"
(tree / "ops").mkdir(parents=True)
shutil.copy(SCRIPT, tree / "ops" / SCRIPT.name)
for rel in [*FILE_MOUNTS, "caddy/sites/apps.caddy", "caddy/local/.gitignore"]:
path = tree / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"# {rel} версия НОВАЯ\n", encoding="utf-8")
(tree / "preview").mkdir()
view = tmp_path / "view"
view.mkdir()
# По умолчанию контейнер видит ровно то же, что лежит на диске.
for rel, dst in FILE_MOUNTS.items():
(view / dst.replace("/", "_")).write_text(
(tree / rel).read_text(encoding="utf-8"), encoding="utf-8"
)
mounts = tmp_path / "mounts"
mounts.write_text(
"".join(f"{tree / rel}|{dst}\n" for rel, dst in {**FILE_MOUNTS, **DIR_MOUNTS}.items()),
encoding="utf-8",
)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_write_exec(bin_dir / "docker", FAKE_DOCKER)
if shutil.which("sha256sum") is None:
_write_exec(bin_dir / "sha256sum", SHA_SHIM)
(tmp_path / "cid").write_text(CID + "\n", encoding="utf-8")
(tmp_path / "log").write_text("", encoding="utf-8")
return tree
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
root = tree.parent.parent
env = {
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
"FAKE_LOG": str(root / "log"),
"FAKE_CID_FILE": str(root / "cid"),
"FAKE_MOUNTS": str(root / "mounts"),
"FAKE_VIEW": str(root / "view"),
**env_extra,
}
proc = subprocess.run(
["sh", str(tree / "ops" / "caddy-apply.sh")],
cwd=str(tree),
env=env,
capture_output=True,
text=True,
)
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
return proc.returncode, proc.stdout + proc.stderr, calls
def _stale(tree: Path, dst: str) -> None:
"""Контейнер остался на старом иноде этого маунта."""
view = tree.parent.parent / "view" / dst.replace("/", "_")
view.write_text("# версия СТАРАЯ\n", encoding="utf-8")
def _recreated(calls: list[str]) -> bool:
return any("--force-recreate" in c for c in calls)
def _reloaded(calls: list[str]) -> bool:
return any("caddy reload" in c for c in calls)
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
def test_nothing_changed_reloads_without_recreate(prod_tree: Path) -> None:
"""Обычный полный деплой (конфиг прокси не трогали): reload, без окна."""
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert not _recreated(calls), (
f"Caddy пересоздан, хотя ничего не изменилось: {calls}. "
"Это и есть #3443: 67 с code=000 на всех доменах при каждом деплое."
)
assert _reloaded(calls), f"конфиг не применён вовсе: {calls}"
def test_changed_file_mount_forces_recreate(prod_tree: Path) -> None:
"""Caddyfile правлен: reload перечитал бы старый инод — нужен recreate."""
_stale(prod_tree, "/etc/caddy/Caddyfile")
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert _recreated(calls), (
f"пересоздания нет: {calls}. Пофайловый bind-маунт держит инод — правка "
"Caddyfile не доехала бы до контейнера, а деплой ушёл бы зелёным."
)
assert "/etc/caddy/Caddyfile" in out, f"решение не названо в логе:\n{out}"
@pytest.mark.parametrize("dst", sorted(set(FILE_MOUNTS.values()) - {"/etc/caddy/Caddyfile"}))
def test_changed_snippet_forces_recreate(prod_tree: Path, dst: str) -> None:
"""Каждый из четырёх сниппетов — тот же класс, не только Caddyfile."""
_stale(prod_tree, dst)
_, _out, calls = _run(prod_tree)
assert _recreated(calls), f"{dst}: правка сниппета не доехала бы: {calls}"
def test_directory_mount_change_does_not_recreate(prod_tree: Path) -> None:
"""caddy/sites/apps.caddy — самый частый случай; каталог инод не держит.
Если сюда приползёт пересоздание «за компанию», окно недоступности вернётся
ровно на тех правках, ради которых заведён быстрый путь #2916.
"""
(prod_tree / "caddy" / "sites" / "apps.caddy").write_text("# новый блок\n", encoding="utf-8")
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert not _recreated(calls), f"правка в КАТАЛОГЕ вызвала пересоздание: {calls}"
assert _reloaded(calls), f"правка в каталоге не применена: {calls}"
def test_broken_config_touches_nothing(prod_tree: Path) -> None:
"""Битый конфиг: ни up, ни reload, ни пересоздания — прокси не тронут.
Иначе опечатка в Caddyfile уводит контейнер в crash-loop и роняет все
домены сразу (ровно то, чем опасен `--force-recreate` вслепую).
"""
rc, out, calls = _run(prod_tree, FAKE_VALIDATE_RC="1")
assert rc != 0, f"скрипт не упал на битом конфиге:\n{out}"
assert not _recreated(calls), f"битый конфиг поехал в пересоздание: {calls}"
assert not _reloaded(calls), f"битый конфиг поехал в reload: {calls}"
assert not any(" up " in f" {c} " for c in calls), f"был `up` при битом конфиге: {calls}"
def test_compose_recreate_is_not_doubled(prod_tree: Path) -> None:
"""compose пересоздал сам (сменилось описание сервиса/образ) — хватит.
Второй `--force-recreate` поверх ещё одно окно недоступности на ровном
месте, а новый контейнер и так читает свежие файлы.
"""
rc, out, calls = _run(prod_tree, FAKE_UP_RECREATES="1")
assert rc == 0, out
assert not _recreated(calls), f"пересоздание сделано дважды: {calls}"
assert not _reloaded(calls), f"reload поверх свежего контейнера: {calls}"
def test_unreadable_container_view_falls_back_to_recreate(prod_tree: Path) -> None:
"""Сверка не отработала (контейнер не отвечает) → прежнее поведение.
Fail-safe направлен в сторону пересоздания: лучше окно в секунды, чем
беззвучно не применённая правка конфига прокси.
"""
(prod_tree.parent.parent / "view" / "_etc_caddy_Caddyfile").unlink()
_, out, calls = _run(prod_tree)
assert _recreated(calls), f"непрочитанный маунт сочли доехавшим: {calls}\n{out}"
def test_unreadable_mount_list_falls_back_to_recreate(prod_tree: Path) -> None:
"""`docker inspect` не ответил → пересоздать, а не «расхождений нет».
Статус `$(docker inspect | while )` это статус `while`, то есть всегда
0, а `pipefail` в POSIX-sh не существует. Провал команды давал бы пустой
список маунтов, ветку «всё доехало» и зелёную строку «окна недоступности
нет» при прокси, работающем по СТАРОМУ конфигу тот самый беззвучный отказ,
ради которого написан скрипт.
"""
rc, out, calls = _run(prod_tree, FAKE_INSPECT_RC="1")
assert rc == 0, out
assert _recreated(calls), f"непрочитанный список маунтов сочли «всё доехало»: {calls}\n{out}"
assert not _reloaded(calls), f"reload вместо пересоздания: {calls}"
def test_no_file_mounts_is_not_silence(prod_tree: Path) -> None:
"""Ноль пофайловых маунтов — не «сверка прошла», а «сверять было нечем».
Так выглядит, например, перевод Caddyfile на именованный том: фильтр
`{{if eq .Type "bind"}}` перестаёт что-либо отбирать, и сверка становится
тавтологически успешной.
"""
root = prod_tree.parent.parent
(root / "mounts").write_text(
"".join(f"{prod_tree / rel}|{dst}\n" for rel, dst in DIR_MOUNTS.items()), encoding="utf-8"
)
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert _recreated(calls), f"пустая сверка сочтена успешной: {calls}\n{out}"
def test_missing_host_file_is_not_skipped_as_a_directory(prod_tree: Path) -> None:
"""Файла на хосте нет — это расхождение, а не «нечего сверять».
Пропуск по `[ -f "$src" ] || continue` склеивает два разных случая: каталог
(пропустить верно инод он не держит) и исчезнувший/нечитаемый файл, для
которого в контейнере как раз живёт старый инод со старым текстом. Файл
удаляется после проверки конфига (в тесте она подставная) проверяется
именно ветка сверки.
"""
(prod_tree / "Caddyfile").unlink()
_, out, calls = _run(prod_tree)
assert _recreated(calls), f"исчезнувший файл сочли доехавшим: {calls}\n{out}"
def test_log_says_how_many_mounts_were_compared(prod_tree: Path) -> None:
"""В логе должно быть ЧИСЛО сверенных файлов, а не только вердикт.
«Сверили пять» и «сверили ноль» обязаны различаться: иначе строка
«перезагружен без пересоздания» одинаково означает и проверку, и её
отсутствие.
"""
_, out, _ = _run(prod_tree)
assert re.search(r"сверено пофайловых маунтов[^\n]*: 5", out), (
f"скрипт не печатает число сверенных маунтов (их пять):\n{out}"
)
def test_validation_precedes_any_action(prod_tree: Path) -> None:
"""Проверка конфига идёт ПЕРВЫМ вызовом, до любого изменения состояния."""
_, out, calls = _run(prod_tree)
assert calls, f"скрипт не сделал ни одного вызова docker:\n{out}"
assert calls[0].startswith("run "), f"первым идёт не проверка конфига: {calls}"
assert "caddy validate" in calls[0], f"первый вызов — не validate: {calls[0]}"
# ── Проводка: оба пути деплоя зовут именно этот скрипт ───────────────────────
def _ssh_script(job: str) -> str:
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
assert len(steps) == 1, f"в job `{job}` нет ровно одного ssh-шага — гейт #3443 ослеп"
script = steps[0]["with"]["script"]
assert script.strip(), f"ssh-скрипт job `{job}` пуст"
return script
def _commands(script: str) -> str:
"""Только команды: комментарии выкинуты, продолжения строк склеены.
Комментарии потому что разбор дефекта живёт в тех же файлах и содержит
его формулировку дословно: гейт по голому тексту краснел бы от объяснения,
а не от кода. Склейка `\\` потому что `--force-recreate` и имя сервиса
легко оказываются на РАЗНЫХ физических строках, и построчный поиск такую
запись не увидел бы (зелено по построению).
"""
kept = [ln for ln in script.splitlines() if not ln.lstrip().startswith("#")]
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
def _forced_caddy_recreates(commands: str) -> list[str]:
"""Строки, которые пересоздают именно сервис caddy."""
return [
ln
for ln in commands.splitlines()
if "--force-recreate" in ln and re.search(r"\bcaddy\b", ln)
]
def test_script_exists_and_is_the_one_under_test() -> None:
"""Признак непустоты: без скрипта проверки выше проходили бы вхолостую."""
assert SCRIPT.is_file(), f"нет {SCRIPT} — проводка ниже проверяла бы пустоту"
@pytest.mark.parametrize("job", ["deploy", "deploy-caddy"])
def test_deploy_applies_caddy_config_through_the_script(job: str) -> None:
assert "ops/caddy-apply.sh" in _commands(_ssh_script(job)), (
f"job `{job}` не зовёт ops/caddy-apply.sh — конфиг прокси применяется "
"мимо разбора #3443 (или безусловным пересозданием, или reload'ом, "
"который на пофайловом маунте читает старый инод)"
)
def test_full_deploy_has_no_unconditional_caddy_recreate() -> None:
"""Главный инвариант: в полном деплое нет безусловного пересоздания Caddy.
Возврат одной строки `up -d --force-recreate --no-deps caddy` в job `deploy`
возвращает 67-секундное окно `code=000` на всех доменах и не краснит
ничего: деплой остаётся зелёным, а увидеть отказ может только непрерывная
проба, запущенная ровно в эту минуту.
"""
bad = _forced_caddy_recreates(_commands(_ssh_script("deploy")))
assert not bad, (
"в полный деплой вернулось безусловное пересоздание Caddy:\n "
+ "\n ".join(bad)
+ "\nПересоздание обязано быть УСЛОВНЫМ — см. ops/caddy-apply.sh: compose "
"сам пересоздаёт контейнер при смене описания сервиса или образа, а "
"вручную это нужно только когда до контейнера не доехал пофайловый "
"bind-маунт (#3443)."
)
def test_failures_are_not_swallowed() -> None:
"""Ни применение конфига, ни сам reload не гасятся `|| true`.
Строка `caddy reload || true` в репозитории уже живёт
(deploy-tradein.yml), то есть это не гипотеза: с ней отказ применения
перестаёт краснеть, и «конфиг доехал» становится неотличимо от «команда
упала, а мы продолжили». Проверяется и вызов скрипта из обеих джоб, и
строка reload внутри самого скрипта.
"""
swallow = re.compile(r"\|\|\s*(true|:)\s*$")
offenders = []
for where, text in [
("deploy", _commands(_ssh_script("deploy"))),
("deploy-caddy", _commands(_ssh_script("deploy-caddy"))),
(SCRIPT.name, SCRIPT.read_text(encoding="utf-8")),
]:
for line in text.splitlines():
code = line.split("#", 1)[0] if not line.lstrip().startswith("#") else ""
if ("caddy-apply.sh" in code or "caddy reload" in code) and swallow.search(code):
offenders.append(f"{where}: {line.strip()}")
assert not offenders, "отказ применения конфига проглочен:\n " + "\n ".join(offenders)
def test_gate_runs_on_changes_to_the_script_itself() -> None:
"""CI-фильтр обязан пускать backend-тесты на правку ops/**.
Все содержательные регрессии живут в ops/caddy-apply.sh: проверки выше его
ИСПОЛНЯЮТ. Без `ops/**` в фильтре PR, правящий только скрипт, даёт
backend=false джоба пропускается, гейт не исполняется, и «пересоздавать
всегда» уезжает в main зелёным. Тот же класс, что #2950/#3448/#3467.
"""
spec = yaml.safe_load((WORKFLOWS / "ci.yml").read_text(encoding="utf-8"))
steps = [
s
for job in spec["jobs"].values()
for s in job.get("steps") or []
if str(s.get("uses", "")).startswith("dorny/paths-filter")
]
assert steps, "в ci.yml не найден paths-filter — проверка прошла бы вхолостую"
patterns = [p for s in steps for p in yaml.safe_load(s["with"]["filters"]).get("backend") or []]
assert "ops/**" in patterns, (
f"фильтр backend не покрывает ops/** (сейчас: {patterns}) — гейт #3443 не "
"побежит на правке ops/caddy-apply.sh, то есть ровно на той правке, от "
"которой стережёт"
)
def test_gate_would_notice_the_regression() -> None:
"""Сам гейт обязан краснеть на возвращённом дефекте — проверка на себя.
Без этого «не нашли force-recreate» неотличимо от «искали не там»: маска
поиска, промахнувшаяся мимо строки, выглядит зелёной ровно так же.
"""
regressed = _commands(
" # безусловное пересоздание caddy вернулось сюда\n"
" docker compose -p gendesign -f docker-compose.prod.yml up -d \\\n"
" --force-recreate --no-deps caddy\n"
)
assert _forced_caddy_recreates(regressed), (
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
)

View file

@ -1,438 +0,0 @@
"""Гейт: быстрый путь «правка только прокси» действительно включается (#3448).
ЧТО СЛУЧИЛОСЬ. Быстрый путь #2916 (`caddy_only` → джоба `deploy-caddy` с
`caddy reload` вместо пересоздания контейнеров) не отработал ни разу за всё
время жизни. Проверено на мерже 84920e6c, где в диффе ровно один файл
`caddy/sites/apps.caddy`: контейнеры пересозданы, в логе Caddy
`serving initial configuration` холодный старт, а не reload.
ПРИЧИНА НЕ пустой `github.event.before` (рабочая гипотеза #3448 опровергнута
логом задачи 29244: `before` = 204e2e09, `git diff` вернул ровно один файл).
Причина в том, что dorny/paths-filter склеивает шаблоны одного фильтра через
`some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`; параметр
`predicate-quantifier` по умолчанию `some`). Список
non_caddy: ['**', '!Caddyfile', '!caddy/**']
значит «подходит под `**` ИЛИ не Caddyfile ИЛИ не caddy/**», а `**` матчит всё
non_caddy был true ВСЕГДА. В логе это видно дословно:
##[group]Filter non_caddy = true
Matching files:
caddy/sites/apps.caddy [modified]
исключённый файл сам себя и «исключил».
ЗАЧЕМ ЭТОТ ФАЙЛ. У самой правки нет отрицательного признака: пропущенную джобу
Forgejo рисует зелёной, поэтому «зелёный deploy-caddy» одинаково выглядит и
когда быстрый путь сработал, и когда его вообще не было. Проверки ниже
ИСПОЛНЯЮТ шаг определения файлов из deploy.yml на настоящем временном
репозитории (включая мерж-коммит ровно случай #3448) и смотрят на значения
флагов, а не на текст воркфлоу. Так же исполняется и прод-сторож из джобы
`deploy-caddy`: быстрый путь пропускает джобу `deploy` целиком, а вместе с ней
и гард свежести :latest (#2950), поэтому перезагружать прокси можно, только
если прод отстаёт РОВНО на конфиг прокси. Регресс к исключающим шаблонам
paths-filter ловит отдельная проверка в конце.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
import pytest
import yaml
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
DEPLOY = WORKFLOWS / "deploy.yml"
NULL_SHA = "0" * 40
# Файлы, которые лежат в тестовом репозитории до правки. Набор подобран так,
# чтобы фолбэк «база не определена» мог отличить полный деплой от пустого.
BASE_FILES = (
"caddy/sites/apps.caddy",
"Caddyfile",
"backend/app/main.py",
"frontend/src/page.tsx",
"data/sql/001.sql",
"docker-compose.prod.yml",
"README.md",
)
GIT_ENV = {
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.com",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.com",
"GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_SYSTEM": "/dev/null",
}
def _detect_script() -> str:
"""Тело шага, который считает изменённые файлы в job `changes`."""
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
job = spec["jobs"]["changes"]
steps = [s for s in job["steps"] if s.get("id") == "filter"]
assert len(steps) == 1, (
"в job `changes` нет ровно одного шага с `id: filter` — определение "
"изменённых файлов переехало, гейт #3448 ослеп"
)
step = steps[0]
assert "run" in step, (
f"шаг `filter` не считает файлы сам, а делегирует их {step.get('uses')!r}. "
"Именно так и возник #3448: у dorny/paths-filter шаблоны одного фильтра "
"склеиваются через ИЛИ, поэтому `non_caddy: ['**', '!caddy/**']` был true "
"ВСЕГДА и быстрый путь не включался ни разу."
)
assert step["run"].strip(), "шаг `filter` пуст"
return step["run"]
def _git(repo: Path, *args: str) -> None:
subprocess.run(
["git", "-C", str(repo), *args], check=True, env=dict(GIT_ENV), capture_output=True
)
def _sha(repo: Path) -> str:
return subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
env=dict(GIT_ENV),
).stdout.strip()
def _commit(repo: Path, files: tuple[str, ...], msg: str = "c") -> None:
for name in files:
path = repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("changed\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", msg, *([] if files else ["--allow-empty"]))
def _base_repo(tmp_path: Path) -> tuple[Path, str]:
"""Репозиторий с одним базовым коммитом; возвращает его sha — это `before`."""
repo = tmp_path / "repo"
repo.mkdir(parents=True)
_git(repo, "init", "-q", "-b", "main")
for name in BASE_FILES:
path = repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("base\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
return repo, _sha(repo)
def _merge_commit(repo: Path, changed: tuple[str, ...]) -> None:
"""Ветка с правкой и мерж `--no-ff` обратно в main.
Мерж, а не обычный коммит, потому что #3448 наблюдался именно на мерже
PR'а: у мерж-коммита две родительские линии, и любой разбор диффа обязан
работать на этой форме. Что `before` нельзя заменить на `HEAD^`, стережёт
отдельная проверка test_multi_commit_push_is_not_truncated: на ОДНОМ
мерж-коммите `HEAD^..HEAD` даёт верный ответ и такую подмену не ловит.
"""
_git(repo, "checkout", "-q", "-b", "feature")
_commit(repo, changed, "feature")
_git(repo, "checkout", "-q", "main")
_git(repo, "merge", "-q", "--no-ff", "-m", "merge feature", "feature")
def _exec(repo: Path, before: str, event: str = "push") -> tuple[dict[str, str], str]:
out_file = repo.parent / "outputs"
out_file.touch()
env = {
"PATH": "/usr/bin:/bin:/usr/local/bin",
"BEFORE": before,
"EVENT": event,
"GITHUB_OUTPUT": str(out_file),
**GIT_ENV,
}
proc = subprocess.run(
["bash", "-c", _detect_script()],
cwd=repo,
env=env,
capture_output=True,
text=True,
)
assert proc.returncode == 0, f"шаг упал:\n{proc.stdout}\n{proc.stderr}"
outputs = dict(
line.split("=", 1)
for line in out_file.read_text(encoding="utf-8").splitlines()
if "=" in line
)
return outputs, proc.stdout
def _run(
tmp_path: Path, changed: tuple[str, ...], *, before: str | None = None, event: str = "push"
) -> tuple[dict[str, str], str]:
repo, base_sha = _base_repo(tmp_path)
_merge_commit(repo, changed)
return _exec(repo, base_sha if before is None else before, event)
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
assert outputs["caddy_only"] == "true", (
f"быстрый путь не включился на правке ТОЛЬКО прокси: {outputs}. "
"Ровно это и есть #3448: deploy-caddy пропускается, идёт полный деплой "
"с пересозданием контейнеров, а Forgejo рисует пропуск зелёным."
)
assert outputs["backend"] == "false"
assert outputs["frontend"] == "false"
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
assert outputs["caddy_only"] == "false", (
f"быстрый путь включился, хотя вместе с конфигом приехал бэкенд: {outputs}. "
"Так прод остался бы на старом образе при зелёном деплое."
)
assert outputs["backend"] == "true"
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
"""База не разрешилась → полный деплой, а не пустой список.
Пустой список изменений это `caddy_only` без единого caddy-файла и
отключённая сборка: отказ, который выглядит как успешный быстрый путь.
"""
for before, event in ((NULL_SHA, "push"), ("", "push"), (None, "workflow_dispatch")):
outputs, log = _run(
tmp_path / f"case-{event}-{before!r}",
("caddy/sites/apps.caddy",),
before=before,
event=event,
)
assert outputs["caddy_only"] == "false", f"before={before!r} event={event}: {outputs}"
assert outputs["backend"] == "true", f"before={before!r} event={event}: {outputs}"
assert outputs["frontend"] == "true", f"before={before!r} event={event}: {outputs}"
assert outputs["infra"] == "true", f"before={before!r} event={event}: {outputs}"
assert "деплой полный" in log
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
"""Решение печатается: и список файлов, и итоговые флаги.
Без этого «сработало» и «просто не совпало» неотличимы единственным
свидетелем остаётся метка Created у контейнера на проде.
"""
_, log = _run(tmp_path, ("caddy/sites/apps.caddy",))
assert "caddy/sites/apps.caddy" in log, f"шаг не печатает список файлов:\n{log}"
assert "caddy_only=true" in log and "backend=false" in log, (
f"шаг не печатает итоговые флаги:\n{log}"
)
# ── Быстрый путь на самом проде: джоба deploy-caddy ──────────────────────────
#
# Пока caddy_only был мёртв, каждый push шёл полным деплоем, и гард свежести
# :latest (#2950, job `deploy`) прикрывал прод по умолчанию. Оживший быстрый
# путь его обходит: при caddy_only=true джоба `deploy` пропускается целиком.
# Дифф between-push (before→HEAD) не знает, что реально доехало до прода:
# отменённая очередью `deploy` предыдущего прогона оставляет прод на старом
# образе, а следующий caddy-only push честно видит «изменился один caddy-файл».
def _caddy_deploy_script() -> str:
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
steps = [s for s in spec["jobs"]["deploy-caddy"]["steps"] if "ssh-action" in str(s.get("uses"))]
assert len(steps) == 1, "в deploy-caddy нет ровно одного ssh-шага — гейт #3448 ослеп"
return steps[0]["with"]["script"]
def _prod_lag_guard() -> str:
"""Кусок ssh-скрипта от вычисления PROD_HEAD до `git reset --hard`."""
script = _caddy_deploy_script()
assert "PROD_HEAD=" in script, (
"джоба deploy-caddy не сверяет отставание прода: быстрый путь перезагрузит "
"прокси и уйдёт зелёным, оставив прод на старом образе (#3448)"
)
# Ищем КОМАНДУ, а не подстроку: `git reset --hard` упоминается выше в
# комментариях, и поиск по тексту нашёл бы объяснение вместо кода.
reset_cmd = re.search(r"(?m)^\s*git reset --hard", script)
assert reset_cmd, "в deploy-caddy пропал `git reset --hard` — гейт опирается на него"
start, reset = script.index("PROD_HEAD="), reset_cmd.start()
assert start < reset, (
"проверка отставания прода стоит ПОСЛЕ `git reset --hard` — при отказе "
"прод-HEAD уже переписан, и следующий прогон снова уйдёт быстрым путём"
)
return "set -euo pipefail\n" + script[start:reset]
def _prod_repo(tmp_path: Path, ahead: tuple[str, ...]) -> Path:
"""Прод-дерево на базовом коммите, origin/main — на `ahead` впереди."""
repo, base_sha = _base_repo(tmp_path)
_commit(repo, ahead, "ahead")
_git(repo, "update-ref", "refs/remotes/origin/main", "HEAD")
_git(repo, "reset", "--hard", "-q", base_sha)
return repo
def _run_guard(repo: Path) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", _prod_lag_guard()],
cwd=repo,
capture_output=True,
text=True,
env={"PATH": "/usr/bin:/bin:/usr/local/bin", **GIT_ENV},
)
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
assert proc.returncode != 0, (
"быстрый путь разрешён, хотя прод отстаёт по коду бэкенда: перезагрузка "
f"прокси подменила бы выкатку, деплой ушёл бы зелёным.\n{proc.stdout}"
)
assert "backend/app/main.py" in proc.stdout, (
f"отказ не называет файлы, из-за которых он произошёл:\n{proc.stdout}"
)
def test_fast_path_takes_the_same_host_lock() -> None:
"""deploy-caddy правит прод-дерево — значит берёт тот же лок, что `deploy`.
Проверка текстовая, как в test_2950: исполнить flock-секцию в тесте нельзя,
а её пропажа не даёт ни одного сигнала до совпадения окон двух деплоев.
"""
script = _caddy_deploy_script()
assert "exec 9>/var/lock/gendesign-docker-deploy.lock" in script, (
"deploy-caddy делает `git reset --hard` в /opt/gendesign в обход лока, "
"которым полный деплой сериализует работу с прод-деревом (#2950)"
)
assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
@pytest.mark.parametrize(
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
)
def test_paths_that_merely_start_with_caddy_are_not_the_fast_path(
tmp_path: Path, changed: tuple[str, ...]
) -> None:
"""`caddy-extra/…` и `Caddyfile.bak` — НЕ конфиг прокси.
Граница шаблона единственное, что отделяет быстрый путь от тихого
пропуска полного деплоя: `^(Caddyfile|caddy)` вместо `^(Caddyfile$|caddy/)`
отправил бы эти правки перезагружать прокси вместо выкатки.
"""
outputs, _ = _run(tmp_path, changed)
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
Без проверки «файлов больше нуля» пустой список формально удовлетворяет
«ни один файл не лежит вне caddy»: сборка отключается, деплой подменяется
перезагрузкой прокси отказ, выглядящий как успешный быстрый путь.
"""
outputs, log = _run(tmp_path, ())
assert outputs["caddy_only"] == "false", f"пустой дифф ушёл в быстрый путь: {outputs}"
assert "изменённых файлов: 0" in log
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
assert outputs["backend"] == "true", outputs
assert outputs["caddy_only"] == "false", outputs
def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
"""Кириллица в пути не должна прятать файл от классификации.
`git diff --name-only` при `core.quotePath=true` (умолчание) отдаёт
не-ASCII пути закавыченными и с \\NNN-экранированием `^backend/`
такую строку не матчит. Старый paths-filter брал `--name-status -z`, где
квотирования нет; при переходе на свой diff это единственное место, где
поведение могло разойтись. В дереве такие пути уже живут (docs/).
"""
outputs, log = _run(tmp_path, ("backend/модуль.py",))
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
"""Push из нескольких коммитов разбирается целиком, а не по последнему.
Ровно та подмена, которую соблазнительно сделать «чтобы не зависеть от
before»: `HEAD^..HEAD`. На одном мерж-коммите она даёт верный ответ и
выглядит рабочей, а здесь молча теряет бэкенд из первого коммита и
включает быстрый путь, то есть пропускает выкатку кода.
"""
repo, base_sha = _base_repo(tmp_path)
_commit(repo, ("backend/app/main.py",), "backend")
_commit(repo, ("caddy/sites/apps.caddy",), "caddy")
outputs, log = _exec(repo, base_sha)
assert outputs["backend"] == "true", f"первый коммит push'а потерян: {outputs}\n{log}"
assert outputs["caddy_only"] == "false", outputs
def _paths_filter_steps() -> list[tuple[Path, str, dict]]:
"""Все шаги dorny/paths-filter во всех воркфлоу (включая .yaml)."""
found = []
for path in sorted(WORKFLOWS.glob("*.y*ml")):
spec = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
for job_name, job in (spec.get("jobs") or {}).items():
for step in job.get("steps") or []:
if str(step.get("uses", "")).startswith("dorny/paths-filter"):
found.append((path, job_name, step))
return found
def test_exclusion_gate_has_something_to_check() -> None:
"""Признак непустоты: проверка ниже обязана что-то находить.
Переименуют действие, разнесут воркфлоу по .yaml, уедут шаги и гейт
пройдёт при нулевом охвате, молча (ровно то, от чего страхуется ci.yml:190).
"""
steps = _paths_filter_steps()
assert steps, (
"не найдено ни одного шага dorny/paths-filter — проверка исключающих "
"шаблонов прошла бы впустую, перепроверь маску поиска"
)
@pytest.mark.parametrize(
"path,job_name,step",
_paths_filter_steps(),
ids=[f"{p.name}:{j}" for p, j, _ in _paths_filter_steps()],
)
def test_no_paths_filter_relies_on_exclusion_patterns(
path: Path, job_name: str, step: dict
) -> None:
"""Ни один paths-filter в репозитории не вычитает пути через `!`.
Класс бага, а не единственный его случай: при `predicate-quantifier: some`
(умолчание) шаблоны фильтра склеиваются через ИЛИ, и `!` ничего не вычитает.
"""
with_ = step.get("with") or {}
if with_.get("predicate-quantifier") == "every":
pytest.skip("predicate-quantifier: every — шаблоны склеиваются через И")
filters = yaml.safe_load(with_.get("filters") or "") or {}
assert filters, f"{path.name}: job {job_name}у paths-filter пустой блок filters"
for filter_name, patterns in filters.items():
bad = [p for p in (patterns or []) if isinstance(p, str) and p.startswith("!")]
assert not bad, (
f"{path.name}: job {job_name}, фильтр {filter_name!r} вычитает пути "
f"шаблонами {bad} — при `some` (умолчание) они склеиваются через ИЛИ "
"и фильтр становится true ВСЕГДА. Так #2916 не сработал ни разу (#3448)."
)

View file

@ -1,112 +0,0 @@
"""Правки Prometheus-конфига/правил обязаны доезжать до работающего процесса.
ЧТО СЛУЧИЛОСЬ НА ПРОДЕ. `GET /api/v1/status/runtimeinfo` внутри
`gendesign-prometheus` 12.09 отдавал `lastConfigTime`, совпадающий со
`startTime` контейнера, конфиг и правила не перечитывались 16 суток.
Деплой при этом был зелёный: `docker compose up -d` не пересоздаёт
контейнер из-за изменения содержимого бинд-маунта (он сравнивает только
описание сервиса), а `--web.enable-lifecycle` был включён в
docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не вызывал.
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
в этом же workflow только для Prometheus починка не пересоздание
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
пути заново, так что новый инод после `git reset --hard` подхватывается
без даунтайма.
Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
подтверждён сменой lastConfigTime.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
def _text() -> str:
return WORKFLOW.read_text(encoding="utf-8")
def test_promtool_checks_config_and_rules() -> None:
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
text = _text()
assert "promtool check config /etc/prometheus/prometheus.yml" in text, (
"нет проверки конфига promtool'ом — битый prometheus.yml долетит до reload"
)
assert "promtool check rules" in text, (
"нет проверки правил promtool'ом — битое правило долетит до reload"
)
def test_reload_endpoint_is_called() -> None:
"""Сам reload обязан вызываться — иначе валидация ничего не решает."""
text = _text()
assert "localhost:9090/-/reload" in text, (
"нет вызова POST /-/reload — конфиг/правила проверяются, но в силу не вступают "
"(#3467: lastConfigTime не менялся 16 суток при зелёном деплое)"
)
def test_reload_happens_after_validation_not_before() -> None:
"""Reload обязан идти ПОСЛЕ promtool, а не до/вместо него."""
text = _text()
check_pos = text.index("promtool check config /etc/prometheus/prometheus.yml")
reload_pos = text.index("localhost:9090/-/reload")
assert check_pos < reload_pos, (
"reload стоит раньше проверки конфига — битый конфиг мог бы применяться вслепую"
)
def test_reload_is_guarded_by_the_promtool_check() -> None:
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
text = _text()
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
else_pos = text.index("else", guard_start)
reload_pos = text.index("localhost:9090/-/reload")
assert guard_start < reload_pos < else_pos, (
"вызов reload лежит вне ветки успешной проверки promtool — "
"битый конфиг всё равно приведёт к reload, либо reload вообще не защищён проверкой"
)
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
text = _text()
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
else_pos = text.index("else", guard_start)
fi_pos = text.index("fi", else_pos)
else_branch = text[else_pos:fi_pos]
assert "localhost:9090/-/reload" not in else_branch, (
"reload вызывается даже в ветке провалившейся проверки"
)
assert "exit 1" in else_branch, (
"провал promtool не роняет шаг — деплой останется зелёным при битом конфиге"
)
def test_acceptance_checks_last_config_time_actually_changed() -> None:
"""Приёмка обязана сверять `lastConfigTime` до/после, а не доверять коду ответа reload.
`wget` на POST /-/reload может отрапортовать успех, даже если Prometheus
молча остался на старом конфиге (например, если бинарь внутри образа не
поддерживает --post-data так, как ожидалось) единственное надёжное
подтверждение реального перечитывания конфига это смена таймстемпа.
"""
text = _text()
assert text.count("lastConfigTime") >= 2, (
"нет сравнения lastConfigTime до/после — reload не проверяется по факту"
)
assert "LAST_CONFIG_BEFORE" in text and "LAST_CONFIG_AFTER" in text, (
"нет явного до/после сравнения таймстемпа последней перезагрузки конфига"
)
verify_start = text.index("LAST_CONFIG_AFTER")
verify_block_end = text.index("Prometheus: конфиг и правила проверены", verify_start)
verify_block = text[verify_start:verify_block_end]
assert "exit 1" in verify_block, (
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
)

View file

@ -1,108 +0,0 @@
"""Гейт: `$value` в тексте алерта — это та величина, которую текст называет.
Найдено 12.09.2026 по боевым сообщениям в Telegram:
«apps / tradein-browser: 2.684e+11% от mem_limit. Дальше OOM-kill.»
«apps / listings: доля HOT 75.21%.» (при пороге срабатывания «доля < 20%»)
Причина у обоих одна и она про ФОРМУ выражения, а не про условие. В PromQL
`A and B` возвращает ЗНАЧЕНИЯ ЛЕВОЙ части, отфильтрованные правой. Значит в
`$value` попадает A, а не то отношение, ради которого правило написано:
- ContainerNearMemoryLimit: слева стоял `container_spec_memory_limit_bytes`,
и в сообщение уходил ЛИМИТ В БАЙТАХ, отрендеренный `humanizePercentage`
(2 684 354 560 «2.684e+11%»). Условие при этом срабатывало верно.
- PostgresLowHotUpdateRatio: слева стоял `rate(tup_upd[6h])` АПДЕЙТОВ В
СЕКУНДУ. Это опаснее: 0.7521 превращалось в «75.21%», число попадало в
правдоподобный диапазон и противоречило собственному порогу («доля < 20%»),
но выглядело настоящим.
Отсюда инвариант, который здесь и проверяется: **если описание рендерит
`$value` как долю (`humanizePercentage`), выражение обязано возвращать долю**
то есть его ЛЕВАЯ часть (до первого `and`/`unless`) обязана содержать деление.
Отсев побочных условий переносится внутрь знаменателя (`X / (Y > 0)`), а не в
`and` слева.
Гейт намеренно не пытается «понять» PromQL целиком: он ловит ровно ту форму,
которая уже дважды уехала в прод, и не мешает правилам, печатающим абсолютные
величины без humanize (PostgresDeadTuplesHigh, PostgresIdleInTransaction).
"""
from __future__ import annotations
import re
from pathlib import Path
import yaml
_RULES_DIR = Path(__file__).resolve().parents[3] / "ops" / "metrics" / "prometheus" / "rules"
def _alerts() -> list[tuple[str, str, str, dict]]:
"""(файл, имя алерта, expr, annotations) по всем файлам правил."""
out: list[tuple[str, str, str, dict]] = []
for path in sorted(_RULES_DIR.glob("*.yml")):
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
for group in doc.get("groups", []):
for rule in group.get("rules", []):
if "alert" in rule:
out.append(
(
path.name,
rule["alert"],
rule.get("expr", ""),
rule.get("annotations") or {},
)
)
return out
def _left_of_and(expr: str) -> str:
"""Часть выражения ДО первого бинарного `and`/`unless` — её значения и видит $value."""
parts = re.split(r"\band\b|\bunless\b", expr, maxsplit=1)
return parts[0]
def test_rules_dir_is_found() -> None:
assert _RULES_DIR.is_dir(), f"нет каталога правил: {_RULES_DIR}"
assert _alerts(), "правила не распарсились — гейт был бы зелёным впустую"
def test_percentage_annotations_come_from_a_ratio() -> None:
"""Текст обещает долю → выражение обязано её и возвращать."""
broken: list[str] = []
for fname, name, expr, ann in _alerts():
text = " ".join(str(v) for v in ann.values())
if "humanizePercentage" not in text:
continue
left = _left_of_and(expr)
if "/" not in left:
broken.append(
f"{fname}::{name}: описание печатает $value как долю, но левая часть "
f"выражения (её и видит $value) деления не содержит: {' '.join(left.split())!r}"
)
assert not broken, "\n".join(broken)
def test_known_two_rules_are_fixed() -> None:
"""Именные проверки для двух правил, которые уже соврали в проде."""
by_name = {name: (expr, ann) for _, name, expr, ann in _alerts()}
expr, ann = by_name["ContainerNearMemoryLimit"]
flat = " ".join(expr.split())
assert flat.startswith("container_memory_working_set_bytes"), (
"слева должно стоять потребление, иначе в Telegram уедет лимит в байтах: " + flat
)
assert "(container_spec_memory_limit_bytes" in flat and "> 0)" in flat, (
"отсев нулевого лимита должен стоять В ЗНАМЕНАТЕЛЕ, а не в `and` слева: " + flat
)
assert "humanizePercentage" in " ".join(ann.values())
expr, ann = by_name["PostgresLowHotUpdateRatio"]
flat = " ".join(expr.split())
assert flat.startswith("rate(pg_table_write_amplification_tup_hot_upd"), (
"слева должен стоять числитель доли HOT, иначе печатается rate(tup_upd): " + flat
)
assert "/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5)" in flat, (
"гейт по объёму апдейтов должен жить в знаменателе — он же защищает от 0/0: " + flat
)

View file

@ -1,36 +0,0 @@
"""Продуктовый счётчик экспорта отчётов (#3471): выгрузок §22-форсайта / ТЗ.
Мера кардинальности та же, что в `test_metrics.py`: единственный лейбл
`format`, фиксированный литерал из `Literal[...]` сигнатуры эндпоинта
(`export_parcel_forecast`) плюс одно статичное значение `best_layouts_pdf`
(ТЗ на проектирование) не кадастровый номер и не идентификатор пользователя.
"""
from __future__ import annotations
from prometheus_client import REGISTRY, generate_latest
from app.observability import metrics as m
def test_reports_exported_counter_has_bounded_format_label() -> None:
assert tuple(m.REPORTS_EXPORTED._labelnames) == ("format",) # type: ignore[attr-defined]
def test_reports_exported_counter_increments_per_format() -> None:
def _value(fmt: str) -> float:
return (
REGISTRY.get_sample_value("sitefinder_reports_exported_total", {"format": fmt}) or 0.0
)
before_pdf = _value("pdf")
before_layouts = _value("best_layouts_pdf")
m.REPORTS_EXPORTED.labels(format="pdf").inc()
m.REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
assert _value("pdf") - before_pdf == 1.0
assert _value("best_layouts_pdf") - before_layouts == 1.0
body = generate_latest(REGISTRY).decode()
assert "sitefinder_reports_exported_total" in body

View file

@ -1,68 +0,0 @@
"""Бинды SQL-стейтментов ЕЭСК-лоадера обязаны совпадать с передаваемыми params.
ПОЧЕМУ ЭТОТ ТЕСТ СУЩЕСТВУЕТ. 18.08-02.09.2026 все 71 UPDATE резервов ПС
падали, а задача оставалась зелёной: упоминание СНЯТОГО параметра load_pct
в SQL-КОММЕНТАРИИ (в синтаксисе «двоеточие+имя») SQLAlchemy text() распарсил
как обязательный бинд, params его не содержал, каждая строка падала на
compile, а per-row except глотал это как «битую строку». ЕЭСК-резервы не
обновлялись две недели, freshness этого не видел (задача done).
Тест ловит МЕХАНИЗМ: собирает все text()-стейтменты модуля и сверяет их
бинд-имена с ключами словаря params из того же контекста. Никакой БД не
нужно дефект живёт на этапе компиляции стейтмента.
"""
from __future__ import annotations
import re
from pathlib import Path
from sqlalchemy import text
LOADER = (
Path(__file__).resolve().parents[1]
/ "app"
/ "services"
/ "site_finder"
/ "eesk_reserve_loader.py"
)
def _extract_update_sql() -> str:
"""Достаёт текст UPDATE power_supply_centers из исходника лоадера."""
src = LOADER.read_text(encoding="utf-8")
m = re.search(r'text\("""\s*(UPDATE power_supply_centers.*?)"""\)', src, re.S)
assert m, "UPDATE power_supply_centers не найден в лоадере"
return m.group(1)
def _extract_params_keys() -> set[str]:
"""Ключи словаря params, который передаётся в этот execute."""
src = LOADER.read_text(encoding="utf-8")
m = re.search(r"params = \{(.*?)\}", src, re.S)
assert m, "словарь params не найден"
return set(re.findall(r'"([a-z_]+)":', m.group(1)))
def test_update_binds_are_subset_of_params() -> None:
"""Каждый бинд стейтмента обязан приходить из params — иначе UPDATE падает
на КАЖДОЙ строке, и per-row SAVEPOINT-except прячет это под «битую строку»."""
stmt = text(_extract_update_sql())
binds = {p.key for p in stmt._bindparams.values()}
params = _extract_params_keys()
missing = binds - params
assert not missing, (
f"стейтмент требует биндов {sorted(missing)}, которых нет в params — "
"все строки батча упадут молча. Частая причина: упоминание "
"«двоеточие+имя» в SQL-комментарии (text() парсит бинды и там)."
)
def test_comment_does_not_reintroduce_phantom_bind() -> None:
"""Регресс 18.08: снятый параметр упомянули в комментарии в живом
синтаксисе бинда. Проверяем ЗНАЧЕНИЕМ: компиляция с ровно теми params,
что собирает лоадер, не требует ничего лишнего."""
stmt = text(_extract_update_sql())
have = _extract_params_keys()
for bp in stmt._bindparams.values():
assert bp.key in have, f"фантомный бинд из комментария: {bp.key!r}"

View file

@ -42,40 +42,6 @@
# код.
# ═══════════════════════════════════════════════════════════════════════════
# ── (tradein_frontend_retry) — подмена контейнера фронта без 502 (#3274) ─────
#
# ЧТО ЭТО. Ретрай ПОДКЛЮЧЕНИЯ к tradein-frontend: пока идёт подмена
# контейнера, Caddy не отдаёт ошибку сразу, а до 2 с переспрашивает апстрим с
# шагом 100 мс. Импортируется ВНУТРЬ каждого `reverse_proxy tradein-frontend`
# в этом файле (9 блоков: gendsgn.ru/trade-in/* и все публичные пути
# meraocenka.ru).
#
# ПОЧЕМУ ЭТО НЕ ТО, ЧТО ОТВЕРГНУТО В #3274. Там `lb_try_duration` отвергнут
# для окна 3090 с — держать посетителя минуту в ожидании хуже честной
# ошибки, инструмент рассчитан на разрыв в сотни миллисекунд. Правка в
# deploy-tradein.yml (frontend вынесен из общего `up -d`) СНАЧАЛА сводит окно
# к этим сотням миллисекунд, и только после этого ретрай становится
# применим. Числа, на которых это стоит (замеры 11.09):
# - прод, ОДИН сервис в `up -d`: контейнер создан 16:42:17.5 → запущен
# 16:42:18.0 — 0,5 с;
# - прод, ПАЧКА сервисов в одном `up -d`: создан 15:01:40 → запущен
# 15:02:10 — 30 с (фаза start ждёт готовности зависимостей, а старый
# контейнер снесён ещё в фазе create);
# - стенд (реальный образ фронта + Caddy 2), одиночная подмена: без ретрая
# 1 × 502, с ретраем 241/241 × 200, один запрос подождал 0,55 с.
#
# ПОТОЛОК. 2 с — это ЦЕНА ОЖИДАНИЯ при настоящей аварии: если фронт лежит
# долго, каждый запрос сначала висит 2 с и лишь потом получает 503-заглушку
# (../deploy-window.caddy.snippet). Поэтому не «побольше на всякий случай»:
# запас над измеренными 0,5 с четырёхкратный, дальше растёт только вред.
# Бэкенду (tradein-backend) этот приём НАМЕРЕННО не дан: его старт — единицы
# секунд (uvicorn + подключение к БД), там 2 с не хватит, а больше — уже тот
# самый вред. Его окно закрывается отдельно, не этой правкой.
(tradein_frontend_retry) {
lb_try_duration 2s
lb_try_interval 100ms
}
# Caddy config.
#
# - gendsgn.ru — main production site, auto-TLS via Let's Encrypt.
@ -133,35 +99,6 @@ gendsgn.ru {
format json
}
# #3471: копия access-лога на stdout. За 3 часа наблюдения в
# gendesign-caddy-1 не было НИ ОДНОЙ строки http.log.access — только ACME/
# TLS/warn от reverse_proxy, статусы/латентность/RPS прокси не видны в
# Loki вообще. Alloy на этом хосте уже собирает stdout контейнеров через
# journald (`loki.source.journal "host"`,
# ops/metrics/alloy/alloy-apps.alloy) — второй bind-монт не нужен.
#
# Секреты в query (`?secret=`, `?token=` и т.п., см. инцидент #3154) режет
# УЖЕ РАБОТАЮЩИЙ `loki.process.scrub_credentials` (#3354, тот же список
# имён параметров, что в app/core/log_scrub.py) — он стоит на пути ЛЮБОГО
# journal-лога, включая этот, поэтому второй слой скраба здесь не заводим.
#
# `log_skip` — общий для ВСЕХ логгеров сайта флаг на запрос (Caddy не даёт
# скипать выборочно по конкретному логгеру), поэтому /health и статика
# Next пропадают заодно и из файлового gendsgn.ru.log выше, не только из
# копии на stdout. Это осознанный побочный эффект, не только экономия:
# /health — 32% строк gendsgn.ru.log в измеренном сегменте (12.09.2026,
# 240 из 742 строк за ~4ч, аптайм-монитор раз в минуту) без диагностической
# ценности, и именно он гонит файловый лог через 50MiB roll_size так часто.
log access_stdout {
output stdout
format json
}
@noisy_access_log {
path /health /_next/static/*
}
log_skip @noisy_access_log
route {
# `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078).
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
@ -200,7 +137,6 @@ gendsgn.ru {
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
handle @uipreview {
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
@ -304,7 +240,6 @@ gendsgn.ru {
handle @tradein {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
# См. комментарий над /trade-in/api/* выше — та же логика (явное
# удаление вместо Set с пустым {http.auth.user.id}), и по той же
# причине здесь больше нет инжекта X-Internal-Auth-Secret
@ -404,24 +339,6 @@ meraocenka.ru {
output file /var/log/caddy/meraocenka.ru.log
}
# #3471: та же копия access-лога на stdout, что у gendsgn.ru — см.
# развёрнутый комментарий там (Alloy/journald, scrub_credentials #3354,
# log_skip общий на все логгеры сайта). `/trade-in/_next/static/*` — 5.1%
# строк meraocenka.ru.log в измерении 12.09.2026 (3782 из 73810 за
# ~17.6 суток без ротации, roll_size здесь вообще не настроен) — статика
# Next не может быть источником 5xx бэкенда. `/health` на этом домене не
# проксируется (allowlist ниже отдаёт по нему 404), но матчер добавлен для
# единообразия с gendsgn.ru — вреда от него ноль.
log access_stdout {
output stdout
format json
}
@noisy_access_log {
path /health /trade-in/_next/static/*
}
log_skip @noisy_access_log
# `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и
# финальный `handle { respond 404 }`, так что путь и без этой строки не
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
@ -438,7 +355,6 @@ meraocenka.ru {
handle / {
rewrite * /trade-in/mera-public
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
# Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже.
# Этот блок вообще не под basic_auth, поэтому анонимный клиент
# тем более может прислать свой X-Authenticated-User. Сейчас
@ -478,18 +394,10 @@ meraocenka.ru {
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется.
# /sitemap.xml дописан той же строкой, а не отдельным handle (как
# robots.txt ниже): Next отдаёт его по /trade-in/mera-public/sitemap.xml
# (src/app/mera-public/sitemap.ts) — ровно в форме, которую уже собирает
# rewrite `/trade-in/mera-public{path}` ниже. robots.txt так не смог бы:
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
# robots.txt существует только в корне app/, не под mera-public/), эта
# форма под `{path}` не попадает — отсюда отдельный handle у него.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business
handle @meraPages {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
@ -500,28 +408,9 @@ meraocenka.ru {
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
# со слэшем и здесь нормализуются.
# БАГ, найденный замером на живом проде 10.09.2026:
# https://meraocenka.ru/articles/?utm_source=vc -> Location:
# https://meraocenka.ru/articles — query терялся, потому что
# `redir * /{re.shortslash.1}` собирает цель заново из regex-захвата
# пути и не переносит исходную query-строку. Вред тихий: статьи
# публикуются с UTM-метками, а мессенджеры и автолинкификаторы дописывают
# слэш к скопированной ссылке — то есть именно трафик по опубликованному
# посту терял атрибуцию, и 301 при этом честно отрабатывал (просто не
# туда), ошибки никто не видел.
#
# Тот же приём, что у @meraLongPages/@meraV3Gone ниже и выше: `uri
# strip_suffix /` снимает ровно завершающий слэш (сам путь уже сужен
# матчером до канонического короткого вида), `redir * {uri} permanent`
# переносит query как есть. Обёртка `route` обязательна по той же
# причине, что у соседей — внутри `handle` порядок директив определяет
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
handle @meraShortSlash {
route {
uri strip_suffix /
redir * {uri} permanent
}
redir * /{re.shortslash.1} permanent
}
# `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант
@ -656,7 +545,6 @@ meraocenka.ru {
respond @foreignRouteChunk 404
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
@ -667,53 +555,6 @@ meraocenka.ru {
handle /favicon.ico {
rewrite * /trade-in/favicon.ico
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
# robots.txt — ОТДЕЛЬНЫЙ handle, а не строка в @meraPages выше: у Next
# robots.txt — конвенция корня app/ (src/app/robots.ts), а не
# mera-public/, поэтому rewrite `/trade-in/mera-public{path}` его не
# обслужит — нужен свой rewrite на /trade-in/robots.txt. По образцу
# favicon.ico прямо над этим блоком, включая тот же периметр-скраб
# заголовка.
handle /robots.txt {
rewrite * /trade-in/robots.txt
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
# og-mera.png — картинка превью ссылок (og:image). Её просят НЕ браузеры
# посетителей, а обходчики мессенджеров и соцсетей (Telegram, VK,
# WhatsApp), причём анонимно и без Referer: если путь не открыт, ссылка на
# сайт разворачивается без картинки, и понять это по логам приложения
# нельзя — запрос просто не доходит.
#
# Отдельный handle по той же причине, что у robots.txt: файл лежит в
# `public/` фронта и раздаётся Next'ом по basePath-корню
# (/trade-in/og-mera.png), а не под /trade-in/mera-public{path}, куда
# рерайтит @meraPages. Адрес намеренно короткий: он попадает в разметку
# каждой публичной страницы и расходится по чужим кэшам, так что менять
# его потом дорого.
handle /og-mera.png {
rewrite * /trade-in/og-mera.png
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
# logo-mera.png — логотип организации из микроразметки (Organization.logo).
# Отдельный файл от og-mera.png выше: превью ссылки широкое, а логотип
# поисковик обрезает близко к квадрату. Свой handle по той же причине —
# файл лежит в `public/` и раздаётся по basePath-корню.
handle /logo-mera.png {
rewrite * /trade-in/logo-mera.png
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}

View file

@ -105,44 +105,11 @@ metrics.gendsgn.ru {
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
# поставил не человек. Прав в системе токен не даёт никаких.
#
# Сервис отвечает только на /ack/*, /glitchtip и /healthz; всё прочее — 404.
# Сервис отвечает только на /ack/* и /healthz; всё прочее — 404.
handle /ack/* {
reverse_proxy alert-ack:8080
}
# Резервный приёмник алертов GlitchTip (#3471). Основной получатель —
# продуктовый бэкенд на Selectel, то есть тот самый сервис, за которым эти
# алерты и следят: пока он лежит, его собственные ошибки доставлять некому.
# Этот путь живёт у другого провайдера и с чистой сетью до Telegram, поэтому
# переживает падение Selectel целиком.
#
# Секрет — в значении query-параметра, а не в пути: путь сам по себе не
# секрет, и его попадание в access-лог безопасно. Само значение вырезает
# scrub_credentials в Alloy до записи в Loki (#3154).
handle /glitchtip* {
reverse_proxy alert-ack:8080
}
# Ретранслятор Bot API продукта (#3471). Путь от Selectel до
# api.telegram.org теряет примерно каждый четвёртый короткий запрос, тот же
# замер с Beget в те же минуты — чистый. Поэтому продуктовый бот ходит в
# Telegram отсюда, а не напрямую.
#
# Путь содержит токен бота (/tg-relay/bot<TOKEN>/<method>) — он не должен
# осесть ни в файловом логе сайта, ни в stdout-копии (#3154), поэтому
# запрос помечен log_skip.
@tg_relay path /tg-relay/*
log_skip @tg_relay
handle_path /tg-relay/* {
reverse_proxy tg-relay:8080 {
# getUpdates — long-poll до ~40с, дефолтный таймаут ответа короче.
transport http {
response_header_timeout 80s
}
}
}
# Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.

View file

@ -231,71 +231,6 @@ services:
mem_limit: 128m
logging: *default-logging
# ── redis-exporter: здоровье общего Redis (только на Poincare) ───────────────
# #3471. Redis — один инстанс на три потребителя: db0 celery-брокер Site
# Finder, db1 SearchCache trade-in, db2 glitchtip (см. комментарий у сервиса
# `redis` в docker-compose.prod.yml). Один `redis_up` покрывает риск для всех
# трёх разом — до этой правки Redis не измерялся вообще, переполнение
# брокера и обычная недоступность снаружи выглядели одинаково — тишиной.
#
# Адрес — через alias `gendesign-redis`, который `redis` регистрирует на
# сети `shared` (см. #2709 в docker-compose.prod.yml) — джойнить ещё и
# `product` не нужно, тем же путём уже идёт postgres-exporter-tradein.
#
# Пароль — из окружения, не хардкод: сегодня на Redis нет requirepass (нет
# переменной ни в docker-compose.prod.yml, ни здесь), но если он появится,
# значение подставляется через METRICS_REDIS_PASSWORD в /opt/gendesign/.env
# на хосте, а не в этот файл.
redis-exporter:
image: oliver006/redis_exporter:v1.65.0
container_name: gendesign-redis-exporter
restart: unless-stopped
profiles: ["apps"]
environment:
REDIS_ADDR: ${METRICS_REDIS_ADDR:-redis://gendesign-redis:6379}
REDIS_PASSWORD: ${METRICS_REDIS_PASSWORD:-}
expose:
- "9121"
networks:
- shared
mem_limit: 64m
logging: *default-logging
# ── celery-exporter: очередь Site Finder (только на Poincare) ────────────────
# #3471. Слепая зона: глубина очереди, число живых воркеров и счётчик
# неуспешных задач нигде не измерялись — залипший воркер и переполненная
# очередь снаружи неотличимы от тишины.
#
# ПОЧЕМУ ОТДЕЛЬНЫЙ ОБРАЗ, А НЕ redis-exporter --check-keys. check-keys дал
# бы LLEN дефолтной очереди "celery" (в backend/app/workers/celery_app.py
# НЕТ task_routes — все таски идут в один дефолтный queue, имя буквально
# "celery") без нового образа вообще. Но он НЕ умеет считать живых
# воркеров и неуспешные таски — то есть закрыл бы только треть минимума
# из задачи. celery-exporter слушает событийную шину Celery через тот же
# брокер и даёт все три метрики разом, поэтому выбран он, а не комбинация
# check-keys + что-то ещё для остальных двух чисел.
#
# ⚠️ ИМЕНА МЕТРИК НИЖЕ (celery_queue_length, celery_worker_up,
# celery_task_failed_total) — по документации проекта на момент правки, БЕЗ
# прогона на реальном брокере (агент писал этот файл без доступа к проду).
# Сверить с `curl http://gendesign-celery-exporter:9808/metrics` на хосте
# после первого деплоя и поправить `ops/metrics/prometheus/rules/infra.yml`
# при расхождении — иначе алерты будут молча ничего не ловить.
celery-exporter:
image: danihodovic/celery-exporter:0.13.0
container_name: gendesign-celery-exporter
restart: unless-stopped
profiles: ["apps"]
command:
- "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}"
- "--queue=celery"
expose:
- "9808"
networks:
- shared
mem_limit: 128m
logging: *default-logging
# ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
# forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
# ничем не ограничен — политики ретенции у GlitchTip нет вообще.

View file

@ -196,12 +196,6 @@ services:
# Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки,
# но уйдёт: алерт важнее подтверждения.
ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru}
# Резервный получатель GlitchTip-алертов (#3471, POST /glitchtip) — второй
# получатель наряду с основным вебхуком в продуктовый бэкенд на Selectel.
# Секрет СВОЙ, не общий с продуктовым TRADEIN_INTERNAL_AUTH_SECRET: разные
# хосты/домены безопасности. Пусто — эндпоинт отвечает 503, остальной
# функционал сервиса не затронут.
ALERT_ACK_GLITCHTIP_SECRET: ${ALERT_ACK_GLITCHTIP_SECRET:-}
volumes:
- ./ops/metrics/alert-ack/app.py:/app/app.py:ro
expose:
@ -216,74 +210,7 @@ services:
timeout: 10s
retries: 5
# ── Ретранслятор Bot API продукта через Beget (#3471) ───────────────────────
#
# Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера
# `tradein-tgbot` на Selectel — 9 успешных из 12, три ConnectTimeout. TCP-443
# до адреса, резолвящегося на Selectel — 5/6. Тот же TCP-443 до адреса,
# резолвящегося на Beget — 8/8. За сутки 508 строк `network error` в логе
# бота, за 30 дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel
# лоссовый, с Beget чистый — Alertmanager (тот же чат, живёт рядом) шлёт без
# проблем. Продуктовые sendMessage/copyMessage/getUpdates идут сюда вместо
# прямого пути; выключается пустым TELEGRAM_RELAY_BASE_URL на стороне
# продукта — это и есть откат.
#
# НЕ рядом с продуктом: смысл ретранслятора именно в том, что он живёт там,
# откуда путь до Telegram чистый, а не там, откуда он лоссовый.
#
# Образ без сборки и без зависимостей (только stdlib) — тот же принцип, что у
# alert-ack: сервис обязан подниматься даже когда сломано всё остальное.
tg-relay:
image: python:3.12-slim
container_name: gendesign-tg-relay
# #3471 (PR #3487 инцидент): без profiles сервис поднимался ВСЕГДА, а при
# пустом TG_RELAY_SECRET делает SystemExit — то есть уходит в бесконечный
# Restarting сразу после деплоя. Профиль включает deploy-metrics.yml, и
# только когда секрет реально задан (см. PROFILES там).
profiles: ["relay"]
restart: unless-stopped
user: "65534:65534"
command: ["python", "-u", "/app/app.py"]
env_file:
- path: ./backend/.env.runtime
required: false
- path: ./backend/.env
required: false
environment:
# Общий секрет с продуктовым клиентом (TELEGRAM_RELAY_SECRET на стороне
# tradein-backend/tradein-tgbot) — домен публичный, без секрета отказ.
TG_RELAY_SECRET: ${TG_RELAY_SECRET:-}
volumes:
- ./ops/metrics/tg-relay/app.py:/app/app.py:ro
expose:
- "8080"
networks:
- shared
mem_limit: 128m
logging: *default-logging
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8080/healthz',timeout=5)"]
interval: 30s
timeout: 10s
retries: 5
# ── Grafana: витрина ─────────────────────────────────────────────────────────
# Grafana здесь ТОЛЬКО рисует — не решает, что считать инцидентом и куда его
# слать. Тревоги живут в Prometheus (правила) и Alertmanager (маршрутизация,
# Telegram); это единственный путь доставки (#3158).
#
# Встроенный Alerting выключен ЯВНО, а не просто «не настроен». Проверка на
# живом API 12.09.2026 нашла: 0 правил, единственный контакт-поинт —
# стоковый grafana-default-email на example@email.com, GF_SMTP_* не заданы.
# То есть кнопка «New alert rule» в интерфейсе есть и работает, а результат
# молча уходит в никуда — ровно та ситуация, из-за которой никто не проверяет
# второй, настоящий путь. Дублирующий движок на том же датасорсе Prometheus
# надёжности всё равно не прибавляет (общая точка отказа), только даёт второе
# место, где правило может быть заведено и забыто.
#
# Если это когда-нибудь понадобится включить обратно — сначала подключить
# реальный SMTP или другой contact point и завести хотя бы одно тестовое
# правило руками, иначе вернётся тот же капкан.
grafana:
image: grafana/grafana:11.5.1
container_name: gendesign-grafana
@ -303,12 +230,6 @@ services:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
GF_SERVER_SERVE_FROM_SUB_PATH: "false"
# Единственный официальный переключатель Grafana Alerting в 11.x — секция
# [unified_alerting], легаси-[alerting] удалён из Grafana ещё в 9.0 и в
# 11.5 в конфиге отсутствует (сверено с grafana.com/docs/grafana/v11.5/
# setup-grafana/configure-grafana/#unified_alerting). false здесь убирает
# раздел Alerting из UI и глушит движок правил целиком — см. #3158 выше.
GF_UNIFIED_ALERTING_ENABLED: "false"
# Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не
# хочется, чтобы наблюдатель сам ходил в интернет без нужды.
GF_ANALYTICS_REPORTING_ENABLED: "false"

View file

@ -1,138 +0,0 @@
#!/bin/sh
# Применить текущий конфиг прокси к работающему Caddy (#3443).
#
# ЗАЧЕМ. Полный деплой ПТИЦЫ пересоздавал сам Caddy БЕЗУСЛОВНО
# (`up -d --force-recreate --no-deps caddy`), а вместе с контейнером исчезал
# единственный процесс, слушающий 80/443. Замер 05.09 (#3274): 67 с `code=000`
# на ВСЕХ доменах хоста — gendsgn.ru, meraocenka.ru и зеркала, включая
# публичный лендинг МЕРЫ. Это не 502/503: принимающего процесса нет вовсе,
# поэтому заглушка окна деплоя (caddy/sites/deploy-window.caddy.snippet) здесь
# бессильна по построению — её отдаёт тот же Caddy.
#
# ЧТО НА САМОМ ДЕЛЕ ТРЕБУЕТ ПЕРЕСОЗДАНИЯ. Безусловный флаг появился 17.05
# (11e78d73) ради нового bind-маунта `./preview` из docker-compose.prod.yml,
# который «не появлялся в running container». Довод неверен: `docker compose
# up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам, как только меняется
# описание сервиса или образ. Проверено на живом демоне (docker 28.4):
# добавлен volume → `Container … Starting/Started`, id контейнера новый;
# тег указан на др. образ → id новый;
# не менялось ничего → `Container … Running`, id тот же.
#
# Остаётся ровно один класс изменений, которого compose не видит: СОДЕРЖИМОЕ
# пофайлового bind-маунта. `git reset --hard` не правит файл на месте, а пишет
# новый инод; контейнер держит примонтированным прежний и продолжает читать
# его — `caddy reload` перечитает ровно тот же старый инод. Тот же механизм уже
# ловили на Alertmanager (27.08, deploy-metrics.yml) и на Alloy (#3380). У Caddy
# так смонтированы пять путей: Caddyfile и четыре сниппета. Каталоги
# (caddy/sites, caddy/local, preview) этим не страдают — правка внутри каталога
# видна контейнеру сразу, поэтому самый частый случай (caddy/sites/apps.caddy)
# пересоздания НЕ требует.
#
# ОТСЮДА ПОРЯДОК: проверить конфиг → `up -d` без `--force-recreate` → если
# контейнер остался тем же, сверить, видит ли он текущее содержимое пофайловых
# маунтов → пересоздать ТОЛЬКО при расхождении, иначе `caddy reload`, который
# не рвёт соединения.
#
# ГРАНИЦА. Сверка по СОДЕРЖИМОМУ, а не по иноду: файл, переписанный тем же
# текстом, пересоздания не требует. Не прочиталось (контейнер не запущен, в
# образе нет sha256sum) — считаем расхождением: fail-safe в сторону прежнего
# поведения, то есть пересоздания.
set -eu
# Оба вызывающих (job `deploy` и job `deploy-caddy` в .forgejo/workflows/deploy.yml)
# работают в /opt/gendesign, но не зависеть от cwd дешевле, чем это помнить.
cd "$(dirname "$0")/.."
COMPOSE="docker compose -p gendesign -f docker-compose.prod.yml"
caddy_cid() { $COMPOSE ps -aq caddy 2>/dev/null | tail -n1; }
# ── 1. Проверка ДО применения ────────────────────────────────────────────────
# Одноразовый контейнер читает файлы С ХОСТА — то есть ровно то, что поедет в
# работающий Caddy. `exec caddy validate` здесь не годится: он проверил бы
# старый инод, то есть предыдущую версию конфига. Образ и парсер те же, что на
# PR-гейте (ci.yml «Guard: Caddyfile синтаксически валиден»).
#
# ГРАНИЦА ЭТОЙ ПРОВЕРКИ. Она обрывает применение до того, как конфиг попадёт в
# работающий Caddy, — но только на быстром пути. В полном деплое ВЫШЕ по
# скрипту (deploy.yml, `up -d $UP_SERVICES`) уже прошёл общий подъём всех
# сервисов, и если правка одновременно ломает Caddyfile И меняет блок caddy в
# docker-compose.prod.yml, контейнер пересоздастся там — с непроверенным
# конфигом и раньше этой строки. Первая линия против этого — гейт на PR (#2913).
echo "→ проверяю конфиг прокси одноразовым контейнером…"
if ! docker run --rm -v "$PWD:/work:ro" -w /work caddy:2 \
caddy validate --config /work/Caddyfile --adapter caddyfile; then
echo "ОШИБКА: конфиг прокси не применён — проверка не пройдена ЛИБО не удалось"
echo " запустить проверочный контейнер (нет образа caddy:2, занят демон,"
echo " недоступен реестр). Причина — в выводе выше, не гадать по этой строке."
echo " Работающий Caddy не тронут, домены живы."
exit 1
fi
# ── 2. Описание сервиса и образ ──────────────────────────────────────────────
before=$(caddy_cid)
$COMPOSE up -d --no-deps caddy
after=$(caddy_cid)
if [ -z "$after" ]; then
echo 'ОШИБКА: после `up -d` контейнера caddy нет — смотри вывод compose выше.'
exit 1
fi
if [ "$before" != "$after" ]; then
echo "✓ Caddy пересоздан compose'ом: изменилось описание сервиса или образ (${before:-нет}${after})."
exit 0
fi
# ── 3. Доехало ли содержимое пофайловых маунтов ──────────────────────────────
# Список маунтов читается ОТДЕЛЬНОЙ командой, а не в конвейере с циклом: в
# `$(docker inspect … | while …)` статус подстановки — это статус `while`, то
# есть всегда 0 (`pipefail` в POSIX-sh нет вовсе). Провал `docker inspect`
# давал бы пустой список → «расхождений нет» → `caddy reload` → зелёная джоба с
# надписью «окна недоступности нет», а прокси работал бы по СТАРОМУ конфигу.
# Это ровно тот беззвучный отказ, ради которого написан весь скрипт.
mounts=$(docker inspect "$after" \
--format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}|{{.Destination}}{{println}}{{end}}{{end}}') \
|| mounts=''
if [ -z "$mounts" ]; then
echo "WARNING: список маунтов Caddy не прочитан (docker inspect молчит или упал) —"
echo " сверить нечем, считаю расхождением: fail-safe в прежнее поведение."
verdicts=""
stale="(маунты не прочитаны)"
else
verdicts=$(printf '%s\n' "$mounts" | while IFS='|' read -r src dst; do
[ -n "${dst:-}" ] || continue
# Каталог инод не держит — пропускаем. Именно `-d`, а не `-f`:
# отсутствующий/нечитаемый ФАЙЛ — не повод молча пропустить, в
# контейнере в этот момент живёт старый инод со старым текстом.
if [ -d "$src" ]; then continue; fi
host_sum=$(sha256sum "$src" 2>/dev/null | cut -d' ' -f1)
seen_sum=$(docker exec "$after" sha256sum "$dst" 2>/dev/null | cut -d' ' -f1)
if [ -n "$host_sum" ] && [ "$host_sum" = "${seen_sum:-НЕРОЧИТАНО}" ]; then
echo "OK $dst"
else
echo "STALE $dst"
fi
done)
# «Сверили пять файлов» и «сверили ноль» обязаны различаться в логе — иначе
# зелёная строка ниже одинаково означает и проверку, и её отсутствие.
checked=$(printf '%s\n' "$verdicts" | grep -c . || true)
echo "→ сверено пофайловых маунтов с тем, что видит контейнер: $checked"
stale=$(printf '%s\n' "$verdicts" | sed -n 's/^STALE //p' | tr '\n' ' ')
if [ "$checked" -eq 0 ]; then
echo "WARNING: ни одного пофайлового bind-маунта не найдено — у Caddy их пять"
echo " (Caddyfile + 4 сниппета). Считаю расхождением: fail-safe."
stale="(пофайловых маунтов не найдено)"
fi
fi
if [ -n "$stale" ]; then
echo "→ до контейнера НЕ доехали пофайловые маунты: $stale"
echo " (bind-маунт файла держит инод: reload перечитал бы старую версию — нужен recreate)"
$COMPOSE up -d --force-recreate --no-deps caddy
echo "✓ Caddy пересоздан — иначе правка осталась бы неприменённой."
exit 0
fi
# ── 4. Всё доехало — перезагрузка без разрыва соединений ─────────────────────
$COMPOSE exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."

View file

@ -27,28 +27,6 @@
METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна)
ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку
ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440)
ALERT_ACK_GLITCHTIP_SECRET секрет резервного вебхука GlitchTip (#3471,
см. POST /glitchtip ниже); пусто 503
РЕЗЕРВНЫЙ КАНАЛ GLITCHTIP (#3471). Все три alert-правила GlitchTip (backend,
frontend, Trade-In) шлют основной вебхук в продуктовый бэкенд на Selectel
тот самый хост, за которым они следят. Если там упал сам бэкенд или Caddy,
алерт об этом теряется именно тогда, когда нужнее всего. `POST /glitchtip`
второй получатель того же алерта, зарегистрированный в GlitchTip отдельной
строкой; живёт на ЭТОМ (инфраструктурном, Beget) хосте и не зависит от
здоровья продукта. Формат тела тот же Slack-совместимый payload, что и у
продуктового приёмника (`tradein-mvp/backend/app/api/v1/glitchtip.py`):
``{"text": str, "attachments": [{"title","title_link","text","color",
"fields":[{"title","value"}]}]}``, GlitchTip заголовков не шлёт вовсе
аутентификация только через секрет в query (``?secret=``) или в заголовке
``X-GlitchTip-Secret`` (тот же выбор, что там же и по той же причине: заголовок
не течёт в access-log, query остаётся, т.к. сам GlitchTip 6.1.6 заголовков не
добавляет). Секрет намеренно СВОЙ (``ALERT_ACK_GLITCHTIP_SECRET``), а не общий
с продуктовым ``TRADEIN_INTERNAL_AUTH_SECRET`` секреты разных хостов/доменов
безопасности компрометировать вместе незачем. Сообщение уходит в ту же тему
клиентских инцидентов (``METRICS_TELEGRAM_CHAT_ID``/``_TOPIC_ID``), что и
Alertmanager-алерты через alert-ack, с явной пометкой «резервный канал», чтобы
не спутать с основным путём.
"""
from __future__ import annotations
@ -73,7 +51,6 @@ TOPIC_ID = os.environ.get("METRICS_TELEGRAM_TOPIC_ID", "")
ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "")
PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/")
TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
API = "https://api.telegram.org/bot{}/{}"
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
@ -176,79 +153,6 @@ def _send_alert(payload: dict) -> None:
_tg("sendMessage", msg)
_GLITCHTIP_TEXT_LIMIT = 3500 # запас под баннер+имя проекта до лимита Telegram 4096
def _verify_glitchtip_secret(provided: str) -> bool:
"""Constant-time сравнение — длина/префикс секрета не утекают через время
ответа (тот же приём, что у продуктового приёмника, см. docstring модуля)."""
return bool(GLITCHTIP_SECRET) and secrets.compare_digest(provided or "", GLITCHTIP_SECRET)
def _glitchtip_field(attachment: dict, label: str) -> str | None:
for field in attachment.get("fields") or []:
if not isinstance(field, dict):
continue
if str(field.get("title", "")).strip().lower() == label.lower():
value = field.get("value")
return str(value) if value is not None else None
return None
def _render_glitchtip(payload: dict) -> str:
"""Собрать текст сообщения из Slack-совместимого payload GlitchTip.
Максимально терпимо к форме тела: GlitchTip шлёт ОДИНАКОВУЮ структуру для
issue- и uptime-алертов, но поля внутри attachments опциональны, а тестовое
сообщение из UI GlitchTip может не иметь attachments вовсе. Ничего в теле
не считаем обязательным падать сервису на резервном канале нельзя.
"""
lines = ["⚠️ РЕЗЕРВНЫЙ КАНАЛ (GlitchTip → alert-ack)"]
lines.append("Основной путь через продуктовый бэкенд мог быть недоступен.")
lines.append("")
text = payload.get("text")
lines.append(html.escape(str(text)) if text else "GlitchTip alert")
attachments = payload.get("attachments")
for attachment in attachments if isinstance(attachments, list) else []:
if not isinstance(attachment, dict):
continue
block: list[str] = []
project = _glitchtip_field(attachment, "Project")
if project:
block.append(f"Проект: {html.escape(project)}")
if attachment.get("title"):
block.append(html.escape(str(attachment["title"])))
if attachment.get("text"):
block.append(html.escape(str(attachment["text"])))
if attachment.get("title_link"):
block.append(f"Ссылка: {html.escape(str(attachment['title_link']))}")
if block:
lines.append("")
lines.extend(block)
out = "\n".join(lines)
if len(out) > _GLITCHTIP_TEXT_LIMIT:
out = out[:_GLITCHTIP_TEXT_LIMIT] + "\n… (обрезано)"
return out
def _send_glitchtip_alert(payload: dict) -> None:
"""Переслать вебхук GlitchTip в ту же тему клиентских инцидентов, что и
Alertmanager через этот сервис. Без кнопки подтверждения это не
firing/resolved инцидент с состоянием, а разовое уведомление резервного
канала."""
msg = {
"chat_id": CHAT_ID,
"text": _render_glitchtip(payload),
"parse_mode": "HTML",
"disable_web_page_preview": "true",
}
if TOPIC_ID:
msg["message_thread_id"] = TOPIC_ID
_tg("sendMessage", msg)
_PAGE = (
"<!doctype html><meta charset=utf-8>"
"<title>{t}</title>"
@ -339,24 +243,11 @@ class Handler(BaseHTTPRequestHandler):
self._reply(code, page.encode())
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
# Тело читается ДО любой развилки и ветки отказа. protocol_version =
# HTTP/1.1, то есть соединение переиспользуется, а Caddy перед нами
# держит пул к апстриму. Ответить 401/404/503, не вычитав тело, значит
# оставить его в сокете — и следующий запрос по тому же соединению
# начнётся с чужих байт. Проверено на проде 12.09.2026: неавторизованный
# зонд на /glitchtip, а следом законный алерт получил
# 501 Unsupported method ('{"text":"probe"}POST'). То есть один
# отказ ронял следующий НАСТОЯЩИЙ алерт — ровно то, ради чего этот
# резервный канал и заводился.
parsed = urllib.parse.urlsplit(self.path)
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
if parsed.path == "/glitchtip":
self._handle_glitchtip(parsed, raw)
return
if parsed.path != "/alertmanager":
if self.path != "/alertmanager":
self._reply(404, b"not found", "text/plain; charset=utf-8")
return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode() or "{}")
except Exception: # noqa: BLE001
@ -370,36 +261,6 @@ class Handler(BaseHTTPRequestHandler):
self._reply(200, b"accepted", "text/plain; charset=utf-8")
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
def _handle_glitchtip(self, parsed: urllib.parse.SplitResult, raw: bytes) -> None:
"""POST /glitchtip — резервный получатель GlitchTip-алертов (#3471).
Секрет из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт
в access-log) либо из query ``?secret=`` (fallback: GlitchTip 6.1.6
заголовков не шлёт вовсе). Несконфигурированный секрет 503, а не
тихий приём без проверки. Неразобранное/нестандартное тело НЕ роняет
запрос это резервный канал, теряться на кривом JSON ему нельзя.
"""
if not GLITCHTIP_SECRET:
self._reply(503, b"glitchtip webhook not configured", "text/plain; charset=utf-8")
return
header_secret = self.headers.get("X-GlitchTip-Secret", "")
query_secret = urllib.parse.parse_qs(parsed.query).get("secret", [""])[0]
if not _verify_glitchtip_secret(header_secret or query_secret):
log.warning("glitchtip webhook: invalid or missing secret")
self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8")
return
try:
payload = json.loads(raw.decode() or "{}")
if not isinstance(payload, dict):
payload = {"text": raw.decode(errors="replace")}
except Exception: # noqa: BLE001 — резервный канал не роняем на кривом теле
payload = {"text": raw.decode(errors="replace")}
self._reply(200, b"accepted", "text/plain; charset=utf-8")
threading.Thread(target=_send_glitchtip_alert, args=(payload,), daemon=True).start()
def main() -> None:
missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v]
@ -407,11 +268,6 @@ def main() -> None:
raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}")
if not PUBLIC_URL:
log.warning("ALERT_ACK_PUBLIC_URL пуст — сообщения уйдут БЕЗ кнопки подтверждения")
if not GLITCHTIP_SECRET:
log.warning(
"ALERT_ACK_GLITCHTIP_SECRET пуст — резервный канал GlitchTip (#3471) "
"отключён, POST /glitchtip будет отвечать 503"
)
port = int(os.environ.get("ALERT_ACK_PORT", "8080"))
log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "", ONCALL or "")
ThreadingHTTPServer(("", port), Handler).serve_forever()

View file

@ -1,256 +0,0 @@
"""Тесты для резервного канала GlitchTip → alert-ack (#3471).
Продукт (tradein-backend на Selectel) САМ объект наблюдения GlitchTip. Если
он лежит, основной вебхук-получатель лежит вместе с ним, и алерт об этом не
доходит именно тогда, когда нужнее всего. `POST /glitchtip` второй
получатель на ДРУГОМ хосте (Beget, рядом с этим сервисом), не зависящий от
здоровья продукта.
Проверяем на уровне функций, а не полного HTTP-транспорта (тот же приём, что
`ops/glitchtip-auth-forwarder/test_forwarder.py`): `BaseHTTPRequestHandler`
неудобно поднимать без реального сокета, а бизнес-логика секрет, рендер,
отправка целиком вынесена в чистые функции модуля.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
# Env — ДО импорта app.py: BOT_TOKEN/CHAT_ID читаются на уровне модуля.
os.environ.setdefault("METRICS_TELEGRAM_BOT_TOKEN", "test-bot-token")
os.environ.setdefault("METRICS_TELEGRAM_CHAT_ID", "-1001234567890")
os.environ.setdefault("METRICS_TELEGRAM_TOPIC_ID", "158")
os.environ["ALERT_ACK_GLITCHTIP_SECRET"] = "correct-secret"
import app as alert_ack # noqa: E402
def test_verify_glitchtip_secret_accepts_correct_value() -> None:
assert alert_ack._verify_glitchtip_secret("correct-secret") is True
def test_verify_glitchtip_secret_rejects_wrong_value() -> None:
"""Неверный секрет — отказ."""
assert alert_ack._verify_glitchtip_secret("wrong-secret") is False
def test_verify_glitchtip_secret_rejects_empty_value() -> None:
assert alert_ack._verify_glitchtip_secret("") is False
def test_verify_glitchtip_secret_fails_closed_when_unconfigured(monkeypatch) -> None:
"""Пустой ALERT_ACK_GLITCHTIP_SECRET — отказ всем, а не тихий fail-open."""
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
assert alert_ack._verify_glitchtip_secret("correct-secret") is False
assert alert_ack._verify_glitchtip_secret("") is False
def test_render_glitchtip_full_payload_marks_fallback_channel() -> None:
payload = {
"text": "GlitchTip Alert: Something broke",
"attachments": [
{
"title": "TypeError: boom",
"title_link": "https://errors.gendsgn.ru/issue/1",
"text": "подробности ошибки",
"color": "#ff0000",
"fields": [{"title": "Project", "value": "tradein-backend"}],
}
],
}
text = alert_ack._render_glitchtip(payload)
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
assert "Проект: tradein-backend" in text
assert "TypeError: boom" in text
assert "https://errors.gendsgn.ru/issue/1" in text
def test_render_glitchtip_survives_payload_without_attachments() -> None:
"""Тело без attachments не роняет сервис — только текст."""
text = alert_ack._render_glitchtip({"text": "просто текст без вложений"})
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
assert "просто текст без вложений" in text
def test_render_glitchtip_survives_empty_payload() -> None:
"""Пустой словарь (нет ни text, ни attachments) — тоже не должен падать."""
text = alert_ack._render_glitchtip({})
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
assert "GlitchTip alert" in text
def test_render_glitchtip_survives_malformed_attachments() -> None:
"""attachments/fields неожиданной формы (не список, не словарь, битые
типы) резервный канал не должен падать на кривом теле."""
payload = {
"text": "test",
"attachments": [
"not-a-dict",
{"fields": "not-a-list"},
{"fields": [{"title": "Project"}]}, # value отсутствует
None,
],
}
text = alert_ack._render_glitchtip(payload)
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None:
"""Верный секрет уже проверен вызывающей стороной (do_POST) — здесь
проверяем, что отрендеренное сообщение реально уходит в тот же chat/topic,
что и Alertmanager-алерты этого сервиса, без кнопки подтверждения."""
calls = []
monkeypatch.setattr(alert_ack, "_tg", lambda method, payload: calls.append((method, payload)))
alert_ack._send_glitchtip_alert({"text": "boom", "attachments": []})
assert len(calls) == 1
method, sent = calls[0]
assert method == "sendMessage"
assert sent["chat_id"] == alert_ack.CHAT_ID
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
assert "reply_markup" not in sent
assert "boom" in sent["text"]
# ── Keep-alive: отказ не должен ронять СЛЕДУЮЩИЙ запрос ──────────────────────
# Эти четыре теста — единственные, что поднимают настоящий сокет. Дефект,
# который они стерегут, живёт именно в транспорте и на уровне функций невидим:
# ветка отказа отвечала, не вычитав тело запроса, а `protocol_version` здесь
# HTTP/1.1, то есть соединение переиспользуется (и Caddy перед сервисом держит
# пул к апстриму). Непрочитанное тело оставалось в сокете, и следующий запрос
# по тому же соединению начинался с чужих байт.
#
# Поймано на проде 12.09.2026: зонд без секрета получил 401, а следующий —
# уже с верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST').
# То есть один отказ съедал следующий НАСТОЯЩИЙ алерт.
def _serve_in_background(monkeypatch):
"""Поднимает Handler на эфемерном порту, глушит отправку в Telegram."""
import threading as _threading
from http.server import ThreadingHTTPServer
sent: list[dict] = []
monkeypatch.setattr(alert_ack, "_send_glitchtip_alert", sent.append)
monkeypatch.setattr(alert_ack, "_send_alert", sent.append)
srv = ThreadingHTTPServer(("127.0.0.1", 0), alert_ack.Handler)
thread = _threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
return srv, sent
def _raw_post(sock, path: str, body: bytes, headers: str = "") -> str:
"""Шлёт POST по уже открытому сокету и возвращает статусную строку."""
req = (
f"POST {path} HTTP/1.1\r\n"
f"Host: localhost\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n"
f"{headers}"
f"\r\n"
).encode() + body
sock.sendall(req)
# Читаем ровно заголовки: тела короткие, Content-Length всегда проставлен.
buf = b""
while b"\r\n\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
break
buf += chunk
head, _, rest = buf.partition(b"\r\n\r\n")
length = 0
for line in head.split(b"\r\n")[1:]:
if line.lower().startswith(b"content-length:"):
length = int(line.split(b":")[1])
while len(rest) < length:
rest += sock.recv(4096)
return head.split(b"\r\n")[0].decode()
def _pair_on_one_connection(monkeypatch, first_headers: str, first_path: str = "/glitchtip"):
import socket
srv, sent = _serve_in_background(monkeypatch)
try:
sock = socket.create_connection(srv.server_address, timeout=5)
try:
first = _raw_post(sock, first_path, b'{"text":"probe"}', first_headers)
second = _raw_post(
sock,
"/glitchtip",
b'{"text":"real alert"}',
"X-GlitchTip-Secret: correct-secret\r\n",
)
finally:
sock.close()
finally:
srv.shutdown()
srv.server_close()
return first, second, sent
def test_rejected_request_does_not_break_next_one_on_same_connection(monkeypatch) -> None:
"""401 без секрета, следом законный алерт по ТОМУ ЖЕ соединению — 200."""
first, second, sent = _pair_on_one_connection(monkeypatch, "")
assert "401" in first
assert "200" in second, f"второй запрос испорчен первым: {second}"
assert sent == [{"text": "real alert"}]
def test_unconfigured_secret_does_not_break_next_request(monkeypatch) -> None:
"""503 при пустом секрете тоже обязан вычитать тело."""
import socket
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
srv, _sent = _serve_in_background(monkeypatch)
try:
sock = socket.create_connection(srv.server_address, timeout=5)
try:
first = _raw_post(sock, "/glitchtip", b'{"text":"probe"}')
second = _raw_post(sock, "/glitchtip", b'{"text":"again"}')
finally:
sock.close()
finally:
srv.shutdown()
srv.server_close()
assert "503" in first
assert "503" in second, f"второй запрос испорчен первым: {second}"
def test_unknown_path_does_not_break_next_request(monkeypatch) -> None:
"""404 на чужом пути — та же ветка раннего ответа, то же требование."""
first, second, sent = _pair_on_one_connection(monkeypatch, "", first_path="/nope")
assert "404" in first
assert "200" in second, f"второй запрос испорчен первым: {second}"
assert sent == [{"text": "real alert"}]
def test_bad_json_on_alertmanager_does_not_break_next_request(monkeypatch) -> None:
"""400 на неразобранном теле /alertmanager — тело уже вычитано, связь цела."""
import socket
srv, sent = _serve_in_background(monkeypatch)
try:
sock = socket.create_connection(srv.server_address, timeout=5)
try:
first = _raw_post(sock, "/alertmanager", b"{not json")
second = _raw_post(
sock,
"/glitchtip",
b'{"text":"real alert"}',
"X-GlitchTip-Secret: correct-secret\r\n",
)
finally:
sock.close()
finally:
srv.shutdown()
srv.server_close()
assert "400" in first
assert "200" in second, f"второй запрос испорчен первым: {second}"
assert sent == [{"text": "real alert"}]

View file

@ -71,16 +71,9 @@ route:
repeat_interval: 3h
inhibit_rules:
# Если хост целиком недоступен, не сыпать отдельно про каждый его
# warning-сервис. `target_matchers` НАМЕРЕННО ограничен одним warning
# (#3471): раньше сюда попадал и critical, и падение node-exporter молча
# гасило заодно PostgresLongTransactionCritical и все critical cAdvisor-
# алерты того же хоста — самое важное сообщение исчезало вместе с шумом,
# который оно должно было подавить. Warning того же хоста подавлять по-
# прежнему стоит (диск/память/своп неотличимы от «нет данных»), а critical
# обязан пережить это подавление и дойти до дежурного отдельно.
# Если хост целиком недоступен, не сыпать отдельно про каждый его сервис.
- source_matchers: [alertname = "HostAgentDown"]
target_matchers: [severity = "warning"]
target_matchers: [severity =~ "warning|critical"]
equal: ["host"]
receivers:

View file

@ -119,27 +119,6 @@ prometheus.scrape "postgres" {
scrape_interval = "60s"
}
// ═══ REDIS И ОЧЕРЕДЬ CELERY (#3471) ═════════════════════════════════════════════
// До этой правки ни одной серии redis_* / celery_* в Prometheus не было: глубина
// очереди, число живых воркеров и потеря соединения с брокером были невидимы —
// переполнение очереди и залипший воркер снаружи выглядели одинаково, тишиной.
prometheus.scrape "redis" {
targets = [
{ __address__ = "gendesign-redis-exporter:9121", job = "redis" },
]
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "30s"
}
prometheus.scrape "celery" {
targets = [
{ __address__ = "gendesign-celery-exporter:9808", job = "celery" },
]
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "30s"
}
// ═══ МЕТРИКИ ПРИЛОЖЕНИЙ ════════════════════════════════════════════════════════
// Эндпоинты появляются в части 3. До этого скрейп просто отдаёт `up 0` — и это
// правильно: цель видна как недоступная, а не отсутствует молча.

View file

@ -57,13 +57,9 @@ prometheus.scrape "cadvisor" {
prometheus.relabel "cadvisor_trim" {
forward_to = [prometheus.remote_write.central.receiver]
// `up`/`scrape_samples_scraped` — служебные ряды самого скрейпа, не
// container_*-метрики. Без явного допуска этот keep-фильтр резал их вместе
// с прочим шумом, и у cAdvisor как job'а не было своей серии `up` вообще —
// его смерть выглядела так же, как «ничего не изменилось» (#3471).
rule {
source_labels = ["__name__"]
regex = "up|scrape_samples_scraped|container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
action = "keep"
}
@ -74,15 +70,9 @@ prometheus.relabel "cadvisor_trim" {
//
// NB: на пустые панели это правило НЕ влияло. Причина была в cAdvisor 0.52 на
// Docker 29 — до сюда доезжал ровно один ряд, корневой. Лечится версией 0.55.1.
//
// `up`/`scrape_samples_scraped` лейбла `name` не несут вовсе (они не про
// конкретный контейнер, а про сам скрейп) — фильтр по нему вырезал бы и их.
// Поэтому здесь смотрим на пару (__name__, name): для служебных рядов
// достаточно самого __name__, для контейнерных метрик по-прежнему обязателен
// непустой name.
rule {
source_labels = ["__name__", "name"]
regex = "up;.*|scrape_samples_scraped;.*|container_[^;]*;.+"
source_labels = ["name"]
regex = ".+"
action = "keep"
}
}

View file

@ -107,7 +107,7 @@
{
"type": "timeseries",
"title": "Запросы по классам ответов",
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка. Линии НЕ стекируются: высота красной линии — это и есть число пятисоток, а не сумма со всем, что под ней.",
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
"targets": [
@ -117,7 +117,7 @@
{ "refId": "D", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m]))", "legendFormat": "{{app}} · 5xx" }
],
"fieldConfig": {
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } },
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 25, "stacking": { "mode": "normal" }, "showPoints": "never", "lineWidth": 1 } },
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*2xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] },
{ "matcher": { "id": "byRegexp", "options": ".*3xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] },

View file

@ -1,161 +0,0 @@
{
"uid": "gendesign-product",
"title": "Продуктовые метрики",
"description": "Числа бизнеса, а не процесса: сколько людей реально что-то сделали в «Мере» и «Птице» (#3471). Источник — те же счётчики Prometheus, что и на дашборде «Приложения», только считают не HTTP-статусы, а продуктовые события (оценка, лид, отчёт, вход, обращение в поддержку). Часовое окно нарочно грубое: при текущем трафике (сотни событий в сутки) минутные всплески — шум, а не сигнал.",
"tags": ["gendesign", "product"],
"timezone": "browser",
"editable": false,
"schemaVersion": 39,
"refresh": "5m",
"time": { "from": "now-24h", "to": "now" },
"panels": [
{ "type": "row", "title": "Мера — воронка продукта", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
{
"type": "timeseries",
"title": "Оценок в час, по исходу",
"description": "insufficient_data — не ошибка: аналогов рядом с адресом не нашлось, расчёт прошёл штатно. Тревожиться стоит, если эта линия начинает расти быстрее ok — значит покрытие рынка проседает, а не то, что сломался расчёт.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 },
"targets": [
{
"refId": "A",
"expr": "sum by (outcome) (increase(mera_estimates_total[1h]))",
"legendFormat": "{{outcome}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "insufficient_data" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
{ "matcher": { "id": "byName", "options": "ok" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Подсказки адреса в час, нашёлся ли результат",
"description": "found=no — человек напечатал адрес, а автокомплит ничего не предложил: либо адреса нет в базе, либо он вне зоны покрытия. Устойчивый рост этой линии — повод расширять покрытие, а не баг одного запроса.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 },
"targets": [
{
"refId": "A",
"expr": "sum by (found) (increase(mera_address_suggestions_total[1h]))",
"legendFormat": "найден: {{found}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*no.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Лиды и скачанные отчёты в час",
"description": "Лид — заявка с телефоном после оценки, отчёт — скачанный PDF по оценке. Оба редкие: сверяйте с недельным окном (кнопка времени вверху), часовой провал сам по себе ни о чём не говорит при таком трафике.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 },
"targets": [
{ "refId": "A", "expr": "increase(mera_leads_total[1h])", "legendFormat": "лиды" },
{ "refId": "B", "expr": "increase(mera_reports_exported_total[1h])", "legendFormat": "отчёты" }
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Входы в час, по исходу",
"description": "Устойчивый рост failed при ровном success — либо перебор паролей, либо сломался клиент (истёкшая сессия, старый билд фронта). При подозрении на перебор смотрите заодно панель «Отказы авторизации и лимитера» на дашборде «Приложения» — там 401/429 по HTTP.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 },
"targets": [
{
"refId": "A",
"expr": "sum by (result) (increase(mera_logins_total[1h]))",
"legendFormat": "{{result}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] },
{ "matcher": { "id": "byName", "options": "success" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Обращения в поддержку в час, по каналу",
"description": "anon — обращения с экрана входа, без логина (типично «не могу войти»), web — уже залогиненные пользователи. Всплеск anon без роста web — обычно означает баг именно в форме/процессе входа, а не общий рост нагрузки на поддержку.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 },
"targets": [
{
"refId": "A",
"expr": "sum by (channel) (increase(mera_support_messages_total[1h]))",
"legendFormat": "{{channel}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "anon" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
]
}
},
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
{
"type": "timeseries",
"title": "Экспортов отчётов по участку в час, по формату",
"description": "Формат — то, что реально скачали: md/json/docx/pptx/pdf/tg-сводка §22-форсайта плюс best_layouts_pdf (ТЗ на проектирование). Провал всех форматов разом при живом трафике на дашборде «Приложения» значит, что сломан сам экспорт, а не рендер одного конкретного формата.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 26 },
"targets": [
{
"refId": "A",
"expr": "sum by (format) (increase(sitefinder_reports_exported_total[1h]))",
"legendFormat": "{{format}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": []
}
}
]
}

View file

@ -1,16 +0,0 @@
# Эта папка сознательно пустая
Grafana умеет провижинить contact points, notification policies и alert rules
файлами отсюда (`/etc/grafana/provisioning/alerting`). Не клади их сюда.
Решение (#3158, 12.09.2026): единственный путь доставки тревог — Prometheus
(правила) + Alertmanager (маршрутизация, Telegram). Grafana только рисует.
Встроенный Alerting выключен явно (`GF_UNIFIED_ALERTING_ENABLED: "false"` в
`docker-compose.metrics.yml`, секция `grafana`) — при живом API 12.09.2026
единственным контакт-поинтом был стоковый `grafana-default-email` на
`example@email.com`, `GF_SMTP_*` не задан, правил ноль. Файл сюда работать не
заставит: движок alerting выключен на уровне сервиса, провижининг в эту папку
Grafana просто не читает.
Если понадобится включить обратно — сначала пересмотреть само решение в
`docker-compose.metrics.yml`, а не просто добавить файл в эту папку.

View file

@ -44,21 +44,6 @@ groups:
summary: "С продуктового хоста 15 минут не приходят метрики"
description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write."
# cAdvisor как job не публикует `up` естественным образом — до фикса
# keep-фильтра в alloy-infra.alloy/alloy-apps.alloy (#3471) эта серия
# вырезалась тем же правилом, что чистит container_* от мусорных
# лейблов. Итог — смерть cAdvisor молча гасила ContainerRestartLoop и
# ContainerNearMemoryLimit: обе метрики просто переставали поступать, а
# выглядело это как «событий не было».
- alert: CadvisorDown
expr: up{job="cadvisor"} == 0 or absent(up{job="cadvisor"})
for: 5m
labels:
severity: critical
annotations:
summary: "cAdvisor не отвечает"
description: "{{ if $labels.host }}{{ $labels.host }}: {{ end }}job=\"cadvisor\" вернул up=0 либо серия пропала целиком. Без неё контейнерные алерты этого хоста молчат вне зависимости от реального состояния контейнеров."
# ── Хост ────────────────────────────────────────────────────────────────────
- name: host
interval: 60s
@ -140,19 +125,11 @@ groups:
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
# Подошёл к своему mem_limit — следующий шаг OOM-kill.
#
# ФОРМА ВЫРАЖЕНИЯ ВАЖНА, а не только условие. `A and B` возвращает ЗНАЧЕНИЯ
# ЛЕВОЙ части, отфильтрованные правой, — то есть в `$value` попадает именно
# A. Прежняя запись (`limit > 0 and working_set/limit > 0.90`) слала в
# Telegram лимит В БАЙТАХ, отрендеренный как процент: боевое сообщение
# 12.09 — «2.684e+11% от mem_limit» при limit = 2 684 354 560 Б. Условие
# при этом срабатывало верно, врал только текст. Поэтому отношение стоит
# СЛЕВА, а отсев нулевого лимита убран внутрь знаменателя: `(X > 0)`
# выбрасывает серии без лимита ДО деления.
- alert: ContainerNearMemoryLimit
expr: |
container_memory_working_set_bytes{name!=""}
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
container_spec_memory_limit_bytes{name!=""} > 0
and container_memory_working_set_bytes{name!=""}
/ container_spec_memory_limit_bytes{name!=""} > 0.90
for: 15m
labels:
severity: warning
@ -160,135 +137,6 @@ groups:
summary: "Контейнер у своего потолка памяти"
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
# tradein-tgbot и tradein-scraper не HTTP-сервисы — у них нет `up{}`
# вообще, поэтому крэш-без-рестарта или удаление контейнера иначе не
# поймать. `absent()` на каждое имя отдельно (не одним regex-селектором):
# regex-селектор с несколькими сериями считается «пустым» только когда
# ПРОПАЛИ ОБЕ — если жив хотя бы один из двух контейнеров, absent() по
# общему selector'у молчит и не заметит пропажу второго.
#
# ВАЖНО, чего это правило НЕ ловит: container_last_seen обновляется, пока
# Docker видит контейнер живым, — зависший, но не упавший процесс
# (внутренний цикл встал, контейнер по-прежнему числится running) эту
# метрику не тронет. Слепая зона «живой процесс с застрявшим циклом»
# остаётся открытой: подходящей метрики для неё сейчас нет.
- alert: TradeInBackgroundContainerMissing
expr: |
absent(container_last_seen{name="tradein-tgbot"})
or absent(container_last_seen{name="tradein-scraper"})
for: 5m
labels:
severity: critical
annotations:
summary: "Фоновый контейнер Меры пропал из cAdvisor"
description: "{{ $labels.name }}: серия container_last_seen исчезла — контейнер, судя по всему, не работает и не перезапускается."
# ── Приложение ──────────────────────────────────────────────────────────────
# `job="app"` — job из alloy-apps.alloy, лейбл `app` различает продукты
# (sitefinder / mera). Метрики отдаёт `MetricsMiddleware`
# (`backend/app/observability/metrics.py` у «Птицы»,
# `tradein-mvp/backend/app/observability/metrics.py` у «Меры») — счётчик
# `http_requests_total{method,route,status}` и гистограмма
# `http_request_duration_seconds{method,route}`. До этой группы доля 5xx и
# задержка были видны только постфактум в GlitchTip, без порога срабатывания
# (#3471).
#
# severity: critical + host: apps здесь ОБЯЗАТЕЛЬНЫ содержательно, не для
# красоты: именно эта пара матчится маршрутом telegram-clients в
# alertmanager.yml.tmpl — тот зовёт дежурного и напоминает каждые 30 минут.
# `host` выставлен статически: `sum by (app)` вырезает его из результата
# запроса, а job="app" в принципе существует только на продуктовом хосте.
- name: app
interval: 60s
rules:
# Гейт по RPS внутри знаменателя — тот же приём, что у
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
# дежурного зря.
- alert: AppHighErrorRate
expr: |
sum by (app) (rate(http_requests_total{job="app", status=~"5.."}[5m]))
/ (sum by (app) (rate(http_requests_total{job="app"}[5m])) > 0.1) > 0.05
for: 5m
labels:
severity: critical
host: apps
annotations:
summary: "Доля 5xx выше 5%"
description: "{{ $labels.app }}: {{ $value | humanizePercentage }} ответов 5xx за последние 5 минут при RPS выше 0.1."
# Порог 5s — заведомо выше рабочего профиля обоих продуктов (у «Меры»
# типичный расчёт 90мс, у «Птицы» верхняя граница гистограммы — 60с под
# тяжёлую геометрию, но это единичные хвостовые запросы, не p95).
# Калибровка по реальному трафику — отдельная задача, не эта.
- alert: AppHighLatencyP95
expr: |
histogram_quantile(0.95, sum by (le, app) (rate(http_request_duration_seconds_bucket{job="app"}[10m]))) > 5
for: 10m
labels:
severity: critical
host: apps
annotations:
summary: "p95 задержки ответа выше 5 секунд"
description: "{{ $labels.app }}: p95 за 10 минут — {{ $value | humanizeDuration }}."
# ── Redis и очередь Celery (#3471) ───────────────────────────────────────────
# Слепая зона: до этих правил ни redis_*, ни celery_* не собирались вовсе.
# Redis — общий инстанс на три потребителя (celery-брокер Site Finder, кэш
# trade-in, glitchtip — см. docker-compose.prod.yml), поэтому его смерть
# клиентская, отсюда severity: critical без явного host: apps — серия
# приходит только с продуктового alloy (alloy-apps.alloy), host в неё
# проставляется через external_labels уже на месте.
#
# ⚠️ Имена метрик celery_queue_length / celery_worker_up /
# celery_task_failed_total — по документации celery-exporter на момент
# написания правил, без проверки на реальном брокере (см. комментарий у
# сервиса celery-exporter в docker-compose.metrics-agent.yml). Сверить после
# первого деплоя.
- name: redis-celery
interval: 60s
rules:
- alert: RedisDown
expr: up{job="redis"} == 0 or redis_up == 0
for: 5m
labels:
severity: critical
annotations:
summary: "Redis недоступен"
description: "redis_exporter не может достучаться до Redis (или сам процесс лёг). Разом теряют связь celery-брокер Site Finder, SearchCache trade-in и glitchtip."
# `absent()` — как у CadvisorDown: если сам celery-exporter не поднялся,
# серии celery_worker_up не будет вообще, а не будет со значением 0.
- alert: NoActiveCeleryWorkers
expr: count(celery_worker_up == 1) == 0 or absent(celery_worker_up)
for: 5m
labels:
severity: critical
annotations:
summary: "Ни одного живого воркера Celery"
description: "celery-exporter не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
# Порог 150 ПРЕДВАРИТЕЛЬНЫЙ: реальных данных по глубине очереди нет (до
# этой правки метрика не собиралась). beat_schedule.py на момент правки
# содержит 44 periodic-задачи с разным временем срабатывания — даже
# маловероятный залп всех разом даёт кратно меньше 150. Порог взят с
# запасом сознательно и требует пересмотра через неделю наблюдений по
# факту `celery_queue_length`.
#
# `delta(...) >= 0` — очередь не УМЕНЬШАЕТСЯ за 15 минут (тот же приём,
# что и "растёт и не разгребается" в тексте задачи): просто высокое
# значение без этого условия поймало бы и здоровый кратковременный всплеск.
- alert: CeleryQueueGrowing
expr: |
celery_queue_length{queue_name="celery"} > 150
and delta(celery_queue_length{queue_name="celery"}[15m]) >= 0
for: 15m
labels:
severity: warning
annotations:
summary: "Очередь Celery растёт и не разгребается"
description: "В очереди {{ $value }} задач, за 15 минут меньше не стало. Похоже на залипший воркер или устойчивый рост нагрузки."
# ── Postgres ────────────────────────────────────────────────────────────────
- name: postgres
interval: 60s
@ -324,20 +172,12 @@ groups:
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
# позволило 91 день не замечать 198 апдейтов на строку.
#
# Та же ловушка `A and B`, что и у ContainerNearMemoryLimit, и здесь она
# опаснее: в `$value` попадал `rate(tup_upd[6h])` — АПДЕЙТОВ В СЕКУНДУ, а
# текст называл это долей HOT. Боевое сообщение 12.09 — «доля HOT 75.21%»
# при пороге срабатывания «доля < 20%»: число само себе противоречило и
# выглядело правдоподобно, поэтому никто не заметил (замер 12.09 по той же
# таблице listings: rate(tup_upd[6h]) = 0.0411 → сообщение сказало бы
# «4.11%», настоящая доля HOT = 0.00%). Гейт по объёму апдейтов
# (> 0.5/с — «трафик есть, значит вопрос осмыслен») перенесён внутрь
# знаменателя: там он и фильтрует серии, и защищает от деления на ноль.
- alert: PostgresLowHotUpdateRatio
expr: |
rate(pg_table_write_amplification_tup_upd[6h]) > 0.5
and
rate(pg_table_write_amplification_tup_hot_upd[6h])
/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5) < 0.2
/ rate(pg_table_write_amplification_tup_upd[6h]) < 0.2
for: 6h
labels:
severity: warning
@ -345,11 +185,6 @@ groups:
summary: "Обновления идут мимо HOT"
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
# Третье правило того же семейства `A and B` — и единственное, где текст
# верен: `$value` тут печатается без humanize, а слева стоит ровно то, что
# описание и называет («N мёртвых»). Совпадение, а не заслуга формы: если
# когда-нибудь захочется печатать здесь ДОЛЮ, отношение придётся вынести
# влево, как в двух правилах выше.
- alert: PostgresDeadTuplesHigh
expr: |
pg_table_write_amplification_dead_tup > 1000000

View file

@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""Ретранслятор Bot API продукта через инфраструктурный хост Beget (#3471).
ЗАЧЕМ. Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера
`tradein-tgbot` на Selectel 9 успешных из 12, три `ConnectTimeout`. TCP на 443
до адреса, резолвящегося на Selectel (149.154.167.220) 5 из 6. Тот же TCP до
адреса, резолвящегося на Beget (149.154.166.110) 8 из 8. За сутки в логе бота
508 строк `network error`, за 30 дней 92 обрыва итерации poll loop. Путь до
Telegram с Selectel лоссовый, с Beget чистый: Alertmanager (живёт на Beget)
пишет в тот же чат без проблем, а бот поддержки на Selectel часть отправок
теряет. Решение не чинить сеть Selectel (вне контроля), а дать продуктовым
сервисам обходной путь через хост, с которого Telegram отвечает надёжно.
ЧТО ПРОКСИРУЕТСЯ. Метод Bot API целиком путь `/bot<TOKEN>/<method>`, тело,
query. Не только отправка: `getUpdates` (long-poll) страдает от потерь ровно
так же, как `sendMessage`, и это тот же HTTP-путь через тот же испорченный
транзит.
ТОКЕН НЕ ЛОГИРУЕТСЯ. Он приходит в пути запроса. `log_request` переопределён
ПОЛНОСТЬЮ (не вызывает `super()`): дефолт stdlib кладёт в лог `requestline`
целиком, включая токен. Здесь путь редактируется до записи в лог.
АУТЕНТИФИКАЦИЯ. Общий секрет в заголовке `X-Relay-Secret`, по образцу общего
секрета `X-Internal-Auth-Secret` в этом же стеке (`app/core/config.py`,
`tradein_internal_auth_secret`) сравнение строкой (не сравнение таймингов:
секрет не является паролем пользователя, ценность атаки по времени здесь
исчезающе мала при секрете длиной от 32 байт, а stdlib `hmac` лишняя
зависимость ради stdlib-only сервиса). Домен публичный, без секрета отказ,
а не тихий приём.
БЕЗ ЗАВИСИМОСТЕЙ. Только стандартная библиотека тот же принцип, что у
`ops/metrics/alert-ack/app.py`: сервис обязан подниматься, даже когда всё
остальное сломано, и не тащить установку пакетов.
ОТКАЗ РЕТРАНСЛЯТОРА НЕ ДОЛЖЕН РОНЯТЬ БОТА. Это реализовано НЕ здесь, а на
стороне клиента (`tradein-mvp/backend/app/services/tgbot/client.py`): при
транспортном отказе похода на ретранслятор клиент делает одну попытку
напрямую к `api.telegram.org`. Здесь достаточно не быть точкой отказа хуже
прямого пути: таймаут до апстрима подобран так, чтобы не обрубать long-poll
`getUpdates` раньше, чем это сделал бы сам Telegram.
Переменные окружения:
TG_RELAY_SECRET обязательна общий секрет, сверяется с
заголовком X-Relay-Secret
TG_RELAY_PORT порт (по умолчанию 8080)
TG_RELAY_UPSTREAM_TIMEOUT_S таймаут запроса к api.telegram.org в секундах
(по умолчанию 75 с запасом над самым долгим
long-poll getUpdates, который шлёт клиент:
timeout=30 + 10с запаса на стороне httpx = 40с)
"""
from __future__ import annotations
import logging
import os
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("tg-relay")
RELAY_SECRET = os.environ.get("TG_RELAY_SECRET", "")
UPSTREAM = "https://api.telegram.org"
UPSTREAM_TIMEOUT_S = float(os.environ.get("TG_RELAY_UPSTREAM_TIMEOUT_S", "75"))
SECRET_HEADER = "X-Relay-Secret"
def redact_path(path: str) -> str:
"""Прячет токен из `/bot<TOKEN>/method[?query]` для логов и ошибок.
Вынесена в модульную функцию (не метод), чтобы быть проверяемой напрямую
без поднятия HTTP-сервера.
"""
if not path.startswith("/bot"):
return path
rest = path[len("/bot") :]
_token_part, sep, tail = rest.partition("/")
if not sep:
# Ни `/method`, ни query — токен без хвоста (или токен+query без slash).
return "/bot<REDACTED>"
tail = tail.split("?", 1)[0]
return f"/bot<REDACTED>/{tail}" if tail else "/bot<REDACTED>"
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — сигнатура из stdlib
log.info("%s", fmt % args)
def log_request(self, code="-", size="-") -> None: # noqa: A003 — сигнатура из stdlib
# ПОЛНОСТЬЮ заменяет реализацию BaseHTTPRequestHandler (не вызывает
# super()): та кладёт в лог self.requestline целиком, а он для Bot API
# содержит токен в пути.
log.info('%s "%s %s" %s', self.address_string(), self.command, redact_path(self.path), code)
def _reply(self, code: int, body: bytes, ctype: str = "application/json") -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def _authorized(self) -> bool:
return bool(RELAY_SECRET) and self.headers.get(SECRET_HEADER) == RELAY_SECRET
def _proxy(self) -> None:
if self.path == "/healthz":
self._reply(200, b"ok", "text/plain; charset=utf-8")
return
if not self._authorized():
self._reply(401, b'{"ok":false,"description":"unauthorized"}')
return
if not self.path.startswith("/bot"):
self._reply(404, b'{"ok":false,"description":"not found"}')
return
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else None
req = urllib.request.Request(
UPSTREAM + self.path,
data=body,
method=self.command,
headers={"Content-Type": self.headers.get("Content-Type") or "application/json"},
)
try:
# Таймаут ЯВНО шире любого long-poll getUpdates клиента — иначе
# ретранслятор обрубит соединение раньше площадки и превратит
# штатный long-poll в вечный network error, то есть станет хуже
# прямого пути, а не лучше.
with urllib.request.urlopen(req, timeout=UPSTREAM_TIMEOUT_S) as resp:
out = resp.read()
self._reply(resp.status, out, resp.headers.get("Content-Type") or "application/json")
except urllib.error.HTTPError as exc:
# Telegram ответил ошибкой (4xx/5xx) — это НЕ отказ ретранслятора,
# передаём как есть, клиент сам решает, ретраить или нет.
out = exc.read()
ctype = exc.headers.get("Content-Type") if exc.headers else None
self._reply(exc.code, out, ctype or "application/json")
except Exception as exc: # noqa: BLE001 — любой отказ апстрима не должен уронить сервис
log.warning("upstream недоступен (%s): %s", redact_path(self.path), type(exc).__name__)
self._reply(502, b'{"ok":false,"description":"relay upstream unreachable"}')
def do_GET(self) -> None: # noqa: N802 — имя из stdlib
self._proxy()
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
self._proxy()
def main() -> None:
if not RELAY_SECRET:
raise SystemExit("не задан TG_RELAY_SECRET — домен публичный, отказ на старте")
port = int(os.environ.get("TG_RELAY_PORT", "8080"))
log.info("tg-relay слушает :%d, upstream_timeout=%.0fs", port, UPSTREAM_TIMEOUT_S)
ThreadingHTTPServer(("", port), Handler).serve_forever()
if __name__ == "__main__":
main()

View file

@ -1,144 +0,0 @@
"""Тесты для tg-relay — ретранслятора Bot API продукта через Beget (#3471).
Гоняют реальный `ThreadingHTTPServer` на localhost (эфемерный порт), апстрим
`urllib.request.urlopen` подменяется моком реальный api.telegram.org НЕ
дёргаем никогда.
"""
from __future__ import annotations
import http.client
import json
import logging
import threading
from unittest import mock
import pytest
import app as relay
def _fake_upstream_response(status: int = 200, body: bytes = b'{"ok": true, "result": []}'):
resp = mock.MagicMock()
resp.status = status
resp.read.return_value = body
resp.headers.get.return_value = "application/json"
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
return resp
@pytest.fixture()
def secret(monkeypatch):
monkeypatch.setattr(relay, "RELAY_SECRET", "test-secret-value")
return "test-secret-value"
@pytest.fixture()
def server(secret):
httpd = relay.ThreadingHTTPServer(("127.0.0.1", 0), relay.Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
yield httpd
finally:
httpd.shutdown()
thread.join(timeout=5)
def _request(server, path, headers=None, method="GET", body=None):
conn = http.client.HTTPConnection(*server.server_address, timeout=5)
try:
conn.request(method, path, body=body, headers=headers or {})
resp = conn.getresponse()
return resp.status, resp.read()
finally:
conn.close()
def test_redact_path_hides_token_from_method_path():
assert relay.redact_path("/bot123456:ABC-DEF/sendMessage") == "/bot<REDACTED>/sendMessage"
def test_redact_path_hides_bare_token():
assert relay.redact_path("/bot123456:ABC-DEF/") == "/bot<REDACTED>"
assert relay.redact_path("/bot123456:ABC-DEF") == "/bot<REDACTED>"
def test_redact_path_leaves_non_bot_paths_untouched():
assert relay.redact_path("/healthz") == "/healthz"
def test_missing_secret_rejected_without_touching_upstream(server):
with mock.patch.object(relay.urllib.request, "urlopen") as mocked:
status, _body = _request(server, "/bot123:TOK/getMe")
assert status == 401
mocked.assert_not_called()
def test_wrong_secret_rejected_without_touching_upstream(server):
with mock.patch.object(relay.urllib.request, "urlopen") as mocked:
status, _body = _request(server, "/bot123:TOK/getMe", headers={"X-Relay-Secret": "wrong"})
assert status == 401
mocked.assert_not_called()
def test_valid_secret_passes_method_path_and_body_unmodified(server, secret):
with mock.patch.object(
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
) as mocked:
status, body = _request(
server,
"/bot123:TOK/sendMessage",
headers={"X-Relay-Secret": secret, "Content-Type": "application/json"},
method="POST",
body=b'{"chat_id": 1, "text": "hi"}',
)
assert status == 200
assert json.loads(body) == {"ok": True, "result": []}
sent_request = mocked.call_args[0][0]
assert sent_request.full_url == "https://api.telegram.org/bot123:TOK/sendMessage"
assert sent_request.data == b'{"chat_id": 1, "text": "hi"}'
assert sent_request.get_method() == "POST"
def test_get_updates_uses_upstream_timeout_wider_than_longest_client_poll(server, secret):
"""Клиент шлёт getUpdates(timeout=30) → httpx ждёт ответ 40с (30 + запас
10с). Апстрим-таймаут ретранслятора обязан быть шире, иначе он обрубит
long-poll раньше площадки и превратит штатный цикл в вечный network error."""
with mock.patch.object(
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
) as mocked:
_request(
server,
"/bot123:TOK/getUpdates",
headers={"X-Relay-Secret": secret},
method="POST",
body=b'{"offset": 1, "timeout": 30}',
)
_req, kwargs = mocked.call_args
assert kwargs["timeout"] >= 40
def test_token_never_appears_in_logs(server, secret, caplog):
token = "999888777:VerySecretTokenValue"
with caplog.at_level(logging.INFO, logger="tg-relay"):
with mock.patch.object(
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
):
_request(server, f"/bot{token}/getMe", headers={"X-Relay-Secret": secret})
for record in caplog.records:
assert token not in record.getMessage()
def test_unauthorized_attempt_does_not_leak_token_either(server, caplog):
token = "999888777:VerySecretTokenValue"
with caplog.at_level(logging.INFO, logger="tg-relay"):
_request(server, f"/bot{token}/getMe")
for record in caplog.records:
assert token not in record.getMessage()

View file

@ -1,187 +0,0 @@
#!/usr/bin/env python3
"""Гейт: подмена tradein-frontend не возвращает окно недоступности (#3274).
ПОЧЕМУ. Публичный лендинг meraocenka.ru лежал 3090 с на КАЖДОМ деплое МЕРЫ.
Причина НЕ медленная подмена контейнера (она стоит полсекунды), а то, что
`docker compose up -d` со СПИСКОМ сервисов работает в две фазы: сначала create
(старый контейнер каждого сервиса останавливается и удаляется иначе занято
`container_name`), потом start, в порядке зависимостей и с ожиданием их
условий. Всё, что между фазами, фронт лежит.
Замер на проде 10.09 (docker inspect .Created/.StartedAt, два деплоя подряд):
пачка сервисов: tradein-backend создан 15:01:40 запущен 15:02:10 (30 с)
один сервис: tradein-frontend создан 16:42:17.5 запущен 16:42:18.0 (0,5 с)
В логе Caddy у первого деплоя три 503 на лендинге (15:01:46, 15:01:52,
15:02:06), у второго ни одного.
ЧТО ДЕРЖИТ РЕШЕНИЕ, И ПОЧЕМУ ЭТО ГЕЙТ, А НЕ КОММЕНТАРИЙ. Половинки лежат в
разных файлах и обе невидимо обратимы:
1) deploy-tradein.yml `frontend` ВЫНЕСЕН из общего `up -d $SERVICES` в
свою команду. Достаточно дописать его обратно в SERVICES «за компанию»,
и окно вернётся целиком, молча: деплой останется зелёным.
2) caddy/sites/apps.caddy каждый `reverse_proxy tradein-frontend:3000`
импортирует (tradein_frontend_retry) (lb_try_duration), который добирает
оставшиеся ~0,5 с. Новый публичный путь копируют с соседнего блока и
если копируют блок БЕЗ импорта, именно этот путь снова отдаёт 502.
Оба отказа не видны ни по статусу джобы, ни по глазам: их видно только
непрерывной пробой во время деплоя (scripts/probe-deploy-window.sh).
ЧЕГО НЕ ЛОВИТ. Это разбор текста, а не исполнение: гейт не проверяет, что
команда реально отработала и что Caddy реально ретраит (это проверяется
пробой на живом деплое). Не смотрит на сервисы, кроме frontend, бэкенду
ретрай намеренно не дан (его старт длиннее, чем разумное ожидание клиента).
Запуск: python3 scripts/check-frontend-swap-window.py [--selftest]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
WORKFLOW = REPO / ".forgejo" / "workflows" / "deploy-tradein.yml"
APPS_CADDY = REPO / "caddy" / "sites" / "apps.caddy"
SNIPPET_NAME = "tradein_frontend_retry"
FRONTEND_UPSTREAM = "reverse_proxy tradein-frontend:3000 {"
# `SERVICES="browser backend tgbot"` / `SERVICES="$SERVICES scraper"`.
_SERVICES_RE = re.compile(r'^\s*SERVICES=(["\']?)(.*?)\1\s*$', re.M)
# Одиночная команда подмены фронта.
_FRONTEND_UP_RE = re.compile(r"up -d --no-deps frontend\s*$", re.M)
def strip_comments(text: str) -> str:
"""Убирает строки-комментарии (shell/YAML/Caddy — везде `#`).
Обязательно: разбор дефекта живёт в комментарии рядом с правкой и содержит
его же формулировку. Без этого гейт спорил бы с собственным объяснением.
"""
return "\n".join(ln for ln in text.splitlines() if not ln.lstrip().startswith("#"))
def check_workflow(text: str) -> list[str]:
body = strip_comments(text)
errors: list[str] = []
for m in _SERVICES_RE.finditer(body):
value = m.group(2)
if re.search(r"(^|\s)frontend(\s|$)", value):
errors.append(
f'SERVICES={value!r} снова содержит `frontend`: он вернётся в общий '
f"`up -d` со списком сервисов, а это и есть окно 3090 с (#3274). "
f"Фронт подменяется отдельной командой `up -d --no-deps frontend`."
)
if not _FRONTEND_UP_RE.search(body):
errors.append(
"в deploy-tradein.yml нет отдельной команды `up -d --no-deps frontend` — "
"подмена фронта либо пропала, либо снова уехала в общую пачку (#3274)."
)
return errors
def check_caddy(text: str) -> list[str]:
errors: list[str] = []
lines = text.splitlines()
body = strip_comments(text)
if f"({SNIPPET_NAME}) {{" not in body:
errors.append(
f"снипет ({SNIPPET_NAME}) не объявлен в apps.caddy — импортировать нечего."
)
elif "lb_try_duration" not in body:
errors.append(
f"снипет ({SNIPPET_NAME}) есть, но без `lb_try_duration` — он больше "
"ничего не добирает, оставшиеся ~0,5 с подмены снова видны как 502."
)
# Блок апстрима фронта: от строки `reverse_proxy tradein-frontend:3000 {`
# до закрывающей скобки на её же отступе. Импорт должен быть внутри.
for i, line in enumerate(lines):
if line.strip().startswith("#") or FRONTEND_UPSTREAM not in line:
continue
indent = len(line) - len(line.lstrip())
block: list[str] = []
for nxt in lines[i + 1 :]:
if nxt.strip() == "}" and (len(nxt) - len(nxt.lstrip())) == indent:
break
block.append(nxt)
if not any(f"import {SNIPPET_NAME}" in b for b in block if not b.strip().startswith("#")):
errors.append(
f"apps.caddy:{i + 1} — `reverse_proxy tradein-frontend:3000` без "
f"`import {SNIPPET_NAME}`: на этом пути подмена контейнера снова "
f"видна посетителю как 502 (#3274)."
)
return errors
def selftest() -> None:
good_wf = 'SERVICES="browser backend tgbot"\n docker compose up -d --no-deps frontend\n'
assert check_workflow(good_wf) == [], check_workflow(good_wf)
bad_wf = 'SERVICES="browser backend frontend tgbot"\n docker compose up -d --no-deps frontend\n'
assert any("SERVICES" in e for e in check_workflow(bad_wf)), "не поймал frontend в SERVICES"
missing_wf = 'SERVICES="browser backend tgbot"\n docker compose up -d --no-deps $SERVICES\n'
assert any("отдельной команды" in e for e in check_workflow(missing_wf)), (
"не поймал пропажу отдельной команды"
)
# Комментарий с той же формулировкой не должен ронять гейт.
commented = '# SERVICES="browser backend frontend tgbot" # так было до #3274\n' + good_wf
assert check_workflow(commented) == [], "гейт спорит с собственным комментарием"
good_caddy = (
"(tradein_frontend_retry) {\n lb_try_duration 2s\n}\n"
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
" import tradein_frontend_retry\n header_up -X-Y\n }\n}\n"
)
assert check_caddy(good_caddy) == [], check_caddy(good_caddy)
bad_caddy = (
"(tradein_frontend_retry) {\n lb_try_duration 2s\n}\n"
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
" header_up -X-Y\n }\n}\n"
)
assert any("без `import" in e for e in check_caddy(bad_caddy)), "не поймал блок без импорта"
no_snippet = (
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
" import tradein_frontend_retry\n }\n}\n"
)
assert any("не объявлен" in e for e in check_caddy(no_snippet)), "не поймал пропажу снипета"
print("SELFTEST OK")
def main() -> int:
if "--selftest" in sys.argv:
selftest()
return 0
errors = check_workflow(WORKFLOW.read_text(encoding="utf-8"))
errors += check_caddy(APPS_CADDY.read_text(encoding="utf-8"))
if errors:
print("Гейт #3274 (окно подмены фронта) НЕ ПРОЙДЕН:\n")
for e in errors:
print(f"{e}")
print(
"\nЧем проверять эффект на живом деплое: scripts/probe-deploy-window.sh "
"(запускать НА хосте прода, см. шапку скрипта)."
)
return 1
print("Гейт #3274: фронт подменяется отдельной командой, все 9 апстримов с ретраем — OK")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,99 +0,0 @@
#!/bin/sh
# Проба окна недоступности при деплое (#3274).
#
# ЗАЧЕМ. Статус джобы деплоя зелёный независимо от того, видел ли посетитель
# ошибку: подмена контейнера происходит ВНУТРИ успешного прогона. Единственный
# честный замер — непрерывный опрос публичного адреса во время деплоя и подсчёт
# САМОЙ ДЛИННОЙ СЕРИИ подряд идущих не-200. Приёмка #3274 сформулирована именно
# так (комментарий от 05.09): «серия не-2xx/000 на / в окне мержа < 2 с».
#
# ГДЕ ЗАПУСКАТЬ — НА САМОМ ХОСТЕ (ssh poincare), не с ноутбука. Боевой периметр
# отбивает частые серии с одного внешнего адреса: замер 11.09 дал 3050 %
# `000` с внешнего IP при интервале 0 и ноль при 2 с, а с самого хоста — 0/10
# без пауз. Иначе защита периметра читается как простой сервиса.
#
# ПОЧЕМУ 000 СЧИТАЕТСЯ ОТДЕЛЬНО. curl отдаёт `000`, когда HTTP-ответа не было
# вообще (TCP/TLS не встал, таймаут). Это может быть и простой (Caddy
# пересоздан), и отбой периметра — сваливать его в одну кучу с 502/503 нельзя,
# иначе своя же защита попадёт в числа простоя.
#
# Запуск:
# scripts/probe-deploy-window.sh [URL] [ИНТЕРВАЛ_С] [ДЛИТЕЛЬНОСТЬ_С] [ФАЙЛ]
# scripts/probe-deploy-window.sh --summary <ФАЙЛ> # пересчитать сводку
# scripts/probe-deploy-window.sh --selftest # проверка счётчика серий
#
# Пример (окно деплоя, 10 минут с шагом 200 мс):
# ssh poincare 'nohup /opt/gendesign/scripts/probe-deploy-window.sh \
# https://meraocenka.ru/ 0.2 600 /tmp/probe-mera.tsv >/dev/null 2>&1 &'
set -eu
# Сводка по TSV (epoch<TAB>ISO-время<TAB>код<TAB>секунды): сколько чего, и главное — самая
# длинная серия подряд идущих не-200 в СЕКУНДАХ (по меткам времени самих
# образцов, а не «число образцов × интервал»: curl с таймаутом растягивает шаг,
# и умножение занизило бы реальную длину окна).
summarize() {
awk -F'\t' '
# Серия закрывается первым 200. Длина в секундах считается ТОЛЬКО в END:
# средний шаг известен лишь после прохода, а внутри цикла он ещё 0 — из-за
# этого первая версия занижала окно ровно на один шаг (поймал --selftest).
{ total++; code[$3]++
if ($3 == "200") {
if (streak > max_n) { max_n = streak; max_first = first_bad_ts; max_last = last_bad_ts; max_at = first_bad_at }
streak = 0
} else {
if (!streak) { first_bad_ts = $1; first_bad_at = $2 }
streak++; last_bad_ts = $1
}
if (prev_ts && $1 - prev_ts < 60) { gaps += $1 - prev_ts; gapn++ }
prev_ts = $1
}
END {
step = (gapn ? gaps / gapn : 0)
if (streak > max_n) { max_n = streak; max_first = first_bad_ts; max_last = last_bad_ts; max_at = first_bad_at }
printf "образцов: %d, шаг ~%.2f с\n", total, step
for (c in code) printf " %s: %d\n", c, code[c]
printf "максимальная серия не-200: %d образцов ≈ %.1f с (начало %s)\n", \
max_n, (max_n ? max_last - max_first + step : 0), (max_at ? max_at : "-")
}
' "$1"
}
if [ "${1:-}" = "--summary" ]; then
summarize "$2"
exit 0
fi
if [ "${1:-}" = "--selftest" ]; then
tmp=$(mktemp)
# 10 образцов с шагом 1 с: три подряд не-200 (2-я…4-я секунды) → серия ≈ 3 с.
printf '1000\tT0\t200\t0.01\n1001\tT1\t502\t0.01\n1002\tT2\t000\t5.00\n1003\tT3\t503\t0.01\n1004\tT4\t200\t0.01\n1005\tT5\t200\t0.01\n1006\tT6\t502\t0.01\n1007\tT7\t200\t0.01\n' >"$tmp"
out=$(summarize "$tmp")
rm -f "$tmp"
echo "$out"
echo "$out" | grep -q "максимальная серия не-200: 3 образцов ≈ 3.0 с" || {
echo "SELFTEST FAILED: серия посчитана неверно" >&2
exit 1
}
echo "$out" | grep -q " 000: 1" || { echo "SELFTEST FAILED: 000 не выделен отдельно" >&2; exit 1; }
echo "SELFTEST OK"
exit 0
fi
URL=${1:-https://meraocenka.ru/}
INTERVAL=${2:-1}
DURATION=${3:-900}
OUT=${4:-/tmp/probe-deploy-window.tsv}
: >"$OUT"
echo "проба: $URL каждые ${INTERVAL}с в течение ${DURATION}с$OUT"
end=$(( $(date +%s) + DURATION ))
while [ "$(date +%s)" -lt "$end" ]; do
# --max-time 5: зависший запрос не должен растягивать шаг пробы на минуты.
# -o /dev/null: тело не нужно, важны код и время.
line=$(curl -sS -o /dev/null --max-time 5 -w '%{http_code}\t%{time_total}' "$URL" 2>/dev/null || echo "000 0")
printf '%s\t%s\t%s\n' "$(date +%s.%N)" "$(date -u +%H:%M:%SZ)" "$line" >>"$OUT"
sleep "$INTERVAL"
done
echo "── сводка ─────────────────────────────────────────"
summarize "$OUT"

View file

@ -38,137 +38,12 @@ BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}"
BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}"
fail=0
noresp=0
# --- ТЕМП ЗАПРОСОВ И ПОВТОРЫ (11.09.2026) --------------------------------
#
# ЧТО СЛУЧИЛОСЬ. Прогон на голове main (a659b187) покраснел на двух последних
# проверках списка с кодом `000`. `000` у curl — это НЕ «пришёл неверный код»,
# а «ответа не было вовсе» (таймаут/обрыв). Периметр при этом был цел: те же
# пути, запрошенные вручную, отдавали 503 и 405 от приложения.
#
# ЗАМЕР (внешний IP, https://gendsgn.ru/trade-in/api/v1/me):
# серия без пауз — 5 обрывов из 12
# серия с паузой 0.5 c — 6 обрывов из 12
# серия с паузой 2 c — 0 обрывов из 8
# с самого хоста прода — 0 обрывов из 10 (все 401)
# То есть приложение отвечает, а частую серию запросов с одного внешнего
# адреса отбивает ВХОД (на хосте активен fail2ban). Смоук шлёт ~43 запроса
# подряд без пауз и попадает ровно под этот эффект — и падают именно
# ПОСЛЕДНИЕ проверки, потому что к концу серии счётчик уже набран.
#
# ОТСЮДА ДВА МЕХАНИЗМА, И НИ ОДИН НЕ ТРОГАЕТ САМИ ОЖИДАНИЯ:
# 1. Повтор ТОЛЬКО там, где ответа не было вовсе. Ответ с «не тем» кодом —
# это результат проверки, он не повторяется никогда: иначе ретрай
# маскировал бы настоящий регресс периметра, ради которого всё написано.
# 2. Пауза между проверками, чтобы серия не выглядела флудом.
#
# ПОЧЕМУ ПАУЗА 2 c, А НЕ МЕНЬШЕ. Это наименьшая величина, у которой есть
# замер: 0.5 c измеренно НЕ помогает (6 обрывов из 12 — не лучше, чем без
# пауз), 2 c даёт ноль обрывов, промежуточные значения никто не мерил, и
# взять их означало бы выдумать число. Цена ИЗМЕРЕНА, а не оценена: прогон
# целиком занимал 89 c и стал занимать 160 c (оба замера 11.09 с локальной
# машины, 43 проверки, все зелёные). Под это в воркфлоу периметра поднят
# timeout-minutes: неотвечающая проверка сетевого класса стоит 3×15 c таймаута
# + 2 c и 4 c пауз ретрая + 2 c паузы между проверками = 53 c, а худший случай
# (прод не отвечает вовсе — мертвы все 43) = 43 × 53 ≈ 2279 c ≈ 38 мин.
# Ни 5, ни 10 минут на него не хватало: job убивали ДО печати FAIL-строк и
# итога, то есть ровно там, где лог нужнее всего.
# Обе величины переопределяются из окружения — для отладки локально
# (`SMOKE_PAUSE=0 bash scripts/smoke-mera-perimeter.sh` даёт прежний темп).
SMOKE_PAUSE="${SMOKE_PAUSE:-2}"
SMOKE_ATTEMPTS="${SMOKE_ATTEMPTS:-3}"
# curl_try: запрос с повтором, если ответа не пришло ВООБЩЕ, и с паузой после.
#
# Признак «ответа не было» берём у самого curl — код возврата из СЕТЕВОГО
# класса (перечислен в константе NETWORK_RC ниже). Он строго эквивалентен
# `%{http_code}` = 000, но доступен ВСЕМ проверкам, включая те, которые
# http_code вообще не запрашивают: до этой правки обрыв в
# check_redirect_location читался как «Location не тот», а обрыв при загрузке
# лэндинга — как «сам лэндинг сломан». Один сторож в общей обёртке чинит все
# места сразу, а не только те две проверки, что покраснели.
#
# СЕТЕВОЙ КЛАСС — И ТОЛЬКО ОН. Коды живут в константе, а не в тексте
# комментария, чтобы описание и поведение не разъехались:
# 6 — DNS не разрешился
# 7 — соединение отвергнуто (вход лежит; REJECT у fail2ban выглядит так же)
# 28 — таймаут
# 35 — обрыв на TLS-хендшейке
# 52 — сервер закрыл соединение, не ответив
# 56 — обрыв при приёме ответа
# Общее у них ровно одно: ответа не получено, и повтор имеет шанс помочь —
# тот самый эффект входа, ради которого повтор и заведён.
#
# ОСТАЛЬНЫЕ КОДЫ НЕ ПОВТОРЯЕМ И НЕ ЗОВЁМ «ответа нет». Протухший, чужой или
# самоподписанный сертификат даёт rc=60 (замер 12.09: expired.badssl.com,
# self-signed.badssl.com, wrong.host.badssl.com — все три). Это ИЗМЕРЕННЫЙ
# отказ периметра, а не потерянный запрос: см. проверку 5b в шапке — без
# site-блока Caddy не выпускает сертификат, и клиент видит обрыв TLS вместо
# редиректа, ради этого проверка и написана. Отказ детерминирован: три попытки
# дадут тот же rc, потратив 6 c пауз, а результат уехал бы в колонку «не
# измеряли» — то есть регресс спрятался бы ровно там, где его надо показать.
NETWORK_RC=" 6 7 28 35 52 56 "
# Ответ, пришедший с «неправильным» кодом, для curl — успех (rc=0), повтора не
# будет; проверка отработает ровно так же, как до правки.
curl_try() {
local attempt=1 rc out
while :; do
out=$(curl "$@" 2>/dev/null)
rc=$?
{ [ "$rc" -eq 0 ] || [ "$attempt" -ge "$SMOKE_ATTEMPTS" ]; } && break
# Код вне сетевого класса — повторять нечего, отдаём rc наверх (см. NETWORK_RC).
case "$NETWORK_RC" in *" $rc "*) ;; *) break ;; esac
# Пауза растёт: 2 c, затем 4 c — ниже 2 c смысла нет (см. замер выше).
echo " RETRY: ответа нет (curl rc=$rc), попытка $((attempt + 1)) из $SMOKE_ATTEMPTS через $((attempt * 2)) c: $*" >&2
sleep "$((attempt * 2))"
attempt=$((attempt + 1))
done
[ "$SMOKE_PAUSE" = "0" ] || sleep "$SMOKE_PAUSE"
printf '%s' "$out"
return "$rc"
}
# curl_failed: красная строка, когда ответа не удалось получить у самого curl.
#
# За одним «rc != 0» прячутся ДВА разных диагноза, и путать их нельзя:
# - сетевой класс → «ответа нет»: цел ли периметр, мы не знаем, проверка НЕ
# измерилась (плюс счётчик noresp и отдельная строка в итоге);
# - всё остальное, прежде всего cert-класс (rc=60) → обычный FAIL: отказ
# ИЗМЕРЕН, это регресс периметра, искать надо конфиг, а не флап входа.
#
# rc печатается в ОБЕИХ ветках: строка RETRY при SMOKE_ATTEMPTS=1 не выводится
# вовсе, и без rc читатель красного лога не отличит «домена нет» (6) от
# «сертификат протух» (60) — а это диагнозы из разных отделов.
curl_failed() {
local desc="$1" url="$2" rc="$3" reason
fail=1
case "$NETWORK_RC" in
*" $rc "*)
echo "FAIL (ответа нет): $desc ($url — вход не отдал ответ после $SMOKE_ATTEMPTS попыток, curl rc=$rc;" \
"это НЕ измеренный код ответа, периметр этой проверкой НЕ проверен — повторите URL вручную)"
noresp=$((noresp + 1))
return
;;
esac
case "$rc" in
60|51|83) reason="TLS-сертификат отвергнут" ;;
58|77) reason="проблема с клиентским сертификатом/CA" ;;
*) reason="curl не выполнил запрос" ;;
esac
echo "FAIL: $desc ($url -> $reason (curl rc=$rc); ответ измерен как отказ, это не потерянный" \
"запрос — повтора не было, код вне сетевого класса «ответа нет»)"
}
check() {
local desc="$1" url="$2" expected="$3"
local code rc
code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
elif [ "$code" = "$expected" ]; then
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null)
if [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
else
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
@ -176,47 +51,13 @@ check() {
fi
}
# check_any: PASS, если код ответа — ЛЮБОЙ из перечисленных.
#
# Нужен там, где проверяемое свойство — «этого адреса наружу нет», а каким
# именно отказом это выражено, зависит от того, какой рубеж ответил первым.
# На gendsgn.ru таких рубежа два: пилотный basic_auth (401) стоит выше
# allowlist'а site-блока (404), и какой сработает — вопрос порядка директив, а
# не безопасности. Фиксировать один конкретный код значило бы ронять смоук при
# снятии пилотного гейта, то есть при изменении, которое к предмету проверки
# отношения не имеет.
check_any() {
local desc="$1" url="$2"
shift 2
local code rc expected="$*"
code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
for want in "$@"; do
if [ "$code" = "$want" ]; then
echo "PASS: $desc ($url -> $code)"
return
fi
done
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected one of: $expected)"
fail=1
}
check_post() {
local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}"
local out code head_and_body rc
local out code head_and_body
# -i: заголовки попадают в вывод вместе с телом — по ним отличаем ответ
# приложения от заглушки Caddy (см. $reject у вызова payments/notify).
out=$(curl_try -s -i -w '\n%{http_code}' --max-time 15 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
out=$(curl -s -i -w '\n%{http_code}' --max-time 15 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
code=${out##*$'\n'}
head_and_body=${out%$'\n'*}
if [ "$code" != "$expected" ]; then
@ -230,72 +71,6 @@ check_post() {
fi
}
# check_redirect_location: сверяет буквальный заголовок Location редиректа,
# а не только код ответа — нужен там, где регресс не меняет код (301
# остаётся 301), а меняет ТОЛЬКО цель (баг 10.09.2026 у @meraShortSlash в
# apps.caddy: query терялась молча, код ответа был зелёным всю дорогу).
# curl без -L (редирект не проходим), -D - дампит заголовки в stdout.
#
# ФОРМА LOCATION НЕ ФИКСИРУЕТСЯ. Caddy отдаёт цель редиректа так, как её
# собрала директива: для `redir * {uri}` это относительный путь
# (`/articles?utm_source=vc`), а для веток с явным хостом — абсолютный URL
# (`https://meraocenka.ru/articles?utm_source=vc`). Обе формы валидны по
# RFC 7231, браузер разрешает относительную сам, и переписывание одной ветки
# конфига на другую — не регресс, ради которого стоит ронять смоук. Поэтому
# сверяется ХВОСТ: путь с query, с какого бы префикса Location ни начинался.
# Ровно это и есть предмет проверки — что query дожила до цели.
check_redirect_location() {
local desc="$1" url="$2" expected_suffix="$3"
local location headers rc
# Заголовки сначала забираем целиком, и только потом разбираем: при обрыве
# соединения grep по пустому выводу дал бы «Location: <none>» — красную
# строку про подмену цели редиректа там, где ответа не было вовсе.
headers=$(curl_try -s -o /dev/null -D - --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
location=$(printf '%s' "$headers" \
| grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//')
case "$location" in
"$expected_suffix"|*"$expected_suffix")
echo "PASS: $desc ($url -> Location: $location)"
;;
*)
echo "FAIL: $desc ($url -> got Location '${location:-<none>}', expected ending with '$expected_suffix')"
fail=1
;;
esac
}
# check_content_type: 200 И заявленный Content-Type начинается с ожидаемого.
#
# Заведён под og:image. Для картинки превью «200» само по себе ничего не
# доказывает: перепутанный rewrite приводит к 200 с HTML-страницей фронта, и
# обходчик мессенджера её молча отбросит — ссылка развернётся без картинки, а
# смоук останется зелёным. Проверять надо ровно то, ради чего адрес открыт.
check_content_type() {
local desc="$1" url="$2" expected_prefix="$3"
local out code ctype rc
out=$(curl_try -s -o /dev/null -D - -w '%{http_code}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
code=${out##*$'
'}
ctype=$(printf '%s' "$out" | grep -i '^content-type:' | tr -d '
' | sed 's/^[Cc]ontent-[Tt]ype: *//' | head -1)
if [ "$code" = "200" ] && [ "${ctype#"$expected_prefix"}" != "$ctype" ]; then
echo "PASS: $desc ($url -> $code, $ctype)"
else
echo "FAIL: $desc ($url -> got '${code:-<no response>}' / '${ctype:-<no content-type>}', expected 200 + $expected_prefix)"
fail=1
fi
}
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный домен отдаёт 200 анонимно.
@ -327,22 +102,6 @@ check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
# человека на форму входа; отвалится handle — вернётся 404 вместо неё.
check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
# 1d3. robots.txt / sitemap.xml — первое, что запрашивает поисковый краулер
# при индексации. robots.txt отдаётся ОТДЕЛЬНЫМ handle (Next-конвенция:
# файл в корне app/, а не под mera-public/), sitemap.xml — той же строкой
# что и остальные @meraPages (см. комментарии в apps.caddy). Отвалится
# любой из handle — сайт не проиндексируется вовсе.
check "meraocenka.ru/robots.txt — public 200" "$BASE_MERA/robots.txt" 200
check "meraocenka.ru/sitemap.xml — public 200" "$BASE_MERA/sitemap.xml" 200
# 1d4. og:image — картинка превью ссылок. Её просят обходчики мессенджеров и
# соцсетей, анонимно и без Referer; отвалится handle — ссылки на сайт
# начнут разворачиваться без картинки, и заметить это по логам
# приложения нельзя (запрос туда просто не доходит). Content-Type
# проверяется вместе с кодом: 200 с HTML вместо PNG выглядел бы так же.
check_content_type "meraocenka.ru/og-mera.png — public 200 + image/png" "$BASE_MERA/og-mera.png" "image/png"
check_content_type "meraocenka.ru/logo-mera.png — public 200 + image/png" "$BASE_MERA/logo-mera.png" "image/png"
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
@ -351,16 +110,6 @@ check_content_type "meraocenka.ru/logo-mera.png — public 200 + image/png" "$BA
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
# 1f. Регресс-тест на баг 10.09.2026 (замер на живом проде): редирект со
# слэшем терял query-строку — статьи публикуются с UTM-метками, а
# мессенджеры и автолинкификаторы дописывают слэш к скопированной ссылке,
# то есть именно этот трафик терял атрибуцию молча (код ответа
# оставался 301, поэтому предыдущая версия смоука проблему не ловила).
# Проверяем сам факт 301 (уже покрыт проверками check выше по коду) И
# буквальный Location — только вторая половина ловит регресс.
check_redirect_location "meraocenka.ru/articles/?utm_source=vc — query сохраняется на редиректе" \
"$BASE_MERA/articles/?utm_source=vc" "/articles?utm_source=vc"
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
@ -389,13 +138,8 @@ check "meraocenka.ru/_next/image — must 404 (не открываем опти
# (замер на проде 02.09.2026: 9 байт).
check_caddy_404() {
local desc="$1" url="$2"
local out code size rc
out=$(curl_try -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
local out code size
out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null)
code=${out%% *}
size=${out##* }
if [ "$code" = "404" ] && [ "$size" = "0" ]; then
@ -416,23 +160,15 @@ check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \
# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться
# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы
# успешной, а публичный сайт молча остался бы без JS.
layout_html=$(curl_try -s --max-time 15 "$BASE_MERA/")
layout_rc=$?
if [ "$layout_rc" -ne 0 ]; then
# Обрыв на загрузке лэндинга раньше попадал в ветку «не нашёл чанк» и читался
# как «сам лэндинг сломан» — диагноз, которого никто не измерял.
curl_failed "HTML лэндинга (ищем в нём layout-чанк)" "$BASE_MERA/" "$layout_rc"
layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \
| grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
if [ -z "$layout_chunk" ]; then
# Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
# чём — поэтому это FAIL, а не «пропустим».
echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
fail=1
else
layout_chunk=$(printf '%s' "$layout_html" \
| grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
if [ -z "$layout_chunk" ]; then
# Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
# чём — поэтому это FAIL, а не «пропустим».
echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
fail=1
else
check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
fi
check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
fi
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
@ -490,26 +226,6 @@ check "trade-in /api/v1/admin/proxies — 404 anonymous (существующа
check "trade-in /api/v1/admin/users — 404 anonymous (несуществующая — тот же ответ)" \
"$BASE_MAIN/trade-in/api/v1/admin/users" 404
# 3b. sitemap.xml публичного лэндинга МЕРЫ не должен просачиваться через
# закрытый B2B-контур gendsgn.ru — корень gendsgn.ru принадлежит Site
# Finder, у него нет своего route на /sitemap.xml под МЕРУ, allowlist-by-
# default этого site-блока (как и у meraocenka.ru) отдаёт 404 на всё
# незаявленное.
#
# ДВА ДОПУСТИМЫХ КОДА, А НЕ ОДИН. Первая редакция этой проверки ждала 404 и
# упала на первом же прогоне против прода: корень gendsgn.ru закрыт
# пилотным basic_auth, и гейт отвечает 401 РАНЬШЕ, чем запрос доходит до
# allowlist'а. Оба ответа означают ровно проверяемое — карты сайта МЕРЫ на
# B2B-домене нет; какой именно рубеж сработал первым, к предмету проверки
# отношения не имеет, а вот снятие пилотного гейта (оно запланировано)
# переключит 401 на 404 и уронило бы жёсткое ожидание на пустом месте.
# Красная строка здесь — только 200: он означал бы, что sitemap реально
# отдаётся из закрытого контура.
#
# Если у Site Finder когда-нибудь появится СВОЙ /sitemap.xml, ожидание
# придётся пересмотреть на «200 с другим содержимым».
check_any "gendsgn.ru/sitemap.xml — must NOT serve MERA sitemap (закрытый контур)" "$BASE_MAIN/sitemap.xml" 401 404
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
# на уровне Caddy до бэкенда).
@ -573,14 +289,6 @@ check "trade-in payments/checkout — 401 anonymous (не публичный п
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
echo "========================================"
if [ "$noresp" -gt 0 ]; then
# Отдельная строка в итоге, а не только у самой проверки: читатель красного
# лога должен сразу видеть, что часть проверок НЕ ИЗМЕРИЛАСЬ, и не искать
# регресс периметра там, где ответа просто не было.
echo "БЕЗ ОТВЕТА: $noresp проверок не получили ответа даже после $SMOKE_ATTEMPTS попыток."
echo " Это не измеренный код ответа. Повторите эти URL вручную (и учтите, что"
echo " вход отбивает частые серии запросов с одного адреса — см. шапку скрипта)."
fi
if [ "$fail" -eq 0 ]; then
echo "ALL CHECKS PASSED"
else

View file

@ -79,7 +79,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.geocode import SuggestResponse, effective_region_code, suggest_addresses
from app.api.v1.geocode import SuggestResponse, suggest_addresses
from app.api.v1.trade_in import coverage_probe, estimate
from app.core.config import settings
from app.core.db import get_db
@ -222,9 +222,6 @@ class PublicSuggestInput(BaseModel):
q: str = Field(min_length=2, max_length=200)
limit: int = Field(default=8, ge=1, le=10)
city_hint: str | None = Field(default=None, max_length=100)
# #3051: явный регион с фронта (если он его когда-нибудь пришлёт) —
# приоритетнее вывода из city_hint, см. effective_region_code.
region_code: int | None = Field(default=None)
def _fold(text: str) -> str:
@ -329,7 +326,6 @@ async def public_suggest(
limit=payload.limit,
db=db,
city_hint=payload.city_hint,
region_code=effective_region_code(payload.region_code, payload.city_hint),
)
finally:
_suggest_slots.release()
@ -483,15 +479,9 @@ class ShowcaseStats(BaseModel):
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
посетитель не может отличить выборку из работы оценщика от её лучшего
хвоста. `eligible` сколько строк прогон СОБРАЛ (данных хватило),
`written` сколько из них показано; `rejection_rule` по какому правилу
отобраны показанные, записанное ТЕМ прогоном, который их посчитал.
`eligible` минус `written` НЕ «столько не поместилось»: с 2026-09-12
витрина показывает полосу расхождения 5 %..+20 %, и в разницу входят
строки, отсеянные полосой. Что это именно отбор, а не вся сверка, говорит
`rejection_rule` поэтому счётчики и правило показываются вместе, одной
подписью, а не порознь.
хвоста. `eligible` минус `written` сколько годных строк не поместилось
в витрину; `rejection_rule` по какому правилу отсеяно остальное,
записанное ТЕМ прогоном, который эти строки посчитал.
"""
considered: int
@ -560,7 +550,7 @@ def public_showcase(
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
Вместе со строками едет `stats` сколько сделок рассмотрено, сколько
строк прогон собрал и по какому правилу из них отобраны показанные. Числа
годных строк не поместилось и по какому правилу отсеяно остальное. Числа
считает пересчёт; без них витрина не имеет права подписаться честно.
"""
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()

View file

@ -40,6 +40,7 @@ from pydantic import BaseModel, Field, field_validator
# убрал legacy app.services.scheduler.scheduler_loop fallback) — тот же orchestrator,
# что и debug-роуты этого файла.
from scraper_kit.base import save_listings
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration.pipeline import (
DEFAULT_REGION_CODE,
run_avito_city_sweep,
@ -48,7 +49,6 @@ from scraper_kit.orchestration.pipeline import (
run_yandex_city_sweep,
run_yandex_full_load,
)
from scraper_kit.providers._base import build_browser_fetcher
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.avito.houses import fetch_house_catalog, save_house_catalog_enrichment
from scraper_kit.providers.avito.imv import (
@ -268,7 +268,6 @@ async def geocode_missing(
db: Annotated[Session, Depends(get_db)],
limit: int = 100,
target: Literal["listings", "deals"] = "listings",
region_code: int = 66,
) -> dict:
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
@ -280,10 +279,6 @@ async def geocode_missing(
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
адреса не выбираются повторно 7 дней cron-loop завершается, не зацикливается.
geom обновляется автоматически триггером.
region_code (дефолт 66, #3051) — фильтрует обе таблицы (`listings`/`deals`
несут колонку) и прокидывается в `known_city_hint`/`geocode`. Дефолт 66
прежнее поведение без изменений (cron не меняется, follow-up ниже).
"""
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
@ -296,14 +291,13 @@ async def geocode_missing(
WHERE lat IS NULL
AND COALESCE(address, '') != ''
{extra_filter}
AND region_code = CAST(:region_code AS int)
AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - interval '7 days')
ORDER BY geocode_tried_at NULLS FIRST
LIMIT :limit
"""
),
{"limit": limit, "region_code": region_code},
{"limit": limit},
)
.mappings()
.all()
@ -338,8 +332,8 @@ async def geocode_missing(
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
city = known_city_hint(row.get("city"), region_code)
result = await geocode(clean, db, city_hint=city, region_code=region_code)
city = known_city_hint(row.get("city"))
result = await geocode(clean, db, city_hint=city)
if result is None:
# Помечаем что пробовали — иначе ретрай на каждом cron.
db.execute(
@ -368,12 +362,10 @@ async def geocode_missing(
WHERE lat IS NULL
AND COALESCE(address, '') != ''
{extra_filter}
AND region_code = CAST(:region_code AS int)
AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - interval '7 days')
"""
),
{"region_code": region_code},
)
).scalar()
return {
@ -516,16 +508,9 @@ async def cian_auto_login(
)
try:
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) —
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError →
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара.
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
async with BrowserFetcher(
source="cian", endpoint=settings.browser_http_endpoint
) as fetcher:
raw_cookies = await fetcher.login(
url=settings.cian_login_url,
email=email,
@ -714,12 +699,7 @@ async def debug_domclick_detail_fetch(
# построен и verified live именно 2026-07-04 (815КБ __SSR_STATE__ через свежий IP).
# Заменяет прежний source="cian" (docstring domclick/detail.py — устаревшая
# рекомендация от 2026-06-27, до появления выделенного пула).
# #3197 (хвост): фабрика вместо прямой конструкции — она и есть то место, где
# proxy_provider/use_pool/environment попадают в тело POST /fetch. Без них узел
# выбирал сайдкар из своего env, а не пул с provider_affinity='domclick'.
async with build_browser_fetcher(
RealScraperConfig(), "domclick", proxy_provider=_kit_proxy_provider()
) as bf:
async with BrowserFetcher(source="domclick", endpoint=settings.browser_http_endpoint) as bf:
try:
enrichment = await domclick_fetch_detail(
body.card_url, browser_fetcher=bf, cookies=cookies

View file

@ -73,7 +73,6 @@ from app.core.password import (
verify_slots_saturated,
)
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.observability.metrics import LOGINS
from app.services.auth_session import create_session, get_user_by_username, revoke_session
from app.services.identity_store import AccessState, get_identity_db
from app.services.user_events import schedule_event
@ -325,7 +324,6 @@ async def _reject_invalid_credentials(
fails = _USERNAME_FAIL_LIMITER.record(username)
delay_s = _throttle_delay_s(fails)
LOGINS.labels(result="failed").inc()
schedule_event(
event_type="login_failed",
username=username,
@ -454,7 +452,6 @@ async def login(
path="/",
)
LOGINS.labels(result="success").inc()
schedule_event(
event_type="login_success",
username=user["username"],

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
@ -10,34 +11,15 @@ from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db, run_db_thread
from app.observability.metrics import ADDRESS_SUGGESTIONS
from app.core.db import get_db
from app.services.estimator import _lookup_house_facts
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
from app.services.regions import DEFAULT_REGION_CODE, region_by_city
logger = logging.getLogger(__name__)
router = APIRouter()
def effective_region_code(region_code: int | None, city_hint: str | None) -> int:
"""Регион для геокодера (#3051): явный `region_code` > вывод из `city_hint` > 66.
До этого хелпера `/suggest` всегда уходил в геокодер с регионом 66
по умолчанию московский `city_hint` («Москва») молча получал
свердловский bbox-констрейнт и терял подсказки. Реестр `app.services.regions`
уже знает, каким городам какой регион соответствует (REGIONS[77].cities
содержит «москва») используем его вместо повторного захардкоженного списка.
"""
if region_code is not None:
return region_code
region = region_by_city(city_hint)
if region is not None:
return region.code
return DEFAULT_REGION_CODE
@router.get("/lookup", response_model=GeocodeResult)
async def lookup(
address: Annotated[str, Query(min_length=3, max_length=500)],
@ -99,21 +81,9 @@ async def suggest_addresses(
),
),
] = None,
region_code: Annotated[
int | None,
Query(
description=(
"Регион покрытия (#3051). None (дефолт) — выводится из `city_hint` "
"через реестр регионов, иначе 66 (Свердловская область, прежнее "
"поведение). 77 — Москва: без него DaData и Nominatim получают "
"свердловский hard-констрейнт и молча возвращают ПУСТО на "
"московском адресе."
),
),
] = None,
) -> SuggestResponse:
"""Автокомплит адресов в регионе `region_code` (дефолт 66 — Свердловская область;
ЕКБ основной трафик, остаётся быстрым fast-path).
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path).
Используется в EstimateForm для подсказок пока пользователь печатает.
Bounded viewbox генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
@ -123,17 +93,8 @@ async def suggest_addresses(
/api/v1/geocode/suggest?q=Малышева
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
/api/v1/geocode/suggest?q=Тверская+6&region_code=77 # Москва
"""
resolved_region_code = effective_region_code(region_code, city_hint)
try:
items = await suggest(
q, db=db, limit=limit, city_hint=city_hint, region_code=resolved_region_code
)
except ValueError as exc:
# Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента.
raise HTTPException(status_code=422, detail=str(exc)) from exc
ADDRESS_SUGGESTIONS.labels(found="yes" if items else "no").inc()
items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
return SuggestResponse(
items=[
SuggestItem(
@ -277,9 +238,9 @@ async def house_facts(
"""
target_house_id: int | None = None
if fias_id is not None:
target_house_id = await run_db_thread(_resolve_house_id_by_fias, db, fias_id)
target_house_id = await asyncio.to_thread(_resolve_house_id_by_fias, db, fias_id)
facts = await run_db_thread(
facts = await asyncio.to_thread(
_lookup_house_facts,
db,
target_house_id=target_house_id,

View file

@ -50,14 +50,10 @@ from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, ValidationError
from starlette.background import BackgroundTask
from app.core.config import settings
from app.services.tgbot.client import TelegramError
from app.services.tgbot.shared import get_telegram_client
from app.tasks.glitchtip_alert_retry import retry_forward_alert
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
@ -187,28 +183,16 @@ def _verify_secret(provided: str) -> None:
raise HTTPException(status_code=401, detail="invalid or missing secret")
@router.post("/ops/glitchtip-webhook", response_model=None)
@router.post("/ops/glitchtip-webhook")
async def glitchtip_webhook(
request: Request,
secret: Annotated[str, Query()] = "",
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
) -> dict[str, str] | JSONResponse:
) -> dict[str, str]:
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
Отказ синхронной попытки (#3471) отвечает 502 как и раньше (#3456 — честный
сигнал отправителю), но ставит доставку в фон
(``app.tasks.glitchtip_alert_retry.retry_forward_alert`` через
``starlette.background.BackgroundTask`` на самом ответе) GlitchTip вебхуки
не ретраит (#3157), без этого текст алерта терялся бы безвозвратно.
``BackgroundTask`` привязан НАПРЯМУЮ к возвращаемому ``JSONResponse``, а не
к ``BackgroundTasks``-зависимости: FastAPI прикрепляет задачи из
``BackgroundTasks`` только к ответу, который вернул сам хендлер, а `raise
HTTPException` строит ОТДЕЛЬНЫЙ ответ в exception-мидлваре задачи,
поставленные до `raise`, в реальности молча терялись бы вместе с ним (это
воспроизведено тестом, не гипотеза).
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) этот
хендлер сам делает единственную проверку секрета.
@ -229,10 +213,7 @@ async def glitchtip_webhook(
received_at = datetime.now(UTC)
text = _build_message(raw_body, received_at)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
client = TelegramClient(settings.telegram_bot_token)
try:
await client.send_message(
chat_id=settings.telegram_alerts_chat_id,
@ -243,30 +224,8 @@ async def glitchtip_webhook(
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramError:
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
# 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
# (#3471, см. app.tasks.glitchtip_alert_retry).
#
# `raise HTTPException` здесь НЕ подходит: FastAPI прикрепляет
# background-задачи только к ответу, который вернул сам хендлер, а
# исключение строит СВОЙ отдельный JSONResponse в exception-мидлваре —
# задача, поставленная до `raise`, никогда бы не выполнилась. Поэтому
# 502 собран и возвращён вручную, с задачей на этом же объекте ответа.
except TelegramApiError:
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
return JSONResponse(
status_code=502,
content={"detail": "failed to forward alert to telegram"},
background=BackgroundTask(
retry_forward_alert,
client,
chat_id=settings.telegram_alerts_chat_id,
text=text,
message_thread_id=settings.telegram_alerts_topic_id or None,
),
)
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
return {"status": "ok"}

View file

@ -33,7 +33,6 @@ from sqlalchemy.orm import Session
from app.api.v1.trade_in import _assert_estimate_access
from app.core.config import settings
from app.core.db import get_db
from app.observability.metrics import LEADS
logger = logging.getLogger(__name__)
@ -54,19 +53,12 @@ _PHONE_MAX_DIGITS = 15
# только в audit-лог (#2497 TODO, теперь закрыт).
#
# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/
# mera-public/content.ts: «приказом директора № 2 от 10 сентября 2026 г.» →
# "2026-09-10"), а не дата этого коммита — версия обязана указывать на редакцию
# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» →
# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию
# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует
# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет
# это соответствие автоматически, так что рассинхронизация здесь падает в CI.
#
# Редакция № 2 от 10.09.2026: в политику добавлен раздел 9 «Файлы cookie и
# веб-аналитика» (на публичный сайт поставлены Яндекс.Метрика и GA4), прежний
# раздел «Контакты» стал десятым. Согласия, собранные до этой даты, ссылаются
# на редакцию № 1 и остаются с версией "2026-08-13" — в этом и смысл хранить
# версию per-row: снимок согласия должен указывать на тот документ, который
# человек видел, а не на текущий.
_CONSENT_POLICY_VERSION = "2026-09-10"
_CONSENT_POLICY_VERSION = "2026-08-13"
# Снимок точного текста согласия, который видит пользователь при отправке лида
# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом
@ -174,7 +166,6 @@ async def create_trade_in_lead(
)
db.commit()
LEADS.inc()
logger.info(
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",

View file

@ -14,6 +14,7 @@ module docstring (честно про то, что не всегда разре
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
from uuid import UUID
@ -22,7 +23,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.db import get_db, run_db_thread
from app.core.db import get_db
logger = logging.getLogger(__name__)
@ -66,7 +67,7 @@ async def erase_person_data_endpoint(
from app.services.data_erasure import erase_person_data
counters = await run_db_thread(
counters = await asyncio.to_thread(
erase_person_data,
db,
username=payload.username,

View file

@ -62,23 +62,18 @@ import hashlib
import logging
import re
import secrets
import time
from datetime import UTC, datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.observability.metrics import SUPPORT_MESSAGES
from app.services.tgbot import web_support_storage as storage
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
from app.services.tgbot.client import TelegramError
from app.services.tgbot.shared import get_telegram_client
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
@ -127,58 +122,10 @@ _INTERACTIVE_SEND_TIMEOUT_S = 5.0
_INTERACTIVE_SEND_MAX_RETRIES = 3
_INTERACTIVE_SEND_MAX_BACKOFF_S = 1.0
# Счётчик ОТКАЗОВ отправки — отдельный от бюджетов выше (#tgsupport-fail-cooldown).
#
# Зачем вообще второй счётчик. `_send_limiter`/`_anon_ip_limiter` расходуются
# ТОЛЬКО на успехе (review L3, non-destructive peek выше) — это верно для
# «не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram
# недоступен, лимита нет ВООБЩЕ. Каждый повтор пользователя при этом стоит до
# `1 + _INTERACTIVE_SEND_MAX_RETRIES` = 4 попыток к api.telegram.org и не
# расходует ни один бюджет. Двух-трёх вкладок с авто-ретраем хватает, чтобы
# выесть лимиты группы ровно в тот момент, когда канал и так еле жив.
#
# Отсюда — дешёвый gate ПЕРЕД походом в Telegram, на своих ключах (тех же, что
# у основных лимитеров: username / anon-thread-key и client IP).
#
# N=5. Отказ одной отправки — не событие: по замеру #tgsupport-retry доля отказов
# на попытку 15-38%, но после 4 попыток до пользователя доходит ~0.8-2% отказов.
# Пять подряд на живом канале — вероятность порядка 1e-10, то есть cooldown
# физически не может сработать на «просто не повезло»; он срабатывает только на
# настоящей недоступности. Плюс `reset()` на успехе: считаем именно ПОДРЯД, одна
# успешная отправка стирает историю.
#
# 30с окна (оно же длительность cooldown: блок держится, пока самый старый из
# N отказов не выпадет из окна). Верхняя граница осмысленности — реальная
# недоступность Telegram по тому же замеру длится минутами, так что 30с заведомо
# короче и пользователя после восстановления канала не наказывают. Нижняя —
# один отказавший интерактивный запрос сам по себе занимает до 23с
# (4 попытки × 5с + 3 паузы × 1с), cooldown короче этого просто не имел бы смысла.
_SEND_FAILURE_LIMIT = 5
_SEND_FAILURE_WINDOW_S = 30.0
_send_failure_limiter = SlidingWindowLimiter(
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
)
# Отдельный экземпляр для IP-ключей — ровно как `_anon_ip_limiter` отдельный от
# `_send_limiter`: ключи разных пространств (IP vs thread-key) в одном словаре
# смешивать нельзя.
_anon_ip_failure_limiter = SlidingWindowLimiter(
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
)
_SEND_UNAVAILABLE_DETAIL = "Telegram сейчас недоступен. Попробуйте через полминуты."
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
_LIST_MESSAGES_LIMIT = 200
# Идемпотентность отправки (#3471 retry-storm) — см. `_resolve_idempotency_key`.
_IDEMPOTENCY_HEADER = "idempotency-key"
# Форма клиентского ключа — как у anon-токена (`_ANON_TOKEN_RE`): произвольная
# opaque-строка клиента, без пробелов/спецсимволов, которые попали бы в SQL-параметр
# как есть. Не матчится — считаем заголовок отсутствующим и уходим на fallback,
# а не пытаемся его "починить" (тот же принцип, что у `_read_anon_token`).
_CLIENT_IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{8,128}\Z")
def _require_username(request: Request) -> str:
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
@ -218,18 +165,6 @@ class SupportMessageOut(BaseModel):
text_body: str
operator_tg_id: int | None = None
created_at: str
# Дошло ли сообщение до БД. `True` по умолчанию — все существующие пути
# (`GET /support/messages`, успешный POST) строят модель из storage-строки и
# ничего про флаг не знают, контракт для них не меняется.
#
# `False` ставит ТОЛЬКО `_unpersisted_message_out`: доставлено оператору, но
# не записано. Флаг нужен потому, что без него деградация неотличима от
# тишины: фронт выбрасывает тело POST и рендерит транскрипт исключительно из
# `GET /support/messages` (`useSupportChat.ts`), где этого сообщения нет —
# поле ввода очищается, сообщение не появляется, ошибки нет. Пользователь
# решает, что не отправилось, и шлёт снова — ровно тот дубль в топике,
# против которого вся эта ветка и сделана.
persisted: bool = True
@field_validator("created_at", mode="before")
@classmethod
@ -255,150 +190,8 @@ def _format_mirror_text(username: str, message_text: str) -> str:
return f"[С САЙТА] {username}:\n{message_text}"
def _too_many_failures_error(retry_after: float) -> HTTPException:
"""429 вместо похода в Telegram — канал только что отказал N раз подряд."""
return HTTPException(
status_code=429,
detail=_SEND_UNAVAILABLE_DETAIL,
headers={"Retry-After": str(int(retry_after) + 1)},
)
def _unpersisted_message_out(text_body: str) -> SupportMessageOut:
"""Синтетический ответ для случая «в топик доставлено, а в БД не записано».
Почему ответ вообще УСПЕШНЫЙ. Сообщение оператору реально доставлено
отдать на это ошибку значит соврать: пользователь повторит, и в топике
окажется дубль (плюс второе предупреждение оператору). Успех здесь честнее
отказа, но он неполный, и об этом клиенту надо сказать явно.
Что сообщает `persisted=False`: «доставлено, но ответить тебе через чат не
смогут; повторять не надо». Именно флаг контракт для клиента, а НЕ `id=0`:
по идентификатору клиент отличить деградацию не обязан и не будет.
`id` при этом всё равно нужен модели, и 0 сознательный сентинел, а не
выдумка: реальные id в `web_support_messages` начинаются с 1 (serial),
поэтому 0 ни с чем не столкнётся, а `GET /support/messages` принимает
`since>=0`. Даже если фронт когда-нибудь начнёт курсорить по возвращённому
id (сейчас он перечитывает тред целиком с `since=0`, `useSupportChat.ts`),
нулевой курсор не перепрыгнет ни одного реального сообщения: занижение
курсора безопасно, завышение нет.
"""
return SupportMessageOut(
id=0,
direction="in",
text_body=text_body,
operator_tg_id=None,
created_at=datetime.now(UTC),
persisted=False,
)
async def _warn_operator_message_not_recorded(*, label: str, topic_message_id: int | None) -> None:
"""Реплаем к только что доставленному зеркалу предупреждает оператора, что
ответ на ЭТО сообщение не смаршрутизируется: треда в БД нет, на реплай к
осиротевшему зеркалу `bridge._handle_group_reply` напишет только WARNING, а
клиент не увидит ничего. Без предупреждения оператор отвечает в пустоту и
считает, что помог.
Текст обращения сюда НЕ дублируется (ПДн) оператор видит его в сообщении,
к которому это реплай.
Формулировка учитывает, что клиенту ответили успехом (`persisted=False`,
«принято, повторять не надо»): рассчитывать на «клиент напишет снова»
оператору нельзя, поднимать тред придётся иначе.
Любой отказ гасится логом: основное сообщение УЖЕ доставлено, превращать
неудачу служебного уведомления в 500 поверх успеха нельзя.
"""
text = (
f"ВНИМАНИЕ · {label}: сообщение доставлено в топик, но НЕ записано в базу "
"(сбой БД). Треда у этого обращения нет — ответить клиенту через бота "
"НЕЛЬЗЯ: реплай на это сообщение никуда не уйдёт. Повтора тоже не ждите, "
"клиенту показано, что сообщение принято и отправлять его снова не нужно. "
"Если в обращении есть контакт — свяжитесь напрямую; иначе передайте "
"дежурному и сообщите об отказе БД."
)
try:
client = get_telegram_client()
await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=text,
message_thread_id=settings.telegram_support_topic_id or None,
# reply_to может отсутствовать (sendMessage не вернул message_id) —
# тогда уведомление уходит отдельным сообщением в топик: хуже, чем
# реплай, но несравнимо лучше тишины.
reply_to_message_id=topic_message_id,
# Тот же узкий интерактивный бюджет (review H1): клиент ждёт ответа
# ручки, а не доставки служебного уведомления.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
except Exception:
# Шире `TelegramError` намеренно: это best-effort хвост уже успешного
# запроса, любой отказ здесь обязан остаться в логе, а не у клиента.
logger.exception(
"web support: не удалось предупредить оператора о несохранённом сообщении (%s)",
label,
)
def _rollback_quietly(db: Session) -> None:
"""Откат после `SQLAlchemyError`. Сам rollback на мёртвом соединении тоже
может бросить, а мы уже решили отдать клиенту успех `get_db` в любом
случае закроет сессию в `finally`."""
try:
db.rollback()
except SQLAlchemyError:
logger.warning("web support: rollback после сбоя БД тоже не удался", exc_info=True)
def _resolve_idempotency_key(request: Request, *, identity_key: str, text: str) -> str:
"""Ключ идемпотентности inbound-отправки (#3471, миграция 301).
Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key`
ЕДИНСТВЕННЫЙ надёжный путь: клиент генерирует ключ ОДИН раз на "намерение
отправить" и переиспользует его на любом ретрае (fetch retry / переотправка
после таймаута) независимо от того, что именно менялось в UI между
попытками (см. `useSendSupportMessage` во фронтенде реальный трафик
обязан идти этим путём). Fallback без заголовка детерминированный
отпечаток sha256(identity, текст, минутное окно) существует ТОЛЬКО для
клиентов, которые заголовок не прислали (п.5 требования старое поведение
не должно сломаться), и у него два честных изъяна, оба снимаются самим
фактом использования заголовка:
1. два РАЗНЫХ по смыслу сообщения с одинаковым текстом от одного и того
же человека в течение одной минуты ("да" и ещё раз "да", "+", "ок")
СХЛОПНУТСЯ в одно второе будет молча проглочено, клиент получит id
первого, оператор не увидит второе сообщение вообще;
2. окно не скользящие 60 секунд, а округление `time.time() // 60` вниз:
фактический срок дедупликации случаен от 0 до 60с в зависимости от
момента внутри минуты, и часть настоящих повторов (ретрай ровно на
границе окна) эту защиту не получит.
Оба пункта перестают быть важны, когда клиент реально шлёт заголовок
(см. `useSendSupportMessage`).
Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ
он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда
попадает ТОЛЬКО как вход в sha256, в открытом виде не сохраняется и не
возвращается жёсткое правило проекта "текст не в логах" (ПДн) при этом
не нарушается, даже если бы этот ключ где-то залогировали.
Префиксы `client:`/`auto:` разводят два namespace'а ключей (защита от
случайного совпадения клиентского токена с fallback-хэшем) дёшево и не
требует отдельной колонки.
"""
header = (request.headers.get(_IDEMPOTENCY_HEADER) or "").strip()
if header and _CLIENT_IDEMPOTENCY_KEY_RE.match(header):
return f"client:{header}"
window = int(time.time() // 60)
fingerprint = f"{identity_key}|{text}|{window}"
return "auto:" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
@router.post("/support/messages", response_model=SupportMessageOut)
async def send_support_message(
request: Request,
payload: SupportMessageInput,
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
@ -426,47 +219,7 @@ async def send_support_message(
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`): канал только что отказал
# N раз подряд — не тратим на этот запрос ещё четыре попытки к Telegram.
cooldown = _send_failure_limiter.retry_after(username)
if cooldown is not None:
raise _too_many_failures_error(cooldown)
# Идемпотентность (#3471). Что именно закрывает этот pre-check — важно не
# переоценить: строка в БД (и её idempotency_key) появляется ТОЛЬКО ПОСЛЕ
# успешной доставки в Telegram (H1 ниже), поэтому потерю самого запроса на
# плече Selectel -> api.telegram.org этот механизм НЕ дедуплицирует — если
# `send_message` не удался, ключ нигде не записан, и повтор клиента после
# неудачи это законная первая попытка. Реально закрывается другой, тоже
# частый случай: доставка УЖЕ состоялась (Telegram принял, строка
# закоммичена), но ответ до клиента не дошёл (обрыв на обратном пути,
# клиентский таймаут) или пользователь кликнул "отправить" второй раз по
# той же ещё не отрисовавшейся отправке — тогда pre-check находит уже
# записанное сообщение по ключу и отдаёт его же, не уходя в Telegram снова.
# Тред может ещё не существовать (это первая попытка этого ключа) — тогда
# сравнивать не с чем, идём в Telegram как обычно; сам факт "нет треда"
# здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке (см. H1) —
# если бы сообщение под этим ключом уже было записано, тред уже был бы.
idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text)
existing_thread_id = storage.find_thread_id(db, username)
if existing_thread_id is not None:
existing = storage.find_inbound_by_idempotency_key(
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
)
if existing is not None:
logger.info(
"web support: repeat send (idempotency key already recorded) "
"username=%s thread_id=%d message_id=%s",
username,
existing_thread_id,
existing["id"],
)
return SupportMessageOut(**existing)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
client = TelegramClient(settings.telegram_bot_token)
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
@ -477,22 +230,16 @@ async def send_support_message(
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
except TelegramError:
except TelegramApiError:
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
# TelegramApiError и не бывает — см. client.py docstring про redaction).
# Предок, а не `TelegramApiError`: при таймауте до Telegram пользователь
# должен увидеть тот же «сервис недоступен», а не 500 (#3456).
logger.exception(
"web support: не удалось отправить зеркало в топик (username=%s)", username
)
_send_failure_limiter.record(username)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
_send_limiter.record(username)
SUPPORT_MESSAGES.labels(channel="web").inc()
# Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы.
_send_failure_limiter.reset(username)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
@ -506,35 +253,15 @@ async def send_support_message(
)
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
#
# Оборотная сторона этого порядка: отказ БД здесь означает, что сообщение
# оператору УЖЕ доставлено. Отдавать на это 500 (как было) — худший из
# вариантов: клиент видит ошибку, шлёт повторно, в топике дубль, а на
# осиротевшее зеркало оператор отвечает в пустоту. Поэтому отвечаем успехом
# (доставка правда состоялась) и отдельным сообщением предупреждаем
# оператора, что отвечать на это зеркало бесполезно.
try:
thread_id = storage.get_or_create_thread(db, username)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
idempotency_key=idempotency_key,
)
db.commit()
except SQLAlchemyError:
# Ни текста обращения (ПДн), ни токена в логе — только кто и что сломалось.
logger.exception(
"web support: сообщение доставлено в топик, но не записано в БД (username=%s)",
username,
)
_rollback_quietly(db)
await _warn_operator_message_not_recorded(
label=f"[С САЙТА] {username}", topic_message_id=topic_message_id
)
return _unpersisted_message_out(payload.text)
thread_id = storage.get_or_create_thread(db, username)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
)
db.commit()
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
return SupportMessageOut(**row)
@ -681,46 +408,8 @@ async def send_anon_support_message(
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`) — оба ключа, как и у
# бюджетов выше: per-token ловит одну вкладку с авто-ретраем, per-IP — ту же
# петлю после сброса куки.
for cooldown in (
_send_failure_limiter.retry_after(thread_key),
_anon_ip_failure_limiter.retry_after(ip),
):
if cooldown is not None:
raise _too_many_failures_error(cooldown)
display_id = _anon_display_id(token)
# Идемпотентность (#3471) — см. развёрнутый комментарий в авторизованной
# ветке. Ограничение специфично для анонимной ветки: если предыдущая
# попытка сама провалилась ДО выдачи куки (`is_new_token=True` тогда и
# сейчас), identity_key каждый раз новый (случайный токен) и pre-check
# структурно не может найти прошлую попытку — это тот же класс проблемы,
# что и потеря куки в сети, вне scope этой правки.
idempotency_key = _resolve_idempotency_key(request, identity_key=thread_key, text=payload.text)
existing_thread_id = storage.find_thread_id(db, thread_key)
if existing_thread_id is not None:
existing = storage.find_inbound_by_idempotency_key(
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
)
if existing is not None:
logger.info(
"web support (anon): repeat send (idempotency key already recorded) "
"%s thread_id=%d message_id=%s",
display_id,
existing_thread_id,
existing["id"],
)
if is_new_token:
_set_anon_cookie(response, token)
return SupportMessageOut(**existing)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
client = TelegramClient(settings.telegram_bot_token)
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
@ -730,22 +419,15 @@ async def send_anon_support_message(
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
except TelegramError:
except TelegramApiError:
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
# Про предок вместо `TelegramApiError` — см. комментарий в парной ручке.
logger.exception(
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
)
_send_failure_limiter.record(thread_key)
_anon_ip_failure_limiter.record(ip)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
_send_limiter.record(thread_key)
_anon_ip_limiter.record(ip)
SUPPORT_MESSAGES.labels(channel="anon").inc()
# Канал жив — счётчики отказов считают именно ПОДРЯД идущие отказы.
_send_failure_limiter.reset(thread_key)
_anon_ip_failure_limiter.reset(ip)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
@ -755,37 +437,15 @@ async def send_anon_support_message(
display_id,
)
# Отказ БД после успешной отправки — см. развёрнутый комментарий в парной
# (авторизованной) ручке: сообщение оператору уже доставлено, 500 тут создаёт
# дубли в топике и «осиротевшее» зеркало, на которое оператор отвечает зря.
try:
thread_id = storage.get_or_create_thread(db, thread_key)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
idempotency_key=idempotency_key,
)
db.commit()
except SQLAlchemyError:
# Ни текста обращения (ПДн), ни токена (bearer треда) в логе — только
# несекретный ярлык треда.
logger.exception(
"web support (anon): сообщение доставлено в топик, но не записано в БД (%s)",
display_id,
)
_rollback_quietly(db)
await _warn_operator_message_not_recorded(
label=f"[С САЙТА · БЕЗ ВХОДА] {display_id}", topic_message_id=topic_message_id
)
# Куку ставим ВСЁ РАВНО: тред в БД не создан, но идентичность посетителя
# обязана пережить этот сбой — иначе следующее сообщение (когда база
# поднимется) заведёт ВТОРОЙ тред, и переписка разъедется на два.
if is_new_token:
_set_anon_cookie(response, token)
return _unpersisted_message_out(payload.text)
thread_id = storage.get_or_create_thread(db, thread_key)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
)
db.commit()
if is_new_token:
_set_anon_cookie(response, token)

View file

@ -22,7 +22,6 @@ from app.core.anon_session import get_or_create_anon_session_id
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.observability.metrics import ESTIMATES, REPORTS_EXPORTED
from app.schemas.trade_in import (
AggregatedEstimate,
AnalogLot,
@ -51,7 +50,6 @@ from app.schemas.trade_in import (
TradeInEstimateInput,
)
from app.services import account_quota
from app.services import regions as regions_mod
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
from app.services.user_events import schedule_event
@ -80,10 +78,8 @@ _estimate_limiter = SlidingWindowLimiter(
# внешние тиры; пила одновременных оценок выедает пул и тормозит весь /api/v1/*.
# Образец — public/mera.py::_suggest_slots (4 слота на секундное автодополнение).
#
# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений.
# Пул под это заведомо шире: 5+15=20 на процесс, и потолок пула держится не
# меньше СУММЫ объявленных потолков одновременности, включая 8 фоновых догрузок
# (core/db.py + tests/test_3408_pool_ceiling.py, #3408). Ожидание слота 5с ≈ две
# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений
# из 15 возможных, остаток пула остаётся прочим ручкам. Ожидание слота 5с ≈ две
# длительности оценки: если за это время слот не освободился, очередь глубока и
# честный ответ — быстрый 429 с Retry-After, а не растущая очередь (очередь под
# нагрузкой — те же занятые соединения плюс таймаут у клиента; mera.py:117-127).
@ -561,13 +557,6 @@ async def estimate(
# #3082: слот возвращаем сразу после дорогой части — инкремент квоты и
# сериализация ответа ниже дёшевы и слот держать не должны.
_estimate_slots.release()
# #3471: считаем оценку "успешно посчитанной" здесь — до квоты и до 429
# ниже, потому что расчёт (дорогая часть) уже прошёл. insufficient_data —
# тоже успех расчёта: медиана не нашлась не потому что что-то сломалось, а
# потому что аналогов не было, это отдельный, а не ошибочный исход.
ESTIMATES.labels(outcome="insufficient_data" if result.insufficient_data else "ok").inc()
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
@ -774,22 +763,8 @@ def load_estimate(
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
# #3051 PR-A: тот же region_code-скоуп, что и POST /estimate — гарантирует
# «регион не резолвится → DEFAULT_REGION_CODE (66)», не NULL (NULL в SQL
# обнулил бы фильтр). row.lat/row.lon персистятся с estimate-time.
target_region = (
regions_mod.region_for_point(row.lat, row.lon)
if row.lat is not None and row.lon is not None
else None
)
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
dkp_raw = _fetch_dkp_corridor(
db,
address=row.address,
rooms=row.rooms,
area=area_f,
city=target_city,
region_code=target_region_code,
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
)
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
@ -1021,7 +996,6 @@ def estimate_pdf(
brand_obj = _resolve_brand(owner_brand_slug, db)
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
REPORTS_EXPORTED.inc()
logger.info(
"PDF generated estimate_id=%s brand=%s size=%d",
estimate_id,
@ -2196,11 +2170,8 @@ def get_street_deals(
"""ДКП-сделки Росреестра по улице целевого адреса.
Open dataset Росреестра агрегирует адреса до улицы (без номера дома).
Поэтому это per-street view, не per-house. До квартир-аналогов выборку
сужает полоса площади ±area_tolerance; комнатность клиента в фильтр НЕ
входит `deals.rooms` не комнатность, а синтетика из той же площади
(#3256, см. estimator._fetch_dkp_corridor). `rooms` остаётся параметром
ручки: он описывает запрос и попадает в лог, но не в WHERE.
Поэтому это per-street view, не per-house. Фильтр по rooms + area
сужает выборку до квартир-аналогов.
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
в import-rosreestr.sh).
@ -2210,7 +2181,6 @@ def get_street_deals(
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
now = datetime.now(tz=UTC)
@ -2254,17 +2224,6 @@ def get_street_deals(
target_city = _resolve_target_city(address)
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
# #dkp-corridor-scope (2026-09-12): region_code — обязательный фильтр, в отличие
# от city_filter выше (который применяется, только если _resolve_target_city
# узнал город обл.66). extract_street_name после фикса московского порядка
# ("Название улица") стал возвращать имя улицы и для Москвы, а
# _resolve_target_city знает ТОЛЬКО города обл.66 → для Москвы target_city=None,
# city_filter пуст, и без region_code одноимённая улица другого региона
# подмешалась бы в выборку (прод-замер: "Ясная" — 168 сделок в 66 и 80 в 77,
# "Советская" — 1202 и 17). region_code заполнен у всех deals (66→108 623,
# 77→212 937, NULL нет) — фильтр не отрезает ни одной существующей строки.
region_code = region_code_for_address(address)
rows = (
db.execute(
text(
@ -2276,11 +2235,7 @@ def get_street_deals(
AND address ILIKE :street_pattern
AND address ~* :street_regex
{city_filter}
AND region_code = CAST(:region_code AS integer)
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто
-- в комментарии estimator._fetch_dkp_corridor.
AND rooms = CAST(:rooms AS integer)
AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
AND price_rub > 0
@ -2291,7 +2246,7 @@ def get_street_deals(
"street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None,
"region_code": region_code,
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
@ -2302,16 +2257,12 @@ def get_street_deals(
)
if not rows:
# #3256: лог называет ТОТ ключ, которым искали. Комнатность клиента в
# выборку не входит (deals.rooms — синтетика из площади), поэтому она
# печатается как контекст запроса, а не как параметр фильтра.
logger.info(
"street-deals: no rows found street=%r area=%.1f±%.0f%% "
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
"street-deals: no rows found street=%r rooms=%d area=%.1f±%.0f%%",
street_name,
rooms,
area_m2,
area_tolerance * 100,
rooms,
)
return StreetDealsResponse(
street=street_name,
@ -2339,14 +2290,12 @@ def get_street_deals(
top10 = [_deal_to_analog(dict(r)) for r in rows[:10]]
logger.info(
"street-deals: street=%r area=%.1f±%.0f%% count=%d median_ppm2=%.0f "
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
"street-deals: street=%r rooms=%d area=%.1f count=%d median_ppm2=%.0f",
street_name,
rooms,
area_m2,
area_tolerance * 100,
count,
median_ppm2,
rooms,
)
return StreetDealsResponse(
@ -2511,12 +2460,8 @@ def get_sales_vs_listings(
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
matching listing на той же улице с близкой area_m2 / listing_date в окне
[deal_date - window_days, deal_date + 30d grace]. Комнатность в ключе стоит
ТОЛЬКО на стороне объявлений (`l.rooms = p_rooms`, комнатность клиента): у
сделок Росреестра `rooms` синтетика из площади, предикат по ней снят
миграцией 300 (#3451/#3256). Поэтому `deal_rooms` в паре может не совпадать
с запрошенным `rooms`.
matching listing на той же улице с такими же rooms / близкой area_m2 /
listing_date в окне [deal_date - window_days, deal_date + 30d grace].
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
чтобы вычислить linkage_rate.
@ -2526,12 +2471,7 @@ def get_sales_vs_listings(
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
"""
from app.services.estimator import (
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
return SalesVsListingsResponse(
@ -2561,36 +2501,16 @@ def get_sales_vs_listings(
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
target_city = _resolve_target_city(address)
# #dkp-corridor-scope (2026-09-12): region_code — фильтр ДОПОЛНИТЕЛЬНО к
# target_city выше, нужен по той же причине, что и в /street-deals: для
# Москвы _resolve_target_city (словарь городов ТОЛЬКО обл.66) возвращает
# None → target_city-фильтр внутри TVF (миграция 205) не срабатывает ни
# для deals, ни для listings, и одноимённая улица другого региона
# подмешивается в пары (см. estimator.region_code_for_address).
#
# TVF street_sales_vs_listings() (миграция 205) параметра региона не
# знает — сама TVF не трогается (это отдельная миграция, вне текущего
# фикса), фильтр применён СНАРУЖИ: оборачиваем вызов в JOIN на deals по
# deal_id и фильтруем region_code ТОЛЬКО на стороне сделок. Сторона
# listings внутри TVF остаётся НЕ отфильтрованной по региону — это
# известный узкий компромисс, не побочный эффект: у listings нет
# общего для всех источников поля региона (city заполнен частично, см.
# комментарий 205 выше), а сам JOIN внутри TVF уже требует совпадения
# street_pattern + rooms + area + date proximity, что резко сужает шанс
# чужого региона на listing-стороне. Полный фикс (передать region_code
# внутрь TVF седьмым/восьмым параметром) — отдельная миграция.
region_code = region_code_for_address(address)
rows = (
db.execute(
text(
"""
SELECT
sv.deal_id, sv.deal_date, sv.deal_price_rub, sv.deal_price_per_m2,
sv.deal_area_m2, sv.deal_rooms, sv.deal_floor, sv.deal_address,
sv.listing_id, sv.listing_source, sv.listing_source_url,
sv.listing_date, sv.listing_price_rub, sv.listing_price_per_m2,
sv.listing_area_m2, sv.days_listing_to_deal, sv.discount_pct
deal_id, deal_date, deal_price_rub, deal_price_per_m2,
deal_area_m2, deal_rooms, deal_floor, deal_address,
listing_id, listing_source, listing_source_url,
listing_date, listing_price_rub, listing_price_per_m2,
listing_area_m2, days_listing_to_deal, discount_pct
FROM street_sales_vs_listings(
CAST(:street_pattern AS text),
CAST(:area_m2 AS numeric),
@ -2599,9 +2519,7 @@ def get_sales_vs_listings(
CAST(:area_tolerance AS numeric),
CAST(:period_months AS integer),
CAST(:target_city AS text)
) sv
JOIN deals d ON d.id = sv.deal_id
WHERE d.region_code = CAST(:region_code AS integer)
)
"""
),
{
@ -2612,7 +2530,6 @@ def get_sales_vs_listings(
"area_tolerance": area_tolerance,
"period_months": period_months,
"target_city": target_city,
"region_code": region_code,
},
)
.mappings()
@ -2844,206 +2761,15 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
COVERAGE_GREEN_MIN_N = 8
COVERAGE_YELLOW_MIN_N = 12
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
# тестом test_offered_cities_match_coverage_cities), а Москвы в том дропдауне
# нет и быть не должно — там выбирают город Свердловской области. Порог при
# этом нужен, и живёт он здесь, в том же _COVERAGE_CITY_THRESHOLDS.
#
# Тир — ЖЁЛТЫЙ (12), не зелёный. Замер на проде 10.09.2026: 200 случайных
# московских объявлений, когорта считалась тем же предикатом, что сама проба
# (см. SQL в coverage_probe ниже) — медиана когорты 14, p25 = 8, доля точек с
# когортой >= 12 равна 0.57, с когортой >= 8 равна 0.77. Контроли той же
# метрикой: Екатеринбург (зелёный, порог 8) — медиана 37, доля >= 12 равна
# 0.865; Нижний Тагил (жёлтый, порог 12) — медиана 11, доля >= 12 равна 0.473.
# То есть по плотности когорты Москва втрое реже зелёного эталона и стоит
# вплотную к жёлтому: с порогом 8 «ok» получали бы 77% адресов, но за этим «ok»
# стояла бы заметно менее надёжная оценка. Плюс модельная сторона для Москвы
# ещё не откалибрована (время правится свердловским рядом СберИндекса, полоса
# цен одна на весь город), так что 43% адресов честнее показать как thin.
COVERAGE_MOSCOW_MIN_N = COVERAGE_YELLOW_MIN_N
# Ключ центроида и display-имя города — РАЗНЫЕ вещи, и здесь это видно яснее
# всего: у Москвы 67 центроидов (сетка, см. `_MOSCOW_GRID_DEG` ниже), а наружу,
# в поле `city` ответа, обязано уходить одно имя «Москва» и один порог. Иначе
# повторится ровно тот баг, что был с Берёзовским: человек получает в ответе
# название района вместо своего города.
COVERAGE_MOSCOW_DISPLAY = "Москва"
# СЕТКА МОСКОВСКИХ ЦЕНТРОИДОВ (11.09.2026).
#
# Откуда координаты. Это не справочник районов и не ручной подбор: точки
# получены кластеризацией НАШЕГО ЖЕ корпуса объявлений средствами PostGIS —
# `ST_ClusterKMeans(ST_Transform(geom, 32637), K) OVER ()` по 35 552 активным
# вторичным объявлениям region_code=77 (geom заполнена у 100%), центроид
# кластера = `ST_Centroid(ST_Collect(...))` в UTM 37N, округление до 4 знаков.
# Поэтому точки стоят там, где реально живут объявления, а не там, где на карте
# нарисован центр района. Подписи в ключах — человеческие ярлыки для читаемости
# (ближайший район/поселение), на резолв они не влияют никак.
#
# Почему точек 67. Кластеризация дала K=56: порог качества был «меньше 2%
# московских объявлений уходит к подмосковному центроиду», свип по K на тех же
# данных — 24 → 6.06%, 30 → 4.02%, 36 → 3.67%, 42 → 3.46%, 48 → 2.23%,
# 54 → 2.12%, 56 → 1.79%, 60 → 1.64%, 75 → 0.84%, 90 → 0.45%, и K=56 — первое
# значение, берущее 2%. Из этих 56 один кластер выброшен как артефакт геокода,
# и 12 точек добавлены вручную по замеру 11.09.2026 (жилые районы, целиком
# проигрывавшие конкурс подмосковной точке): 56 1 + 12 = 67.
#
# Выброшенный призрак. «Москва (Восточный, эксклав)» 56.0087/37.7960: n=1,
# отрыв от остальной сетки 17.9 км. Адрес объявления — «ВАО, р-н Восточный, 4»,
# а настоящий район Восточный лежит на 55.81/37.85: геокодер промахнулся
# на 22 км. Точка стояла в 3.06 км от Пушкино и раздавала ему имя «Москва».
# Взамен добавлена «Москва (Восточный)» по фактическому центру девяти
# объявлений района. Проверены ВСЕ кластеры с n<60 либо изоляцией >12 км
# (9 штук, адреса подняты с прода) — артефакт ровно один. Второй кандидат,
# «Москва (юг ТАО)» 55.2643/37.1033 (n=1, адрес без улицы и дома =
# settlement-fallback геокодера), ОСТАВЛЕН намеренно: точка стоит внутри ТАО,
# при радиусе 8 км не достаёт ни до одного города области, а снос стоил бы
# покрытия анклава.
#
# Где проходит граница с областью. Двумя механизмами сразу, и нужны оба.
# (1) Конкурс ближайшего центроида: 32 подмосковные точки
# (`_COVERAGE_NEGATIVE_CENTROIDS_DEG` ниже) участвуют в поиске ближайшего, но
# порога не имеют, поэтому адрес, для которого выиграл подмосковный центр,
# получает «город не определён». (2) Радиус, который теперь СВОЙСТВО ЦЕНТРОИДА
# (`_CENTROID_RADIUS_KM` ниже), а не одна константа на всю страну: у московских
# точек он 8 км, у свердловских остались прежние 25.
#
# Почему радиус пришлось сделать свойством точки. 25 км были рассчитаны на ОДИН
# центроид в центре города — круг вокруг Кремля. У плотной сетки круги
# СКЛАДЫВАЮТСЯ, и объединение 67 кругов по 25 км — это уже не «Москва с
# запасом», а пятно, накрывающее половину области. Наро-Фоминск, Кубинка и Чехов
# резолвились как «Москва» именно так: до ближайшей точки сетки им 9.83, 17.18
# и 22.25 км (расстояния посчитаны _haversine_km этого же модуля, сфера
# R=6371 — замер на эллипсоиде PostGIS даёт на 0.1-0.5 км больше), то есть
# внутрь общего круга они попадали, а собственной
# отрицательной точки у них нет. Прежнее утверждение в этом месте — будто
# радиус 25 км «больше не ограничение, запас четырёхкратный» — было ложным:
# ограничение не исчезло, оно поменяло знак. Раньше радиус резал покрытие
# изнутри (адрес дальше 25 км от Кремля терял город), теперь протекал наружу.
#
# Откуда 8 км. Свип по радиусу на итоговой сетке, тот же корпус (35 552
# активных вторичных объявления, region_code=77): R = 5, 6, 7 и 8 дают
# ОДИНАКОВЫЙ результат — 0.00% объявлений вне радиуса, 29/29 московских
# контрольных адресов резолвятся в «Москву», 32/32 областных дают «не
# определён». Верхний край плато жёсткий: при R=10 внутрь входит Наро-Фоминск
# (9.83 км — город БЕЗ отрицательной точки), при R=12 — ещё Голицыно и
# Лыткарино. Берём верхний край: 8 км — это 3.5 км запаса на адреса вне
# корпуса (худший московский контроль — Рублёво, 4.44 км до сетки) и всё ещё
# на 1.83 км ниже потолка 9.83. Брать меньше нечем оправдать: по корпусу
# выигрыша нет, а запас на дырки в сетке тает.
#
# Остаточная цена. 48 московских объявлений из 35 552 = 0.14% (на прежней
# конфигурации — 636 = 1.79%) всё ещё выигрываются подмосковным центром:
# Молжаниновский 15, Реутов 8, Подольск 7, Долгопрудный 7, Мытищи 7,
# Красногорск 3, Королёв 1. Крупнейший кусок снимается 13-й точкой
# «Молжаниновский» 55.9480/37.3478 (0.14% → 0.09%) — она в замере посчитана,
# но в список не внесена, чтобы конфигурация здесь совпадала с той, на которой
# прогнаны оба контрольных набора. Одно объявление не берётся ничем:
# 56.0087/37.7960, тот самый кривой геокод, — лечится перегеокодированием
# строки адреса, а не сеткой.
#
# Не перегенерируйте кластеризацию перед правкой: ST_ClusterKMeans без сида
# недетерминирован, разброс между прогонами ±0.2 п.п. Список ниже перепроверен
# отдельным прогоном именно в том виде, в каком он здесь записан.
_MOSCOW_GRID_DEG: dict[str, tuple[float, float]] = {
"Москва (Рогово, ТАО)": (55.2146, 37.0695),
"Москва (юг ТАО)": (55.2643, 37.1033),
"Москва (Вороновское, ТАО)": (55.3159, 37.1759),
"Москва (Кленовское, ТАО)": (55.3343, 37.3299),
"Москва (ТАО у Подольска)": (55.3569, 37.4010),
"Москва (Шишкин Лес, ТАО)": (55.4202, 37.1687),
"Москва (Щапово, ТАО)": (55.4212, 37.3979),
"Москва (Новофёдоровское, ТАО)": (55.4307, 36.8675),
"Москва (Троицк, юг)": (55.4568, 37.2867),
"Москва (Остафьево, НАО)": (55.4760, 37.5249),
"Москва (Киевский, ТАО)": (55.4884, 36.9199),
"Москва (Троицк)": (55.4953, 37.3150),
"Москва (Щербинка, НАО)": (55.5046, 37.5578),
"Москва (Ватутинки, НАО)": (55.5187, 37.3605),
"Москва (Первомайское, ТАО)": (55.5405, 37.1599),
"Москва (Коммунарка, НАО)": (55.5474, 37.4927),
"Москва (Филимонковское, НАО)": (55.5568, 37.3188),
"Москва (Северное Бутово)": (55.5615, 37.5697),
"Москва (Саларьево, НАО)": (55.5862, 37.4568),
"Москва (Марушкино, НАО)": (55.5933, 37.1658),
"Москва (Бирюлёво Западное)": (55.5945, 37.6447),
"Москва (Внуковское, НАО)": (55.6030, 37.3796),
"Москва (Ясенево)": (55.6138, 37.5535),
"Москва (Зябликово)": (55.6225, 37.7303),
"Москва (Ново-Переделкино)": (55.6317, 37.3215),
"Москва (Нагорный)": (55.6425, 37.6245),
"Москва (Солнцево)": (55.6455, 37.3966),
"Москва (Тропарёво-Никулино)": (55.6576, 37.4684),
"Москва (Люблино)": (55.6696, 37.7461),
"Москва (Обручевский)": (55.6711, 37.5432),
"Москва (Даниловский)": (55.6921, 37.6397),
"Москва (Некрасовка)": (55.7033, 37.9093),
"Москва (Раменки)": (55.7048, 37.4870),
"Москва (Кузьминки)": (55.7143, 37.7723),
"Москва (Кунцево)": (55.7281, 37.4239),
"Москва (Якиманка)": (55.7290, 37.6055),
"Москва (Таганский)": (55.7370, 37.6845),
"Москва (Пресненский)": (55.7486, 37.5282),
"Москва (Новогиреево)": (55.7545, 37.8107),
"Москва (Тверской)": (55.7655, 37.6038),
"Москва (Хорошёво-Мнёвники)": (55.7657, 37.4668),
"Москва (Преображенское)": (55.7886, 37.7045),
"Москва (Хорошёвский)": (55.7951, 37.5373),
"Москва (Строгино)": (55.7982, 37.3859),
"Москва (Северное Измайлово)": (55.8071, 37.7881),
"Москва (Останкинский)": (55.8086, 37.6174),
"Москва (Покровское-Стрешнево)": (55.8203, 37.4470),
"Москва (Митино)": (55.8467, 37.3877),
"Москва (Отрадное)": (55.8558, 37.5796),
"Москва (Ховрино)": (55.8581, 37.4924),
"Москва (Бабушкинский)": (55.8633, 37.6740),
"Москва (Лианозово)": (55.8944, 37.5622),
"Москва (Куркино — Молжаниново)": (55.9169, 37.3837),
"Москва (Зеленоград, юг)": (55.9793, 37.1620),
"Москва (Зеленоград, север)": (55.9958, 37.2147),
# Кластер «Москва (Восточный, эксклав)» (56.0087, 37.7960) ВЫБРОШЕН
# 11.09.2026 как артефакт геокода — см. «Выброшенный призрак» выше.
#
# Ниже — 12 точек, добавленных тем же замером вручную: жилые районы
# Москвы, которые целиком проигрывали конкурс ближайшего подмосковной
# точке. В комментарии — сколько объявлений возвращает точка и кому они
# уходили (прод-корпус, is_active AND region_code=77).
"Москва (Восточный)": (55.8194, 37.8740), # +9, уходили к Балашихе
"Москва (Левобережный)": (55.8713, 37.4592), # +91, к Химкам
"Москва (Орехово-Борисово Южное)": (55.5986, 37.7233), # +80, к Развилке
"Москва (Северный)": (55.9278, 37.5411), # +79, к Долгопрудному
"Москва (Митино-запад)": (55.8410, 37.3507), # +78, к Красногорску
"Москва (Ивановское)": (55.7717, 37.8326), # +65, к Реутову
"Москва (Жулебино)": (55.6885, 37.8521), # +62, к Люберцам
"Москва (Можайский-запад)": (55.7008, 37.3936), # +50, к Немчиновке
"Москва (Новокосино)": (55.7385, 37.8576), # +44, к Реутову
"Москва (Капотня)": (55.6341, 37.7981), # +29, к Дзержинскому
"Москва (Куркино)": (55.8895, 37.3980), # +20, к Химкам
"Москва (Некрасовка-восток)": (55.6838, 37.9216), # +13, к Люберцам
}
# Все ключи сетки — один display «Москва» и один порог. Кортеж выводится из
# самого словаря, чтобы список ключей физически не мог разойтись с сеткой.
COVERAGE_MOSCOW_CENTROID_KEYS = tuple(_MOSCOW_GRID_DEG)
def _fold_city(name: str) -> str:
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
# Ключ — folded ИМЯ ЦЕНТРОИДА, значение — (display-имя города, порог). Для
# свердловских городов ключ и display совпадают, для московских центроидов —
# нет (все 67 ключей сетки дают «Москва»).
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
**{
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
for k in COVERAGE_MOSCOW_CENTROID_KEYS
},
}
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
@ -3066,15 +2792,7 @@ _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
# статичного справочника из 8 географических центров населённых пунктов —
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
# рядом с порогами, которые они резолвят.
# Радиусы матчинга. Радиус — СВОЙСТВО ЦЕНТРОИДА (таблица `_CENTROID_RADIUS_KM`
# ниже, применяется в `_resolve_coverage_city`), потому что 25 км осмысленны
# ровно для конфигурации «один центроид на город», а у Москвы центроидов 67 и
# их круги складываются — разбор в комментарии над `_MOSCOW_GRID_DEG`.
# Свердловские точки радиуса не меняли: у каждой из них один центр на город,
# складываться нечему, а замер, которым выбраны эти 25 км, к сетке отношения
# не имеет.
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дефолт: один центроид на город, дальше — not_covered
COVERAGE_MOSCOW_MATCH_RADIUS_KM = 8.0 # сетка из 67 точек; верхний край плато 5..8, потолок 9.83
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Екатеринбург": (56.8389, 60.6057),
@ -3086,99 +2804,8 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298),
"Серов": (59.6047, 60.5772),
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
# Как сетка получена, почему точек именно столько, где проходит граница
# с областью и какова остаточная цена — см. большой комментарий над
# `_MOSCOW_GRID_DEG`. Здесь только подстановка: координаты и ключи живут
# в одном месте, чтобы их нельзя было рассинхронизировать.
**_MOSCOW_GRID_DEG,
}
# ОТРИЦАТЕЛЬНЫЕ центроиды: подмосковные города и посёлки, попадающие внутрь
# радиуса московских точек. Список 11.09.2026 перепроверен на итоговой
# конфигурации (67 точек сетки, радиус 8 км): все 32 точки живы — ни одну
# не перехватывает московский центроид, в самой точке выигрывает она сама,
# и ответ остаётся «город не определён». Внутрь 8 км от какой-нибудь точки
# сетки попадают 13 из них (Химки, Реутов, Люберцы, Котельники, Дзержинский,
# Красногорск, Долгопрудный, Мытищи, Видное, Одинцово, Подольск, Апрелевка,
# Селятино) — каждая выигрывает собственным центром. Ближайшая пара после
# добавления новых точек: «Митино-запад» в 1.6 км от Красногорска. Участвуют
# в конкурсе ближайшего центроида наравне с городами из
# `_CITY_CENTROIDS_DEG`, но НАМЕРЕННО отсутствуют в `_COVERAGE_CITY_THRESHOLDS`:
# центроид без порога = «город не определён», тот же ответ, что для точки
# в чистом поле. Обоснование и цена — в комментарии «ГРАНИЦА С ОБЛАСТЬЮ» выше.
#
# Координаты — WGS84, общедоступные центры НП; список закрытый и расширяется
# только по замеру (точка вне радиуса всех московских бесполезна, точка внутри
# Москвы сожгла бы ещё часть покрытия). Ключи здесь и в `_CITY_CENTROIDS_DEG`
# не должны пересекаться — это проверяется тестом.
#
# Города области, которые НЕ попадают ни в один московский радиус, здесь и не
# нужны: Наро-Фоминск (9.83 км до сетки), Кубинка (17.18), Чехов (22.25) отдают
# «город не определён» просто потому, что вне 8 км. При прежних 25 км все трое
# резолвились как «Москва» — это и был дефект, ради которого радиус переехал
# в свойство центроида.
_COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Андреевка": (55.9772, 37.1100),
"Менделеево": (56.0333, 37.2333),
"Сходня": (55.9500, 37.3000),
"Чёрная Грязь": (55.9667, 37.3167),
"Лунёво": (56.0000, 37.3500),
"Поварово": (56.0667, 37.0667),
"Дедовск": (55.8672, 37.1200),
"Нахабино": (55.8500, 37.1833),
"Реутов": (55.7614, 37.8564),
"Подольск": (55.4312, 37.5450),
"Апрелевка": (55.5500, 37.0700),
"Немчиновка": (55.7050, 37.3450),
"Химки": (55.8894, 37.4450),
"Одинцово": (55.6789, 37.2639),
"Лобня": (56.0100, 37.4750),
"Мытищи": (55.9116, 37.7308),
"Котельники": (55.6553, 37.8619),
"Красногорск": (55.8317, 37.3300),
"Люберцы": (55.6767, 37.8931),
"Дзержинский": (55.6294, 37.8500),
"Развилка": (55.5842, 37.7392),
"Климовск": (55.3667, 37.5333),
"Балашиха": (55.7969, 37.9386),
"Истра": (55.9167, 36.8667),
"Долгопрудный": (55.9386, 37.5100),
"Королёв": (55.9142, 37.8256),
"Видное": (55.5519, 37.7133),
"Селятино": (55.5081, 36.9825),
"Томилино": (55.6528, 37.9472),
"Некрасовский": (56.0500, 37.5500),
"Барвиха": (55.7333, 37.2333),
"Железнодорожный": (55.7444, 38.0128),
}
# Радиус матчинга по центроиду: ключ — folded имя центроида, значение — км.
# Чего здесь нет — берёт `COVERAGE_CITY_MATCH_RADIUS_KM` (25 км), то есть все
# свердловские точки. Таблица выводится из самих словарей координат, чтобы
# радиус физически не мог разойтись со списком точек.
#
# Почему у отрицательных точек радиус бесконечный. Отрицательный центроид
# ничего не «накрывает»: он влияет на ответ только там, где он ближе всех
# (в своей ячейке Вороного), а выигравший центроид без порога даёт «город не
# определён» на ЛЮБОМ расстоянии — обе ветки `_resolve_coverage_city` (вышли
# за радиус / нет порога) возвращают один и тот же ответ. Поэтому конечное
# число здесь было бы декорацией: поведение оно не меняет, но читалось бы как
# «дальше отрицательная точка перестаёт действовать», чего не происходит.
# math.inf говорит правду — границу отрицательной точки задаёт геометрия
# соседей, а не радиус, и попытка «подрезать» её числом ничего не вернёт
# Москве, зато спрячет этот факт от следующего читателя.
_CENTROID_RADIUS_KM: dict[str, float] = {
**{_fold_city(k): COVERAGE_MOSCOW_MATCH_RADIUS_KM for k in COVERAGE_MOSCOW_CENTROID_KEYS},
**{_fold_city(k): math.inf for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG},
}
def _centroid_radius_km(centroid_key: str) -> float:
"""Радиус матчинга КОНКРЕТНОГО центроида в км (см. `_CENTROID_RADIUS_KM`)."""
return _CENTROID_RADIUS_KM.get(_fold_city(centroid_key), COVERAGE_CITY_MATCH_RADIUS_KM)
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
@ -3194,49 +2821,23 @@ def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
в пределах СОБСТВЕННОГО радиуса этого центроида (`_centroid_radius_km`:
25 км у свердловских городов, 8 км у точек московской сетки почему так,
см. комментарий над `_MOSCOW_GRID_DEG`); иначе город не определён.
Детерминированно
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
и без участия клиента см. комментарий над `_CITY_CENTROIDS_DEG` про то,
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
больше НЕ допускаются в качестве источника истины.
В конкурсе ближайшего участвуют И отрицательные центроиды
(`_COVERAGE_NEGATIVE_CENTROIDS_DEG`) подмосковные точки без порога.
Выигравший центроид без порога означает «город не определён»: так граница
Москвы и области проходит по конкурсу центров, а не по кругу радиуса
(см. «ГРАНИЦА С ОБЛАСТЬЮ» над `_CITY_CENTROIDS_DEG`).
Возвращается display-имя из `_COVERAGE_CITY_THRESHOLDS`, а НЕ ключ
центроида: у Москвы 67 центроидов на один город, и наружу все они обязаны
отдавать «Москва» с одним порогом (см. `COVERAGE_MOSCOW_CENTROID_KEYS`).
"""
nearest_city: str | None = None
nearest_km = math.inf
for city, (clat, clon) in (
*_CITY_CENTROIDS_DEG.items(),
*_COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
):
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
distance_km = _haversine_km(lat, lon, clat, clon)
if distance_km < nearest_km:
nearest_km = distance_km
nearest_city = city
# Радиус берётся у ПОБЕДИТЕЛЯ конкурса, а не общий: круги плотной сетки
# складываются, и один глобальный радиус либо режет Свердловскую область,
# либо протекает из Москвы в область (см. `_CENTROID_RADIUS_KM`).
if nearest_city is None or nearest_km > _centroid_radius_km(nearest_city):
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
return "", 0, False
# .get(), а не индексирование: ближайшим мог оказаться отрицательный
# центроид (или новый центроид, для которого забыли завести порог) — это
# «город не определён», а не KeyError и 500 на живом адресе.
entry = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(nearest_city))
if entry is None:
return "", 0, False
display, threshold = entry
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
return display, threshold, True

View file

@ -47,7 +47,6 @@ from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
from app.core.db import DB_CONNECT_ARGS
class AuthDatabaseNotConfiguredError(RuntimeError):
@ -102,18 +101,6 @@ def _build() -> tuple[Engine, sessionmaker[Session]]:
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True,
# #3463. Те же потолки, что у продуктового движка, — ОДНОЙ константой на оба:
# потолок на одном движке и мина на втором это не починка, а половина.
# Этот движок живёт на ГОРЯЧЕМ пути: `core/rbac.py` резолвит session-cookie
# в middleware, синхронно на event loop'е, на КАЖДОМ запросе с cookie
# (на проде IDENTITY_STORE=auth во всех трёх сервисах образа — сверено 12.09,
# `printenv` в контейнерах). Без потолка `ACCESS EXCLUSIVE` на `auth.sessions`
# вешает не четыре слота `/estimate`, а весь uvicorn-воркер (он один, без
# --workers) — включая `/health`.
# Срабатывание потолка безопасно: вызов в rbac.py уже под `except Exception`
# с фолбэком на заголовочную аутентификацию, то есть отмена запроса даёт тот
# же путь, что и любой другой сбой реестра, а не 500.
connect_args=DB_CONNECT_ARGS,
)
except (ArgumentError, ValueError):
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до

View file

@ -1287,69 +1287,6 @@ class Settings(BaseSettings):
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
# Общий (не per-тему) лимит частоты отправки в ОДНУ группу — Telegram
# считает ~20 сообщений/минуту на группу суммарно по всем её темам (#3471:
# всплеск GlitchTip-алертов + поток поддержки в ту же группу давали 429 и
# потерю сообщений). `TELEGRAM_SUPPORT_CHAT_ID`/`TELEGRAM_ALERTS_CHAT_ID` на
# проде равны (одна группа, темы разные) — обе половины делят один
# площадочный бюджет.
#
# РАЗДЕЛЕНО ПО РОЛЯМ (review H2, #3471), а не одна общая константа: лимитер
# живёт in-memory В ЭКЗЕМПЛЯРЕ `TelegramClient`, а в эту группу пишут ДВА
# независимых процесса — API под uvicorn (`app/services/tgbot/shared.py`,
# интерактивные ручки + GlitchTip-вебхук) и контейнер бота (`app/tgbot_main.py`,
# long-polling воркер). У них НЕТ общего счётчика (это отдельная задача —
# Redis-based распределённый лимитер), поэтому если каждому дать по 18,
# сумма (2×18=36) УДВОИТ площадочный лимit и 429 вернётся ровно там же.
# Бюджет делится статически так, чтобы СУММА была заметно НИЖЕ ~20: у API
# больше — там же интерактивные ответы клиентам, у бота меньше — там же
# обычно только зеркалирование/уведомления, которые могут подождать дольше
# (см. `TelegramGroupRateLimiter.acquire` про `max_wait=None` для фона).
# 0/отрицательное значение выключает лимитер для соответствующего процесса.
#
# ЧЕСТНО ПРО ОГРАНИЧЕНИЕ ЭТОГО ДИЗАЙНА (review, #3471):
# `telegram_group_rate_limit_api_per_minute` — ОДИН общий бюджет на ВСЕ
# отправки процесса API в эту группу, а туда
# пишут И зеркала веб-чата поддержки (`app/api/v1/support.py`), И
# GlitchTip-алерты (`app/api/v1/glitchtip.py`) — обе ручки идут через один
# и тот же `get_telegram_client()` (см. `app/services/tgbot/shared.py`).
# Приоритета между ними НЕТ: кто первый встал в очередь `TelegramGroupRateLimiter`,
# тот и получил слот. Оба пути передают узкий `timeout` (5с у support, 8с у
# glitchtip) — он же становится потолком ожидания слота (см.
# `TelegramClient._request`, review H1). Значит при всплеске алертов (пачка
# ошибок прода бьёт в вебхук залпом) реально возможен сценарий: бюджет
# 12/мин исчерпан алертами → следующая отправка живого клиента в веб-чате
# ждёт до 5с и получает `TelegramRateLimitedError` → 502 клиенту поддержки.
# То есть при достаточно большом всплеске алертов веб-чат ДЕЙСТВИТЕЛЬНО
# может временно вставать. Разделить бюджет по ИСТОЧНИКУ (не по процессу) —
# отдельная задача: нужен свой `TelegramGroupRateLimiter` на алерты с явно
# малой квотой и/или приоритет для support-трафика; здесь НЕ сделано
# (вне бюджета этой правки).
telegram_group_rate_limit_api_per_minute: int = Field(
default=12, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_API_PER_MINUTE"
)
telegram_group_rate_limit_bot_per_minute: int = Field(
default=6, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_BOT_PER_MINUTE"
)
# ── Ретранслятор Bot API через Beget (#3471) ─────────────────────────────
# Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` с Selectel — 9
# успешных из 12 (три ConnectTimeout), TCP-443 до адреса Selectel — 5/6, TCP-443
# до адреса Beget — 8/8; за сутки 508 строк `network error` в логе бота, за 30
# дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel лоссовый, с
# Beget чистый (Alertmanager там же шлёт без проблем) — поэтому продуктовый
# трафик Bot API идёт через маленький HTTP-ретранслятор на Beget
# (`ops/metrics/tg-relay`), а не напрямую.
#
# Пусто (дефолт) = прежнее поведение, прямой путь к api.telegram.org — это и
# есть механизм отката, если ретранслятор сам подведёт. При заданном адресе
# клиент (`app/services/tgbot/client.py`) всё равно делает одну попытку
# напрямую при транспортном отказе похода на ретранслятор — хуже прямого
# пути быть не должно ни при каких условиях.
# ENV: TELEGRAM_RELAY_BASE_URL, TELEGRAM_RELAY_SECRET.
telegram_relay_base_url: str = Field(default="", validation_alias="TELEGRAM_RELAY_BASE_URL")
telegram_relay_secret: str = Field(default="", validation_alias="TELEGRAM_RELAY_SECRET")
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.

View file

@ -1,68 +1,14 @@
import asyncio
import logging
from collections.abc import Callable, Generator
from typing import Any
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import settings
logger = logging.getLogger(__name__)
# #3463. Потолок ОДНОГО statement'а, секунды·1000. Ставится на КОННЕКТЕ (libpq
# `options`), а не в питоновской обёртке: обёртка (`run_db_thread` ниже) при
# отмене обязана ДОЖДАТЬСЯ потока, иначе поток остаётся сиротой в общей
# `Session` — ровно то, ради чего писался #3449. Значит верхняя граница ожидания
# = длительность самого запроса, и задать её может только сервер.
#
# 30 с выбраны так, чтобы потолок НИКОГДА не стал биндящим ограничением для
# честной работы, но остался конечным:
# * самый длинный ОБЪЯВЛЕННЫЙ бюджет на `/estimate` — 20 с (`estimate_avito_imv_timeout_s`,
# config.py:852); дальше 12 с геокод, 8 с Yandex/Cian/house_meta. 30 с = 1.5× от максимума;
# * ОСНОВНАЯ опора по планировщику — `scrape_runs` (длительности целых прогонов, они
# не вытесняются): самая долгая ЧИСТО-БД задача за 14 суток — listing_source_snapshot,
# 9.7 с ЦЕЛИКОМ (и у неё сверх того свой `SET LOCAL statement_timeout = 900000`,
# который перекрывает это значение — гейт tests/test_3463_db_timeouts.py);
# * самый длинный set-based statement ЧЕРЕЗ движок из замеренных — матч ГАР→houses
# (`services/gar_flats_loader._MATCH_SQL`): 2.07 с с городским фильтром и 6.46 с без
# него (`city_filter=None`, флаг CLI). Запас ~3×, и это СЧИТАЮЩИЙ запрос, а не ждущий.
#
# `pg_stat_statements` опорой по планировщику НЕ является: при `max = 5000` он вытесняет
# редкие записи (проверено 12.09 — `dealloc` вырос на единицу за десять минут, и из топа
# пропали ВСЕ записи с `calls = 1`, включая `REFRESH MATERIALIZED VIEW` 30.85 с и KNN
# `cadastral_geo_match` 2.45 с). Суточная задача до следующих суток там не доживает, так
# что «самый долгий запрос 4.27 с» верно только для ВЫСОКОЧАСТОТНЫХ запросов.
_STATEMENT_TIMEOUT_MS = 30_000
# Ожидание БЛОКИРОВКИ — заведомо меньше: ждать лок дольше секунд смысла нет, лучше
# деградировать. 5 с — та же величина, что у миграций проекта
# (`SET LOCAL lock_timeout = '5s'` в data/sql/250,251,260,272,277…), снизу ограничена
# deadlock_timeout (на проде 1 с — сверено 12.09). Именно этот потолок закрывает
# сценарий #3463: под `ACCESS EXCLUSIVE` на `geocode_cache` запрос ЖДЁТ лок, а не
# считает, — statement_timeout тут только страховка от «считает вечно».
_LOCK_TIMEOUT_MS = 5_000
# idle_in_transaction_session_timeout НАМЕРЕННО не трогаем: тем же движком живёт tgbot,
# и `services/tgbot/bridge.py` держит транзакцию открытой ПОВЕРХ long-poll Telegram
# (замер на проде 12.09, 3 пробы с шагом 7 с: одна и та же сессия, запрос
# `SELECT value FROM tg_support_state …`, возраст транзакции циклически растёт до ~29 с).
# Сессионный потолок на простой в транзакции ронял бы long-poll КАЖДЫЙ цикл —
# гарантированно, а не в редком случае.
DB_CONNECT_ARGS = {
"options": f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
}
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
future=True,
# #3463. Накрывает ВСЕ три сервиса образа (backend / scraper / tgbot — один и тот
# же `app.core.db`, см. docker-compose.prod.yml) и обе стороны: продуктовый путь
# `/estimate` и задачи планировщика. Миграции идут мимо (psql из
# .forgejo/workflows/deploy-tradein.yml, не этот движок) — их DDL под своим
# `SET LOCAL lock_timeout` и потолком не ограничен.
connect_args=DB_CONNECT_ARGS,
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
@ -70,41 +16,6 @@ engine = create_engine(
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True,
# #3408 п.1. Потолок пула обязан быть НЕ МЕНЬШЕ суммы потолков одновременности,
# которые сам же процесс и объявляет: 4 оценки (`api/v1/trade_in`
# `_ESTIMATE_CONCURRENCY`) + 4 подсказки (`api/public/mera` `_SUGGEST_CONCURRENCY`)
# + 8 фоновых догрузок (`services/estimator` `_MAX_DEFERRED_REFRESH_TASKS`) = 16.
# Дефолт SQLAlchemy 5+10=15 меньше этой суммы, то есть исчерпать пул можно
# штатной работой, не абузом. Гейт — tests/test_3408_pool_ceiling.py.
#
# Инвариант «пул >= суммы объявленных потолков» — это ПОЛ, а не гарантия:
# сумма считает по одному коннекту на объявленную единицу работы, а коннект
# держит и всё остальное — любая ручка с `Depends(get_db)` тоже. Глобального
# обработчика `sqlalchemy.exc.TimeoutError` в app/main.py нет, поэтому на
# исчерпанном пуле соседние ручки отдают 500 (после ожидания `pool_timeout`),
# а не медленный 200.
#
# pool_size оставлен дефолтным (5): это ПОСТОЯННО открытые коннекты, а в покое
# прод держит 5-6 (замер 11.09). Растёт только overflow — коннекты пика,
# которые пул закрывает сам. Потолок процесса: 5 + 15 = 20; воркер один
# (docker-compose.prod.yml, uvicorn без --workers), Postgres max_connections=100.
max_overflow=15,
# #3408 п.2. Дефолтные 30 с ожидания коннекта длиннее ЛЮБОГО бюджета внешнего
# источника в эстиматоре: 8 с (Yandex / Cian / house_meta), 12 с (geocode),
# 20 с (Avito IMV — `estimate_avito_imv_timeout_s`, config.py:852). Сам чекаут
# прервать `asyncio.wait_for` не может: он занимает поток `asyncio.to_thread`
# целиком, а пул потоков конечен (min(32, cpu+4)) — исчерпанный пул коннектов
# так превращается в исчерпанный пул потоков. 5 с короче самого КОРОТКОГО
# бюджета: занятый пул деградирует ОДИН источник, а не весь запрос.
#
# Отдельный коммит в конце ветки намеренно (ревью PR #3444): это единственная
# правка, которая меняет режим отказа с «медленно» на «быстро с ошибкой», и
# едет она во ВСЕ сервисы образа — backend, scraper, tgbot
# (tradein-mvp/docker-compose.prod.yml). За 29 ч логов исчерпания пула не было
# ни разу, то есть новое значение на проде пока не на чем проверить.
# ТРИГГЕР ОТКАТА на 30 с: любое `QueuePool limit ... timed out` в логах
# бэкенда ЛИБО рост failed+zombie в `scrape_runs` после деплоя.
pool_timeout=5,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
@ -119,39 +30,3 @@ def get_db() -> Generator[Session, None, None]:
yield db
finally:
db.close()
async def run_db_thread[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
"""`asyncio.to_thread(fn, ...)`, который при отмене ДОЖИДАЕТСЯ своего потока (#3449).
Для синхронной работы по сессии, которая шагу НЕ принадлежит она общая со
всем остальным запросом (`Depends(get_db)`). Поток отменить нельзя: у
`asyncio.to_thread` отменяется только ожидание со стороны loop'а. Корутина
умирает по бюджету источника (`estimator._with_budget` = `asyncio.wait_for`,
геокодер 12 с), а поток продолжает работать с ТОЙ ЖЕ `Session`, пока
вызывающий уже идёт дальше по коду следующий источник,
`_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной
`Session` дают «another operation is in progress» / `InvalidRequestError` на
СЛЕДУЮЩЕМ шаге: у источников такую ошибку глушит `except` вокруг вызова, у
персиста оценки не глушит ничего 500 и потерянная оценка клиента.
Поэтому отмена пробрасывается ПОСЛЕ того, как поток отпустил сессию. Цена
бюджет источника переезжает на длину ОДНОГО шага БД (чекаут `pool_timeout`
плюс сам запрос), а не на длину фетча, ради которой бюджет и заведён.
Только защита от сироты: транзакцию шаг НЕ завершает (в середине геокодинга
commit зафиксировал бы частичное состояние оценки). Кому нужен ещё и возврат
коннекта в пул перед внешним HTTP `estimator._db_step`, он поверх этого.
Гейт tests/test_3449_geocoder_cancel_orphan.py.
"""
step = asyncio.ensure_future(asyncio.to_thread(fn, *args, **kwargs))
try:
return await asyncio.shield(step)
except asyncio.CancelledError:
await asyncio.wait([step])
if not step.cancelled() and step.exception() is not None:
# Результата уже никто не ждёт: без явного чтения asyncio напечатает
# «Task exception was never retrieved» вообще без контекста.
logger.warning("шаг БД упал уже после отмены: %s", step.exception())
raise

View file

@ -161,18 +161,6 @@ class SlidingWindowLimiter:
del self._hits[k]
return len(bucket)
def reset(self, key: str) -> None:
"""Обнуляет окно под *key*.
Нужен вызывающим, которые считают не «сколько сделано», а «сколько ПОДРЯД»
например счётчик отказов отправки (`support.py:_send_failure_limiter`):
успешная отправка означает, что канал жив, и предыдущие отказы больше не
должны приближать cooldown. Для лимитеров-бюджетов (`_send_limiter`,
`_anon_ip_limiter`, `_notify_limiter`) не используется там обнуление
по запросу было бы дырой в самом лимите.
"""
self._hits.pop(key, None)
def check(self, key: str) -> float | None:
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
для вызывающих, которым не нужно различать "попытка"/"успех" (см.

View file

@ -19,7 +19,6 @@ from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from sentry_sdk.types import Event, Hint
from app.api.public import mera as public_mera
from app.api.v1 import (
@ -51,7 +50,6 @@ from app.core.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware
from app.observability import metrics as app_metrics
from app.observability.sentry_scrub import scrub_pii_event
from app.services.tgbot.shared import close_telegram_client, init_telegram_client
logger = logging.getLogger(__name__)
@ -80,43 +78,31 @@ install_query_secret_filter()
# frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn:
from app.observability.sentry_scrub import (
drop_payments_disabled_event,
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_public_address,
stabilize_retry_error_fingerprint,
)
def _before_send(event: Event, hint: Hint) -> Event | None:
"""Композиция payments-disabled drop + платёжный body-wipe + PII-scrub +
Telegram bot-токен redaction + RetryError fingerprint-стабилизация
(#tgsupport-web, PR-D2, glitchtip-noise, #3471) — см.
app/tgbot_main.py._before_send (идентичная композиция без последнего
шага, тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже
держит TelegramClient в стек-фреймах при ошибке sendMessage, а
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
TelegramClient в стек-фреймах при ошибке sendMessage, а
include_local_variables=False ниже первый рубеж защиты.
#3471: payments-disabled drop идёт ПЕРВЫМ шагом — это единственный
процесс из трёх entrypoint'ов, который реально держит ASGI-роут
`/api/v1/payments/*`, поэтому именно здесь события возникают; ранний
return None экономит остальную композицию на заведомо отбрасываемом
событии.
PR-D2: платёжный body-wipe идёт следующим шагом, а не заменяет
остальные режет `request.data` целиком только для `/payments/*`,
остальные пути (extra/contexts/traceback) по-прежнему проходят
ключ-based scrub и token-redaction. Тот же обработчик передан ОБОИМ
каналам ниже (before_send и before_send_transaction) вчерашний баг в
Птице закрыл только error-канал, transaction-канал остался вообще без
обработчика.
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные
режет `request.data` целиком только для `/payments/*`, остальные пути
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
(before_send и before_send_transaction) вчерашний баг в Птице закрыл
только error-канал, transaction-канал остался вообще без обработчика.
RetryError-стабилизация этот процесс обслуживает /api/v1/geocode/*
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint."""
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
if dropped is None:
return None
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
@ -230,19 +216,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
# this API process never actually launched scheduler_loop() in production.
# Общий на приложение Telegram-клиент (#tg-connection-resilience): ручки
# support/glitchtip раньше создавали его на КАЖДЫЙ запрос, то есть каждое
# зеркалирование сообщения начиналось с полного TCP+TLS-хендшейка до
# api.telegram.org. Один пул keep-alive на процесс, закрываем на shutdown.
# Без токена не создаём: ручки в этом случае и так отвечают 503.
if settings.telegram_bot_token:
init_telegram_client()
try:
yield
finally:
await close_telegram_client()
yield
app = FastAPI(

View file

@ -99,47 +99,6 @@ BUILD_INFO = Gauge(
# смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно.
BUILD_INFO.labels(app="mera", release=f"{APP_VERSION}+{BUILD_SHA}").set(1)
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
#
# Источник списка — реальные `event_type` из `user_events` (миграция 184) плюс
# ручки, которые сами в этот аудит-лог не пишут (suggest, PDF-экспорт). Метки
# везде — фиксированный литерал из кода вызова (outcome/found/channel/result),
# НЕ значение из запроса: username, адрес, estimate_id в метку не идут —
# это ровно то, что взрывает кардинальность ряда у Prometheus.
ESTIMATES = Counter(
"mera_estimates_total",
"Запрошенных оценок trade-in, по исходу расчёта",
labelnames=("outcome",), # ok | insufficient_data
)
ADDRESS_SUGGESTIONS = Counter(
"mera_address_suggestions_total",
"Запросов автокомплита адреса (/geocode/suggest), нашёлся ли результат",
labelnames=("found",), # yes | no
)
REPORTS_EXPORTED = Counter(
"mera_reports_exported_total",
"Скачанных PDF-отчётов по оценке trade-in",
)
LEADS = Counter(
"mera_leads_total",
"Сохранённых контактных заявок (телефон + согласие) с результата оценки",
)
SUPPORT_MESSAGES = Counter(
"mera_support_messages_total",
"Сообщений в поддержку, дошедших до Telegram-топика, по каналу",
labelnames=("channel",), # web | anon
)
LOGINS = Counter(
"mera_logins_total",
"Попыток входа в личный кабинет, по исходу",
labelnames=("result",), # success | failed
)
def route_label(scope: Scope) -> str:
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.

View file

@ -25,7 +25,6 @@ import re
from typing import Any
from sentry_sdk.types import Event
from starlette.exceptions import HTTPException as _StarletteHTTPException
from tenacity import RetryError
_REDACTED = "[REDACTED]"
@ -257,41 +256,6 @@ def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | N
return event
_PAYMENTS_DISABLED_DETAIL = "payments are disabled"
def drop_payments_disabled_event(event: Event, hint: dict[str, Any]) -> Event | None:
"""before_send-хук: роняет 503 "payments are disabled" из
`payments.py._require_enabled` (issue #3471, GlitchTip-группа TRADE-IN-3GG).
Источник внутренний IP смоук-проверки: кнопки оплаты во фронте нет,
клиентского трафика на эти пути нет вообще, а выключенный платёжный контур
(`settings.payments_enabled=False`) штатно отвечает 503 на каждый такой
запрос 167 событий за 29.08-12.09 размывали ленту, на этом фоне терялась
настоящая ошибка. Само поведение ручки НЕ меняется (503 остаётся)
фильтруется только репортинг в трекер: sentry_sdk `StarletteIntegration`
репортит любой `HTTPException` с кодом из `failed_request_status_codes`
(по умолчанию весь диапазон 5xx) как error-событие, даже когда исключение
штатно обработано FastAPI и превращено в корректный HTTP-ответ.
Матчим `isinstance` реального объекта исключения из `hint["exc_info"]` (тот
же контракт, что `stabilize_retry_error_fingerprint` ниже) + точный текст
`detail` НЕ код 503 сам по себе, чтобы не проглотить другие 503 (напр.
будущий maintenance-режим другого роутера).
"""
if not isinstance(event, dict):
return event
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
if (
isinstance(exc_value, _StarletteHTTPException)
and exc_value.status_code == 503
and exc_value.detail == _PAYMENTS_DISABLED_DETAIL
):
return None
return event
_PUBLIC_API_URL_SEGMENT = "/api/public/"
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.

View file

@ -43,16 +43,14 @@ if settings.glitchtip_dsn:
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.types import Event, Hint
from app.observability.sentry_scrub import (
drop_payments_disabled_event,
scrub_payment_request_body,
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
def _before_send(event: Event, hint: Hint) -> Event | None:
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event
сегодня), но payments_confirm/payments_reconcile (PR-E, тот же
`tradein-scraper` контейнер) будут звать Т-Банк API отсюда belt-and-
@ -60,13 +58,6 @@ if settings.glitchtip_dsn:
`request`/`extra`. Тот же обработчик на оба канала ниже см.
app/main.py._before_send (идентичный мотив, не дублировать без причины).
#3471: payments-disabled drop — тот же belt-and-suspenders мотив, что и
payment body-wipe выше по докстрингу: этот процесс сегодня не отвечает
503 из `_require_enabled` (нет ASGI/роутов), реальный источник шума
`app/main.py`, но фильтр держим одинаковым во всех трёх entrypoint'ах,
чтобы поведение не разошлось, если payments-код когда-нибудь переедет
сюда же.
PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут
следом за платёжным body-wipe: этот процесс гоняет
`geocode_missing_listings` (ночной batch, сотни адресов за прогон)
@ -75,16 +66,13 @@ if settings.glitchtip_dsn:
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
См. sentry_scrub docstring.
"""
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
if dropped is None:
return None
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
scrubbed = scrub_pii_event(scrubbed, hint)
if scrubbed is None:
return None
return stabilize_retry_error_fingerprint(scrubbed, hint) # type: ignore[arg-type,return-value]
return stabilize_retry_error_fingerprint(scrubbed, hint)
sentry_sdk.init(
dsn=settings.glitchtip_dsn,

View file

@ -158,14 +158,10 @@ class DkpCorridor(BaseModel):
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
rooms + площади ±15% за период. Показывается как тонкая референсная линия
«коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит за
[low,high]×slack добавляется текстовая пометка.
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
линия «коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит
за [low,high]×slack добавляется текстовая пометка. НЕ хард-клампит оценку.
None / count=0 если по улице нет сопоставимых сделок.
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
порога не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
"""
count: int # число ДКП-сделок в выборке
@ -187,30 +183,6 @@ class DkpCorridor(BaseModel):
# None = сделки без даты (в проде не встречается) — потребитель молчит.
latest_deal_date: date | None = None
@computed_field # type: ignore[prop-decorator]
@property
def advisory_only(self) -> bool:
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
путей коридора: soft-кламп headline сверху и radius-floor снизу
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
цену зона n=3..9 на экране была неотличима от работающей.
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
deals-headline-fallback берёт медиану коридора начиная с трёх сделок.
Потребителю (витрине) поэтому корректно говорить про РАЗМЕР ВЫБОРКИ, а
не про то, что цену коридор не трогал.
Производное от count, поэтому верно во ВСЕХ конструкторах DkpCorridor
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
вторым числом.
"""
from app.core.config import settings # локально: schemas остаётся import-light
return self.count < settings.estimate_corridor_clamp_min_n
class PriceTrendPoint(BaseModel):
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).

View file

@ -1,464 +0,0 @@
"""Макро-ряды ЦБ РФ: ипотека по субъектам (XLSX) + ключевая ставка (SOAP).
CONTEXT: продукту нужен региональный макро-контекст ипотечного рынка (динамика ставок,
объёмов выдач и задолженности по субъектам РФ) и дневная ключевая ставка. Этот модуль
только собирает данные в cbr_mortgage_series / cbr_key_rate (292_cbr_macro_series.sql)
подключение к estimator вынесено в отдельное продуктовое решение (out of scope).
ИСТОЧНИК (ипотека): три XLSX-дашборда cbr.ru, wide-формат (территория × месяц),
строка 1 пустая, строка 2 заголовок-описание, строка 3 шапка периодов
(«Январь 2019», «Февраль 2019», ), строки 4+ территории в колонке A
(РФ целиком федеральные округа субъекты). Unpivot в длинный ряд
(region, period_month, series, value).
ИСТОЧНИК (ключевая ставка): POST https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx,
SOAP 1.1 (Content-Type: text/xml; charset=utf-8, SOAPAction: http://web.cbr.ru/KeyRate),
метод KeyRate(fromDate, ToDate). Ответ DataSet-XML со строками
<KR><DT>дата</DT><Rate>значение</Rate></KR> (namespace-агностичный парсинг: берём по
локальному имени тега, т.к. .NET DataSet оборачивает их в diffgram/reset-default-ns).
TLS: cbr.ru отдаёт RU-сертификат verify=False (open data, без auth/PII).
psycopg v3: CAST(:x AS type), НИКОГДА :x::type.
Дизайн-инвариант (как domrf_kapremont_loader): сервис-функции НЕ коммитят коммитит
caller (app/tasks/cbr_macro_pull.py). Изоляция сбоев try/except на серию,
db.rollback() в except (иначе аборченная транзакция каскадит на следующие серии).
"""
from __future__ import annotations
import io
import logging
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import date, datetime
import httpx
import openpyxl
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
DOWNLOAD_TIMEOUT_SEC = 60.0
# ---------------------------------------------------------------------------
# Ипотека по субъектам — конфиг серий
# ---------------------------------------------------------------------------
_MORTGAGE_BASE_URL = "https://www.cbr.ru/vfs/statistics/BankSector/Mortgage"
NEW_LOANS_URL = f"{_MORTGAGE_BASE_URL}/02_11_New_loans_mortgage.xlsx"
RATES_URL = f"{_MORTGAGE_BASE_URL}/02_13_Rates_mortgage.xlsx"
DEBT_URL = f"{_MORTGAGE_BASE_URL}/02_14_Debt_mortgage.xlsx"
@dataclass(frozen=True)
class CbrMortgageSeries:
slug: str
url: str
sheet: str
# По одной серии на файл (лист «в рублях» — основной ряд каждого дашборда).
CBR_MORTGAGE_SERIES: list[CbrMortgageSeries] = [
CbrMortgageSeries("new_loans_rub", NEW_LOANS_URL, "в рублях"),
CbrMortgageSeries("rate_rub", RATES_URL, "ставка в рублях"),
CbrMortgageSeries("debt_rub", DEBT_URL, "в рублях"),
]
@dataclass(slots=True, frozen=True)
class CbrMortgageRow:
region: str
period_month: date
series: str
value: float
# ---------------------------------------------------------------------------
# Период: русские имена месяцев → date(y, m, 1)
# ---------------------------------------------------------------------------
RU_MONTHS: dict[str, int] = {
"Январь": 1,
"Февраль": 2,
"Март": 3,
"Апрель": 4,
"Май": 5,
"Июнь": 6,
"Июль": 7,
"Август": 8,
"Сентябрь": 9,
"Октябрь": 10,
"Ноябрь": 11,
"Декабрь": 12,
}
def parse_ru_period(period_str: str) -> date:
"""Разобрать шапку периода «Январь 2019» → date(2019, 1, 1)."""
parts = str(period_str).strip().split()
if len(parts) != 2:
raise ValueError(f"cbr_macro: не удалось разобрать период {period_str!r}")
month_name, year_str = parts
month = RU_MONTHS.get(month_name)
if month is None:
raise ValueError(f"cbr_macro: неизвестное имя месяца {month_name!r} в {period_str!r}")
try:
year = int(year_str)
except ValueError as exc:
raise ValueError(f"cbr_macro: неверный год в периоде {period_str!r}") from exc
return date(year, month, 1)
def parse_period_cell(cell: object) -> date:
"""Разобрать ячейку шапки периода в первое число месяца.
ЦБ отдаёт шапку в двух формах, зависящих от файла:
* текст «Январь 2019» 02_11_New_loans_mortgage, 02_13_Rates_mortgage;
* настоящий datetime/date 02_14_Debt_mortgage.
Обе приводятся к date(y, m, 1).
"""
if isinstance(cell, datetime):
return date(cell.year, cell.month, 1)
if isinstance(cell, date):
return date(cell.year, cell.month, 1)
return parse_ru_period(str(cell))
# ---------------------------------------------------------------------------
# Unpivot листа (чистая функция — тестируется без сети/файла)
# ---------------------------------------------------------------------------
HEADER_SCAN_ROWS = 6
"""Сколько первых строк просматривать в поисках шапки периодов."""
MIN_HEADER_PERIODS = 6
"""Минимум распознанных периодов, чтобы считать строку шапкой (а не данными)."""
def _locate_header_row(rows: list[tuple[object, ...]]) -> tuple[int, dict[int, date]]:
"""Найти строку шапки периодов и колонки-периоды в ней.
Раскладка у файлов ЦБ РАЗНАЯ и жёстко фиксировать индекс строки нельзя:
* 02_11 / 02_13 строка 1 пустая, строка 2 описание, шапка в строке 3 (idx 2);
* 02_14 (задолженность) строка 1 описание, шапка уже в строке 2 (idx 1),
и периоды там datetime, а не «Январь 2019».
Ранее индекс был захардкожен на idx 2, из-за чего серия задолженности молча
давала ноль строк. Поэтому шапку ищем: берём строку с наибольшим числом
распознанных периодов среди первых HEADER_SCAN_ROWS.
"""
best_idx = -1
best_periods: dict[int, date] = {}
for row_idx, row in enumerate(rows[:HEADER_SCAN_ROWS]):
periods: dict[int, date] = {}
for col_idx, cell in enumerate(row):
if col_idx == 0 or cell is None or str(cell).strip() == "":
continue
try:
periods[col_idx] = parse_period_cell(cell)
except ValueError:
continue
if len(periods) > len(best_periods):
best_idx, best_periods = row_idx, periods
if len(best_periods) < MIN_HEADER_PERIODS:
return -1, {}
return best_idx, best_periods
def parse_mortgage_sheet(
rows: list[tuple[object, ...]], *, series_slug: str
) -> list[CbrMortgageRow]:
"""Unpivot wide-листа ЦБ (территория × месяц) в длинный ряд.
rows результат ws.iter_rows(values_only=True). Строка шапки периодов ищется
динамически (`_locate_header_row`), территории идут сразу после неё.
"""
if len(rows) < 3:
return []
header_idx, periods = _locate_header_row(rows)
if header_idx < 0:
logger.warning(
"cbr_macro: шапка периодов не найдена для series=%s — лист пропущен", series_slug
)
return []
out: list[CbrMortgageRow] = []
for data_row in rows[header_idx + 1 :]:
if not data_row or data_row[0] is None:
continue
region = str(data_row[0]).strip()
if not region:
continue
for col_idx, period_month in periods.items():
if col_idx >= len(data_row):
continue
raw_value = data_row[col_idx]
if raw_value is None:
continue
try:
value = float(raw_value)
except (TypeError, ValueError):
logger.warning(
"cbr_macro: пропуск ячейки region=%r period=%s series=%s — не число: %r",
region,
period_month,
series_slug,
raw_value,
)
continue
out.append(
CbrMortgageRow(
region=region, period_month=period_month, series=series_slug, value=value
)
)
return out
# ---------------------------------------------------------------------------
# Загрузка XLSX + upsert
# ---------------------------------------------------------------------------
_UPSERT_MORTGAGE_SQL = text("""
INSERT INTO cbr_mortgage_series (region, period_month, series, value, source, fetched_at)
VALUES (
CAST(:region AS text),
CAST(:period_month AS date),
CAST(:series AS text),
CAST(:value AS double precision),
'cbr',
now()
)
ON CONFLICT (region, period_month, series)
DO UPDATE SET
value = EXCLUDED.value
-- fetched_at НЕ трогаем (#2846 у sber_price_index): означает
-- «когда мы ВПЕРВЫЕ увидели этот период», а не время последней загрузки.
""")
def _upsert_mortgage_rows(db: Session, rows: list[CbrMortgageRow]) -> int:
for row in rows:
db.execute(
_UPSERT_MORTGAGE_SQL,
{
"region": row.region,
"period_month": row.period_month.isoformat(),
"series": row.series,
"value": row.value,
},
)
return len(rows)
def _download(url: str, *, client: httpx.Client) -> bytes:
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
def load_cbr_mortgage_series(
db: Session,
series: CbrMortgageSeries,
*,
client: httpx.Client | None = None,
dry_run: bool = False,
) -> dict[str, int]:
"""Скачать один XLSX-дашборд, разобрать один лист, upsert-нуть ряд.
Не коммитит коммитит caller.
"""
own_client = client is None
if own_client:
# cbr.ru отдаёт RU-сертификат НУЦ Минцифры → verify=False. Открытые данные,
# без auth/PII (прецедент: sber_index.py, domrf_kapremont_loader.py).
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
data = _download(series.url, client=client)
finally:
if own_client:
client.close()
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
try:
if series.sheet not in wb.sheetnames:
raise ValueError(
f"cbr_macro: лист {series.sheet!r} отсутствует в {series.url} "
f"(есть: {wb.sheetnames!r})"
)
ws = wb[series.sheet]
rows_raw = list(ws.iter_rows(values_only=True))
finally:
wb.close()
parsed = parse_mortgage_sheet(rows_raw, series_slug=series.slug)
upserted = 0 if dry_run else _upsert_mortgage_rows(db, parsed)
return {"rows": len(parsed), "upserted": upserted}
def pull_cbr_mortgage(
db: Session,
*,
series_list: list[CbrMortgageSeries] | None = None,
client: httpx.Client | None = None,
dry_run: bool = False,
) -> dict[str, int]:
"""Забрать все (или выбранные) серии ипотечной статистики.
Per-series try/except: одна сбойнувшая серия логируется и не роняет остальные
(урок #1345 у sber_index: без rollback аборченная транзакция каскадит дальше).
"""
if series_list is None:
series_list = CBR_MORTGAGE_SERIES
counters = {"upserted": 0, "skipped": 0, "errors": 0}
own_client = client is None
if own_client:
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
for series in series_list:
try:
result = load_cbr_mortgage_series(db, series, client=client, dry_run=dry_run)
if result["rows"] == 0:
logger.info("cbr_macro: пустой результат для series=%s", series.slug)
counters["skipped"] += 1
else:
counters["upserted"] += result["upserted"]
logger.info(
"cbr_macro: upserted %d rows for series=%s", result["upserted"], series.slug
)
except Exception:
db.rollback()
logger.exception("cbr_macro: series=%s сбойнула — пропуск", series.slug)
counters["errors"] += 1
finally:
if own_client:
client.close()
return counters
# ---------------------------------------------------------------------------
# Ключевая ставка — SOAP
# ---------------------------------------------------------------------------
CBR_SOAP_URL = "https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx"
KEY_RATE_START = date(2013, 9, 13) # инструмент введён Советом директоров ЦБ РФ
_KEY_RATE_ENVELOPE = """<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" \
xmlns:xsd="http://www.w3.org/2001/XMLSchema" \
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<KeyRate xmlns="http://web.cbr.ru/">
<fromDate>{from_date}</fromDate>
<ToDate>{to_date}</ToDate>
</KeyRate>
</soap:Body>
</soap:Envelope>"""
def build_key_rate_envelope(from_date: date, to_date: date) -> str:
return _KEY_RATE_ENVELOPE.format(from_date=from_date.isoformat(), to_date=to_date.isoformat())
def _local_tag(tag: str) -> str:
"""Локальное имя тега без namespace-префикса ('{ns}KR''KR')."""
return tag.rsplit("}", 1)[-1]
def parse_key_rate_response(xml_text: str) -> list[tuple[date, float]]:
"""Разобрать SOAP-ответ KeyRate: строки <KR><DT>…</DT><Rate>…</Rate></KR>.
Namespace-агностично (ищем по локальному имени тега) .NET DataSet
оборачивает строки в diffgram/reset-default-ns, точный путь не гарантирован.
"""
root = ET.fromstring(xml_text)
out: list[tuple[date, float]] = []
for el in root.iter():
if _local_tag(el.tag) != "KR":
continue
dt_text: str | None = None
rate_text: str | None = None
for child in el:
name = _local_tag(child.tag)
if name == "DT":
dt_text = child.text
elif name == "Rate":
rate_text = child.text
if not dt_text or rate_text is None:
continue
try:
rate_date = datetime.fromisoformat(dt_text).date()
rate = float(rate_text)
except (ValueError, TypeError):
logger.warning(
"cbr_macro: пропуск строки KeyRate — не распознана: DT=%r Rate=%r",
dt_text,
rate_text,
)
continue
out.append((rate_date, rate))
return out
def fetch_key_rate(
client: httpx.Client, *, from_date: date, to_date: date
) -> list[tuple[date, float]]:
body = build_key_rate_envelope(from_date, to_date)
headers = {
"Content-Type": "text/xml; charset=utf-8",
"SOAPAction": "http://web.cbr.ru/KeyRate",
}
resp = client.post(CBR_SOAP_URL, content=body.encode("utf-8"), headers=headers)
resp.raise_for_status()
return parse_key_rate_response(resp.text)
_UPSERT_KEY_RATE_SQL = text("""
INSERT INTO cbr_key_rate (rate_date, rate, fetched_at)
VALUES (CAST(:rate_date AS date), CAST(:rate AS double precision), now())
ON CONFLICT (rate_date)
DO UPDATE SET
rate = EXCLUDED.rate
-- fetched_at НЕ трогаем та же логика, что и у cbr_mortgage_series.
""")
def _upsert_key_rate_rows(db: Session, rows: list[tuple[date, float]]) -> int:
for rate_date, rate in rows:
db.execute(_UPSERT_KEY_RATE_SQL, {"rate_date": rate_date.isoformat(), "rate": rate})
return len(rows)
def load_key_rate(
db: Session,
*,
from_date: date | None = None,
to_date: date | None = None,
client: httpx.Client | None = None,
dry_run: bool = False,
) -> dict[str, int]:
"""Скачать ключевую ставку за диапазон дат, upsert-нуть в cbr_key_rate.
Не коммитит коммитит caller. from_date/to_date по умолчанию вся история
инструмента (KEY_RATE_START) до сегодня.
"""
if from_date is None:
from_date = KEY_RATE_START
if to_date is None:
to_date = date.today()
own_client = client is None
if own_client:
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
rows = fetch_key_rate(client, from_date=from_date, to_date=to_date)
finally:
if own_client:
client.close()
upserted = 0 if dry_run else _upsert_key_rate_rows(db, rows)
result = {"rows": len(rows), "upserted": upserted}
logger.info("cbr_macro: key_rate load DONE (dry_run=%s): %s", dry_run, result)
return result

View file

@ -34,6 +34,25 @@ from app.services.scraper_settings import get_scraper_delay
logger = logging.getLogger(__name__)
class _PoolCurlConfig(RealScraperConfig):
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
services.scraper.environment), а этот бэкфилл запускается ручкой
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
`settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
Флаг рубильник раскатки pool-режима для планировщика, а не решение «этому пути
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
"""
@property
def use_proxy_pool_curl(self) -> bool:
return True
@dataclass
class CianPriceHistoryResult:
checked: int = 0
@ -65,13 +84,7 @@ async def backfill_cian_price_history(
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
# (mark_banned на CianBlockedError / mark_health / release).
#
# Флаг читается из окружения как у всех (#3386 хвост): до #3387 у контейнера
# `backend` не было `USE_PROXY_POOL_CURL`, и здесь стоял подкласс с зашитым
# `use_proxy_pool_curl = True` — иначе `curl_proxy_url` игнорировал бы
# `proxy_provider`. Теперь переменная задана и сервису `backend` (compose), а
# зашитая константа делала рубильник неотключаемым ровно на этом пути.
scraper_config = RealScraperConfig()
scraper_config = _PoolCurlConfig()
proxy_provider = RealProxyProvider()
if listing_id is not None:
@ -131,7 +144,7 @@ async def backfill_cian_price_history(
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
# логом, который читается как «Циан нас блокирует».
logger.warning(
logger.error(
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
"на listing_id=%s (обработано %d из %d)",
exc,

View file

@ -201,7 +201,7 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
# ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый
# статичный узел мог быть источником бана, см. proxy_egress module docstring).
# Явный отказ вместо слепого прохода через заведомо подозрительный egress.
logger.warning(
logger.error(
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
"cookies НЕ помечены протухшими, retry на следующем такте",
exc,

View file

@ -349,7 +349,6 @@ async def suggest_addresses(
limit: int = 8,
city: str | None = "Екатеринбург",
region: str | None = None,
regions: list[str] | None = None,
) -> list[DadataSuggestion]:
"""Автокомплит адресов через DaData /suggest/address.
@ -368,13 +367,6 @@ async def suggest_addresses(
БЕЗ типа («Свердловская», а тип отдельно в `region_type`="обл").
Передашь «Свердловская область» совпадений не будет, и запрос
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
regions: НЕСКОЛЬКО регионов разом `locations` у DaData это список, и
элементы в нём складываются по ИЛИ. Нужно там, где один регион даёт
заведомо неверный ответ: адрес Авито по Москве и области («Юбилейная
ул.,20Б») лежит либо в 77, либо в 50, и констрейнт из одного региона
молча притягивает подмосковный дом к московской улице. Имеет
приоритет над `region`/`city`. Имена так же БЕЗ типа («Москва»,
«Московская»).
Returns:
list[DadataSuggestion] пустой список если:
@ -402,11 +394,7 @@ async def suggest_addresses(
"query": query.strip(),
"count": max(1, min(int(limit), 20)),
}
if regions:
# Список → ИЛИ по регионам (см. docstring). Пустые имена выбрасываем:
# `{"region": ""}` — не «любой регион», а гарантированный ноль хитов.
body["locations"] = [{"region": r} for r in regions if r and r.strip()]
elif region:
if region:
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,

View file

@ -1,157 +0,0 @@
"""Ключ города ДКП-сделки для ценовых полос (#3051 «Москва», округа).
ПРОБЛЕМА. deal_city_price_bands ключуется (region_code, city), а deals.city у
ВСЕХ 212 937 московских сделок буквально 'Москва' на весь город получалась
ОДНА полоса 34221..718870 /м². Москва неоднородна на порядок (Хамовники против
Некрасовки), поэтому единая полоса одновременно и не режет опечатки в дорогом
центре, и режет легитимный рынок на окраинах.
РЕШЕНИЕ. Ключом города становится
COALESCE(NULLIF(raw_payload->>'src_city', ''), city)
у московских сделок Росреестра raw_payload.src_city несёт муниципальный округ
('муниципальный округ Хамовники' и т.п.): заполнен у 198 600 из 212 937 сделок
(93.27%), 197 различных значений. Оставшиеся 6.73% (14 376 сделок) отдают
'Москва' и образуют СВОЮ строку-фолбэк (n=14376 tier 'full', полоса
22475..772165), а не проваливаются в глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2,
откалиброванные под Екатеринбург. Ключи дизъюнктны: либо 'муниципальный округ X',
либо ровно 'Москва' b.city = ключ_сделки всегда матчит одну строку.
ИНВАРИАНТ РЕГИОНА 66. У сделок Свердловской области src_city пуст у ВСЕХ
108 623 строк, поэтому COALESCE отдаёт city и ключ не меняется. Проверено на
проде 2026-09-10: сделок региона 66, где ключ отличается от city, 0 штук;
пересчёт по новому выражению даёт те же 383 строки полос, все совпадают с
текущими по (ppm2_min, ppm2_max, n_deals, tier).
NULL-семантика. Если raw_payload отсутствует целиком (NULL), то
NULL->>'src_city' = NULL NULLIF(NULL,'') = NULL COALESCE отдаёт city.
Если src_city есть, но пустая строка NULLIF гасит её в NULL, тот же исход.
Питоновский helper ниже повторяет эту семантику один-в-один (пустая строка
считается отсутствующей, пробелы НЕ подрезаются SQL их тоже не подрезает).
Модуль намеренно крошечный и без зависимостей: выражение обязано быть ОДНИМ на
derivation (app/tasks/deal_city_price_bands_refresh.py) и на все читающие места
(app/services/estimator.py). Три копии выражения = три места, где полосы
разъезжаются молча.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
# Имя вычисляемой колонки-ключа в SELECT'ах, которые читают сделки для питонового
# пути фильтрации (_fetch_deals → _is_plausible_deal).
DEAL_CITY_KEY_COLUMN = "city_key"
def deal_city_key_sql(alias: str = "d") -> str:
"""SQL-выражение ключа города сделки.
alias префикс таблицы deals в запросе ('d' для `FROM deals d`, '' для
`FROM deals` без алиаса, как в derivation задачи полос).
"""
prefix = f"{alias}." if alias else ""
return f"COALESCE(NULLIF({prefix}raw_payload->>'src_city', ''), {prefix}city)"
def deal_city_key(row: Mapping[str, Any]) -> str | None:
"""Питоновский эквивалент deal_city_key_sql для уже прочитанной строки сделки.
Порядок: готовая колонка DEAL_CITY_KEY_COLUMN (её считает SQL) src_city из
raw_payload, если строку прочитали вместе с payload city. Пустая строка
трактуется как отсутствие значения как NULLIF(x, '') в SQL.
"""
key = row.get(DEAL_CITY_KEY_COLUMN)
if key:
return str(key)
raw = row.get("raw_payload")
if isinstance(raw, Mapping):
src = raw.get("src_city")
if src:
return str(src)
city = row.get("city")
return str(city) if city else None
# ── Двухступенчатый поиск полосы (#3051, разрыв на деплое) ───────────────────
#
# Таблицу deal_city_price_bands наполняет НОЧНАЯ задача, а читающая сторона
# уезжает на ключ-округ сразу с деплоем. В окне между деплоем и первым рефрешем
# по региону 77 в таблице лежит ровно ОДНА строка city='Москва': 93.27%
# московских сделок искали бы ключ 'муниципальный округ X', не находили и падали
# на глобальные DEAL_MIN_PPM2=50000 / DEAL_MAX_PPM2=800000 (калибровка ЕКБ) —
# нижняя граница прыгала бы с 34221 до 50000 и молча выбрасывала легитимно
# дешёвые сделки. Это ХУЖЕ, чем было до правки.
#
# Поэтому поиск полосы ДВУХСТУПЕНЧАТЫЙ и одинаковый во всех трёх читающих местах
# (два SQL-джойна ДКП-коридора + питоновский путь _fetch_deals):
# ступень 1 — строка по ключу-округу (deal_city_key / deal_city_key_sql);
# ступень 2 — строка по deals.city;
# ступень 3 — глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2.
# Порядок деплоя перестаёт иметь значение, а округ, для которого строки ещё нет
# (свежий округ, n<10, мусорное значение src_city), деградирует в ГОРОДСКУЮ
# полосу, а не в екатеринбургскую калибровку.
#
# Ступень не расщепляется по границам: ppm2_min и ppm2_max в таблице NOT NULL
# (data/sql/178_deal_city_price_bands.sql), значит найденная строка отдаёт ОБЕ
# границы — COALESCE не может взять min из округа, а max из города.
#
# ИНВАРИАНТ РЕГИОНА 66. src_city пуст у всех его 108 623 сделок → ключ ступени 1
# равен ключу ступени 2, обе ступени находят одну и ту же строку, результат
# байт-в-байт прежний. Вторая ступень для него — тавтология, не изменение.
DEAL_CITY_BAND_ALIAS = "b" # ступень 1: строка по ключу-округу
DEAL_CITY_BAND_FALLBACK_ALIAS = "bc" # ступень 2: строка по deals.city
def deal_city_band_join_sql(alias: str = "d", indent: str = "") -> str:
"""Два LEFT JOIN'а к deal_city_price_bands: ступень 1 (округ) + ступень 2 (город).
alias префикс таблицы deals, indent отступ строк со 2-й (косметика SQL).
"""
prefix = f"{alias}." if alias else ""
b, bc = DEAL_CITY_BAND_ALIAS, DEAL_CITY_BAND_FALLBACK_ALIAS
lines = [
f"LEFT JOIN deal_city_price_bands {b}",
f" ON {b}.region_code = {prefix}region_code",
f" AND {b}.city = {deal_city_key_sql(alias)}",
f"LEFT JOIN deal_city_price_bands {bc}",
f" ON {bc}.region_code = {prefix}region_code",
f" AND {bc}.city = {prefix}city",
]
return ("\n" + indent).join(lines)
def deal_city_band_bounds_sql(
min_param: str = ":ppm_min", max_param: str = ":ppm_max", indent: str = ""
) -> str:
"""Границы полосы одним выражением: округ → город → глобальные константы."""
b, bc = DEAL_CITY_BAND_ALIAS, DEAL_CITY_BAND_FALLBACK_ALIAS
lines = [
f"BETWEEN COALESCE({b}.ppm2_min, {bc}.ppm2_min, CAST({min_param} AS int))",
f" AND COALESCE({b}.ppm2_max, {bc}.ppm2_max, CAST({max_param} AS int))",
]
return ("\n" + indent).join(lines)
def resolve_city_band(
bands: Mapping[tuple[int, str], tuple[int, int]] | None,
region_code: int,
city_key: str | None,
city: str | None,
default: tuple[int, int],
) -> tuple[int, int]:
"""Питоновский эквивалент двух LEFT JOIN'ов выше: округ → город → default.
Ступени и их порядок обязаны совпадать с SQL-версией: разъехавшийся порядок
означал бы, что питоновский фильтр сделок судит по другой полосе, чем
ДКП-коридор на тех же данных.
"""
table = bands or {}
for key in (city_key, city):
if key is None:
continue
band = table.get((region_code, key))
if band is not None:
return band
return default

View file

@ -1,157 +0,0 @@
"""Радиусные агрегаты ДТП вокруг точки — `dtp_incidents` (#3410).
Отдельный модуль, не расширение `location_index.py`: та модель считает ЦЕНОВОЙ индекс
(медиана /м² локально vs по городу) с фиксированным bbox продукт-ядра города другая
предметная область и другой контракт результата (status/coverage у location_index
завязан на `regions_mod`/`bbox_product_core`, ДТП покрывает всю область без city-bbox
ограничения источника). Общее только сам паттерн запроса (bbox-префильтр +
`ST_DWithin(geom::geography, )`) и dtp_incidents/osm_poi_ekb_local roднит один и тот же
"пустая таблица -> unavailable" graceful fallback (см. `_fetch_nearby_poi` в
location_index.py) переиспользован здесь буквально.
Bbox-префильтр здесь, в отличие от location_index (там фиксированный bbox city-ядра),
строится ВОКРУГ ТОЧКИ по радиусу (`_bbox_from_point`) dtp_incidents не ограничена
одним городом, фиксированного bbox покрытия нет. Дешёвый lat/lon BETWEEN перед
ST_DWithin тот же приём, что в шаблоне recon_geo.md п.3.
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
"""
from __future__ import annotations
import logging
import math
from typing import Any
from pydantic import BaseModel
from sqlalchemy import text
logger = logging.getLogger(__name__)
# Радиус по умолчанию для "фактора безопасности" района — сопоставим по порядку
# величины с DEFAULT_POI_RADIUS_M=1200 в location_index.py (тот же масштаб "пешком от
# дома"), но не импортируется оттуда: разные предметные области, совпадение числа
# не должно создавать ложную связь между модулями.
DEFAULT_DTP_RADIUS_M = 1000
# Сколько лет ДТП назад учитывать по умолчанию — источник копит данные с 2015 (см.
# докстринг dtp_stat_loader.py), но "фактор безопасности" района должен отражать
# ТЕКУЩУЮ ситуацию, а не десятилетнюю историю.
DEFAULT_YEARS_LOOKBACK = 5
_METERS_PER_DEGREE_LAT = 111_320.0
def _bbox_from_point(lat: float, lon: float, radius_m: int) -> tuple[float, float, float, float]:
"""(south, north, west, east) — bbox вокруг точки радиусом radius_m (приближённо)."""
lat_delta = radius_m / _METERS_PER_DEGREE_LAT
lon_scale = math.cos(math.radians(lat))
# Защита от вырождения делителя у полюсов — для Свердловской обл. (~56-61° с.ш.)
# cos никогда не приближается к нулю, но защита дешёвая и делает функцию safe везде.
lon_delta = radius_m / (_METERS_PER_DEGREE_LAT * max(lon_scale, 0.01))
return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta)
class DtpAreaStats(BaseModel):
"""Результат compute_dtp_stats."""
status: str # "ok" | "unavailable" (dtp_incidents пуста — рефреш ещё не запускался)
radius_m: int
years: int
incidents_count: int
severe_count: int
dead: int
injured: int
_DTP_STATS_SQL = text(
"""
SELECT
count(*) AS incidents_count,
count(*) FILTER (WHERE severity = 'Тяжёлый') AS severe_count,
COALESCE(sum(dead), 0) AS dead_total,
COALESCE(sum(injured), 0) AS injured_total
FROM dtp_incidents
WHERE dtp_at >= NOW() - make_interval(years => CAST(:years AS integer))
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
AND CAST(:bbox_east AS double precision)
AND ST_DWithin(
geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
CAST(:radius_m AS double precision)
)
"""
)
def compute_dtp_stats(
db: Any,
lat: float,
lon: float,
*,
radius_m: int = DEFAULT_DTP_RADIUS_M,
years: int = DEFAULT_YEARS_LOOKBACK,
) -> DtpAreaStats:
"""Посчитать агрегаты ДТП в радиусе `radius_m` вокруг точки за последние `years` лет.
Graceful fallback: `dtp_incidents` пуста (рефреш ещё не запускался на этом
окружении, source dormant по умолчанию миграция 295) -> status="unavailable",
нули НЕ фабрикуются. Ноль инцидентов при непустой таблице легитимный "ok"
результат (в радиусе правда ничего не было), отличается статусом от "нет данных".
"""
total = db.execute(text("SELECT count(*) FROM dtp_incidents")).scalar() or 0
if total == 0:
logger.warning(
"dtp_index: dtp_incidents пуста (рефреш ещё не запускался на этом "
"окружении) — unavailable, без сфабрикованных нулей"
)
return DtpAreaStats(
status="unavailable",
radius_m=radius_m,
years=years,
incidents_count=0,
severe_count=0,
dead=0,
injured=0,
)
bbox_south, bbox_north, bbox_west, bbox_east = _bbox_from_point(lat, lon, radius_m)
row = (
db.execute(
_DTP_STATS_SQL,
{
"lat": lat,
"lon": lon,
"radius_m": radius_m,
"years": years,
"bbox_south": bbox_south,
"bbox_north": bbox_north,
"bbox_west": bbox_west,
"bbox_east": bbox_east,
},
)
.mappings()
.first()
)
if row is None:
return DtpAreaStats(
status="ok",
radius_m=radius_m,
years=years,
incidents_count=0,
severe_count=0,
dead=0,
injured=0,
)
return DtpAreaStats(
status="ok",
radius_m=radius_m,
years=years,
incidents_count=int(row["incidents_count"] or 0),
severe_count=int(row["severe_count"] or 0),
dead=int(row["dead_total"] or 0),
injured=int(row["injured_total"] or 0),
)

View file

@ -1,281 +0,0 @@
"""dtp-stat.ru loader: слой ДТП по Свердловской обл. в `dtp_incidents` (#3410).
CONTEXT: карточка района МЕРЫ хочет "фактор безопасности" сколько ДТП/тяжёлых/
погибших/раненых было рядом за последние годы. dtp-stat.ru агрегирует открытые данные
ГИБДД в GeoJSON по регионам, без auth.
ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip ZIP
(~5 МБ) с одним файлом `sverdlovskaia-oblast.geojson` (~71 МБ распакованного,
FeatureCollection, ~31k Point-фич). Проверено живьём 08.09.2026: HTTP 200,
5 047 685 байт. Домен обычный (не RU-gov минцифровский сертификат) verify=False
НЕ ставим, лишний.
МОНИТОРИНГ ПРОТУХАНИЯ (не реализован в этом PR зафиксировано, чтобы следующий не
считал пустую дельту багом): на момент проверки 08.09.2026 дамп источника заморожен,
Last-Modified 26.02.2026. TRUNCATE+INSERT переливает те же ~31k строк каждый рефреш
это ожидаемо, а не признак сломанного парсинга. Если нужен freshness-монитор см.
паттерн `recon_macro.md` (СберИндекс), здесь не сделан осознанно (вне скоупа #3410).
СТРИМИНГ (обязательно, файл большой): `json.load()` целиком держал бы ~71 МБ + объектный
граф в памяти разом. Используем `ijson.items(stream, "features.item")` поверх
файлового объекта из `zipfile.ZipFile.open(name)` постоянная память вне зависимости
от размера файла, каждая feature обрабатывается и отбрасывается по одной.
ПДн НЕ СОХРАНЯЕМ (осознанное решение, зафиксировано и в 294_dtp_incidents.sql):
`properties.vehicles[].participants[]` источника несёт пол/роль/нарушения физлиц
это персональные данные конкретных людей, продукту не нужны и не разрешены. Парсер
(`_parse_feature`) НИКОГДА не читает ключи `vehicles`/`participants` ни в raw-jsonb,
ни отдельной колонкой. Из `properties` берём только неперсональные агрегаты и атрибуты
происшествия (see DtpIncidentRow).
Идемпотентность: TRUNCATE + bulk INSERT в одной транзакции (тот же паттерн, что
`app/tasks/osm_poi_ekb_refresh.py` полная замена, не upsert; повторный прогон с тем
же дампом даёт тот же результат).
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
Массивы (`weather`, `nearby`, тип `text[]`) psycopg v3 адаптирует Python `list[str]`
в `text[]` напрямую через `CAST(:x AS text[])` (тот же идиом, что
`app/tasks/deactivate_stale_avito.py`).
"""
from __future__ import annotations
import io
import logging
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import IO, Any
import httpx
import ijson
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
SRC_URL = "https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip"
DOWNLOAD_TIMEOUT_SEC = 180
INSERT_CHUNK_SIZE = 2000
# properties.datetime — "2015-01-01 03:00:00", без явной таймзоны в источнике.
# Храним как naive → колонка timestamptz получит его в timezone сессии БД (обычно UTC).
# Источник не публикует TZ, поэтому точная привязка к МСК не гарантирована — для
# радиусных агрегатов по годам (dtp_index.py) это несущественно.
_DT_FMT = "%Y-%m-%d %H:%M:%S"
@dataclass(slots=True)
class DtpIncidentRow:
"""Одна строка ДТП — ТОЛЬКО неперсональные атрибуты (см. докстринг модуля)."""
source_id: str
dtp_at: datetime | None
category: str | None
severity: str | None
dead: int | None
injured: int | None
address: str | None
light: str | None
weather: list[str] | None
nearby: list[str] | None
lat: float
lon: float
def _parse_datetime(raw: Any) -> datetime | None:
if not isinstance(raw, str) or not raw.strip():
return None
try:
return datetime.strptime(raw.strip(), _DT_FMT)
except ValueError:
logger.warning("dtp_stat_loader: не распарсен datetime %r", raw)
return None
def _parse_str_list(raw: Any) -> list[str] | None:
if not isinstance(raw, list) or not raw:
return None
return [str(item) for item in raw if item is not None]
def _parse_int(raw: Any) -> int | None:
if isinstance(raw, bool):
return None
if isinstance(raw, int):
return raw
return None
# Санити-рамка координат. Выгрузка dtp-stat по Свердловской области содержит битые
# точки: на реальном дампе 2026-02-26 из 31 253 записей 74 лежат вне широт региона,
# в том числе 10 ровно в (0.0, 0.0) и такие значения, как (1.0, 1.0) и (41.0, 47.0).
# Пускать их в geo-таблицу нельзя: слой кормит радиусные агрегаты локации.
# Рамка намеренно шире официальных границ области (~56.0-62.3 N, ~57.2-66.2 E),
# чтобы не срезать легитимные приграничные точки.
REGION_LAT_RANGE = (55.0, 63.5)
REGION_LON_RANGE = (56.0, 67.5)
def _coords_plausible(lat: float, lon: float) -> bool:
"""Точка похожа на реальное ДТП в регионе выгрузки, а не на мусор источника."""
if lat == 0.0 and lon == 0.0:
return False
if not (REGION_LAT_RANGE[0] <= lat <= REGION_LAT_RANGE[1]):
return False
return REGION_LON_RANGE[0] <= lon <= REGION_LON_RANGE[1]
def _parse_feature(feature: dict[str, Any]) -> DtpIncidentRow | None:
"""Чистая функция: одна GeoJSON Feature -> DtpIncidentRow, либо None (пропуск).
НЕ читает `properties.vehicles` / `properties.participants` намеренно (ПДн,
см. докстринг модуля). Пропускает фичи без валидной Point-геометрии/id, а также
с координатами вне санити-рамки региона (см. `_coords_plausible`).
"""
geometry = feature.get("geometry") or {}
if geometry.get("type") != "Point":
return None
coords = geometry.get("coordinates")
if not isinstance(coords, list) or len(coords) < 2:
return None
try:
lon, lat = float(coords[0]), float(coords[1])
except (TypeError, ValueError):
return None
if not _coords_plausible(lat, lon):
return None
props = feature.get("properties") or {}
source_id = props.get("id")
if source_id is None:
return None
return DtpIncidentRow(
source_id=str(source_id),
dtp_at=_parse_datetime(props.get("datetime")),
category=props.get("category") if isinstance(props.get("category"), str) else None,
severity=props.get("severity") if isinstance(props.get("severity"), str) else None,
dead=_parse_int(props.get("dead_count")),
injured=_parse_int(props.get("injured_count")),
address=props.get("address") if isinstance(props.get("address"), str) else None,
light=props.get("light") if isinstance(props.get("light"), str) else None,
weather=_parse_str_list(props.get("weather")),
nearby=_parse_str_list(props.get("nearby")),
lat=lat,
lon=lon,
)
def iter_incidents(stream: IO[bytes]) -> Iterator[DtpIncidentRow]:
"""Потоковый парс GeoJSON FeatureCollection -> DtpIncidentRow, по одной фиче за раз.
`stream` бинарный файловый объект (из `zipfile.ZipFile.open()` или обычного
`open(path, "rb")`). Использует `ijson.items(..., "features.item")` постоянная
память, НЕ читает файл целиком.
"""
for feature in ijson.items(stream, "features.item"):
row = _parse_feature(feature)
if row is not None:
yield row
def _open_geojson_in_zip(zf: zipfile.ZipFile) -> IO[bytes]:
names = [n for n in zf.namelist() if n.lower().endswith(".geojson")]
if not names:
raise ValueError(f"zip не содержит .geojson записей: {zf.namelist()!r}")
return zf.open(names[0])
def download_dtp_zip(*, client: httpx.Client) -> bytes:
"""Скачать ZIP dtp-stat.ru. Открытые данные, без auth/PII — стандартный verify."""
resp = client.get(SRC_URL, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
_TRUNCATE_SQL = text("TRUNCATE dtp_incidents")
_INSERT_SQL = text(
"""
INSERT INTO dtp_incidents
(source_id, dtp_at, category, severity, dead, injured, address, light,
weather, nearby, lat, lon, geom)
VALUES
(CAST(:source_id AS text), CAST(:dtp_at AS timestamptz),
CAST(:category AS text), CAST(:severity AS text),
CAST(:dead AS integer), CAST(:injured AS integer),
CAST(:address AS text), CAST(:light AS text),
CAST(:weather AS text[]), CAST(:nearby AS text[]),
CAST(:lat AS double precision), CAST(:lon AS double precision),
ST_SetSRID(ST_MakePoint(CAST(:lon AS double precision), CAST(:lat AS double precision)),
4326))
ON CONFLICT (source_id) DO NOTHING
"""
)
def _row_params(row: DtpIncidentRow) -> dict[str, Any]:
return {
"source_id": row.source_id,
"dtp_at": row.dtp_at,
"category": row.category,
"severity": row.severity,
"dead": row.dead,
"injured": row.injured,
"address": row.address,
"light": row.light,
"weather": row.weather,
"nearby": row.nearby,
"lat": row.lat,
"lon": row.lon,
}
def load_dtp_incidents(
db: Session,
*,
src_path: str | Path | None = None,
client: httpx.Client | None = None,
chunk_size: int = INSERT_CHUNK_SIZE,
dry_run: bool = False,
) -> dict[str, int]:
"""TRUNCATE dtp_incidents; потоковый парс + bulk INSERT из ZIP dtp-stat.ru.
`src_path` локальный ZIP (тесты/ручной прогон); если None качаем `SRC_URL`
через `client` (обязателен параметром, чтобы был чем подменить в тестах;
создаётся временный `httpx.Client()` если не передан).
`dry_run=True` парсит и считает строки, НЕ трогает БД (ни TRUNCATE, ни INSERT).
Не коммитит коммитит caller (app/tasks/dtp_stat_refresh.py).
"""
owns_client = client is None
client = client or httpx.Client()
try:
if src_path is not None:
with zipfile.ZipFile(src_path) as zf, _open_geojson_in_zip(zf) as stream:
rows = list(iter_incidents(stream))
else:
data = download_dtp_zip(client=client)
with zipfile.ZipFile(io.BytesIO(data)) as zf, _open_geojson_in_zip(zf) as stream:
rows = list(iter_incidents(stream))
finally:
if owns_client:
client.close()
parsed = len(rows)
inserted = 0
if not dry_run:
db.execute(_TRUNCATE_SQL)
for i in range(0, len(rows), chunk_size):
chunk = rows[i : i + chunk_size]
result = db.execute(_INSERT_SQL, [_row_params(r) for r in chunk])
inserted += result.rowcount or 0
result_counts = {"parsed": parsed, "inserted": inserted}
logger.info("dtp_stat_loader load DONE (dry_run=%s): %s", dry_run, result_counts)
return result_counts

File diff suppressed because it is too large Load diff

View file

@ -1,58 +0,0 @@
"""ФНС opendata lookup по ИНН — тонкое чтение `fns_legal_entity_facts`.
CONTEXT: см. `fns_opendata_loader.py`. Этот модуль единственная точка чтения
загруженных фактов. ПОТРЕБИТЕЛЯ У НЕГО ПОКА НЕТ: ничто в продукте (скоринг
застройщика/УК, estimator и т.п.) сюда не ходит подключение решается отдельной
задачей вне этого PR.
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
"""
from __future__ import annotations
from collections import defaultdict
from datetime import date
from typing import TypedDict
from sqlalchemy import text
from sqlalchemy.orm import Session
_LOOKUP_SQL = text(
"""
SELECT dataset, series, period, value, org_name
FROM fns_legal_entity_facts
WHERE inn = CAST(:inn AS text)
ORDER BY dataset, series, period
"""
)
class FnsFact(TypedDict):
series: str
period: date
value: float
org_name: str | None
def get_facts_by_inn(db: Session, inn: str) -> dict[str, list[FnsFact]]:
"""Факты по ИНН, сгруппированные по dataset (`revexp`/`sshr2019`/`debtam`/`snr`).
Пустой/пробельный ИНН и отсутствие данных `{}` (не исключение вызывающий код
не обязан оборачивать lookup в try/except ради нормального «нет данных»).
"""
normalized = (inn or "").strip()
if not normalized:
return {}
rows = db.execute(_LOOKUP_SQL, {"inn": normalized}).mappings().all()
out: dict[str, list[FnsFact]] = defaultdict(list)
for row in rows:
out[row["dataset"]].append(
{
"series": row["series"],
"period": row["period"],
"value": row["value"],
"org_name": row["org_name"],
}
)
return dict(out)

View file

@ -1,497 +0,0 @@
"""ФНС opendata loader: доходы/расходы, ССЧ, недоимка, спецрежимы юрлиц → lookup по ИНН.
CONTEXT: открытых данных ЕГРН по правообладателям-физлицам не существует (218-ФЗ
ст. 62) это жёсткий блокер. По ЮРЛИЦАМ данные открыты, лицензия ФНС
(nalog.gov.ru/opendata) разрешает переработку и перераспространение легальный обход
для того среза, где он в принципе доступен. Наборы (маска slug'а: 7707329152-<slug>):
revexp доходы и расходы;
sshr2019 среднесписочная численность;
debtam недоимка и задолженность;
snr спецрежимы.
ЕГРЮЛ-данных здесь НЕТ: ни адреса, ни ОКВЭД, ни учредителей только ИНН + наименование
+ показатели набора.
ПОТРЕБИТЕЛЯ У ЭТОГО СЕРВИСА ПОКА НЕТ. Этот модуль (+ fns_lookup.py) только
загрузка и lookup по ИНН. Подключение к продукту (например, скоринг застройщика/УК
в оценке) сюда сознательно не входит и не реализовано это решается отдельной
задачей вне текущего PR. `estimator.py` не тронут.
ИСТОЧНИК: https://www.nalog.gov.ru/opendata/7707329152-<slug>/ HTML-каталог набора.
Прямая ссылка на .zip вида
https://file.nalog.ru/opendata/7707329152-revexp/data-20260825-structure-20180110.zip
резолвится ДИНАМИЧЕСКИ парсом href со страницы каталога (`resolve_dataset_file_url`):
дата в имени файла меняется с каждой публикацией набора хардкодить URL нельзя,
протухнет на следующей публикации. Внутри .zip множество XML-файлов.
ПАРСИНГ XML generic-харвестер, а не хардкод конкретных тегов набора. Точная схема
атрибутов XSD structure-20180110 варьируется по набору и НЕ была вживую сверена в
этом окружении (нет сетевого доступа к file.nalog.ru отсюда). Вместо этого: любой
XML-элемент, несущий ИНН-подобный атрибут (ИННЮЛ/ИНН данные ФНС opendata, как и
ГАР/ФИАС, лежат в атрибутах элементов, не в тексте, см. gar_flats_loader.py), даёт
identity строки; ЛЮБОЙ его собственный числовой атрибут (кроме ИНН/КПП/ОКПО/ОКТМО/
ОКВЭД/ОГРН и атрибутов-имени) становится отдельным фактом (series=имя атрибута,
value=число). Это устойчиво к точным названиям показателей набора ценой чуть более
широкого набора series, чем «официальный» словарь показателей ПЕРЕД первым боевым
прогоном на реальном .zip стоит свериться с фактическим XML и, если харвестер тянет
лишнее (например служебные коды), сузить `_ID_ATTRS_EXCLUDE`.
ПАМЯТЬ: .zip целиком лежит в памяти как байты (httpx возвращает `.content` иначе
не проверить целостность архива до распаковки), но НИ ОДИН XML-член НЕ
распаковывается в память/на диск целиком: каждый открывается потоково через
`zf.open(name)` и парсится `lxml.etree.iterparse` с очисткой обработанных элементов
(тот же приём, что в gar_flats_loader.py, там на многогигабайтных standalone XML,
здесь на множестве XML внутри одного архива, открываемых по одному).
TLS: nalog.gov.ru/file.nalog.ru отдают сертификат НУЦ Минцифры httpx с default
trust store не верифицирует verify=False. Открытые данные, без auth/PII.
Прецеденты: sber_index.py, domrf_kapremont_loader.py.
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
"""
from __future__ import annotations
import io
import logging
import re
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date
from urllib.parse import urljoin
import httpx
from lxml import etree
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
CATALOG_URL_TEMPLATE = "https://www.nalog.gov.ru/opendata/7707329152-{slug}/"
DATASET_SLUGS: tuple[str, ...] = ("revexp", "sshr2019", "debtam", "snr")
DOWNLOAD_TIMEOUT_SEC = 300
UPSERT_CHUNK_SIZE = 500
# Атрибуты-идентификаторы/классификаторы — не показатели, исключаем из харвеста.
_INN_ATTRS = ("ИННЮЛ", "ИНН")
_NAME_ATTRS = ("НаимОрг", "НаимОрганизации", "НаимОрганизацииПолн", "НаимЮЛПолн")
_PERIOD_ATTRS = ("ДатаСост",)
# Служебные атрибуты, которые парсятся как числа, но фактом не являются:
# идентификаторы, коды и даты документа.
_ID_ATTRS_EXCLUDE = frozenset(
{
"ИННЮЛ",
"ИНН",
"КПП",
"ОКПО",
"ОКТМО",
"ОКВЭД",
"ОГРН",
"ИдДок",
"ИдФайл",
"ДатаДок",
"ДатаСост",
"ВерсФорм",
"ВерсПрог",
"КолДок",
"ТипИнф",
*_NAME_ATTRS,
}
)
_RECORD_TAGS = frozenset({"Документ", "Док", "СвЮЛ", "Сведения"})
"""Теги, на которых собирается запись. Реальные выгрузки ФНС используют `Документ`;
остальные запас под соседние наборы. Ограничение обязательно: иначе `Файл`
переоткрывал бы уже собранные документы и удваивал записи."""
_ZIP_HREF_RE = re.compile(r'href="([^"]+\.zip)"', re.IGNORECASE)
_PUBLISH_DATE_RE = re.compile(r"data-(\d{8})-")
# ─────────────────────────────────────────────────────────────────────────────
# Резолв ссылки со страницы каталога (URL меняется на каждой публикации)
# ─────────────────────────────────────────────────────────────────────────────
def resolve_dataset_file_url(html: str, *, base_url: str) -> tuple[str, date | None]:
"""Ссылка на .zip актуальной версии набора, снятая со страницы каталога.
Ищет href, оканчивающийся на .zip и несущий токен даты `data-YYYYMMDD-` эта
дата меняется с каждой публикацией (см. docstring модуля), поэтому URL нельзя
хардкодить. Если на странице несколько подходящих ссылок (несколько версий
структуры/публикаций), берёт с МАКСИМАЛЬНОЙ датой детерминированно, без сети.
Возвращает (абсолютный_url, published_on). published_on None, если ссылка
найдена, но токен даты не распарсился (защитный случай, не должен происходить
для ссылок, прошедших регэксп даты).
Raises ValueError, если на странице нет ни одной ссылки вида *data-YYYYMMDD-*.zip.
"""
candidates: list[tuple[str, date]] = []
for m in _ZIP_HREF_RE.finditer(html):
href = m.group(1)
date_m = _PUBLISH_DATE_RE.search(href)
if not date_m:
continue
token = date_m.group(1)
try:
published = date(int(token[:4]), int(token[4:6]), int(token[6:8]))
except ValueError:
continue
candidates.append((href, published))
if not candidates:
raise ValueError(f"каталог {base_url}: не найдено ссылок вида href=*data-YYYYMMDD-*.zip")
href, published = max(candidates, key=lambda c: c[1])
return urljoin(base_url, href), published
# ─────────────────────────────────────────────────────────────────────────────
# In-memory модель факта
# ─────────────────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class FnsRecord:
inn: str
dataset: str
series: str
period: date
value: float
org_name: str | None
def to_params(self) -> dict[str, object]:
return {
"inn": self.inn,
"dataset": self.dataset,
"series": self.series,
"period": self.period,
"value": self.value,
"org_name": self.org_name,
}
# ─────────────────────────────────────────────────────────────────────────────
# Стриминговый парсер XML внутри ZIP (lxml iterparse + очистка, без extractall)
# ─────────────────────────────────────────────────────────────────────────────
def _find_attr(elem: etree._Element, candidates: tuple[str, ...]) -> str | None:
for attr in candidates:
raw = elem.get(attr)
if raw:
stripped = raw.strip()
if stripped:
return stripped
return None
def _parse_numeric(raw: str) -> float | None:
stripped = raw.strip()
if not stripped:
return None
try:
return float(stripped.replace(",", "."))
except ValueError:
return None
def _find_attr_deep(elem: etree._Element, candidates: tuple[str, ...]) -> str | None:
"""Найти атрибут на самом элементе ИЛИ на любом его потомке.
В реальных выгрузках ФНС ИНН и показатели лежат на РАЗНЫХ соседних элементах
внутри `<Документ>` см. докстринг `harvest_element`.
"""
found = _find_attr(elem, candidates)
if found is not None:
return found
for child in elem.iterdescendants():
found = _find_attr(child, candidates)
if found is not None:
return found
return None
def _period_from_element(elem: etree._Element, fallback: date) -> date:
"""Отчётная дата документа из `ДатаСост` (ДД.ММ.ГГГГ), иначе — переданная."""
raw = _find_attr_deep(elem, _PERIOD_ATTRS)
if raw is None:
return fallback
try:
day, month, year = (int(part) for part in raw.split("."))
return date(year, month, day)
except (ValueError, TypeError):
return fallback
def _numeric_attrs(node: etree._Element, seen: set[str]) -> list[tuple[str, float]]:
"""Числовые не-служебные атрибуты узла, без повторов по имени серии."""
out: list[tuple[str, float]] = []
for key, raw in node.attrib.items():
if key in _ID_ATTRS_EXCLUDE or key in seen:
continue
value = _parse_numeric(raw)
if value is None:
continue
seen.add(key)
out.append((key, value))
return out
def harvest_element(elem: etree._Element, *, dataset: str, period: date) -> list[FnsRecord]:
"""Один XML-элемент → 0..N FnsRecord (один на каждый числовой не-id атрибут).
Реальная форма выгрузки (проверено на data-20260825 набора revexp, 2118 XML
в архиве, 40 002 факта на первых 20 001 организаций)::
<Документ ИдДок="..." ДатаДок="25.08.2026" ДатаСост="31.12.2025">
<СведНП НаимОрг="ООО ..." ИННЮЛ="4205406898"/>
<СведДохРасх СумДоход="341864000.00" СумРасход="282224000.00"/>
</Документ>
То есть ИНН живёт на `СведНП`, а показатели на СОСЕДНЕМ элементе. Поэтому
носители ИНН ищутся по всему поддереву, а значения без собственного ИНН
(`СведДохРасх` и подобные) привязываются к организации ТОЛЬКО когда носитель в
поддереве один иначе непонятно, чьи это цифры, и мы их не выдумываем.
Отчётный период берётся из `ДатаСост` документа, если он есть; переданный
`period` запасное значение.
Чистая функция (без БД/сети). Поддерево без ИНН даёт пустой список.
"""
nodes = [elem, *elem.iterdescendants()]
holders = [n for n in nodes if _find_attr(n, _INN_ATTRS) is not None]
if not holders:
return []
doc_period = _period_from_element(elem, period)
shared = [n for n in nodes if n not in holders] if len(holders) == 1 else []
out: list[FnsRecord] = []
for holder in holders:
inn = _find_attr(holder, _INN_ATTRS)
if inn is None: # pragma: no cover - отфильтровано выше
continue
org_name = _find_attr(holder, _NAME_ATTRS) or (
_find_attr_deep(elem, _NAME_ATTRS) if len(holders) == 1 else None
)
seen: set[str] = set()
for node in (holder, *shared):
for series, value in _numeric_attrs(node, seen):
out.append(
FnsRecord(
inn=inn,
dataset=dataset,
series=series,
period=doc_period,
value=value,
org_name=org_name,
)
)
return out
def _iter_xml_records(fh: object, *, dataset: str, period: date) -> Iterator[FnsRecord]:
"""Стримит FnsRecord из одного XML-потока `fh` (открытый член ZIP или файл).
`events=("end",)` + `elem.clear()` + срез предыдущих сиблингов bounded memory
на произвольно большом XML (приём из gar_flats_loader._stream_rows).
"""
context = etree.iterparse(
fh, events=("end",), recover=True, huge_tree=True, resolve_entities=False
)
for _event, elem in context:
if not isinstance(elem.tag, str) or elem.tag not in _RECORD_TAGS:
continue
yield from harvest_element(elem, dataset=dataset, period=period)
# Чистим ТОЛЬКО на границе записи. `end`-события детей приходят раньше
# родительского, поэтому безусловный `elem.clear()` на каждом элементе
# вычищал `<СведНП>`/`<СведДохРасх>` ДО закрытия `<Документ>` — и парсер
# молча извлекал ноль записей из реального дампа.
elem.clear()
parent = elem.getparent()
if parent is not None:
while elem.getprevious() is not None:
del parent[0]
del context
def iter_dataset_records(zip_bytes: bytes, *, dataset: str, period: date) -> Iterator[FnsRecord]:
"""Обходит все *.xml внутри `zip_bytes`, элемент за элементом, без extractall.
Каждый XML-член открывается через `zf.open(name)` как поток (НЕ `zf.read()`
целиком, НЕ `zf.extractall()`) см. docstring модуля § ПАМЯТЬ.
Raises ValueError, если в архиве нет ни одного .xml.
"""
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
names = [n for n in zf.namelist() if n.lower().endswith(".xml")]
if not names:
raise ValueError(f"zip набора {dataset} не содержит .xml записей: {zf.namelist()!r}")
for name in names:
with zf.open(name) as fh:
yield from _iter_xml_records(fh, dataset=dataset, period=period)
# ─────────────────────────────────────────────────────────────────────────────
# UPSERT фактов + версия набора
# ─────────────────────────────────────────────────────────────────────────────
_UPSERT_FACTS_SQL = text(
"""
INSERT INTO fns_legal_entity_facts (inn, dataset, series, period, value, org_name, loaded_at)
VALUES (
CAST(:inn AS text), CAST(:dataset AS text), CAST(:series AS text),
CAST(:period AS date), CAST(:value AS numeric), CAST(:org_name AS text), now()
)
ON CONFLICT (inn, dataset, series, period) DO UPDATE SET
value = EXCLUDED.value,
org_name = EXCLUDED.org_name,
loaded_at = now()
WHERE fns_legal_entity_facts.value IS DISTINCT FROM EXCLUDED.value
"""
)
_SELECT_VERSION_SQL = text(
"SELECT file_url, published_on FROM fns_dataset_versions WHERE dataset = CAST(:dataset AS text)"
)
_UPSERT_VERSION_SQL = text(
"""
INSERT INTO fns_dataset_versions (dataset, file_url, published_on, loaded_at)
VALUES (CAST(:dataset AS text), CAST(:file_url AS text), CAST(:published_on AS date), now())
ON CONFLICT (dataset) DO UPDATE SET
file_url = EXCLUDED.file_url,
published_on = EXCLUDED.published_on,
loaded_at = now()
"""
)
def _chunks(items: list[FnsRecord], size: int) -> Iterator[list[FnsRecord]]:
for i in range(0, len(items), size):
yield items[i : i + size]
def upsert_records(
db: Session, records: list[FnsRecord], *, chunk_size: int = UPSERT_CHUNK_SIZE
) -> int:
"""Батчевый UPSERT в fns_legal_entity_facts. НЕ коммитит (коммитит caller).
SAVEPOINT на каждый батч сбойный батч откатывается изолированно, остальные
доезжают (тот же приём, что и gar_flats_loader.upsert_gar_houses).
"""
upserted = 0
failed_batches = 0
for batch in _chunks(records, chunk_size):
params = [r.to_params() for r in batch]
try:
with db.begin_nested():
db.execute(_UPSERT_FACTS_SQL, params)
upserted += len(batch)
except Exception:
failed_batches += 1
logger.warning(
"fns_opendata upsert: батч из %d строк сбойнул (пропущен)",
len(batch),
exc_info=True,
)
if failed_batches:
logger.warning("fns_opendata upsert: сбойных батчей=%d", failed_batches)
return upserted
def get_loaded_version(db: Session, dataset: str) -> tuple[str, date | None] | None:
"""Уже загруженная версия набора (file_url, published_on) или None."""
row = db.execute(_SELECT_VERSION_SQL, {"dataset": dataset}).first()
if row is None:
return None
return row[0], row[1]
def record_dataset_version(
db: Session, dataset: str, file_url: str, published_on: date | None
) -> None:
"""UPSERT версии набора. НЕ коммитит (коммитит caller)."""
db.execute(
_UPSERT_VERSION_SQL,
{"dataset": dataset, "file_url": file_url, "published_on": published_on},
)
# ─────────────────────────────────────────────────────────────────────────────
# Orchestration
# ─────────────────────────────────────────────────────────────────────────────
def _download(url: str, *, client: httpx.Client) -> bytes:
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
def load_dataset(
db: Session,
slug: str,
*,
client: httpx.Client | None = None,
dry_run: bool = False,
force: bool = False,
chunk_size: int = UPSERT_CHUNK_SIZE,
) -> dict[str, int | str]:
"""Резолвит актуальную ссылку набора `slug` → скачивает .zip → парсит → UPSERT.
Пропускает скачивание, если `fns_dataset_versions` уже содержит ТУ ЖЕ ссылку
(force=True форсирует перекачку). dry_run резолв ссылки происходит (проверяем
каталог доступен), скачивание/парс/запись нет. `client` для тестов
(httpx.MockTransport); если не передан, открывается и закрывается свой.
НЕ коммитит коммитит caller.
"""
if slug not in DATASET_SLUGS:
raise ValueError(f"неизвестный slug набора: {slug!r}, ожидались {DATASET_SLUGS}")
owns_client = client is None
if client is None:
# TLS: см. docstring модуля § TLS — verify=False, открытые данные без auth/PII.
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
catalog_url = CATALOG_URL_TEMPLATE.format(slug=slug)
resp = client.get(catalog_url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
file_url, published_on = resolve_dataset_file_url(resp.text, base_url=catalog_url)
existing = get_loaded_version(db, slug)
if not force and existing is not None and existing[0] == file_url:
logger.info("fns_opendata %s: версия %s уже загружена, skip", slug, file_url)
return {"dataset": slug, "skipped": 1, "records": 0, "upserted": 0}
if dry_run:
logger.info(
"fns_opendata %s: dry_run — резолвлен %s (published_on=%s), скачивание пропущено",
slug,
file_url,
published_on,
)
return {"dataset": slug, "skipped": 0, "records": 0, "upserted": 0}
zip_bytes = _download(file_url, client=client)
period = published_on or date.today()
records = list(iter_dataset_records(zip_bytes, dataset=slug, period=period))
upserted = upsert_records(db, records, chunk_size=chunk_size)
record_dataset_version(db, slug, file_url, published_on)
result: dict[str, int | str] = {
"dataset": slug,
"skipped": 0,
"records": len(records),
"upserted": upserted,
}
logger.info("fns_opendata load %s DONE (dry_run=%s): %s", slug, dry_run, result)
return result
finally:
if owns_client:
client.close()
def load_datasets(
db: Session,
slugs: tuple[str, ...] = DATASET_SLUGS,
*,
client: httpx.Client | None = None,
dry_run: bool = False,
force: bool = False,
) -> dict[str, dict[str, int | str]]:
"""load_dataset для каждого slug из `slugs`. НЕ коммитит между наборами (caller)."""
return {
slug: load_dataset(db, slug, client=client, dry_run=dry_run, force=force) for slug in slugs
}

View file

@ -1,688 +0,0 @@
"""АИС ППК «ФРТ» (бывш. Реформа ЖКХ) loader: houses.area_land/foundation_type/
elevators_total + добор year_built/material_walls/total_floors/entrances/is_emergency/
flat_count/heat_supply_type/gas_supply_type/hot_water (issue #frt-mkd).
CONTEXT: houses.area_land и houses.foundation_type отсутствовали вовсе; elevators_total
новая колонка (в источнике ОБЩЕЕ число лифтов, houses раздельно хранит только
passenger_elevators/cargo_elevators, поэтому мапить в них нельзя потеряли бы тип).
Остальные поля (year_built, material_walls, ...) уже существуют и заполнены частично
другими источниками (ДОМ.РФ капремонт, ГИС-ЖКХ) этот loader их ДОБИРАЕТ через
COALESCE (только NULL), никогда не перезаписывает.
ИСТОЧНИК: АИС ППК ФРТ open data, https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/{node_id}
node 110 = реестр МКД региона 66 (Свердловская обл.) проверено живьём 08.09.2026:
HTTP 200, application/octet-stream, zip один CSV `export-reestrmkd-66-*.csv`,
UTF-8 BOM, разделитель ';', 60 колонок, ~41.8k строк по СО.
robots.txt источника запрещает /opendata/export/ и требует Crawl-delay 10. Один прогон
этого loader'а делает РОВНО ОДИН GET (один node_id) — задержка не нужна. Если когда-нибудь
понадобится тянуть несколько node_id за один запуск (напр. node 427 аварийный фонд РФ,
node 1 реестр УО РФ, оба вне скоупа этого PR) между запросами обязательна пауза
CRAWL_DELAY_SEC (константа ниже), иначе нарушаем Crawl-delay из robots.txt.
ЧТО НЕ ДЕЛАЕМ (осознанно):
* project_type НЕ мапим в houses.series_name свободный текст, фактический дубль
материала стен (замер: пусто 10635, «кирпичный» 1754, «нет данных» 1496,
«панельный» 1012, «Блочный»/«блочный» двумя разными строками). Как серию
использовать нельзя.
* energy_efficiency НЕ добавляем в houses миграция 284 уже приняла это решение;
реальный класс присвоен лишь ~10% домов (у большинства «Не присвоен»).
* elevators_count НЕ мапим в houses.passenger_elevators источник отдаёт общее
число лифтов без разбивки, а houses хранит passenger/cargo раздельно. Пишем в
отдельную houses.elevators_total.
* playground/sportsground id справочника (498/499/500), не булев в staging как
есть, houses.has_playground не трогаем.
* estimator.py не трогаем встраивание признаков дома в подбор аналогов вне скоупа.
МУСОРНЫЕ ЗНАЧЕНИЯ источника (встречаются как обычные строки текстовых полей):
'', 'нет данных', 'Не заполнено', 'отсутствует', 'данные отсутствуют', 'нет'.
Единый хелпер `clean_text()` отфильтровывает их при парсе КАЖДОГО текстового поля.
TLS: домен xn--80adsazqn.xn--p1aee.xn--p1ai отдаёт RU-сертификат НУЦ Минцифры, не
верифицируемый дефолтным trust store'ом httpx — та же ситуация, что sber_index.py и
domrf_kapremont_loader.py. Открытые данные без auth/PII verify=False приемлем.
psycopg v3: SQL через `text(...)` использует CAST(:x AS type), НИКОГДА :x::type.
"""
from __future__ import annotations
import csv
import io
import logging
import tempfile
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass, fields
from datetime import date
from pathlib import Path
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Константы
# ─────────────────────────────────────────────────────────────────────────────
BASE_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai"
DEFAULT_NODE_ID = 110 # реестр МКД региона 66 — проверено живьём 08.09.2026
DEFAULT_REGION_CODE = 66
DOWNLOAD_TIMEOUT_SEC = 180
UPSERT_CHUNK_SIZE = 500
# robots.txt источника: Crawl-delay 10. Используется только если когда-нибудь понадобится
# тянуть несколько node_id за один запуск (сейчас — ровно один GET на прогон).
CRAWL_DELAY_SEC = 10
# Мусорные значения текстовых полей источника — не данные, а «пусто» в других обёртках.
_NOISE_VALUES = frozenset(
{"", "нет данных", "не заполнено", "отсутствует", "данные отсутствуют", "нет"}
)
# Правдоподобные границы, используются и на парсе (staging), и повторно в backfill SQL.
YEAR_BUILT_MIN = 1850
YEAR_BUILT_FUTURE_SLACK = 2
FLOOR_COUNT_MAX_BOUND = 100
ENTRANCE_COUNT_MAX_BOUND = 50
ELEVATORS_COUNT_MAX_BOUND = 50
FLAT_COUNT_MAX_BOUND = 3000
AREA_LAND_MAX_BOUND = 500_000 # м2, с большим запасом для дворовых территорий МКД
def plausible_year_max() -> int:
"""Верхняя граница правдоподобного года постройки (текущий год + slack)."""
return date.today().year + YEAR_BUILT_FUTURE_SLACK
# ─────────────────────────────────────────────────────────────────────────────
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
# ─────────────────────────────────────────────────────────────────────────────
def clean_text(raw: str | None) -> str | None:
"""Строка источника → строка|None, мусорные значения-заглушки схлопываются в None."""
if raw is None:
return None
s = raw.strip()
if not s or s.lower() in _NOISE_VALUES:
return None
return s
def parse_decimal_comma(raw: str | None) -> float | None:
"""«930,60» / «930.60» / «» / мусор → float|None. Запятая — decimal separator АИС ФРТ."""
s = clean_text(raw)
if s is None:
return None
try:
return float(s.replace(",", "."))
except ValueError:
return None
def parse_int_field(
raw: str | None, *, min_value: int | None = None, max_value: int | None = None
) -> int | None:
"""Устойчивый str → int|None с опциональным sanity-гейтом [min_value, max_value]."""
s = clean_text(raw)
if s is None:
return None
value: int | None = None
try:
value = int(s)
except ValueError:
try:
value = int(float(s.replace(",", ".")))
except ValueError:
return None
if min_value is not None and value < min_value:
return None
if max_value is not None and value > max_value:
return None
return value
def parse_bool_da_net(raw: str | None) -> bool | None:
"""«Да»/«Нет» (регистронезависимо) → bool|None. Пусто/иное → None.
НЕ идёт через clean_text() «нет» само по себе входит в _NOISE_VALUES (заглушка
текстовых полей типа «отсутствует»/«нет»), и там это совпадение уместно. Но для
is_alarm «Нет» валидный содержательный ответ («не аварийный»), а не отсутствие
данных, поэтому здесь разбираем сырую строку напрямую.
"""
if raw is None:
return None
s = raw.strip()
if not s:
return None
low = s.lower()
if low == "да":
return True
if low == "нет":
return False
return None
@dataclass(slots=True)
class FrtMkdRow:
"""Одна строка реестра МКД АИС ФРТ (house-per-row), готова к UPSERT в staging."""
houseguid: str
region_code: int
address: str | None
built_year: int | None
exploitation_start_year: int | None
project_type: str | None
house_type: str | None
is_alarm: bool | None
floor_count_max: int | None
floor_count_min: int | None
entrance_count: int | None
elevators_count: int | None
energy_efficiency: str | None
quarters_count: int | None
living_quarters_count: int | None
unliving_quarters_count: int | None
area_total: float | None
area_residential: float | None
area_non_residential: float | None
area_common_property: float | None
area_land: float | None
parking_square: float | None
playground: int | None
sportsground: int | None
other_beautification: str | None
foundation_type: str | None
floor_type: str | None
wall_material: str | None
basement_area: float | None
chute_type: str | None
chute_count: int | None
heating_type: str | None
hot_water_type: str | None
cold_water_type: str | None
sewerage_type: str | None
gas_type: str | None
ventilation_type: str | None
firefighting_type: str | None
drainage_type: str | None
management_organization_id: int | None
method_of_forming_overhaul_fund: str | None
def parse_frt_mkd_csv(path: str | Path, *, region_code: int) -> dict[str, FrtMkdRow]:
"""CSV реестра МКД АИС ФРТ → dict по houseguid. Строки без houseguid пропускаются.
Дубликаты houseguid в источнике РЕАЛЬНЫ и взаимодополняющи: в выгрузке региона 66
за 2026-09-01 из 41 790 строк 912 guid'ов повторяются (968 лишних строк), причём у
одной строки пары заполнен, например, `area_total`, а у другой нет. Поэтому
«последняя побеждает» терять нельзя: дубликаты СЛИВАЮТСЯ по полям, побеждает первое
непустое значение (`_merge_rows`). Иначе бэкфилл недосчитывался бы заполненных полей
примерно у 2 % домов области.
"""
rows: dict[str, FrtMkdRow] = {}
with open(path, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f, delimiter=";")
for raw in reader:
houseguid = clean_text(raw.get("houseguid"))
if not houseguid:
continue
parsed = FrtMkdRow(
houseguid=houseguid,
region_code=region_code,
address=clean_text(raw.get("address")),
built_year=parse_int_field(
raw.get("built_year"), min_value=YEAR_BUILT_MIN, max_value=plausible_year_max()
),
exploitation_start_year=parse_int_field(
raw.get("exploitation_start_year"),
min_value=YEAR_BUILT_MIN,
max_value=plausible_year_max(),
),
project_type=clean_text(raw.get("project_type")),
house_type=clean_text(raw.get("house_type")),
is_alarm=parse_bool_da_net(raw.get("is_alarm")),
floor_count_max=parse_int_field(
raw.get("floor_count_max"), min_value=1, max_value=FLOOR_COUNT_MAX_BOUND
),
floor_count_min=parse_int_field(
raw.get("floor_count_min"), min_value=1, max_value=FLOOR_COUNT_MAX_BOUND
),
entrance_count=parse_int_field(
raw.get("entrance_count"), min_value=1, max_value=ENTRANCE_COUNT_MAX_BOUND
),
elevators_count=parse_int_field(
raw.get("elevators_count"), min_value=0, max_value=ELEVATORS_COUNT_MAX_BOUND
),
energy_efficiency=clean_text(raw.get("energy_efficiency")),
quarters_count=parse_int_field(raw.get("quarters_count"), min_value=0),
living_quarters_count=parse_int_field(
raw.get("living_quarters_count"), min_value=0, max_value=FLAT_COUNT_MAX_BOUND
),
unliving_quarters_count=parse_int_field(
raw.get("unliving_quarters_count"), min_value=0
),
area_total=parse_decimal_comma(raw.get("area_total")),
area_residential=parse_decimal_comma(raw.get("area_residential")),
area_non_residential=parse_decimal_comma(raw.get("area_non_residential")),
area_common_property=parse_decimal_comma(raw.get("area_common_property")),
area_land=parse_decimal_comma(raw.get("area_land")),
parking_square=parse_decimal_comma(raw.get("parking_square")),
# playground/sportsground — id справочника благоустройства, не булев флаг.
playground=parse_int_field(raw.get("playground"), min_value=0),
sportsground=parse_int_field(raw.get("sportsground"), min_value=0),
other_beautification=clean_text(raw.get("other_beautification")),
foundation_type=clean_text(raw.get("foundation_type")),
floor_type=clean_text(raw.get("floor_type")),
wall_material=clean_text(raw.get("wall_material")),
basement_area=parse_decimal_comma(raw.get("basement_area")),
chute_type=clean_text(raw.get("chute_type")),
chute_count=parse_int_field(raw.get("chute_count"), min_value=0),
heating_type=clean_text(raw.get("heating_type")),
hot_water_type=clean_text(raw.get("hot_water_type")),
cold_water_type=clean_text(raw.get("cold_water_type")),
sewerage_type=clean_text(raw.get("sewerage_type")),
gas_type=clean_text(raw.get("gas_type")),
ventilation_type=clean_text(raw.get("ventilation_type")),
firefighting_type=clean_text(raw.get("firefighting_type")),
drainage_type=clean_text(raw.get("drainage_type")),
management_organization_id=parse_int_field(
raw.get("management_organization_id"), min_value=0
),
method_of_forming_overhaul_fund=clean_text(
raw.get("method_of_forming_overhaul_fund")
),
)
prev = rows.get(houseguid)
rows[houseguid] = _merge_rows(prev, parsed) if prev is not None else parsed
return rows
def _merge_rows(prev: FrtMkdRow, new: FrtMkdRow) -> FrtMkdRow:
"""Слить дубликат houseguid: для каждого поля берём первое непустое значение.
Источник отдаёт повторы одного дома разными строками с частично заполненными
полями (см. `parse_frt_mkd_csv`). Побеждает уже накопленное значение; новое
подставляется только туда, где накопленного нет.
"""
merged: dict[str, object] = {}
for field in fields(FrtMkdRow):
current = getattr(prev, field.name)
merged[field.name] = current if current is not None else getattr(new, field.name)
return FrtMkdRow(**merged) # type: ignore[arg-type]
# ─────────────────────────────────────────────────────────────────────────────
# HTTP: скачивание zip + извлечение CSV
# ─────────────────────────────────────────────────────────────────────────────
def _download_zip(url: str, *, client: httpx.Client) -> bytes:
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
def _extract_csv_from_zip(data: bytes, dest_dir: Path) -> Path:
"""Распаковывает первый *.csv из zip-байтов в dest_dir, возвращает путь."""
with zipfile.ZipFile(io.BytesIO(data)) as zf:
csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")]
if not csv_names:
raise ValueError(f"zip не содержит .csv записей: {zf.namelist()!r}")
extracted = zf.extract(csv_names[0], dest_dir)
return Path(extracted)
def fetch_frt_mkd_csv(dest_dir: Path, *, node_id: int, client: httpx.Client) -> Path:
"""Скачивает zip export/{node_id}, распаковывает CSV в dest_dir, возвращает путь."""
url = f"{BASE_URL}/opendata/export/{node_id}"
return _extract_csv_from_zip(_download_zip(url, client=client), dest_dir)
# ─────────────────────────────────────────────────────────────────────────────
# UPSERT staging (frt_mkd, мигр. 290)
# ─────────────────────────────────────────────────────────────────────────────
_UPSERT_SQL = text(
"""
INSERT INTO frt_mkd (
houseguid, region_code, address, built_year, exploitation_start_year,
project_type, house_type, is_alarm, floor_count_max, floor_count_min,
entrance_count, elevators_count, energy_efficiency, quarters_count,
living_quarters_count, unliving_quarters_count, area_total, area_residential,
area_non_residential, area_common_property, area_land, parking_square,
playground, sportsground, other_beautification, foundation_type, floor_type,
wall_material, basement_area, chute_type, chute_count, heating_type,
hot_water_type, cold_water_type, sewerage_type, gas_type, ventilation_type,
firefighting_type, drainage_type, management_organization_id,
method_of_forming_overhaul_fund, loaded_at
)
VALUES (
CAST(:houseguid AS text), CAST(:region_code AS smallint), CAST(:address AS text),
CAST(:built_year AS smallint), CAST(:exploitation_start_year AS smallint),
CAST(:project_type AS text), CAST(:house_type AS text), CAST(:is_alarm AS boolean),
CAST(:floor_count_max AS smallint), CAST(:floor_count_min AS smallint),
CAST(:entrance_count AS smallint), CAST(:elevators_count AS smallint),
CAST(:energy_efficiency AS text), CAST(:quarters_count AS int),
CAST(:living_quarters_count AS int), CAST(:unliving_quarters_count AS int),
CAST(:area_total AS numeric), CAST(:area_residential AS numeric),
CAST(:area_non_residential AS numeric), CAST(:area_common_property AS numeric),
CAST(:area_land AS numeric), CAST(:parking_square AS numeric),
CAST(:playground AS int), CAST(:sportsground AS int),
CAST(:other_beautification AS text), CAST(:foundation_type AS text),
CAST(:floor_type AS text), CAST(:wall_material AS text),
CAST(:basement_area AS numeric), CAST(:chute_type AS text),
CAST(:chute_count AS smallint), CAST(:heating_type AS text),
CAST(:hot_water_type AS text), CAST(:cold_water_type AS text),
CAST(:sewerage_type AS text), CAST(:gas_type AS text),
CAST(:ventilation_type AS text), CAST(:firefighting_type AS text),
CAST(:drainage_type AS text), CAST(:management_organization_id AS bigint),
CAST(:method_of_forming_overhaul_fund AS text), now()
)
ON CONFLICT (houseguid) DO UPDATE SET
region_code = EXCLUDED.region_code,
address = EXCLUDED.address,
built_year = EXCLUDED.built_year,
exploitation_start_year = EXCLUDED.exploitation_start_year,
project_type = EXCLUDED.project_type,
house_type = EXCLUDED.house_type,
is_alarm = EXCLUDED.is_alarm,
floor_count_max = EXCLUDED.floor_count_max,
floor_count_min = EXCLUDED.floor_count_min,
entrance_count = EXCLUDED.entrance_count,
elevators_count = EXCLUDED.elevators_count,
energy_efficiency = EXCLUDED.energy_efficiency,
quarters_count = EXCLUDED.quarters_count,
living_quarters_count = EXCLUDED.living_quarters_count,
unliving_quarters_count = EXCLUDED.unliving_quarters_count,
area_total = EXCLUDED.area_total,
area_residential = EXCLUDED.area_residential,
area_non_residential = EXCLUDED.area_non_residential,
area_common_property = EXCLUDED.area_common_property,
area_land = EXCLUDED.area_land,
parking_square = EXCLUDED.parking_square,
playground = EXCLUDED.playground,
sportsground = EXCLUDED.sportsground,
other_beautification = EXCLUDED.other_beautification,
foundation_type = EXCLUDED.foundation_type,
floor_type = EXCLUDED.floor_type,
wall_material = EXCLUDED.wall_material,
basement_area = EXCLUDED.basement_area,
chute_type = EXCLUDED.chute_type,
chute_count = EXCLUDED.chute_count,
heating_type = EXCLUDED.heating_type,
hot_water_type = EXCLUDED.hot_water_type,
cold_water_type = EXCLUDED.cold_water_type,
sewerage_type = EXCLUDED.sewerage_type,
gas_type = EXCLUDED.gas_type,
ventilation_type = EXCLUDED.ventilation_type,
firefighting_type = EXCLUDED.firefighting_type,
drainage_type = EXCLUDED.drainage_type,
management_organization_id = EXCLUDED.management_organization_id,
method_of_forming_overhaul_fund = EXCLUDED.method_of_forming_overhaul_fund,
loaded_at = now()
WHERE frt_mkd.address IS DISTINCT FROM EXCLUDED.address
OR frt_mkd.built_year IS DISTINCT FROM EXCLUDED.built_year
OR frt_mkd.is_alarm IS DISTINCT FROM EXCLUDED.is_alarm
OR frt_mkd.wall_material IS DISTINCT FROM EXCLUDED.wall_material
OR frt_mkd.foundation_type IS DISTINCT FROM EXCLUDED.foundation_type
OR frt_mkd.area_land IS DISTINCT FROM EXCLUDED.area_land
OR frt_mkd.elevators_count IS DISTINCT FROM EXCLUDED.elevators_count
"""
)
def _chunk_rows(items: list[FrtMkdRow], size: int) -> Iterator[list[FrtMkdRow]]:
for i in range(0, len(items), size):
yield items[i : i + size]
def upsert_frt_mkd(
db: Session, rows: list[FrtMkdRow], *, chunk_size: int = UPSERT_CHUNK_SIZE
) -> int:
"""UPSERT списка FrtMkdRow в frt_mkd, чанками по SAVEPOINT. Не коммитит (caller).
Идемпотентно (IS DISTINCT FROM gate на ключевых полях в _UPSERT_SQL). Сбойный чанк
откатывается изолированно (SAVEPOINT-паттерн domrf_kapremont_loader/zhkh_flats_loader).
"""
upserted = 0
for chunk in _chunk_rows(rows, chunk_size):
try:
with db.begin_nested():
for r in chunk:
res = db.execute(
_UPSERT_SQL,
{
"houseguid": r.houseguid,
"region_code": r.region_code,
"address": r.address,
"built_year": r.built_year,
"exploitation_start_year": r.exploitation_start_year,
"project_type": r.project_type,
"house_type": r.house_type,
"is_alarm": r.is_alarm,
"floor_count_max": r.floor_count_max,
"floor_count_min": r.floor_count_min,
"entrance_count": r.entrance_count,
"elevators_count": r.elevators_count,
"energy_efficiency": r.energy_efficiency,
"quarters_count": r.quarters_count,
"living_quarters_count": r.living_quarters_count,
"unliving_quarters_count": r.unliving_quarters_count,
"area_total": r.area_total,
"area_residential": r.area_residential,
"area_non_residential": r.area_non_residential,
"area_common_property": r.area_common_property,
"area_land": r.area_land,
"parking_square": r.parking_square,
"playground": r.playground,
"sportsground": r.sportsground,
"other_beautification": r.other_beautification,
"foundation_type": r.foundation_type,
"floor_type": r.floor_type,
"wall_material": r.wall_material,
"basement_area": r.basement_area,
"chute_type": r.chute_type,
"chute_count": r.chute_count,
"heating_type": r.heating_type,
"hot_water_type": r.hot_water_type,
"cold_water_type": r.cold_water_type,
"sewerage_type": r.sewerage_type,
"gas_type": r.gas_type,
"ventilation_type": r.ventilation_type,
"firefighting_type": r.firefighting_type,
"drainage_type": r.drainage_type,
"management_organization_id": r.management_organization_id,
"method_of_forming_overhaul_fund": r.method_of_forming_overhaul_fund,
},
)
upserted += res.rowcount
except Exception:
logger.warning(
"frt_mkd upsert: чанк из %d строк сбойнул (откат savepoint)",
len(chunk),
exc_info=True,
)
return upserted
def load_frt_mkd(
db: Session,
*,
src_path: str | Path | None = None,
work_dir: str | Path | None = None,
node_id: int = DEFAULT_NODE_ID,
region_code: int = DEFAULT_REGION_CODE,
chunk_size: int = UPSERT_CHUNK_SIZE,
dry_run: bool = False,
) -> dict[str, int]:
"""Скачивает (если src_path не задан) реестр МКД node_id, парсит, UPSERT в frt_mkd.
src_path локальный CSV (пропустить скачивание; тесты/ops-дебаг). work_dir куда
распаковывать скачанный zip (по умолчанию временный каталог, удаляется после).
dry_run парс происходит, но НИ ОДНОЙ записи в БД не делается. Не коммитит (caller).
"""
tmp_ctx: tempfile.TemporaryDirectory[str] | None = None
if src_path is None:
if work_dir is not None:
dest = Path(work_dir)
dest.mkdir(parents=True, exist_ok=True)
else:
tmp_ctx = tempfile.TemporaryDirectory()
dest = Path(tmp_ctx.name)
# verify=False: источник отдаёт RU-сертификат НУЦ Минцифры, не верифицируемый
# дефолтным trust store'ом (та же ситуация, что sber_index.py и
# domrf_kapremont_loader.py). Открытые данные без auth/PII — приемлемо.
with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False) as client:
src_path = fetch_frt_mkd_csv(dest, node_id=node_id, client=client)
try:
rows_by_guid = parse_frt_mkd_csv(src_path, region_code=region_code)
rows = list(rows_by_guid.values())
upserted = 0 if dry_run else upsert_frt_mkd(db, rows, chunk_size=chunk_size)
finally:
if tmp_ctx is not None:
tmp_ctx.cleanup()
result = {"rows": len(rows), "upserted": upserted}
logger.info("frt_mkd load DONE (dry_run=%s): %s", dry_run, result)
return result
# ─────────────────────────────────────────────────────────────────────────────
# Backfill houses (COALESCE-семантика — только NULL-поля)
# ─────────────────────────────────────────────────────────────────────────────
# Матч: s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid) —
# тот же приоритетный COALESCE-джойн, что domrf_kapremont_loader/zhkh_flats_loader.
# Каждое поле СВОИМ гейтом "h.col IS NULL AND s.col-в-границах" — ни одно поле, уже
# заполненное другим источником, не перезаписывается. frt_matched_at проставляется один
# раз (COALESCE(h.frt_matched_at, now())) при ЛЮБОМ найденном матче — метка «дом уже
# сверялся с этим источником», даже если дозаполнять было уже нечего.
_BACKFILL_HOUSES_SQL = text(
"""
UPDATE houses h
SET year_built = CASE
WHEN h.year_built IS NULL
AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN s.built_year
ELSE h.year_built
END,
material_walls = COALESCE(h.material_walls, s.wall_material),
material_floors = COALESCE(h.material_floors, s.floor_type),
total_floors = CASE
WHEN h.total_floors IS NULL
AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int)
THEN s.floor_count_max
ELSE h.total_floors
END,
entrances = CASE
WHEN h.entrances IS NULL
AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int)
THEN s.entrance_count
ELSE h.entrances
END,
elevators_total = CASE
WHEN h.elevators_total IS NULL
AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int)
THEN s.elevators_count
ELSE h.elevators_total
END,
is_emergency = COALESCE(h.is_emergency, s.is_alarm),
flat_count = CASE
WHEN h.flat_count IS NULL
AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int)
THEN s.living_quarters_count
ELSE h.flat_count
END,
heat_supply_type = COALESCE(h.heat_supply_type, s.heating_type),
gas_supply_type = COALESCE(h.gas_supply_type, s.gas_type),
hot_water = COALESCE(h.hot_water, s.hot_water_type),
area_land = CASE
WHEN h.area_land IS NULL
AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric)
THEN s.area_land
ELSE h.area_land
END,
foundation_type = COALESCE(h.foundation_type, s.foundation_type),
frt_matched_at = COALESCE(h.frt_matched_at, now())
FROM frt_mkd s
WHERE s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
AND (
(h.year_built IS NULL
AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
OR (h.material_walls IS NULL AND s.wall_material IS NOT NULL)
OR (h.material_floors IS NULL AND s.floor_type IS NOT NULL)
OR (h.total_floors IS NULL
AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int))
OR (h.entrances IS NULL
AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int))
OR (h.elevators_total IS NULL
AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int))
OR (h.is_emergency IS NULL AND s.is_alarm IS NOT NULL)
OR (h.flat_count IS NULL
AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int))
OR (h.heat_supply_type IS NULL AND s.heating_type IS NOT NULL)
OR (h.gas_supply_type IS NULL AND s.gas_type IS NOT NULL)
OR (h.hot_water IS NULL AND s.hot_water_type IS NOT NULL)
OR (h.area_land IS NULL
AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric))
OR (h.foundation_type IS NULL AND s.foundation_type IS NOT NULL)
OR h.frt_matched_at IS NULL
)
"""
)
_BACKFILL_HOUSES_COUNT_SQL = text(
"""
SELECT count(*)
FROM houses h
JOIN frt_mkd s
ON s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
WHERE (
(h.year_built IS NULL
AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
OR (h.material_walls IS NULL AND s.wall_material IS NOT NULL)
OR (h.material_floors IS NULL AND s.floor_type IS NOT NULL)
OR (h.total_floors IS NULL
AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int))
OR (h.entrances IS NULL
AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int))
OR (h.elevators_total IS NULL
AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int))
OR (h.is_emergency IS NULL AND s.is_alarm IS NOT NULL)
OR (h.flat_count IS NULL
AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int))
OR (h.heat_supply_type IS NULL AND s.heating_type IS NOT NULL)
OR (h.gas_supply_type IS NULL AND s.gas_type IS NOT NULL)
OR (h.hot_water IS NULL AND s.hot_water_type IS NOT NULL)
OR (h.area_land IS NULL
AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric))
OR (h.foundation_type IS NULL AND s.foundation_type IS NOT NULL)
OR h.frt_matched_at IS NULL
)
"""
)
def backfill_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
"""COALESCE-добор houses.* из frt_mkd (только NULL-поля). Не коммитит (caller).
dry_run ноль записей, только SELECT count(*) по тому же предикату.
"""
bounds = {
"ymin": YEAR_BUILT_MIN,
"ymax": plausible_year_max(),
"floor_max": FLOOR_COUNT_MAX_BOUND,
"entrance_max": ENTRANCE_COUNT_MAX_BOUND,
"elevator_max": ELEVATORS_COUNT_MAX_BOUND,
"flat_max": FLAT_COUNT_MAX_BOUND,
"area_land_max": AREA_LAND_MAX_BOUND,
}
if dry_run:
would_update = db.execute(_BACKFILL_HOUSES_COUNT_SQL, bounds).scalar_one()
result = {"would_update": would_update, "houses_updated": 0}
logger.info("backfill_houses (frt_mkd) DRY-RUN: %s", result)
return result
updated = db.execute(_BACKFILL_HOUSES_SQL, bounds).rowcount
result = {"houses_updated": updated}
logger.info("backfill_houses (frt_mkd) DONE: %s", result)
return result

View file

@ -26,19 +26,11 @@ from sqlalchemy.orm import Session
from tenacity import retry, stop_after_attempt, wait_exponential
from app.core.config import settings
from app.core.db import run_db_thread
from app.services import dadata
from app.services.regions import REGIONS as _ALL_REGIONS
from app.services.regions import Region, is_within_bbox
_REGION_66 = _ALL_REGIONS[66]
# #3051: маркер `address.state` Nominatim по региону, для region cross-check
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
# без записи здесь получают `marker=None` → cross-check пропускается
# (fallback на bbox-only, прежнее поведение).
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
logger = logging.getLogger(__name__)
# ── Общий ограничитель темпа обращений к Nominatim (#2953) ──────────────────
@ -178,15 +170,8 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
"""`value` как city_hint, если это узнаваемое имя города `REGIONS[region_code]`, иначе None.
#3051: `region_code` (дефолт 66) — параметризация под трек «Москва»: словарь
городов берётся из `REGIONS[region_code].cities`, а не жёстко из
`SVERDLOVSK_OBLAST_CITIES`. Для `region_code=66` (дефолт, все существующие
вызовы без аргумента) byte-identical прежнему поведению: `REGIONS[66].cities
is SVERDLOVSK_OBLAST_CITIES` (тот же frozenset-объект, см. модульный уровень).
Неизвестный `region_code` ValueError (явная ошибка, не молчаливый None).
def known_city_hint(value: str | None) -> str | None:
"""`value` как city_hint, если это узнаваемое имя города региона 66, иначе None.
Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()`
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
@ -215,14 +200,7 @@ def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
"""
if not value:
return None
if region_code == 66:
cities = SVERDLOVSK_OBLAST_CITIES
else:
try:
cities = _ALL_REGIONS[region_code].cities
except KeyError as exc:
raise ValueError(f"unknown region_code={region_code!r}") from exc
return value if " ".join(value.lower().split()) in cities else None
return value if " ".join(value.lower().split()) in SVERDLOVSK_OBLAST_CITIES else None
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
@ -253,41 +231,20 @@ _OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р", "р-он", "район", "жк"})
# region_code → скомпилированный regex городов региона (word-boundary), кэш по
# коду. 66 — literal reuse `_OBLAST_CITY_RE` (тот же объект, byte-identical),
# остальные регионы строятся из `REGIONS[region_code].cities` при первом
# обращении (#3051).
_REGION_CITY_RE: dict[int, re.Pattern[str]] = {66: _OBLAST_CITY_RE}
def _has_oblast_marker(text_lower: str) -> bool:
"""True если текст уже содержит упоминание области/города региона 66.
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
уже привязан к другому городу/области иначе получим двойной город
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
def _region_city_re(region_code: int) -> re.Pattern[str]:
cached = _REGION_CITY_RE.get(region_code)
if cached is not None:
return cached
cities = _ALL_REGIONS[region_code].cities
compiled = re.compile(r"\b(?:" + "|".join(re.escape(c) for c in cities) + r")\b")
_REGION_CITY_RE[region_code] = compiled
return compiled
def _has_oblast_marker(text_lower: str, region_code: int = 66) -> bool:
"""True если текст уже содержит упоминание области/города `region_code`.
Используется чтобы НЕ навязывать "Екатеринбург, "/"Москва, " в запрос,
когда адрес уже привязан к другому городу/области иначе получим двойной
город ("Екатеринбург, Нижний Тагил, Ленина 10" / "Москва, Москва, Тверская
1", #3051 п. б) и провайдер вернёт мусор/пусто.
Матчинг по границе слова/фразы (`_region_city_re`), НЕ substring и с
исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
`region_code=66` (дефолт) дополнительно матчит "свердловск*" областной
маркер без города; у прочих регионов такого обобщённого маркера нет,
город региона уже покрывает случай (для 77 "москва" в `region.cities`).
Матчинг по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
"""
normalized = " ".join(text_lower.split())
if region_code == 66 and _OBLAST_MARKER_RE.search(normalized):
if _OBLAST_MARKER_RE.search(normalized):
return True
for m in _region_city_re(region_code).finditer(normalized):
for m in _OBLAST_CITY_RE.finditer(normalized):
prefix_words = normalized[: m.start()].split()
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
@ -295,14 +252,12 @@ def _has_oblast_marker(text_lower: str, region_code: int = 66) -> bool:
return False
def _resolve_city_for_geocode(
address: str, city_hint: str | None, region_code: int = 66
) -> tuple[str | None, bool]:
def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]:
"""Определяет, какой город подставлять в запрос внешнему провайдеру
(Nominatim), когда сам текст адреса города не называет.
Приоритет:
1. Адрес уже содержит маркер города/области `region_code` (`_has_oblast_marker`)
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
город уже указан пользователем в тексте адреса, ничего подставлять не
нужно. Возвращает (None, True).
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
@ -311,18 +266,15 @@ def _resolve_city_for_geocode(
для жителей других городов области это давало уверенно неверную цену
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
обе улицы называются одинаково). Теперь НЕ подставляем никакой город
провайдер ищет по region-viewbox/bbox (см. `OBLAST66_VIEWBOX`,
`_region_viewbox`), без привязки к конкретному городу. Возвращает
провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без
привязки к конкретному городу. Возвращает
(None, False) второй элемент False сигнализирует, что город
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
`region_code` (дефолт 66, #3051) — byte-identical прежнему поведению для
всех вызовов без аргумента.
Returns:
(city_or_none, city_specified_by_user).
"""
if _has_oblast_marker(address.lower(), region_code):
if _has_oblast_marker(address.lower()):
return None, True
hint = (city_hint or "").strip()
if hint:
@ -726,15 +678,13 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
def _nominatim_region_ok(item: dict, region_code: int = 66) -> bool | None:
def _nominatim_region_ok(item: dict) -> bool | None:
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
True/False если state однозначно про/не про регион `region_code`. None если
поле отсутствует/не строка, ИЛИ регион не имеет записи в `_REGION_STATE_MARKERS`
тогда accept-логика падает обратно на bbox.
True/False если state однозначно про/не про Свердловскую область. None если
поле отсутствует/не строка тогда accept-логика падает обратно на bbox.
Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри генерального OBLAST66_BBOX
(специально щедрого), но их state явно другой регион. `region_code=66`
(дефолт) byte-identical прежнему поведению (`"свердловск" in state.lower()`).
(специально щедрого), но их state явно другой регион.
"""
addr = item.get("address")
if not isinstance(addr, dict):
@ -742,65 +692,22 @@ def _nominatim_region_ok(item: dict, region_code: int = 66) -> bool | None:
state = addr.get("state")
if not isinstance(state, str) or not state:
return None
marker = _REGION_STATE_MARKERS.get(region_code)
if marker is None:
return None
return marker in state.lower()
return "свердловск" in state.lower()
def _region_viewbox(region: Region) -> str:
"""Nominatim `viewbox` (lon_min,lat_max,lon_max,lat_min) из `region.bbox_region`.
Для region 66 см. `OBLAST66_VIEWBOX["viewbox"]` литеральная константа
(byte-identical), эта функция для 66 не вызывается.
"""
lat_min, lat_max, lon_min, lon_max = region.bbox_region
return f"{lon_min},{lat_max},{lon_max},{lat_min}"
def _viewbox_for_region(region_code: int) -> str:
"""Nominatim `viewbox` по коду региона — ЕДИНАЯ точка для всех тиров.
`region_code=66` литеральная `OBLAST66_VIEWBOX["viewbox"]`: значение
историческое, из bbox не выводится, поэтому byte-identical прежнему
поведению. Прочие регионы рамка из реестра (`_region_viewbox`).
"""
if region_code == 66:
return OBLAST66_VIEWBOX["viewbox"]
return _region_viewbox(_ALL_REGIONS[region_code])
def _region_default_city(region_code: int) -> str:
"""Главный город региона — текстовый суффикс запроса, когда город не назван.
`region_code=66` литеральный "Екатеринбург" (byte-identical dual-query
#2580/C2). Прочие — `canonical_city` реестра, иначе `city_token` с заглавной.
"""
if region_code == 66:
return "Екатеринбург"
region = _ALL_REGIONS[region_code]
return region.canonical_city or region.city_token.capitalize()
async def _nominatim_query(
client: httpx.AsyncClient, address: str, region_code: int = 66
) -> dict | None:
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
"""Single Nominatim search. Возвращает лучший item или None.
ВАЖНО: фильтруем результаты по bbox региона `region_code` прямо тут, чтобы
при опечатках не возвращать Пермский край / Челябинск но не резать
легитимные Нижний Тагил / Серов и т.д. (генеральный bbox всего региона).
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
опечатках не возвращать Пермский край / Челябинск но не резать легитимные
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
bbox региона (byte-identical для region_code=66, даже если Nominatim
ранжировал его не первым) иначе первый кандидат внутри генерального bbox
региона. Плюс region cross-check (`address.state`) отсекает кандидатов
ЯВНО из другого региона (Тюмень и т.п.), даже если координаты попали в
генеральный bbox. `region_code=66` (дефолт) byte-identical прежнему
поведению (те же bbox-значения и та же viewbox-строка).
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
не первым) иначе первый кандидат внутри OBLAST66. Плюс region cross-check
(`address.state`) отсекает кандидатов ЯВНО из другого региона (Тюмень и
т.п.), даже если координаты попали в генеральный bbox.
"""
region = _ALL_REGIONS[region_code]
viewbox = _viewbox_for_region(region_code)
await _nominatim_throttle()
response = await client.get(
"https://nominatim.openstreetmap.org/search",
@ -810,8 +717,8 @@ async def _nominatim_query(
"limit": "3",
"countrycodes": "ru",
"addressdetails": "1",
"viewbox": viewbox,
"bounded": "1", # строго в пределах региона
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1", # строго в пределах области (region 66)
},
)
response.raise_for_status()
@ -823,11 +730,11 @@ async def _nominatim_query(
lon_f = float(item["lon"])
except Exception:
continue
if _nominatim_region_ok(item, region_code) is False:
continue # регион явно не тот, что запрошен — не рассматриваем
if is_within_bbox(lat_f, lon_f, region.bbox_tight):
return item # tight-bbox приоритетнее — тот же результат, что и раньше
if oblast_fallback is None and is_within_bbox(lat_f, lon_f, region.bbox_region):
if _nominatim_region_ok(item) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
oblast_fallback = item
return oblast_fallback
@ -849,25 +756,21 @@ async def _nominatim_query(
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_lookup(
address: str, city_hint: str | None = None, region_code: int = 66
) -> GeocodeResult | None:
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
Бан-policy: User-Agent с email обязателен.
Tier 1: bounded регион `region_code` на оригинальный адрес.
Tier 2: bounded регион `region_code` на typo-варианты (Цвилинга Цвиллинга).
Tier 1: bounded область (region 66) на оригинальный адрес.
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга Цвиллинга).
#2580 (C): city_hint, если известен, подставляется в текст запроса — без
него `_nominatim_query` полагается ТОЛЬКО на region-bbox фильтр + tie-break
(предпочитает tight bbox региона), который для одноимённых улиц ВНУТРИ
региона (напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не
различает город. Эмпирически подтверждено: "Ленина 1" без города
случайное село внутри области; "Нижний Тагил, Ленина 1" корректно
резолвится. Nominatim единственный живой внешний провайдер (#2593:
Yandex Geocoder удалён) city_hint должен реально влиять на его результат,
не только на кэш-ключ. `region_code=66` (дефолт, #3051) — byte-identical
прежнему поведению для всех вызовов без аргумента.
него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона
(напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не различает город.
Эмпирически подтверждено: "Ленина 1" без города случайное село внутри
области; "Нижний Тагил, Ленина 1" корректно резолвится. Nominatim
единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) —
city_hint должен реально влиять на его результат, не только на кэш-ключ.
"""
headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
@ -875,18 +778,18 @@ async def _nominatim_lookup(
"Accept-Language": "ru,en;q=0.8",
"Referer": "https://tradein-mvp.local/",
}
city, _ = _resolve_city_for_geocode(address, city_hint, region_code)
city, _ = _resolve_city_for_geocode(address, city_hint)
query = f"{city}, {address}" if city else address
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
# Tier 1: оригинал
item = await _nominatim_query(client, query, region_code)
item = await _nominatim_query(client, query)
# Tier 2: typo-variants
if item is None:
for variant in _typo_variants(address, limit=4):
variant_city, _ = _resolve_city_for_geocode(variant, city_hint, region_code)
variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
variant_query = f"{variant_city}, {variant}" if variant_city else variant
item = await _nominatim_query(client, variant_query, region_code)
item = await _nominatim_query(client, variant_query)
if item is not None:
logger.info("nominatim typo-fixed: %s%s", address, variant)
break
@ -958,24 +861,8 @@ class GeocodeSuggestion:
# 'locality' вместо 'city' — consistent с Nominatim-веткой).
_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
# region_code → значение поля DaData `region` (БЕЗ типа: «Свердловская», а не
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"}
def _dadata_region_name(region_code: int) -> str:
"""Имя региона для hard-констрейнта DaData. Неизвестный код → ValueError."""
try:
return _DADATA_REGION_NAMES[region_code]
except KeyError as exc:
raise ValueError(f"dadata suggest: unknown region_code={region_code!r}") from exc
async def _dadata_suggest(
query: str, limit: int = 8, region_code: int = 66
) -> list[GeocodeSuggestion]:
async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
"""Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion.
Дроп candidate'ов без координат (DaData возвращает их для широких categories
@ -986,8 +873,9 @@ async def _dadata_suggest(
внутри `suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/
Серов/etc никогда не появились бы в подсказках.
"""
region_name = _dadata_region_name(region_code)
raw = await dadata.suggest_addresses(query, limit=limit, city=None, region=region_name)
raw = await dadata.suggest_addresses(
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
)
if not raw:
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
@ -997,7 +885,7 @@ async def _dadata_suggest(
"dadata suggest: 0 кандидатов для %r при region=%r"
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
query[:60],
region_name,
SVERDLOVSK_OBLAST_REGION,
)
out: list[GeocodeSuggestion] = []
for s in raw:
@ -1020,14 +908,8 @@ async def _dadata_suggest(
return out
async def _nominatim_query_multi(
client: httpx.AsyncClient, query: str, limit: int, region_code: int = 66
) -> list[dict]:
"""Один Nominatim search с рамкой региона `region_code`. Возвращает up to N items.
`region_code=66` (дефолт) byte-identical прежнему поведению: та же
viewbox-строка `OBLAST66_VIEWBOX` (см. `_viewbox_for_region`).
"""
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
await _nominatim_throttle()
response = await client.get(
"https://nominatim.openstreetmap.org/search",
@ -1036,7 +918,7 @@ async def _nominatim_query_multi(
"format": "json",
"limit": str(limit),
"countrycodes": "ru",
"viewbox": _viewbox_for_region(region_code),
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1",
"addressdetails": "1",
},
@ -1073,12 +955,7 @@ def _dedupe_nominatim_items(*item_lists: list[dict]) -> list[dict]:
async def _nominatim_query_city_aware(
client: httpx.AsyncClient,
query: str,
city: str | None,
city_specified: bool,
limit: int,
region_code: int = 66,
client: httpx.AsyncClient, query: str, city: str | None, city_specified: bool, limit: int
) -> list[dict]:
"""Строит и выполняет Nominatim-запрос(ы) с учётом того, известен ли город.
@ -1101,25 +978,18 @@ async def _nominatim_query_city_aware(
ЕКБ-кандидаты идут первыми (majority-случай, привычный порядок).
"""
if city:
return await _nominatim_query_multi(
client, f"{query}, {city}", limit, region_code=region_code
)
return await _nominatim_query_multi(client, f"{query}, {city}", limit)
if city_specified:
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Город неизвестен — dual-query с суффиксом главного города региона
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
default_city = _region_default_city(region_code)
city_data = await _nominatim_query_multi(
client, f"{query}, {default_city}", limit, region_code=region_code
)
bare_data = await _nominatim_query_multi(client, query, limit, region_code=region_code)
return _dedupe_nominatim_items(city_data, bare_data)[:limit]
return await _nominatim_query_multi(client, query, limit)
ekb_data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
bare_data = await _nominatim_query_multi(client, query, limit)
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
async def _nominatim_suggest(
query: str, limit: int = 8, city_hint: str | None = None, region_code: int = 66
query: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный
запрос ничего не находит).
@ -1137,26 +1007,17 @@ async def _nominatim_suggest(
"Accept": "application/json",
"Accept-Language": "ru,en;q=0.8",
}
city, city_specified = _resolve_city_for_geocode(query, city_hint, region_code)
city, city_specified = _resolve_city_for_geocode(query, city_hint)
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
# Tier 1: оригинальный query
data = await _nominatim_query_city_aware(
client, query, city, city_specified, limit, region_code=region_code
)
data = await _nominatim_query_city_aware(client, query, city, city_specified, limit)
# Tier 2: typo-варианты если оригинал пустой
if not data:
for variant in _typo_variants(query, limit=3):
variant_city, variant_specified = _resolve_city_for_geocode(
variant, city_hint, region_code
)
variant_city, variant_specified = _resolve_city_for_geocode(variant, city_hint)
data = await _nominatim_query_city_aware(
client,
variant,
variant_city,
variant_specified,
limit,
region_code=region_code,
client, variant, variant_city, variant_specified, limit
)
if data:
logger.info("nominatim suggest typo-fixed: %s%s", query, variant)
@ -1832,11 +1693,7 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int =
async def suggest(
query: str,
db: Session | None = None,
limit: int = 8,
city_hint: str | None = None,
region_code: int = 66,
query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). Cadastral FDW DaData Nominatim [].
@ -1850,22 +1707,10 @@ async def suggest(
(#2593: Yandex Geocoder, который был primary external provider до DaData,
удалён). DaData region-constraint уже охватывает всю область (не только
ЕКБ) city_hint ей не нужен.
region_code: регион покрытия (дефолт 66, #3051) — какой регион уходит в
hard-констрейнты провайдеров: DaData `region` (`_dadata_region_name`) и
Nominatim `viewbox`+bounded (`_viewbox_for_region`). БЕЗ него московский
адрес молча схлопывался в пустой список: оба констрейнта ФИЛЬТРЫ, а не
boost, и «не тот регион» неотличимо от «адрес не найден». Локальные
ЕКБ-тиры (кадастр) для region_code != 66 пропускаются целиком данных
по другим регионам в FDW физически нет. Дефолт byte-identical
прежнему поведению по Свердловской области.
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
"""
if not query or len(query.strip()) < 2:
return []
try:
_ALL_REGIONS[region_code]
except KeyError as exc:
raise ValueError(f"suggest: unknown region_code={region_code!r}") from exc
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
# EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query явно
@ -1875,21 +1720,17 @@ async def suggest(
# иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed`
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим —
# они уже oblast-aware.
# #3051: `region_code != 66` закрывает кадастровый тир ДО `_ekb_local_tiers_allowed`
# — gendesign_cad_buildings содержит только ЕКБ, звать его для Москвы значит
# платить FDW-round-trip ради гарантированного нуля (тот же гейт в
# `_geocode_resolve`; сигнатуру `_ekb_local_tiers_allowed` умышленно не трогаем).
if db is not None and region_code == 66 and _ekb_local_tiers_allowed(query, city_hint):
if db is not None and _ekb_local_tiers_allowed(query, city_hint):
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
parsed = _parse_street_house(query.strip())
if parsed is not None:
street, house = parsed
hit = await run_db_thread(_cadastral_house_match, db, street, house)
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
if hit is not None:
return [hit]
# 1b. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
cad_results = await run_db_thread(_cadastral_forward_sync, db, query.strip(), limit)
cad_results = await asyncio.to_thread(_cadastral_forward_sync, db, query.strip(), limit)
if cad_results:
return cad_results
@ -1897,7 +1738,7 @@ async def suggest(
# лучший fit для РФ адресов.
if settings.dadata_api_token:
try:
dadata_results = await _dadata_suggest(query, limit, region_code)
dadata_results = await _dadata_suggest(query, limit)
if dadata_results:
return dadata_results
except Exception:
@ -1905,16 +1746,14 @@ async def suggest(
# Tier 3: Nominatim (последний fallback — OSM, без ключа)
try:
return await _nominatim_suggest(query, limit, city_hint=city_hint, region_code=region_code)
return await _nominatim_suggest(query, limit, city_hint=city_hint)
except Exception:
logger.exception("nominatim suggest failed")
return []
# ── Public API ───────────────────────────────────────────────────────────────
async def geocode(
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
) -> GeocodeResult | None:
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
@ -1932,21 +1771,8 @@ async def geocode(
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
за собой чужие оценки. Координаты НЕ выбрасываются деградация честная и
видимая, а не отказ.
`region_code` (дефолт 66, #3051) — какой `REGIONS`-регион искать (bbox,
city-словарь, ЕКБ-only локальные тиры). Неизвестный код `ValueError`
сразу, а не глубоко внутри `_nominatim_query`. Все существующие вызовы без
аргумента получают region_code=66 byte-identical прежнему поведению.
`_city_substituted` region_code не принимает: инвариант завязан на
ЕКБ-bbox координат результата (`is_within_ekb_bbox`), который для другого
региона (Москва и т.п.) структурно не совпадает условие 3 инварианта
никогда не сработает, ложного понижения confidence до "locality" не будет.
"""
try:
_ALL_REGIONS[region_code]
except KeyError as exc:
raise ValueError(f"geocode: unknown region_code={region_code!r}") from exc
result = await _geocode_resolve(address, db, city_hint, region_code)
result = await _geocode_resolve(address, db, city_hint)
if result is None or not _city_substituted(address, result):
return result
logger.warning(
@ -1962,7 +1788,7 @@ async def geocode(
async def _geocode_resolve(
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
address: str, db: Session, city_hint: str | None = None
) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
@ -1976,10 +1802,6 @@ async def _geocode_resolve(
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
разных городов по одному и тому же тексту адреса не перезатирали
друг друга.
region_code: регион покрытия (дефолт 66, #3051). ЕКБ-only локальные
тиры (geoportal/cad_buildings/houses) применяются ТОЛЬКО при 66
это ЕКБ-специфичные реестры, у других регионов данных в них нет.
Прокидывается в Nominatim-тир (bbox/viewbox/city-словарь).
Returns:
GeocodeResult или None если ни один провайдер не отвечает.
@ -1990,13 +1812,13 @@ async def _geocode_resolve(
if not address or len(address.strip()) < 3:
return None
_, city_specified = _resolve_city_for_geocode(address, city_hint, region_code)
_, city_specified = _resolve_city_for_geocode(address, city_hint)
city_ambiguous = not city_specified
addr_norm = _cache_key(normalize_address(address), city_hint)
# 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop)
cached = await run_db_thread(_cache_get, db, addr_norm)
cached = await asyncio.to_thread(_cache_get, db, addr_norm)
if cached is not None:
logger.info("geocode cache hit: %s", addr_norm)
return replace(cached, city_ambiguous=city_ambiguous)
@ -2016,20 +1838,13 @@ async def _geocode_resolve(
# Раньше решение по тексту адреса принималось от противного (список из 37
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582).
#
# #3051: `region_code != 66` закрывает эти тиры целиком, ДО вызова
# `_ekb_local_tiers_allowed` — geoportal/cad_buildings/houses физически не
# содержат данных других регионов (не "город не распознан словарём 66", а
# "реестра для этого региона нет вовсе"), а сама `_ekb_local_tiers_allowed`
# (её ЕКБ-словари: `_names_non_ekb_city`/`_names_unrecognized_locality`)
# region_code не принимает — умышленно не трогаем её сигнатуру.
use_local_ekb = region_code == 66 and _ekb_local_tiers_allowed(address, city_hint)
use_local_ekb = _ekb_local_tiers_allowed(address, city_hint)
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
if use_local_ekb and parsed is not None:
street, house = parsed
try:
hit = await run_db_thread(_geoportal_house_match, db, street, house)
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
except Exception:
logger.warning("geoportal house-match raised — fall through", exc_info=True)
hit = None
@ -2042,7 +1857,7 @@ async def _geocode_resolve(
confidence="exact",
city_ambiguous=city_ambiguous,
)
await run_db_thread(_cache_put, db, addr_norm, result)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode geoportal house-match: %s → (%.5f, %.5f)",
addr_norm,
@ -2057,7 +1872,7 @@ async def _geocode_resolve(
# (литеральная подстрока не совпадает).
if use_local_ekb and parsed is not None:
street, house = parsed
hit = await run_db_thread(_cadastral_house_match, db, street, house)
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
if hit is not None:
result = GeocodeResult(
lat=hit.lat,
@ -2067,7 +1882,7 @@ async def _geocode_resolve(
confidence="exact",
city_ambiguous=city_ambiguous,
)
await run_db_thread(_cache_put, db, addr_norm, result)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode cadastral house-match: %s → (%.5f, %.5f)",
addr_norm,
@ -2078,7 +1893,9 @@ async def _geocode_resolve(
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
if use_local_ekb:
cad_suggestions = await run_db_thread(_cadastral_forward_sync, db, address.strip(), limit=1)
cad_suggestions = await asyncio.to_thread(
_cadastral_forward_sync, db, address.strip(), limit=1
)
if cad_suggestions:
s = cad_suggestions[0]
result = GeocodeResult(
@ -2089,7 +1906,7 @@ async def _geocode_resolve(
confidence="exact",
city_ambiguous=city_ambiguous,
)
await run_db_thread(_cache_put, db, addr_norm, result)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
)
@ -2097,10 +1914,10 @@ async def _geocode_resolve(
# 3. Nominatim fallback
try:
result = await _nominatim_lookup(address, city_hint, region_code)
result = await _nominatim_lookup(address, city_hint)
if result is not None:
result = replace(result, city_ambiguous=city_ambiguous)
await run_db_thread(_cache_put, db, addr_norm, result)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result
except Exception:
@ -2119,7 +1936,7 @@ async def _geocode_resolve(
if use_local_ekb and parsed is not None:
local_street, _parsed_house = parsed
local_house = _extract_local_house_token(address) or _parsed_house
hit = await run_db_thread(_local_houses_match, db, local_street, local_house)
hit = await asyncio.to_thread(_local_houses_match, db, local_street, local_house)
if hit is not None:
result = GeocodeResult(
lat=hit.lat,
@ -2328,7 +2145,7 @@ async def reverse_geocode(
"""
# 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI)
if db is not None:
cad = await run_db_thread(_cadastral_reverse_sync_full, db, lat, lon)
cad = await asyncio.to_thread(_cadastral_reverse_sync_full, db, lat, lon)
if cad is not None:
address, snap_lat, snap_lon = cad
return ReverseGeocodeResult(

View file

@ -325,34 +325,6 @@ async def _job_landing_stats(
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
# ── landing_showcase_deals — sync пересчёт витрины сделок в executor ─────────
async def _job_landing_showcase_deals(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Пересчёт витрины сделок публичного лэндинга (#3469).
ЛАЙФСАЙКЛ ПРОГОНА ВЕДЁТ HANDLER, а не задача. `refresh_landing_showcase_deals`
писалась под ручной запуск (`python -m app.tasks.landing_showcase_deals`) и про
`run_id` ничего не знает тот же случай, что у `_job_refresh_search_matview`,
и решается так же: done/failed ставим здесь.
Параметры берём ИЗ РАСПИСАНИЯ только те, что в нём есть: дефолты живут в
сигнатуре задачи, и повтор их здесь дал бы два места, которые разъедутся.
"""
from app.tasks.landing_showcase_deals import refresh_landing_showcase_deals
kwargs = {k: params[k] for k in ("sample", "since", "limit", "city") if k in params}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
)
ctx.runs.mark_done(db, run_id, counters)
except Exception:
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -572,19 +544,6 @@ async def _job_osm_poi_ekb_refresh(
)
# ── dtp_stat_refresh — sync ZIP-скачивание+парс ДТП в executor ────────────────
async def _job_dtp_stat_refresh(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.dtp_stat_refresh import run_dtp_stat_refresh
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_dtp_stat_refresh(db, run_id=run_id, params=params),
)
# ── house_imv_backfill — async Avito-IMV с heartbeat, lifecycle в job ─────────
async def _job_house_imv_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -702,133 +661,6 @@ async def _job_domrf_kapremont_load(
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── fns_opendata_load — bulk-дампы ФНС по юрлицам → fns_legal_entity_facts ────
# Открытые данные ФНС по ЮРЛИЦАМ (лицензия nalog.gov.ru/opendata разрешает
# переработку/перераспространение) — легальный обход того, что открытых данных
# ЕГРН по правообладателям-физлицам не существует (218-ФЗ ст. 62). Потребителя у
# fns_legal_entity_facts на момент добавления НЕТ (см. docstring
# app/services/fns_opendata_loader.py и fns_lookup.py) — расписание сидируется
# enabled=false, включение отдельным осознанным шагом.
async def _job_fns_opendata_load(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Скачать наборы ФНС opendata (revexp/sshr2019/debtam/snr) → fns_legal_entity_facts.
Тело переиспользует load_dataset (тот же дизайн-инвариант модуля, что у
_job_domrf_kapremont_load: не дублируем логику CLI). commit() после каждого
набора сбой на debtam не должен откатывать уже загруженный revexp.
"""
from app.services.fns_opendata_loader import DATASET_SLUGS, load_dataset
datasets = params.get("datasets") or list(DATASET_SLUGS)
def _run() -> dict[str, int]:
total_records = 0
total_upserted = 0
for slug in datasets:
result = load_dataset(db, slug)
db.commit()
total_records += int(result["records"])
total_upserted += int(result["upserted"])
return {
"records": total_records,
"upserted": total_upserted,
# см. докстринг _job_domrf_kapremont_load: выделенные колонки прогона +
# гейт «три подряд нулевых прогона».
"total_seen": total_records,
"new_count": total_upserted,
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: fns_opendata_load crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── frt_mkd_load — АИС ППК ФРТ, реестр МКД region 66 (issue #frt-mkd) ────────
async def _job_frt_mkd_load(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Скачать реестр МКД АИС ФРТ (node_id) → staging frt_mkd → backfill houses.
Тело переиспользует те же функции, что и CLI (app/tasks/frt_mkd_load.py) не
дублируем логику. node_id/region_code берутся из scrape_schedules.default_params
(мигр. 291), с фоллбеком на дефолты loader'а.
"""
from app.services.frt_mkd_loader import (
DEFAULT_NODE_ID,
DEFAULT_REGION_CODE,
backfill_houses,
load_frt_mkd,
)
node_id = int(params.get("node_id", DEFAULT_NODE_ID))
region_code = int(params.get("region_code", DEFAULT_REGION_CODE))
def _run() -> dict[str, int]:
load_counts = load_frt_mkd(db, node_id=node_id, region_code=region_code)
db.commit()
houses_counts = backfill_houses(db)
db.commit()
return {
"rows": load_counts["rows"],
"upserted": load_counts["upserted"],
"houses_updated": houses_counts["houses_updated"],
"total_seen": load_counts["rows"],
"new_count": houses_counts["houses_updated"],
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: frt_mkd_load crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── cbr_macro_pull — макро-ряды ЦБ РФ (ипотека по субъектам + ключевая ставка) ─
async def _job_cbr_macro_pull(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Ипотечные ряды (XLSX) + ключевая ставка (SOAP) → cbr_mortgage_series/cbr_key_rate.
Тело переиспользует те же функции, что и CLI (app/tasks/cbr_macro_pull.py)
дизайн-инвариант модуля: не дублируем логику. Lifecycle не свой
mark_done/mark_failed здесь, как у _job_domrf_kapremont_load.
"""
from app.services.cbr_macro import load_key_rate, pull_cbr_mortgage
def _run() -> dict[str, int]:
mortgage_counts = pull_cbr_mortgage(db)
db.commit()
key_rate_counts = load_key_rate(db)
db.commit()
return {
"mortgage_upserted": mortgage_counts["upserted"],
"mortgage_skipped": mortgage_counts["skipped"],
"mortgage_errors": mortgage_counts["errors"],
"key_rate_upserted": key_rate_counts["upserted"],
# выделенные колонки прогона + гейт «три подряд нулевых прогона» (#2625).
"total_seen": mortgage_counts["upserted"] + key_rate_counts["rows"],
"new_count": mortgage_counts["upserted"] + key_rate_counts["upserted"],
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: cbr_macro_pull crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
async def _job_purge_expired_trade_in_data(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -918,13 +750,6 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
post_claim=reschedule_after_minutes(param="interval_minutes", default=360),
),
"rosreestr_dkp_import": Handler(_job_rosreestr_dkp, "rosreestr_dkp_import"),
# Wildcard (#3051 п.3): rosreestr_dkp_import_77 (Москва, миграция 288) и любой
# будущий region_code-суффикс из той же семьи резолвятся сюда через
# resolve_handler по префиксу (тот же механизм, что deactivate_stale_* /
# avito_city_sweep_* — см. scraper_kit.orchestration.scheduler.resolve_handler).
# region_code берётся из default_params строки расписания (import_rosreestr_dkp
# сам валидирует его через app.services.regions.REGIONS), Handler-тело общее.
"rosreestr_dkp_import_*": Handler(_job_rosreestr_dkp, "rosreestr_dkp_import_*"),
"listing_source_snapshot": Handler(_job_listing_source_snapshot, "listing_source_snapshot"),
"asking_to_sold_ratio_refresh": Handler(
_job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh"
@ -939,7 +764,6 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
"landing_showcase_deals": Handler(_job_landing_showcase_deals, "landing_showcase_deals"),
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
@ -999,13 +823,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
"dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
"frt_mkd_load": Handler(_job_frt_mkd_load, "frt_mkd_load"),
"cbr_macro_pull": Handler(_job_cbr_macro_pull, "cbr_macro_pull"),
"fns_opendata_load": Handler(_job_fns_opendata_load, "fns_opendata_load"),
"purge_expired_trade_in_data": Handler(
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
),

View file

@ -302,7 +302,7 @@ def resolve_proxy_url(db: Session, source: str) -> str | None:
# Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного
# здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env.
logger.warning(
logger.error(
"proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН "
"не прошёл фильтр для этого источника (banned_for_source=%d, "
"unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через "

View file

@ -1309,8 +1309,6 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
транзиентный сбой узла перманентный бан, используется пока только для логов):
- "timeout" сеть недоступна/медленная (httpx.TimeoutException)
- "connect_error" прокси не поднят/не слушает/DNS (httpx.ConnectError)
- "proxy_error" сам прокси отверг соединение (httpx.ProxyError, напр. 407 от
провайдера это состояние пула, а не инцидент; #3471)
- "http_error" ipify ответил ошибкой через прокси (auth/upstream)
- "other" прочее
@ -1330,14 +1328,6 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
except httpx.ConnectError:
logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url))
return False, None, None, "connect_error"
except httpx.ProxyError as exc:
# #3471: сам прокси-провайдер отверг соединение (чаще всего 407 —
# исчерпан лимит/просрочен пакет) — штатный исход health-пробы, не
# инцидент приложения. Одна строка без трейса: узел + причина текстом
# исключения, полный traceback здесь не несёт новой информации и только
# засорял логи (184 строки/сутки, #3471).
logger.warning("proxy_pool: health probe proxy_error proxy=%s reason=%s", _mask(url), exc)
return False, None, None, "proxy_error"
except httpx.HTTPStatusError as exc:
logger.warning(
"proxy_pool: health probe http_error proxy=%s status=%s",

View file

@ -28,18 +28,9 @@ class Region:
"""Один регион покрытия продукта.
bbox_tight ядро города: geocoder-фильтрация фуззи-матчей провайдеров
(не принять соседний город за совпадение по опечатке). У
региона БЕЗ одного центрального города (50 область, много
сопоставимых по объёму городов, ни один не «ядро») равен
bbox_product_core: эмпирический пояс, где данные РЕАЛЬНО
наблюдались (перцентили 0.5..99.5 координат сырья), а не
административная граница см. REGIONS[50] и обоснование там.
(не принять соседний город за совпадение по опечатке).
bbox_wide город + легитимное приграничье: ingest-guard координат,
ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит tight.
У 50 полный наблюдённый диапазон координат (min..max, без
перцентильной обрезки) вместо «город + отступ»: без своего
города отступать не от чего, поэтому граница «легитимности»
здесь тоже эмпирическая, просто менее обрезанная, чем tight.
bbox_region генеральный bbox региона: fallback-accept для провайдеров без
структурного region-поля. Содержит wide.
bbox_product_core гео-охват ПРОДУКТА в этом регионе: location_index
@ -47,29 +38,15 @@ class Region:
out_of_coverage. У 66 УЖЕ (не равен) tight: исторический bbox
location_index (56.70..56.95/60.50..60.75), синхронизирован с
EKB_BBOX Overpass-загрузчика POI основного gendesign-бэкенда
(комментарий в обе стороны, см. site_finder/poi_loader.py). У
50 намеренно НЕ административный bbox (обещать охват там,
где нет ни одного объявления, нельзя) эмпирический пояс
фактических данных, см. REGIONS[50].
(комментарий в обе стороны, см. site_finder/poi_loader.py).
city_token нормализованный токен главного города (нижний регистр, е==ё
нормализует потребитель matching.normalize). У региона без
единого центра (50) самый объёмный по данным город, который
ОДНОВРЕМЕННО де-факто административный: см. REGIONS[50].
нормализует потребитель matching.normalize).
cities узнаваемые города региона (для city_hint / prefix-логики
геокодера). НЕ исчерпывающий список основные центры.
enrichment_tiers какие тиры обогащения РЕАЛЬНО доступны региону.
Регион без тира должен деградировать ЯВНО (потребитель
спрашивает unsupported_tier_reason и логирует/маркирует),
а не молча считать дальше без источника.
canonical_city #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
строк, приходящих из источника без надёжного city-поля
(Росреестр по Москве отдаёт муниципальный округ/поселение
вместо города «Раменки», «Сосенское» а не «Москва»).
None источник несёт свой city как есть, без override
(регион 66: byte-for-byte прежнее поведение). Not-None
потребитель (import_rosreestr_dkp) подставляет это имя
вместо city источника и НЕ фильтрует по city IS NOT NULL
(иначе на 77 теряется ~10% строк с пустым city).
"""
code: int
@ -81,7 +58,6 @@ class Region:
city_token: str
cities: frozenset[str]
enrichment_tiers: frozenset[str]
canonical_city: str | None = None
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
@ -168,127 +144,20 @@ REGIONS: dict[int, Region] = {
# sber_index покрывают регион 66. Пустое множество здесь — не заглушка,
# а ФАКТ, который потребители обязаны озвучивать (см. класс-докстринг).
enrichment_tiers=frozenset(),
# #3051: Росреестр по Москве отдаёт в city муниципальный округ/поселение
# ("муниципальный округ Раменки", "поселение Сосенское"), не сам город —
# import_rosreestr_dkp подставляет каноничное имя вместо city источника.
canonical_city="Москва",
),
50: Region(
code=50,
name="Московская область",
# У области НЕТ города-ядра (в отличие от 66/77) — 20 сопоставимых по
# объёму городов-спутников. Поэтому tight/wide/product_core здесь не
# «город + отступ», а ЭМПИРИЧЕСКИЙ пояс данных: разброс координат
# подмосковного сырья Циан (45 294 строки, отбор по городскому
# поддомену ссылки ≠ www, замер на дату добавления региона):
# полный диапазон: lat 54.673..56.762, lon 35.920..39.888
# перцентили 0.5..99.5: lat 54.834..56.728, lon 36.193..39.545
# tight = product_core = перцентильный пояс (без выбросов из хвоста
# распределения — то немногое, что уверенно наблюдали). wide = полный
# диапазон (min..max) — легитимное приграничье для ingest-guard шире
# tight, но всё ещё эмпирическое, не административное.
bbox_tight=(54.834, 56.728, 36.193, 39.545),
bbox_wide=(54.673, 56.762, 35.920, 39.888),
# region — административный bbox МО целиком (fallback-accept должен
# покрывать всю область, а не только пояс, где уже есть данные):
# lat 54.20..56.96, lon 35.14..40.21.
bbox_region=(54.20, 56.96, 35.14, 40.21),
# product_core НЕ равен bbox_region: location_index не должен обещать
# медианы там, где по факту нет ни одного объявления (deals=0,
# listings=0 на дату добавления — импорт 411 056 сделок Росреестра из
# FDW идёт отдельным PR). Равен tight — см. выше.
bbox_product_core=(54.834, 56.728, 36.193, 39.545),
# Красногорск: и самый объёмный город по факту сырья (см. cities ниже,
# по убыванию объёма), и де-факто административный центр региона —
# Правительство Московской области физически размещается в Красногорске
# с 2013 г. (Москва как формальный административный центр — экстра-
# территориальна и уже занята регионом 77). Единственный кандидат,
# обоснованный ОБОИМИ критериями сразу.
city_token="красногорск",
cities=frozenset(
{
"красногорск",
"балашиха",
"видное",
"люберцы",
"звенигород",
"химки",
"мытищи",
"подольск",
"одинцово",
"солнечногорск",
"домодедово",
"королёв",
"королев",
"котельники",
"дмитров",
"электросталь",
"реутов",
"щёлково",
"щелково",
"серпухов",
"ногинск",
"железнодорожный",
}
),
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст
# намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в
# карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не
# становится: у Москвы набор тиров тоже пуст, а свой ряд она читает —
# поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде
# за пределами этого реестра не спрашивают).
enrichment_tiers=frozenset(),
# Источники по области несут настоящий city (Химки, Балашиха — не
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
# нечего и незачем, в отличие от 77.
canonical_city=None,
),
}
DEFAULT_REGION_CODE = 66
def _bbox_area(bbox: BBox) -> float:
"""Грубая «площадь» bbox в кв. градусах (lat_range * lon_range).
Не учитывает сжатие долготы на широте (cos(lat)) не нужно: значение
используется ТОЛЬКО чтобы сравнить специфичность bbox'ов разного порядка
(город vs область), не как настоящая площадь в км²."""
lat_min, lat_max, lon_min, lon_max = bbox
return (lat_max - lat_min) * (lon_max - lon_min)
# Порядок обхода для region_for_point: от САМОГО специфичного (маленький
# bbox_region) к самому общему — НЕ sorted(REGIONS) по числовому коду.
#
# Почему код региона как ключ порядка сломался: bbox_region(50) (Московская
# область целиком, lat 54.20..56.96/lon 35.14..40.21) геометрически СОДЕРЖИТ
# bbox_region(77) (Москва, 55.10..56.10/36.80..38.10) как прямоугольники — а
# 50 < 66 < 77 по числу. При обходе `sorted(REGIONS)` регион 50 проверялся бы
# ПЕРВЫМ (50 < 77) и забирал бы себе ВСЕ точки Москвы, включая центр
# (55.75, 37.62) — она лежит в bbox_region обоих регионов одновременно. Старый
# докстринг называл это «на случай, если когда-нибудь пересекутся» — случай
# наступил прямо при добавлении региона 50, не гипотетически.
#
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает это БЕЗ
# ручного списка: чем компактнее регион, тем раньше его проверяют, поэтому
# вложенный регион (77 внутри 50) всегда выигрывает у объемлющего, а будущий
# новый регион сам встанет в верную позицию по своей площади — правку этого
# места повторять не придётся.
_POINT_LOOKUP_ORDER: tuple[int, ...] = tuple(
sorted(REGIONS, key=lambda code: (_bbox_area(REGIONS[code].bbox_region), code))
)
def region_for_point(lat: float, lon: float) -> Region | None:
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
Обход `_POINT_LOOKUP_ORDER` (компактный bbox_region раньше обширного), а
не числовой код региона: код как ключ порядка ломается ровно на паре
50/77, см. комментарий у `_POINT_LOOKUP_ORDER`.
Регионы географически не пересекаются; порядок обхода детерминирован кодом
региона на случай, если когда-нибудь пересекутся (первый по коду выигрывает
и это станет видно в тестах реестра, а не в проде).
"""
for code in _POINT_LOOKUP_ORDER:
for code in sorted(REGIONS):
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
return REGIONS[code]
return None
@ -296,16 +165,7 @@ def region_for_point(lat: float, lon: float) -> Region | None:
def region_by_city(city: str | None) -> Region | None:
"""Регион, в чьём списке городов есть `city` (нормализованный нижний
регистр, е/ё не различаются). None город не узнан ни одним регионом.
В отличие от `region_for_point`, здесь нет геометрической вложенности
сравнение точное (токен строки), не bbox-containment, поэтому порядок по
коду региона не создаёт баг ordering'а САМ ПО СЕБЕ. Он МОГ бы сломаться,
если бы одно имя города оказалось в `cities` двух регионов (тогда побеждал
бы меньший код) список городов 50 сверен вручную с `cities` регионов 66
и 77, пересечений нет (закреплено test_no_city_name_duplicated_across_regions
в tests/test_3051_region_registry_moscow_oblast.py).
"""
регистр, е/ё не различаются). None город не узнан ни одним регионом."""
if not city:
return None
token = " ".join(city.lower().replace("ё", "е").split())

View file

@ -40,18 +40,6 @@ REF_AREA codes (sberindex internal region IDs, not ОКАТО/ISO):
77 = Москва verified 2026-05-31 brute-force against /api/sowa
(region name from ref_area field of response);
real_estate_deals: 2017-01 148 471 2026-04 309 510 руб/м².
50 = Московская область verified live 2026-09-12 (все три дашборда, ref_area
в ответе = «Московская область»); real_estate_deals 116 месячных точек
2017-01..2026-08, residential_real_estate_prices 47 (2022-10..2026-08),
dinamika-tsen-obyavlenii 56 (2021-12..2026-07) та же глубина и свежесть,
что у Москвы.
Область заведена ЗАРАНЕЕ, до появления региона 50 в реестре regions.REGIONS. Ряд
источника наполняется месяцами и задним числом не восстанавливается, поэтому
загружать его надо начинать раньше, чем он понадобится оценщику. На саму оценку
это не влияет: estimator берёт ряды через _SBER_REGION_SERIES, кода 50 там нет, и
до его появления область в расчёт не попадает. Мониторинг свежести тоже не
затронут SBER_REQUIRED_REGIONS считается от карты ЭСТИМАТОРА, а не отсюда.
"""
from __future__ import annotations
@ -83,7 +71,6 @@ SBER_REF_AREAS: dict[str, str] = {
"643": "Россия",
"66": "Свердловская область",
"77": "Москва",
"50": "Московская область",
}

View file

@ -25,7 +25,6 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в
from __future__ import annotations
import json
import logging
from typing import Any
@ -51,7 +50,6 @@ from sqlalchemy.orm import Session
from app.core.shutdown import shutdown_requested
from app.services import scrape_runs as runs_mod
from app.services.regions import REGIONS
__all__ = ["compute_next_run_at", "has_running_run"]
@ -240,24 +238,6 @@ async def _execute_cian_backfill(
_DKP_SOURCE = "rosreestr_dkp_import"
def _dkp_source_for_region(region_code: int) -> str:
"""Имя scrape_runs.source для чекпоинта данного региона (#3051 п.3).
66 байт-в-байт прежнее имя ('rosreestr_dkp_import'), под которым годами
писались scrape_runs. Остальные регионы получают суффикс кода тот же
формат, что и у строки scrape_schedules ('rosreestr_dkp_import_77',
seed миграция 289), которую резолвит wildcard 'rosreestr_dkp_import_*'
в product_handlers.py. Изоляция чекпоинтов между регионами держится именно
на разных source: _resume_dkp_cursor ищет ПРЕДЫДУЩИЙ прогон с ТЕМ ЖЕ source,
поэтому курсор региона 77 никогда не подхватит last_id региона 66 (и
наоборот) они просто разные строки в scrape_runs.source.
"""
if region_code == 66:
return _DKP_SOURCE
return f"{_DKP_SOURCE}_{region_code}"
# Потолок возраста чекпоинта: старше — last_id прошлого прогона не подхватываем, прогон
# стартует с id=0 (issue #3168). У предиката `id > last_id` нет протухания в смысле
# свипов (он остаётся корректным сколь угодно долго), но апстрим
@ -283,9 +263,7 @@ _DKP_RESUME_CANDIDATE_SQL = text("""
""")
def _resume_dkp_cursor(
db: Session, run_id: int, source: str = _DKP_SOURCE
) -> tuple[int, dict[str, Any]]:
def _resume_dkp_cursor(db: Session, run_id: int) -> tuple[int, dict[str, Any]]:
"""Продолжить last_id прошлого прогона или начать с 0 — решение + explain (issue #3168).
last_id раньше жил только в памяти процесса (init на 0 при каждом запуске): heartbeat
@ -293,14 +271,7 @@ def _resume_dkp_cursor(
обратно. Обрыв (деплой/OOM/рестарт хоста) откатывал прогресс на 0 и заставлял
пере-сканировать источник с начала.
`source` (#3051 п.3) — per-region ключ чекпоинта (см. _dkp_source_for_region):
дефолт _DKP_SOURCE сохраняет прежнее поведение вызовов без явного аргумента
(регион 66). Кандидат ищется СТРОГО по этому source прогон региона 77
(source='rosreestr_dkp_import_77') никогда не видит last_id региона 66
(source='rosreestr_dkp_import') и наоборот: разные регионы физически не
матчат друг друга в WHERE source = :source ниже.
Кандидат ПОСЛЕДНИЙ прогон ЭТОГО source (тот же принцип, что и
Кандидат ПОСЛЕДНИЙ прогон этого source (тот же принцип, что и
scraper_kit.orchestration.scheduler._pick_resume, локальная копия ладдера контракт
другой: нет params/interval_days, курсор числовой, а не bucket-set):
- 'running' / 'zombie' прогон, которого не завершили штатно.
@ -314,7 +285,7 @@ def _resume_dkp_cursor(
кодом (kit_runs.update_heartbeat merge, не замена), чтобы решение было видно в
scrape_runs, а не только в логе.
"""
row = db.execute(_DKP_RESUME_CANDIDATE_SQL, {"source": source, "rid": run_id}).fetchone()
row = db.execute(_DKP_RESUME_CANDIDATE_SQL, {"source": _DKP_SOURCE, "rid": run_id}).fetchone()
verdict: dict[str, Any] = {"resume_from": None}
if row is None:
@ -351,92 +322,48 @@ def import_rosreestr_dkp(
) -> None:
"""Import ДКП-сделок из gendesign rosreestr_deals через postgres_fdw.
Python-порт import-rosreestr.sh (Variant C из #563). #3051 п.3: параметризовано
по региону (params["region_code"], реестр app.services.regions.REGIONS)
было хардкод region_code=66.
Python-порт import-rosreestr.sh (Variant C из #563).
Источник: foreign table gendesign_rosreestr_deals (создана в migration 072,
okato/quarter_cad_number/district добавлены миграцией 289).
Источник: foreign table gendesign_rosreestr_deals (создана в migration 072).
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
Область покрытия задаётся параметром, а не литералом (#3051 п.6): region_code
приходит из params, дефолт 66 = вся Свердловская область (не только Екатеринбург
прежний ILIKE-фильтр по подстроке города снят, unlocks +47183 сделок вне ЕКБ уже
сидящих в source foreign table). Неизвестный код региона (нет в REGIONS) ValueError,
прогон падает явно, а не молча импортирует мусор с чужим region_code.
region_code=66 (регион БЕЗ canonical_city в реестре) поведение байт-в-байт
прежнее: city/address строятся из city источника, обязателен фильтр
city IS NOT NULL AND trim(city) != ''.
Регион С canonical_city (77 Москва): Росреестр отдаёт в city муниципальный
округ/поселение ("муниципальный округ Раменки", "поселение Сосенское"), НЕ
город city/address подставляют region.canonical_city, а не city источника;
фильтр city IS NOT NULL НЕ применяется (иначе теряется ~10% строк с пустым
city источника). Исходные city/okato/quarter_cad_number/district уходят в
raw_payload (jsonb) единственная ветка SQL решает это через bind-параметр
:canonical_city (CASE WHEN ... IS NOT NULL), а не отдельный Python if/else на
конкретный код региона.
Типы документов тоже параметр (#3051 п.3): doc_types, дефолт ['ДКП'] = прежнее
поведение (только вторичка #549 / Fix_Rosreestr_Dkp_Filter_May24). Для Москвы
ДДУ идут по ценам котлована и медиану развалят, поэтому смешивать их с ДКП можно
только осознанно и с колонкой deals.doc_type (миграция 288), которая теперь
заполняется на импорте.
Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург
прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом)
снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже
сидящих в source foreign table). address и deals.city строятся из реального city
источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки
источника (= 66 при текущем фильтре).
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
- region_code = :region_code (параметризовано, было хардкод 66)
- city IS NOT NULL AND trim(city) != '' ТОЛЬКО если у региона нет canonical_city
- region_code = 66 (вся Свердловская область, все города)
- city IS NOT NULL AND trim(city) != '' (непустой город корректный address)
- realestate_type_code = '002001003000' (квартира)
- area BETWEEN 18 AND 200
- deal_price BETWEEN 1000000 AND 100000000
- street IS NOT NULL AND trim(street) != ''
- doc_type = ANY(:doc_types) (param, default ['ДКП'])
- doc_type = 'ДКП' (только вторичка #549 / Fix_Rosreestr_Dkp_Filter_May24)
- period_start_date >= since (default '2024-01-01')
dedup_hash: 'ros:dkp:' || id плоский натуральный ключ (инъективный, без коллизий,
human-readable). До #576 здесь был md5('ros:dkp:' || id); миграция 077 конвертировала
существующие строки. source_id хранит исходный rosreestr id (дедуп переустанавливаем).
Префикс ':dkp:' НАМЕРЕННО оставлен неизменным после параметризации doc_types: id
уникален в источнике сам по себе, независимо от типа документа, поэтому ключ и без
того не коллизирует; а вот смена формы ключа осиротила бы все уже загруженные строки
(их пришлось бы конвертировать ещё одной миграцией ровно то, что делала 077).
Rooms: выводятся из площади (Росреестр не отдаёт кол-во комнат).
Batch-процессинг: читаем из FDW батчами по batch_size через cursor-based пагинацию
(WHERE id > last_id ORDER BY id). Heartbeat обновляется каждый батч (= checkpoint),
мержем (kit_runs.update_heartbeat), а не заменой. На старте _resume_dkp_cursor решает
продолжить с last_id прошлого прогона или начать с 0 чекпоинт переживает рестарт
процесса (деплой/OOM/SIGTERM), пока не старше суток (issue #3168). Курсор — ПЕР
РЕГИОН (#3051 п.3): _resume_dkp_cursor вызывается с source=_dkp_source_for_region
(region_code), поэтому last_id региона 77 никогда не подхватывает last_id региона
66 они разные scrape_runs.source ('rosreestr_dkp_import' vs
'rosreestr_dkp_import_77'), см. докстринг _dkp_source_for_region.
процесса (деплой/OOM/SIGTERM), пока не старше суток (issue #3168).
SAVEPOINT per row один сбойный row не откатывает батч.
Координаты: NULL после импорта геокодинг остаётся follow-up (geocode-deals).
TODO (follow-up): запустить geocode backfill после import.
Cleanup: удаляет legacy строки address='Екатеринбург, реальная сделка' (pre-#549,
region-agnostic синтетические строки существовали только для ЕКБ).
Cleanup: удаляет legacy строки address='Екатеринбург, реальная сделка' (pre-#549).
"""
since: str = str(params.get("since", "2024-01-01"))
batch_size: int = int(params.get("batch_size", 2000))
# #3051 п.6: регион — параметр, дефолт 66 сохраняет текущее прод-поведение
# (расписание получает явный region_code в миграции 288).
region_code: int = int(params.get("region_code", 66))
# #3051 п.3: типы документов — параметр, дефолт ['ДКП'] = прежний литерал.
doc_types: list[str] = [str(t) for t in params.get("doc_types") or ["ДКП"]]
region = REGIONS.get(region_code)
if region is None:
raise ValueError(
f"rosreestr_dkp_import: region_code={region_code} не найден в "
f"app.services.regions.REGIONS (известны: {sorted(REGIONS)}) — "
"прогон остановлен, чтобы не импортировать сделки с неизвестным "
"региональным контекстом (city/address-правила для него не определены)"
)
dkp_source = _dkp_source_for_region(region_code)
counters: dict[str, int] = {
"rows_fetched": 0,
@ -473,7 +400,7 @@ def import_rosreestr_dkp(
)
db.rollback()
last_id, resume_verdict = _resume_dkp_cursor(db, run_id, source=dkp_source)
last_id, resume_verdict = _resume_dkp_cursor(db, run_id)
total_batches = 0
kit_runs.update_heartbeat(db, run_id, resume_verdict)
logger.info(
@ -523,20 +450,9 @@ def import_rosreestr_dkp(
id,
id AS source_id_src,
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
-- #3051: регион с canonical_city (Москва) подставляет его вместо
-- city источника (округ/поселение, не город) CASE на bind-параметре,
-- не Python if/else на код региона.
CASE
WHEN CAST(:canonical_city AS text) IS NOT NULL
THEN CAST(:canonical_city AS text) || ', ' || trim(street)
ELSE trim(city) || ', ' || trim(street)
END AS address,
trim(city) || ', ' || trim(street) AS address,
region_code,
CASE
WHEN CAST(:canonical_city AS text) IS NOT NULL
THEN CAST(:canonical_city AS text)
ELSE trim(city)
END AS city,
trim(city) AS city,
CASE
WHEN area < 30 THEN 0
WHEN area < 44 THEN 1
@ -556,45 +472,21 @@ def import_rosreestr_dkp(
year_build AS year_built,
round(deal_price)::bigint AS price_rub,
round(price_per_sqm)::int AS price_per_m2,
period_start_date AS deal_date,
doc_type,
-- Исходный city/okato/quarter_cad_number/district ТОЛЬКО когда
-- city перезаписан canonical_city выше (иначе NULL, регион 66
-- byte-for-byte прежний: raw_payload не заполнялся и не заполняется).
CASE
WHEN CAST(:canonical_city AS text) IS NOT NULL THEN
jsonb_build_object(
'src_city', city,
'okato', okato,
'quarter_cad_number', quarter_cad_number,
'district', district
)
ELSE NULL
END AS raw_payload
period_start_date AS deal_date
FROM gendesign_rosreestr_deals
WHERE region_code = CAST(:region_code AS int)
AND (
CAST(:canonical_city AS text) IS NOT NULL
OR (city IS NOT NULL AND trim(city) <> '')
)
WHERE region_code = 66
AND city IS NOT NULL AND trim(city) <> ''
AND realestate_type_code = '002001003000'
AND area BETWEEN 18 AND 200
AND deal_price BETWEEN 1000000 AND 100000000
AND street IS NOT NULL AND trim(street) <> ''
AND doc_type = ANY(CAST(:doc_types AS text[]))
AND doc_type = 'ДКП'
AND period_start_date >= CAST(:since AS date)
AND id > CAST(:last_id AS bigint)
ORDER BY id
LIMIT CAST(:batch_size AS int)
"""),
{
"since": since,
"last_id": last_id,
"batch_size": batch_size,
"region_code": region_code,
"canonical_city": region.canonical_city,
"doc_types": doc_types,
},
{"since": since, "last_id": last_id, "batch_size": batch_size},
)
.mappings()
.all()
@ -632,7 +524,7 @@ def import_rosreestr_dkp(
INSERT INTO deals (
source, dedup_hash, source_id, address, region_code, city,
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
deal_date, doc_type, raw_payload
deal_date
)
VALUES (
'rosreestr',
@ -647,9 +539,7 @@ def import_rosreestr_dkp(
CAST(:year_built AS int),
CAST(:price_rub AS bigint),
CAST(:price_per_m2 AS int),
CAST(:deal_date AS date),
CAST(:doc_type AS text),
CAST(:raw_payload AS jsonb)
CAST(:deal_date AS date)
)
ON CONFLICT (dedup_hash) DO UPDATE SET
address = EXCLUDED.address,
@ -661,9 +551,7 @@ def import_rosreestr_dkp(
year_built = EXCLUDED.year_built,
price_rub = EXCLUDED.price_rub,
price_per_m2 = EXCLUDED.price_per_m2,
deal_date = EXCLUDED.deal_date,
doc_type = EXCLUDED.doc_type,
raw_payload = EXCLUDED.raw_payload
deal_date = EXCLUDED.deal_date
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
OR deals.city IS DISTINCT FROM EXCLUDED.city
@ -674,8 +562,6 @@ def import_rosreestr_dkp(
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
OR deals.doc_type IS DISTINCT FROM EXCLUDED.doc_type
OR deals.raw_payload IS DISTINCT FROM EXCLUDED.raw_payload
RETURNING (xmax = 0) AS was_inserted
"""),
{
@ -691,12 +577,6 @@ def import_rosreestr_dkp(
"price_rub": row["price_rub"],
"price_per_m2": row["price_per_m2"],
"deal_date": row["deal_date"],
"doc_type": row["doc_type"],
"raw_payload": (
json.dumps(row["raw_payload"], ensure_ascii=False)
if row["raw_payload"] is not None
else None
),
},
).fetchone()
if result is None:

View file

@ -29,13 +29,9 @@
Telegram 403 (клиент заблокировал бота) is_blocked=true + уведомление в
топике (только для Telegram-ветки у веб-клиента нет "заблокировал бота").
C) Дедуп: update_id <= сохранённого offset skip. Offset сохраняется И
коммитится в той же транзакции, что и запись сообщения (см. `process_update`),
после КАЖДОГО апдейта рестарт воркера не переигрывает уже обработанные
апдейты и не подвисает вечно на «ядовитом» апдейте. Исключение
ТРАНЗИЕНТНЫЙ сетевой отказ (`TelegramNetworkError`, т.е. исчерпанный бюджет
ретраев клиента): такой апдейт СОЗНАТЕЛЬНО остаётся неподтверждённым, чтобы
Telegram отдал его снова, иначе ответ оператора пропадал бы навсегда
(#tg-connection-resilience). Потолок переигрываний — `_MAX_NETWORK_REPLAYS`.
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
`finally`), после КАЖДОГО апдейта рестарт воркера не переигрывает уже
обработанные апдейты и не подвисает вечно на «ядовитом» апдейте.
D) TELEGRAM_BOT_TOKEN пуст бот выключен проверяется в `app.tgbot_main`
(entrypoint), не здесь.
E) /start клиенту короткое приветствие МЕРЫ, без зеркалирования в топик
@ -80,7 +76,7 @@ from app.core.config import settings
from app.core.ratelimit import SlidingWindowLimiter
from app.core.shutdown import shutdown_requested
from app.services.tgbot import web_support_storage
from app.services.tgbot.client import TelegramApiError, TelegramClient, TelegramNetworkError
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
@ -135,59 +131,6 @@ FLOOD_LIMITED_TEXT = (
# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил).
_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено."
# Уведомления оператору в топике отправляются ИЗНУТРИ poll loop, который
# однопоточный: пока висит одна отправка, не обрабатывается НИ ОДИН следующий
# апдейт. Поэтому им нужен интерактивный бюджет, а не воркерный дефолт клиента
# (5 ретраев, backoff до 30с, полный retry_after на 429 — минуты стопа на
# ВТОРИЧНОМ действии). Числа — те же, что у интерактивных отправок веб-чата
# поддержки (`_INTERACTIVE_SEND_*` в app/api/v1/support.py, подобраны замером
# прода #tgsupport-retry); сознательно ДУБЛИРУЕМ, а не импортируем из слоя API —
# воркер не должен зависеть от роутера.
_NOTIFY_SEND_TIMEOUT_S = 5.0
_NOTIFY_SEND_MAX_RETRIES = 3
_NOTIFY_SEND_MAX_BACKOFF_S = 1.0
# Потолок ожидания в TelegramGroupRateLimiter для отправок ИЗ ЭТОГО модуля,
# которые не проходят через _notify_topic (review M1, #3471). Без него
# `client.send_message`/`copy_message` без явного `timeout` наследуют
# лимитер-политику "без потолка" (см. TelegramClient._request) — приемлемую
# ДЛЯ ФОНОВОЙ отправки как таковой, но НЕ здесь: эти вызовы идут внутри
# `run_poll_loop`, который на каждый апдейт держит ОТКРЫТУЮ сессию БД
# (SessionLocal, см. вызывающих) и однопоточно блокирует опрос СЛЕДУЮЩИХ
# апдейтов — минутный сон здесь стопорит и БД-соединение, и весь мост, а не
# только одно сообщение. Второй повод — SIGTERM drain: `tgbot_main._DRAIN_TIMEOUT_S`
# даёт 100с на завершение текущей итерации; ожидание слота дольше этого
# бюджета уже не успевает подчиниться cooperative drain. 20с — заметно больше,
# чем разумная очередь при исчерпанном лимите (окно 60с, обычно секунды), но
# заметно МЕНЬШЕ минуты и вписывается в drain-бюджет с запасом.
_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S = 20.0
# Потолок переигрываний ОДНОГО update_id на транзиентных сетевых отказах
# (#tg-connection-resilience).
#
# Зачем потолок: без него «вечно недоставляемый» апдейт заклинил бы очередь
# НАВСЕГДА — ровно то, от чего защищал прежний безусловный `finally: save_offset`.
# Потерять одно сообщение плохо, потерять весь поток — хуже, поэтому на потолке
# offset всё-таки сдвигается, но ГРОМКО (`logger.error`), а не молча.
#
# Про дубли: `TelegramNetworkError` означает исчерпанный бюджет ретраев клиента,
# при этом запрос МОГ дойти до Telegram (потерялся ответ) — переигрывание тогда
# доставит клиенту то же сообщение второй раз. Это осознанный at-least-once
# компромисс: дубль и клиент, и оператор ВИДЯТ и могут поправить, а тихая потеря
# ответа не оставляет следа нигде, кроме строчки в логе. Полноценная
# идемпотентность по (update_id, target_chat_id) потребовала бы нового
# персистентного состояния (колонка/таблица + миграция) ради редкого случая;
# вместо этого число возможных дублей жёстко ограничено сверху — не больше
# (_MAX_NETWORK_REPLAYS - 1) повторов на апдейт.
_MAX_NETWORK_REPLAYS = 3
# update_id -> сколько раз мы уже отказались подтверждать этот апдейт.
# In-memory осознанно: воркер long-polling однопоточный, запись живёт ровно до
# подтверждения апдейта (`pop` в `process_update`), так что словарь не растёт.
# Рестарт воркера обнуляет счётчик — это допустимо (новый процесс = новая сеть),
# потолок всё равно действует в пределах каждой жизни процесса.
_network_replay_attempts: dict[int, int] = {}
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
class BridgeStorage(Protocol):
@ -238,13 +181,7 @@ class BridgeStorage(Protocol):
) -> int | None: ...
def record_web_out_message(
self,
*,
thread_id: int,
text_body: str,
operator_tg_id: int | None,
topic_message_id: int | None = None,
support_chat_id: int | None = None,
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> None: ...
@ -397,20 +334,14 @@ class SqlBridgeStorage:
со ЧУЖИМ (не NULL, не текущим) support_chat_id исторический артефакт
ротации support-группы, не валидный маршрут сегодня. NULL (строки до
миграции 188, если есть) лениентный wildcard-матч (единственный
действовавший чат на тот момент).
БЕЗ фильтра по direction (#3471 P0, было `AND direction = 'in'`): с тех
пор как `record_message` на исходящем ответе тоже сохраняет
`topic_message_id` (id сообщения оператора В ТОПИКЕ), реплай оператора
на СВОЙ предыдущий ответ обязан резолвиться так же, как реплай на
зеркало клиента иначе продолжение диалога без повторного цитирования
клиента тихо проваливалось в orphan-check."""
действовавший чат на тот момент)."""
row = self._db.execute(
text(
"""
SELECT chat_id
FROM tg_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
AND direction = 'in'
AND (support_chat_id = CAST(:support_chat_id AS bigint)
OR support_chat_id IS NULL)
ORDER BY created_at DESC
@ -440,21 +371,13 @@ class SqlBridgeStorage:
)
def record_web_out_message(
self,
*,
thread_id: int,
text_body: str,
operator_tg_id: int | None,
topic_message_id: int | None = None,
support_chat_id: int | None = None,
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> None:
web_support_storage.record_outbound(
self._db,
thread_id=thread_id,
text_body=text_body,
operator_tg_id=operator_tg_id,
topic_message_id=topic_message_id,
support_chat_id=support_chat_id,
)
@ -477,51 +400,6 @@ def _format_topic_header(
return f"Новое обращение от {display_name}{username_part} (chat_id={chat_id})"
async def _notify_topic(
client: TelegramClient,
*,
text: str,
reply_to_message_id: int | None,
context: str,
) -> bool:
"""Служебное уведомление оператору в support-топик (вторичное действие).
Два свойства, которых не было у прямых `client.send_message` вызовов:
1) узкий интерактивный бюджет (`_NOTIFY_SEND_*`) иначе одна такая отправка
стопорит весь однопоточный poll loop на минуты;
2) собственный `except` провал УВЕДОМЛЕНИЯ не отменяет основную ветку
обработки (клиент уже помечен заблокированным / медиа-реплай уже отклонён)
и не решает судьбу апдейта.
`context` только технические идентификаторы (chat_id/thread_id), НЕ текст
переписки: логи моста принципиально не содержат ПДн.
Возвращает True, если уведомление реально ушло, False если само
уведомление тоже упало (напр. Telegram недоступен). Вызывающий, для
которого проваленное уведомление означает ПОЛНУЮ тишину (ни клиенту, ни
оператору), обязан на False залогировать `logger.error` с идентификаторами
(#3471 P0) — иначе единственный след остаётся только в этом WARNING.
"""
try:
await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=text,
message_thread_id=settings.telegram_support_topic_id or None,
reply_to_message_id=reply_to_message_id,
timeout=_NOTIFY_SEND_TIMEOUT_S,
max_retries=_NOTIFY_SEND_MAX_RETRIES,
max_backoff=_NOTIFY_SEND_MAX_BACKOFF_S,
)
return True
except Exception:
logger.warning(
"tgbot bridge: не удалось отправить уведомление оператору в топик (%s) — "
"основная ветка обработки не отменяется",
context,
exc_info=True,
)
return False
# ── Update routing ────────────────────────────────────────────────────────────
async def _handle_private_message(
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
@ -550,11 +428,7 @@ async def _handle_private_message(
text_body = message.get("text")
if text_body == "/start":
# E) команда — не содержательное обращение, топик не засоряем.
await client.send_message(
chat_id=chat_id,
text=GREETING_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
await client.send_message(chat_id=chat_id, text=GREETING_TEXT)
return
if not settings.telegram_support_chat_id:
@ -564,11 +438,7 @@ async def _handle_private_message(
chat_id,
)
# Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет.
await client.send_message(
chat_id=chat_id,
text=SERVICE_UNAVAILABLE_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
await client.send_message(chat_id=chat_id, text=SERVICE_UNAVAILABLE_TEXT)
return
message_id = message.get("message_id")
@ -596,11 +466,7 @@ async def _handle_private_message(
# за окно — `_flood_notify_limiter.check()` возвращает None (и сам
# фиксирует попытку) ровно один раз за окно.
if _flood_notify_limiter.check(flood_key) is None:
await client.send_message(
chat_id=chat_id,
text=FLOOD_LIMITED_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
await client.send_message(chat_id=chat_id, text=FLOOD_LIMITED_TEXT)
return
_flood_limiter.record(flood_key)
@ -611,7 +477,6 @@ async def _handle_private_message(
chat_id=settings.telegram_support_chat_id,
text=header,
message_thread_id=settings.telegram_support_topic_id or None,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
mirrored = await client.copy_message(
@ -619,7 +484,6 @@ async def _handle_private_message(
from_chat_id=chat_id,
message_id=message_id,
message_thread_id=settings.telegram_support_topic_id or None,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
@ -684,20 +548,19 @@ async def _handle_group_reply(
chat_id=target_chat_id,
from_chat_id=settings.telegram_support_chat_id,
message_id=message_id,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
except TelegramApiError as exc:
if exc.error_code == 403:
# Клиент заблокировал бота — фиксируем и уведомляем оператора в топике.
storage.mark_blocked(target_chat_id)
await _notify_topic(
client,
await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=(
f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — "
"бот заблокирован."
),
message_thread_id=settings.telegram_support_topic_id or None,
reply_to_message_id=message_id,
context=f"403 на доставке клиенту chat_id={target_chat_id}",
)
return
raise
@ -707,22 +570,10 @@ async def _handle_group_reply(
chat_id=target_chat_id,
direction="out",
tg_message_id=tg_message_id,
# #3471 P0: id ЭТОГО сообщения оператора В ТОПИКЕ (было безусловно
# None) — без него реплай оператора на СВОЙ предыдущий ответ не
# резолвился (искать было нечего), маршрут держался только на
# зеркале клиента. Совпадение с `in`-записью структурно исключено:
# `message_id` — id реплая оператора, а зеркало клиента уже занимает
# другой message_id в том же чате.
topic_message_id=message_id,
topic_message_id=None,
kind=_infer_kind(message),
text_body=message.get("text") or message.get("caption"),
operator_tg_id=operator_id,
# Deep review PR #3479: без этого out-строка была бы вечным
# wildcard для `find_chat_by_topic_message` (матчит support_chat_id
# IS NULL под ЛЮБЫМ текущим чатом) — при ротации support-группы
# (188) новый message_id мог бы совпасть со старой out-строкой и
# увести ответ ЧУЖОМУ клиенту. Симметрично in-ветке выше (строка ~601).
support_chat_id=settings.telegram_support_chat_id,
)
return
@ -741,11 +592,11 @@ async def _handle_group_reply(
web_thread_id,
kind,
)
await _notify_topic(
client,
await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT,
message_thread_id=settings.telegram_support_topic_id or None,
reply_to_message_id=message_id if isinstance(message_id, int) else None,
context=f"медиа-реплай на веб-зеркало thread_id={web_thread_id}",
)
return
@ -756,64 +607,11 @@ async def _handle_group_reply(
operator = message.get("from") or {}
operator_id = operator.get("id")
try:
storage.record_web_out_message(
thread_id=web_thread_id,
text_body=text_body,
operator_tg_id=operator_id,
# #3471 P0: id ЭТОГО сообщения оператора в топике — без него
# реплай оператора на СВОЙ предыдущий веб-ответ не резолвится
# (см. `find_thread_by_topic_message`, direction-фильтр снят).
topic_message_id=message_id if isinstance(message_id, int) else None,
# Deep review PR #3479: БЕЗ этого out-строка писалась бы с
# support_chat_id=NULL — `find_thread_by_topic_message` матчит
# NULL под ЛЮБЫМ текущим чатом (лениентный wildcard для легаси
# строк до 187/188), т.е. каждая out-строка стала бы вечным
# wildcard. При ротации support-группы новый message_id мог бы
# совпасть со старой out-строкой и увести ответ в ЧУЖОЙ тред —
# ровно то, от чего защищала скоупинг-миграция 187/188.
support_chat_id=settings.telegram_support_chat_id,
)
except SQLAlchemyError:
# #3471 P0: для веб-треда ЭТА запись — и есть доставка клиенту (веб-
# фронт вычитывает ответ обычным polling'ом web_support_messages).
# Откат без уведомления означал бы: оператор уверен, что ответил,
# клиент ждёт молча. Offset ниже всё равно сдвигается — НЕ потому,
# что апдейт "частично применён в Telegram" (в этой ветке до сбоя в
# Telegram ничего не уходило вообще: сам реплай оператора Telegram
# уже полностью доставил ДО того, как мы начали его разбирать,
# ретраить на стороне площадки нечего), а потому что действует общая
# политика `process_update` для `SQLAlchemyError` — сбой БД не
# переигрывается (в отличие от `TelegramNetworkError`), а
# сигнализируется громко; здесь это explicit-просьба оператору
# прислать ответ заново — human-in-the-loop retry вместо
# технического. rollback() ОБЯЗАН отработать ДО уведомления —
# сессия в failed-transaction state, а `_notify_topic` шлёт через
# `client`, не через `storage`, поэтому сам rollback тут не нужен для
# отправки, но нужен, чтобы process_update дальше не упал на
# save_offset/commit тем же PendingRollbackError (см. #3 review).
storage.rollback()
notified = await _notify_topic(
client,
text=(
"Не удалось сохранить ваш ответ из-за сбоя базы данных — клиенту "
"он НЕ доставлен. Пожалуйста, отправьте ответ ещё раз."
),
reply_to_message_id=message_id if isinstance(message_id, int) else None,
context=f"сбой БД на доставке веб-ответа thread_id={web_thread_id}",
)
if not notified:
# Оба канала молчат (БД и уведомление) — единственный след,
# который останется, это эта строка. Идентификаторы, НЕ текст
# (ПДн в лог не идёт) — по ним человек найдёт ответ оператора в
# топике и перешлёт его руками (#3471 P0).
logger.error(
"tgbot bridge: сбой БД на веб-ответе И не удалось уведомить "
"оператора (thread_id=%d, message_id=%s) — ответ клиенту "
"потерян молча, требуется ручной разбор support-топика",
web_thread_id,
message_id,
)
storage.record_web_out_message(
thread_id=web_thread_id,
text_body=text_body,
operator_tg_id=operator_id,
)
return
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
@ -838,46 +636,25 @@ async def _handle_group_reply(
async def process_update(
update: dict[str, Any], client: TelegramClient, storage: BridgeStorage
) -> bool:
) -> None:
"""Маршрутизирует один Telegram update. Дедуп (C) + атомарный offset-commit.
Возвращает True, если offset сдвинут (апдейт подтверждён, Telegram его больше
не отдаст), и False, если апдейт СОЗНАТЕЛЬНО оставлен неподтверждённым ради
переигрывания. На False вызывающий (`run_poll_loop`) ОБЯЗАН прервать разбор
пачки: offset у Telegram единая «высшая отметка», подтверждение любого
СЛЕДУЮЩЕГО апдейта неявно подтвердило бы и этот, и переигрывания не было бы.
Дедуп: update_id <= сохранённого offset skip без side-effects. Offset
сохраняется и коммитится ПОСЛЕ обработки (в т.ч. если обработка упала
иначе «ядовитый» апдейт блокировал бы весь поток навсегда).
Дедуп: update_id <= сохранённого offset skip без side-effects.
Судьба offset'а по классам отказа:
- `TelegramNetworkError` (транзиентный: Telegram не ответил, бюджет ретраев
клиента исчерпан) offset НЕ двигаем, `rollback()` частичных записей,
апдейт переигрывается на следующей итерации. Иначе ответ оператора
терялся НАВСЕГДА: copyMessage не дошёл, `record_message` не выполнился,
Telegram апдейт больше не отдаст, а оператор уверен, что ответил
(#tg-connection-resilience). Ограничено `_MAX_NETWORK_REPLAYS` — на
потолке offset всё-таки сдвигается с `logger.error`, иначе «вечно
недоставляемый» апдейт заклинил бы поток навсегда.
- `SQLAlchemyError` (сбой БД) offset двигаем, но `rollback()` ОБЯЗАН
отработать ПЕРЕД `save_offset`: сбой БД оставляет сессию в
failed-transaction state, иначе `save_offset` сам кинет
`PendingRollbackError`, `process_update` вылетит без сохранения offset'а,
следующая итерация получит СТАРЫЙ offset от `get_offset()` и переиграет
тот же апдейт copyMessage задублирует зеркало клиента в топике на
каждый повтор поллинга (#3 review, воспроизведено). Для веб-ветки
(`_handle_group_reply` `record_web_out_message`) этот `SQLAlchemyError`
перехватывается ЛОКАЛЬНО, до этого места: там запись в БД И ЕСТЬ
доставка клиенту, поэтому rollback сопровождается уведомлением оператору
в топике, что ответ НЕ доставлен (#3471 P0) — сюда, на верхний уровень,
это исключение уже не долетает.
- любое прочее исключение (в т.ч. `TelegramApiError` площадка ОТВЕТИЛА
отказом, повтор ничего не изменит) offset двигаем, «ядовитый» апдейт
не блокирует поток.
Различаем сбой БД (`SQLAlchemyError`) от прочих (Telegram API и т.п.):
сбой БД оставляет сессию в failed-transaction state `rollback()` ОБЯЗАН
отработать ПЕРЕД `save_offset`, иначе тот сам кинет `PendingRollbackError`,
`process_update` вылетит без сохранения offset'а, следующая итерация
`run_poll_loop` получит СТАРЫЙ offset от `get_offset()` и переиграет тот же
апдейт заново copyMessage задублирует зеркало клиента в топике на
каждый повтор поллинга (#3 review, воспроизведено).
"""
update_id = update.get("update_id")
if not isinstance(update_id, int):
logger.warning("tgbot bridge: update без валидного update_id — игнор")
return True
return
current_offset = storage.get_offset()
if update_id <= current_offset:
@ -886,7 +663,7 @@ async def process_update(
update_id,
current_offset,
)
return True
return
message = update.get("message")
try:
@ -900,35 +677,6 @@ async def process_update(
await _handle_group_reply(message, client, storage)
# иначе — необрабатываемый тип чата/апдейта (edited_message, канал и
# т.п.) — тихий игнор, но offset всё равно сдвигаем ниже.
except TelegramNetworkError:
attempts = _network_replay_attempts.get(update_id, 0) + 1
# Частичные записи этого апдейта не должны уехать в БД чужим commit'ом
# (сессия одна на всю пачку) — переигрывание начинается с чистого листа.
storage.rollback()
if attempts < _MAX_NETWORK_REPLAYS:
_network_replay_attempts[update_id] = attempts
logger.warning(
"tgbot bridge: Telegram недоступен на update_id=%d (отказ %d из %d) — "
"offset НЕ сдвигаем, апдейт переиграется на следующей итерации",
update_id,
attempts,
_MAX_NETWORK_REPLAYS,
)
return False
logger.error(
"tgbot bridge: update_id=%d исчерпал потолок переигрываний (%d сетевых "
"отказов подряд) — сдвигаем offset, содержимое апдейта ПОТЕРЯНО; поток не "
"блокируем, требуется ручной разбор support-топика "
"(chat_id=%s, message_id=%s)",
update_id,
_MAX_NETWORK_REPLAYS,
# Идентификаторы, а НЕ текст: это единственная строка, по которой
# человек найдёт потерянный ответ оператора в топике и перешлёт его
# руками. Без них в логе остаётся только update_id, которого в
# интерфейсе Telegram не видно. Текст сообщения — ПДн, в лог не идёт.
(message or {}).get("chat", {}).get("id") if isinstance(message, dict) else None,
(message or {}).get("message_id") if isinstance(message, dict) else None,
)
except SQLAlchemyError:
logger.exception(
"tgbot bridge: DB-ошибка на update_id=%d — rollback перед сохранением "
@ -942,12 +690,9 @@ async def process_update(
"(не блокируем поток на 'ядовитом' апдейте)",
update_id,
)
# Апдейт подтверждён — счётчик переигрываний больше не нужен (словарь не растёт).
_network_replay_attempts.pop(update_id, None)
storage.save_offset(update_id)
storage.commit()
return True
finally:
storage.save_offset(update_id)
storage.commit()
# ── Long-polling loop ─────────────────────────────────────────────────────────
@ -972,20 +717,8 @@ async def run_poll_loop(
updates = await client.get_updates(
offset=offset + 1, timeout=poll_timeout_s, allowed_updates=["message"]
)
for idx, update in enumerate(updates):
if not await process_update(update, client, storage):
# Апдейт намеренно не подтверждён (транзиентный сетевой
# отказ). Обрабатывать остаток пачки НЕЛЬЗЯ: offset —
# единая «высшая отметка», подтверждение следующего
# апдейта неявно подтвердило бы и этот. Остаток Telegram
# отдаст заново на следующей итерации.
logger.warning(
"tgbot bridge: update_id=%s не подтверждён — остаток пачки "
"(%d апдейтов) разберём на следующей итерации",
update.get("update_id"),
len(updates) - idx - 1,
)
break
for update in updates:
await process_update(update, client, storage)
consecutive_errors = 0
except Exception:
consecutive_errors += 1

View file

@ -12,22 +12,11 @@ Docs: https://core.telegram.org/bots/api
Ретраи:
- HTTP 429 (Too Many Requests) уважаем `parameters.retry_after` из тела ответа
(Telegram сам говорит сколько ждать), fallback на `_DEFAULT_RETRY_AFTER_S`.
- HTTP 5xx / транспортные ошибки (`httpx.TransportError`: timeout, connect,
обрыв протокола, прокси) экспоненциальный backoff, `capped` на
`_MAX_BACKOFF_S`.
- Прочие отказы запроса (`httpx.RequestError`: битый ответ) НЕ ретряются,
сразу `TelegramNetworkError`: повтор не чинит ни испорченный ответ, ни
кривую конфигурацию.
- HTTP 5xx / сетевые ошибки (timeout/connect) экспоненциальный backoff,
`capped` на `_MAX_BACKOFF_S`.
- Любая другая 4xx (400/401/403/404) НЕ ретраится, сразу `TelegramApiError`
(запрос некорректен или прав нет повтор не поможет).
Наружу летит только свой тип: `TelegramApiError` (площадка ответила отказом) или
`TelegramNetworkError` (не ответила), общий предок `TelegramError`. Сырые
httpx-исключения из клиента не выходят: инвариант держат ДВА `except` в
`_request` `httpx.TransportError` (ретраится) и страховочный
`httpx.RequestError` (не ретраится), вместе покрывающие всё дерево отказов
запроса, включая те, что появятся в httpx позже.
БЕЗОПАСНОСТЬ: наши `logger.*`-вызовы здесь содержат только имя метода API,
HTTP-статус и `description` из ответа Telegram токен туда не пишем.
Это НЕ гарантирует, что токен не утечёт по другим стокам: он живёт в
@ -43,7 +32,6 @@ from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
import httpx
@ -55,194 +43,8 @@ _DEFAULT_RETRY_AFTER_S = 5.0
_MAX_BACKOFF_S = 30.0
_DEFAULT_MAX_RETRIES = 5
# Telegram документирует ~20 сообщений/минуту на ОДНУ группу (общий лимит на
# все темы супергруппы разом, не на тему по отдельности — превышение даёт 429
# на ЛЮБОЕ следующее сообщение в группу, кто бы его ни отправлял). До #3471
# лимита на нашей стороне не было вовсе: всплеск GlitchTip-алертов + поток
# сообщений поддержки в ту же группу (разные темы, общий чат) укладывались в
# 429 и теряли сообщения — retry в `_request` уважает `retry_after`, но не
# предотвращает сам всплеск. `_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE` — чуть
# ниже площадочного потолка, с запасом на неточность скользящего окна и на то,
# что сама площадка не обязана быть педантичной ровно к 20-й отправке.
_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18
_RATE_LIMIT_WINDOW_S = 60.0
class TelegramGroupRateLimiter:
"""Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу.
Зачем ключ `chat_id`, а не `(chat_id, message_thread_id)`: лимит Telegram
считается на группу целиком, все темы супергруппы делят один бюджет.
Ограничитель с ключом по теме позволил бы двум темам суммарно превысить
лимит группы и всё равно поймать 429 ровно баг, который здесь чинится.
Реализация скользящее окно (список меток времени последних отправок за
`_RATE_LIMIT_WINDOW_S`), а не токен-бакет с фиксированным пополнением:
окно точнее соответствует тому, как Telegram считает лимит («N сообщений
за последние 60 секунд», а не «N сообщений в календарную минуту»).
Конкурентность (asyncio, один процесс, несколько отправителей): на каждый
`chat_id` свой `asyncio.Lock`. Лок держится ВКЛЮЧАЯ время ожидания
(`asyncio.sleep`), а не только на чтение/запись счётчика это осознанно:
цель не просто «не гонять счётчик без гонки», а ФАКТИЧЕСКИ сериализовать
отправителей в этот чат, чтобы они не просыпались все разом по истечении
окна и не били по лимиту повторно.
ВАЖНО про ключ (проверено на проде, review 2026-09-12): `TELEGRAM_SUPPORT_CHAT_ID`
и `TELEGRAM_ALERTS_CHAT_ID` это ОДНА И ТА ЖЕ группа, различаются только
темы (`*_TOPIC_ID`). Именно поэтому ключ лимитера `chat_id`, а НЕ
`(chat_id, message_thread_id)`: поток алертов и поток поддержки сегодня
физически делят один Telegram-бюджет группы, и лимитер обязан это
отражать. Ключ по `chat_id` при этом остаётся корректным и в гипотезе, что
когда-нибудь эти два потока разведут по разным группам, тогда у каждой
просто появится свой независимый лок/бюджет автоматически, без правки кода.
Регистр локов защищён отдельным `asyncio.Lock` только на момент
создания записи сам подсчёт/сон идёт уже под персональным локом чата.
"""
def __init__(
self,
max_per_window: int = _DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE,
window_s: float = _RATE_LIMIT_WINDOW_S,
) -> None:
self._max_per_window = max_per_window
self._window_s = window_s
self._registry_lock = asyncio.Lock()
self._locks: dict[int, asyncio.Lock] = {}
self._sent_at: dict[int, list[float]] = {}
async def _lock_for(self, chat_id: int) -> asyncio.Lock:
async with self._registry_lock:
lock = self._locks.get(chat_id)
if lock is None:
lock = asyncio.Lock()
self._locks[chat_id] = lock
return lock
async def acquire(self, chat_id: int, max_wait: float | None = None) -> None:
"""Блокируется, пока в окне `_window_s` для `chat_id` есть свободный слот.
`max_wait` (review H1, #3471): потолок ожидания очереди. `None` (дефолт)
без потолка, ждать сколько нужно; это ПРАВИЛЬНОЕ поведение для
фоновых отправок бота, где потерять сообщение хуже, чем подождать.
Если задан и слот не появился вовремя бросает `TelegramRateLimitedError`
(честный отказ), а НЕ продолжает ждать: интерактивная HTTP-ручка не
может легально держать открытый запрос браузера дольше своего
собственного таймаута. Вызывающая сторона `TelegramClient._request`,
см. её докстринг про то, откуда берётся конкретное значение.
Логирование (review L1): предупреждение об ожидании пишется РОВНО ОДИН
раз за вызов `acquire` (флаг `warned`), а не на каждой итерации сна
при реальной перегрузке группы это иначе валит лог сотнями одинаковых
строк вместо одного сигнала «была очередь».
Побочный эффект (review M2): перед постановкой в очередь чистит ЧУЖИЕ
полностью просроченные записи в `_sent_at`/`_locks` см. `_cleanup_stale`.
"""
if self._max_per_window <= 0:
return # 0/отрицательное значение конфига = лимитер выключен
now0 = time.monotonic()
await self._cleanup_stale(now0)
deadline = None if max_wait is None else now0 + max_wait
lock = await self._lock_for(chat_id)
async with lock:
warned = False
while True:
now = time.monotonic()
if deadline is not None and now >= deadline:
raise TelegramRateLimitedError(chat_id, max_wait or 0.0)
history = self._sent_at.setdefault(chat_id, [])
cutoff = now - self._window_s
while history and history[0] <= cutoff:
history.pop(0)
if len(history) < self._max_per_window:
history.append(now)
return
wait_s = history[0] + self._window_s - now
if deadline is not None:
wait_s = min(wait_s, max(deadline - now, 0.0))
if not warned:
logger.warning(
"tg group rate limit: chat_id=%s — лимит %d/%.0fs исчерпан, "
"отправки встают в очередь (одно предупреждение на серию)",
chat_id,
self._max_per_window,
self._window_s,
)
warned = True
await asyncio.sleep(max(wait_s, 0.01))
async def _cleanup_stale(self, now: float) -> None:
"""Чистит ЧУЖИЕ (не текущий вызов `acquire`) записи с полностью
просроченной историей (review M2, #3471).
Зачем: `_locks`/`_sent_at` ключуются по ЛЮБОМУ `chat_id`, включая
личные чаты каждого клиента бота (`bridge.py` зеркалит их 1:1 через тот
же `TelegramClient`) большинство из них шлют боту одно сообщение и
больше никогда не возвращаются. Без чистки оба словаря растут
монотонно на всё время жизни долгоживущего процесса (медленная утечка).
Безопасность удаления: лок пропускаем, если `lock.locked()` значит
кто-то ИМЕННО СЕЙЧАС работает с этим `chat_id`, трогать нельзя. Если
лок свободен и вся история старше окна запись безвредно удалить:
следующий `acquire` для того же `chat_id` просто создаст её заново
пустой (`setdefault`), с тем же результатом, что и не удаляй мы её.
"""
cutoff = now - self._window_s
async with self._registry_lock:
stale = [cid for cid, ts in self._sent_at.items() if not ts or ts[-1] <= cutoff]
for cid in stale:
lock = self._locks.get(cid)
if lock is not None and lock.locked():
continue
self._sent_at.pop(cid, None)
self._locks.pop(cid, None)
# Раздельные таймауты вместо скаляра. httpx разворачивает скаляр в
# connect=read=write=pool, поэтому long-poll `getUpdates` (read = 30с, которые
# Telegram держит запрос, + 10с запаса = 40с) ставил 40 секунд и на УСТАНОВКУ
# соединения. Живой connect до api.telegram.org из прод-контейнера занимает
# 0.036с — 40-секундное ожидание коннекта было чистой слепотой: худший цикл
# 4 попытки × 40с + backoff ≈ 174с, и всё это время бот не видит ответов
# оператора (замер 12.09.2026: разрывы в логе 06:40:10 → 06:42:22 → 06:43:35,
# 576 строк `network error` и 7 полных исчерпаний бюджета ретраев за сутки).
# connect/write/pool к ожиданию ОТВЕТА Telegram отношения не имеют и коротки.
_CONNECT_TIMEOUT_S = 5.0
_WRITE_TIMEOUT_S = 10.0
_POOL_TIMEOUT_S = 5.0
# Пул keep-alive соединений на ОДИН экземпляр клиента. Параллелизма тут почти
# нет (long-polling — один запрос за раз, интерактивные ручки — единицы в
# минуту), так что смысл пула не в ширине, а в том, чтобы TCP+TLS-хендшейк не
# повторялся на каждый запрос и каждый ретрай.
_MAX_KEEPALIVE_CONNECTIONS = 5
_MAX_CONNECTIONS = 10
# Сколько держать простаивающее соединение. Задаём ЯВНО, потому что дефолт
# httpx — 5 секунд, и с ним пул не давал бы ничего там, где он нужнее всего:
# poll loop переиспользует соединение (следующий getUpdates уходит сразу), а
# вот веб-поддержка шлёт сообщения раз в минуты — за 5с соединение протухает и
# каждое зеркало снова платит полный TCP+TLS.
#
# Плата за длинный keep-alive — возросший шанс взять из пула соединение, которое
# уже закрыла та сторона; httpx отдаёт это как `RemoteProtocolError` («Server
# disconnected without sending a response»). Он ретраится с #3457, так что
# сценарий закрыт: попытка на протухшем соединении стоит один повтор, а не отказ.
_KEEPALIVE_EXPIRY_S = 90.0
class TelegramError(Exception):
"""Общий предок отказов клиента: и «ответил ok: false», и «не ответил вовсе».
Нужен ровно затем, чтобы вызывающий мог одной строкой сказать «Telegram не
сработал» и отдать свой 502. До #3456 сетевой отказ прилетал наружу сырым
`httpx.ConnectTimeout`, мимо `except TelegramApiError`, и FastAPI отдавал
500 см. `TelegramNetworkError`.
"""
class TelegramApiError(TelegramError):
class TelegramApiError(Exception):
"""Telegram Bot API ответил `ok: false` (после исчерпания ретраев, если применимо)."""
def __init__(self, method: str, error_code: int, description: str) -> None:
@ -252,59 +54,6 @@ class TelegramApiError(TelegramError):
super().__init__(f"Telegram API {method} failed: {error_code} {description}")
class TelegramNetworkError(TelegramError):
"""Ответа от Telegram не было: таймаут/обрыв, ретраи исчерпаны.
Отдельный тип, а не `TelegramApiError`, потому что `error_code`/`description`
брать неоткуда Telegram ничего не сказал. Вызывающие, которым важна ТОЛЬКО
реакция площадки (`bridge`, разбирающий 403 «бот заблокирован»), продолжают
ловить `TelegramApiError` и этот отказ не перехватывают.
Причина сохраняется в `__cause__`: в GlitchTip виден исходный httpx-класс,
по которому и отличают таймаут соединения от сброса TLS (#3156).
"""
def __init__(self, method: str, reason: str, attempts: int) -> None:
self.method = method
self.reason = reason
self.attempts = attempts
super().__init__(f"Telegram {method} unreachable after {attempts} attempts: {reason}")
class TelegramRateLimitedError(TelegramError):
"""Слот в `TelegramGroupRateLimiter` не появился за `max_wait` секунд (review H1).
Бросается ТОЛЬКО когда вызывающий явно (или неявно, через `timeout`)
попросил ограниченное ожидание фоновые вызовы без такого ограничения
ждут очередь сколько нужно и этого исключения никогда не увидят. Общий
предок `TelegramError` существующие `except TelegramError` в
`app.api.v1.support`/`glitchtip` подхватывают этот отказ автоматически, без
правки самих ручек, и отвечают своим честным 502 вместо зависшего запроса.
"""
def __init__(self, chat_id: int, max_wait: float) -> None:
self.chat_id = chat_id
self.max_wait = max_wait
super().__init__(
f"Telegram group rate limit: no slot for chat_id={chat_id} within {max_wait:.1f}s"
)
def _request_timeout(read: float) -> httpx.Timeout:
"""Разворачивает «сколько ждать ответа» (скаляр вызывающего) в таймауты httpx.
`read` запрошенный бюджет ОТВЕТА (для long-poll это `poll_timeout + 10s`);
connect/write/pool фиксированы модульными константами и коротки: ждать
ответа Telegram не то же самое, что ждать установки соединения.
"""
return httpx.Timeout(
connect=_CONNECT_TIMEOUT_S,
read=read,
write=_WRITE_TIMEOUT_S,
pool=_POOL_TIMEOUT_S,
)
def _extract_retry_after(
response: httpx.Response, default: float = _DEFAULT_RETRY_AFTER_S
) -> float:
@ -338,111 +87,16 @@ def _error_from_body(response: httpx.Response) -> tuple[int, str]:
class TelegramClient:
"""Bot API клиент поверх ОДНОГО долгоживущего `httpx.AsyncClient`.
Соединение переиспользуется всё время жизни экземпляра: `AsyncClient`
создаётся лениво при первом запросе и хранится в `self._http`. Раньше он
создавался ВНУТРИ цикла ретраев то есть keep-alive не было вовсе: полный
TCP+TLS-хендшейк на каждый запрос и на каждую повторную попытку, и заново
кидался кубик «встанет ли коннект». Для long-polling'а, ходящего каждые
~30с в бесконечном цикле, это была основная статья сетевых отказов.
Отсюда требование к вызывающим: экземпляр НАДО переиспользовать (один на
процесс воркера, один на FastAPI-приложение см.
`app.services.tgbot.shared`), а не создавать на каждый запрос, и закрывать
через `aclose()` или `async with`.
Таймаут теперь per-request: у `AsyncClient` он стоит дефолтом, а каждый
вызов `_request` передаёт свой `httpx.Timeout` (long-poll свои 40с на
read, интерактивные ручки свой узкий бюджет).
"""
"""Bot API клиент на httpx.AsyncClient. Каждый вызов — отдельное короткоживущее соединение."""
def __init__(
self,
token: str,
base_url: str = "https://api.telegram.org",
timeout: float = _DEFAULT_TIMEOUT_S,
relay_base_url: str = "",
relay_secret: str = "",
group_rate_limit_per_minute: int = _DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE,
) -> None:
# ── Ретранслятор через Beget (#3471) ────────────────────────────────
# `relay_base_url` пуст по умолчанию → `_relay_base is _direct_base`,
# и `_post` ниже не делает второй попытки (фолбэчить с прямого пути
# НА прямой же путь бессмысленно). Заданный адрес переключает основной
# путь на ретранслятор, прямой остаётся ЗАПАСНЫМ на случай его отказа.
self._direct_base = f"{base_url}/bot{token}"
self._relay_base = f"{relay_base_url}/bot{token}" if relay_base_url else self._direct_base
self._relay_secret = relay_secret
self._base = self._relay_base
self._base = f"{base_url}/bot{token}"
self._timeout = timeout
self._http: httpx.AsyncClient | None = None
# Один лимитер на экземпляр клиента — см. `TelegramGroupRateLimiter`.
# Ретранслятор здесь ни при чём: лимитер стоит ДО `_post`, то есть
# считает отправку независимо от того, уйдёт она через relay или
# напрямую (#3471) — оба пути ниже по стеку от этой точки.
self._rate_limiter = TelegramGroupRateLimiter(group_rate_limit_per_minute)
def _http_client(self) -> httpx.AsyncClient:
"""Ленивое создание переиспользуемого AsyncClient (вне цикла ретраев)."""
if self._http is None:
self._http = httpx.AsyncClient(
timeout=_request_timeout(self._timeout),
limits=httpx.Limits(
max_keepalive_connections=_MAX_KEEPALIVE_CONNECTIONS,
max_connections=_MAX_CONNECTIONS,
keepalive_expiry=_KEEPALIVE_EXPIRY_S,
),
)
return self._http
async def aclose(self) -> None:
"""Закрывает пул соединений. Идемпотентно; после — клиент снова ленив."""
http, self._http = self._http, None
if http is not None:
await http.aclose()
async def __aenter__(self) -> TelegramClient:
return self
async def __aexit__(self, *_exc: object) -> None:
await self.aclose()
async def _post(
self,
client: httpx.AsyncClient,
url: str,
payload: dict[str, Any],
timeout: httpx.Timeout,
) -> httpx.Response:
"""POST с фолбэком на прямой путь при отказе РЕТРАНСЛЯТОРА (#3471).
Когда ретранслятор не настроен, `_relay_base is _direct_base`, `via_relay`
ниже всегда `False`, и метод ведёт себя как простой `client.post` этот
путь ничем не отличается от поведения до #3471 (механизм отката).
Когда настроен: `url` бьёт в `self._relay_base`. Транспортный отказ (не
ответ Telegram ЧЕРЕЗ ретранслятор, а отказ ДО него TCP/TLS до самого
relay-хоста) даёт РОВНО ОДНУ попытку напрямую к api.telegram.org не
рекурсивно: если недоступен и прямой путь, исключение поднимается как
обычно и подхватывается retry-циклом `_request` на общих основаниях (со
следующей попытки цикл снова пробует ретранслятор first временный
сбой relay не должен постоянно понижать клиента до прямого пути).
"""
via_relay = self._relay_base != self._direct_base and url.startswith(self._relay_base)
headers = (
{"X-Relay-Secret": self._relay_secret} if via_relay and self._relay_secret else None
)
try:
return await client.post(url, json=payload, timeout=timeout, headers=headers)
except httpx.TransportError:
if not via_relay:
raise
direct_url = self._direct_base + url[len(self._relay_base) :]
logger.warning(
"tg client: ретранслятор недоступен, одна попытка напрямую к Telegram"
)
return await client.post(direct_url, json=payload, timeout=timeout)
async def _request(
self,
@ -452,24 +106,9 @@ class TelegramClient:
timeout: float | None = None,
max_retries: int = _DEFAULT_MAX_RETRIES,
max_backoff: float | None = None,
rate_limit_max_wait: float | None = None,
) -> Any:
"""POST `method` с JSON-телом `payload`. Ретраит 429/5xx/network, иначе raise сразу.
`rate_limit_max_wait` (review H1/M1, #3471) — потолок ожидания слота в
`TelegramGroupRateLimiter.acquire`. Приоритет:
1. Явный `rate_limit_max_wait` используется как есть (bridge.py
передаёт его точечно для конкретных мест, см. `_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S`).
2. Иначе, если вызывающий передал явный `timeout` используем
`effective_timeout` КАК ЕСТЬ. Интерактивные ручки (`app.api.v1.support`,
`app.api.v1.glitchtip`) и так ОБЯЗАНЫ передавать узкий `timeout`
(5-8с, см. их собственные докстринги) этого достаточно, чтобы
очередь лимитера не держала открытый HTTP-запрос браузера дольше
его же собственного бюджета, БЕЗ дополнительной правки этих ручек.
3. Иначе `None` без потолка. Это дефолт для фоновых отправок бота
(`app.tgbot_main`/`bridge.py` без явного `timeout`), где потерять
сообщение хуже, чем подождать дольше.
`max_backoff` (#tgsupport-retry) — потолок паузы МЕЖДУ попытками. По
умолчанию `_MAX_BACKOFF_S` (30с) и полный `retry_after` из тела 429 это
воркерная политика, она НЕ меняется. Интерактивный вызывающий передаёт узкий
@ -483,51 +122,17 @@ class TelegramClient:
это штатные 30-60с) на число попыток и подвесил бы синхронный HTTP-запрос на
минуты ровно то, от чего предостерегает докстринг `send_message`.
"""
effective_timeout = timeout if timeout is not None else self._timeout
# Лимитер применяется ТОЛЬКО к методам с `chat_id` в payload (отправка
# в конкретный чат) — `getUpdates` его не несёт и лимиту не подлежит.
# Списывается ОДИН слот на логический вызов `_request` (то есть на
# одну попытку отправки конкретного сообщения), а не на каждую HTTP
# попытку внутри ретрай-цикла ниже: ретраи по 429/5xx лечат один и тот
# же send, а не порождают новые отправки. Приоритет `wait_cap` — см.
# докстринг параметра `rate_limit_max_wait` выше.
chat_id = payload.get("chat_id")
if isinstance(chat_id, int):
wait_cap = rate_limit_max_wait
if wait_cap is None and timeout is not None:
wait_cap = effective_timeout
await self._rate_limiter.acquire(chat_id, max_wait=wait_cap)
url = f"{self._base}/{method}"
# Раздельные таймауты считаем ОДИН раз и передаём per-request: у общего
# AsyncClient свой дефолт, а бюджет ответа у каждого вызова свой.
request_timeout = _request_timeout(effective_timeout)
effective_timeout = timeout if timeout is not None else self._timeout
backoff_cap = _MAX_BACKOFF_S if max_backoff is None else max_backoff
attempt = 0
# Клиент берём ДО цикла: пересоздавать его на каждую попытку значило бы
# заново платить за TCP+TLS ровно там, где сеть уже показала себя плохо.
client = self._http_client()
while True:
attempt += 1
try:
response = await self._post(client, url, payload, request_timeout)
except httpx.TransportError as exc:
# Ловим ВЕСЬ `TransportError`, а не узкий кортеж
# `(TimeoutException, NetworkError)`: `RemoteProtocolError`
# («Server disconnected without sending a response» — бытовой
# ответ api.telegram.org из РФ), `ProxyError`,
# `LocalProtocolError` и `UnsupportedProtocol` — СЁСТРЫ
# `NetworkError` по `TransportError`, а не наследники. Кортеж
# оставлял дыру ровно того класса, который чинил #3456: отказ
# вылетал сырым httpx мимо `except TelegramError` в ручках и
# снова давал 500 вместо 502 — и вдобавок не ретраился ни разу.
# Расширение ретраев на `RemoteProtocolError` наследует уже
# принятый здесь риск at-least-once (запрос мог дойти до
# Telegram, потерялся ответ) — он тот же, что у давно
# ретраящегося `ReadTimeout`; политика не меняется.
#
async with httpx.AsyncClient(timeout=effective_timeout) as client:
response = await client.post(url, json=payload)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
# Тип исключения обязан попасть в строку (#3156). У
# httpx.ReadError и httpx.ConnectError `str(exc)` пуст, и лог
# выглядел так: «network error (попытка 1/3): — retry через 2s»
@ -543,7 +148,7 @@ class TelegramClient:
attempt,
reason,
)
raise TelegramNetworkError(method, reason, attempt) from exc
raise
backoff = min(2.0**attempt, backoff_cap)
logger.warning(
"tg client: %s — network error (попытка %d/%d): %s — retry через %.1fs",
@ -555,26 +160,6 @@ class TelegramClient:
)
await asyncio.sleep(backoff)
continue
except httpx.RequestError as exc:
# Страховка на остаток дерева отказов запроса: сегодня это
# `DecodingError` (битая компрессия в ответе), завтра — всё, что
# httpx заведёт под `RequestError`. `TooManyRedirects` сюда НЕ
# относится: клиент создаётся с дефолтным `follow_redirects=False`
# и редиректы не ходит. Порядок `except`-ов
# значим: `TransportError` — наследник `RequestError`, и стоять
# обязан ВЫШЕ, иначе сетевые отказы перестали бы ретраиться.
#
# Без ретраев намеренно: это не «площадка недоступна», а
# испорченный ответ или кривая конфигурация — повтор не лечит
# ни то, ни другое, а пять попыток с backoff подвесили бы
# интерактивную ручку почти на минуту впустую. Свой тип тут
# нужен ровно за тем же, за чем и выше: чтобы ручка увидела
# `TelegramError` и отдала 502, а не 500.
reason = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
logger.error(
"tg client: %s — запрос не состоялся (без ретраев): %s", method, reason
)
raise TelegramNetworkError(method, reason, attempt) from exc
if response.status_code == 429:
retry_after = _extract_retry_after(response)
@ -647,11 +232,8 @@ class TelegramClient:
) -> list[dict[str, Any]]:
"""Long-polling getUpdates. `timeout` — сколько Telegram держит запрос открытым (сек).
Запас `+10s` относится к READ-таймауту (сколько ждём ответа), чтобы не
обрывать соединение раньше, чем ответит сам Telegram long-poll. На
connect/write/pool он НЕ распространяется они короткие и фиксированы
(`_CONNECT_TIMEOUT_S` и соседи): установка соединения либо занимает
десятки миллисекунд, либо не состоится вовсе.
HTTP-таймаут запроса берётся с запасом (`timeout + 10s`), чтобы не обрывать
соединение раньше, чем ответит сам Telegram long-poll.
"""
payload: dict[str, Any] = {"offset": offset, "timeout": timeout}
if allowed_updates is not None:
@ -669,12 +251,8 @@ class TelegramClient:
message_id: int,
message_thread_id: int | None = None,
reply_to_message_id: int | None = None,
rate_limit_max_wait: float | None = None,
) -> dict[str, Any]:
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла.
`rate_limit_max_wait` см. `TelegramClient._request`; используется
`bridge.py` для точечного потолка ожидания на конкретных местах (review M1)."""
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла."""
payload: dict[str, Any] = {
"chat_id": chat_id,
"from_chat_id": from_chat_id,
@ -684,9 +262,7 @@ class TelegramClient:
payload["message_thread_id"] = message_thread_id
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
result = await self._request(
"copyMessage", payload, rate_limit_max_wait=rate_limit_max_wait
)
result = await self._request("copyMessage", payload)
return result if isinstance(result, dict) else {}
async def send_message(
@ -699,7 +275,6 @@ class TelegramClient:
timeout: float | None = None,
max_retries: int | None = None,
max_backoff: float | None = None,
rate_limit_max_wait: float | None = None,
) -> dict[str, Any]:
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
@ -723,103 +298,5 @@ class TelegramClient:
kwargs["max_retries"] = max_retries
if max_backoff is not None:
kwargs["max_backoff"] = max_backoff
if rate_limit_max_wait is not None:
kwargs["rate_limit_max_wait"] = rate_limit_max_wait
result = await self._request("sendMessage", payload, **kwargs)
return result if isinstance(result, dict) else {}
async def get_chat(self, chat_id: int) -> dict[str, Any]:
"""getChat — метаданные чата. Единственная цель здесь — startup-проверка
(см. `verify_chat_and_topic`): подтвердить, что `chat_id` валиден и бот
не выгнан/не заблокирован, без единого видимого сообщения."""
result = await self._request("getChat", {"chat_id": chat_id}, max_retries=1)
return result if isinstance(result, dict) else {}
async def send_chat_action(
self,
*,
chat_id: int,
action: str = "typing",
message_thread_id: int | None = None,
) -> bool:
"""sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет.
Используется НЕ по прямому назначению (индикация набора), а как способ
проверить существование `message_thread_id` (темы форума) см.
`verify_chat_and_topic`. Это единственный метод Bot API, который
принимает `message_thread_id` и не создаёт message-объект: если тема
удалена/переименована в другую с иным id, Telegram отвечает `Bad
Request: message thread not found` мгновенно, а в истории чата не
остаётся ни строки (индикатор эфемерный и не персистится)."""
payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
if message_thread_id:
payload["message_thread_id"] = message_thread_id
result = await self._request(
"sendChatAction", payload, max_retries=1, max_backoff=5.0
)
return bool(result)
async def verify_chat_and_topic(
client: TelegramClient,
*,
chat_id: int,
topic_id: int,
label: str,
) -> bool:
"""Разовая startup-проверка: чат существует, бот в нём не забанен, тема жива.
Зачем нужна: бот пишет в тему форума по числовому id из настроек. Если
тему удалили, переименовали в другую (новый id) или id в конфиге просто
неверный отправка начинает падать НА КАЖДОМ сообщении, а узнаём мы об
этом только по молчанию у людей (симметрично истории #tgsupport с сетевыми
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
«через сутки тишины» на «в первую секунду после старта/рестарта».
Способ намеренно НЕ `sendMessage`+`deleteMessage`:
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не
выгнан/не заблокирован чистый read, нулевой видимый след.
- `send_chat_action` (typing-индикатор с `message_thread_id`)
единственный способ провалидировать САМУ тему без создания
message-объекта: Telegram обязан знать про `message_thread_id`, чтобы
показать «печатает...» именно в нужном треде, и явно отказывает, если
такой темы нет. `sendMessage`+`deleteMessage` тоже сработал бы, но
оставлял бы видимый (пусть на секунды) артефакт в истории треда при
КАЖДОМ рестарте контейнера на rolling-деплое это многократно в
сутки; typing-индикатор того же результата достигает без единого
сообщения.
НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог
уровня error задача явно требует шума в логе, а не падения воркера
(тема пуста/невалидна это деградация уведомлений, а не фатальный сбой
самого бота, который всё ещё должен принимать входящие).
`chat_id == 0` (не настроено) считается успехом без обращения к API:
это штатный kill-switch (см. `app.core.config`), а не ошибка конфигурации.
"""
if not chat_id:
return True
try:
await client.get_chat(chat_id)
if topic_id:
await client.send_chat_action(
chat_id=chat_id, action="typing", message_thread_id=topic_id
)
except TelegramError as exc:
logger.error(
"tg topic check [%s]: чат/тема недоступны для отправки "
"(chat_id=%s, topic_id=%s) — %s. Сообщения в эту тему БУДУТ "
"падать, пока конфигурация не исправлена.",
label,
chat_id,
topic_id,
exc,
)
return False
logger.info(
"tg topic check [%s]: чат и тема доступны для отправки (chat_id=%s, topic_id=%s)",
label,
chat_id,
topic_id,
)
return True

View file

@ -1,58 +0,0 @@
"""Общий на приложение `TelegramClient` (#tg-connection-resilience).
Зачем: до этого три HTTP-ручки (`api.v1.support` ×2, `api.v1.glitchtip`) делали
`TelegramClient(token)` на КАЖДЫЙ входящий запрос, а клиент внутри пересоздавал
`httpx.AsyncClient` на каждую попытку то есть keep-alive не было ни на каком
уровне и каждый запрос начинался с полного TCP+TLS-хендшейка до
api.telegram.org. Здесь живёт один экземпляр на процесс: создаётся в lifespan
(`app.main`), закрывается на shutdown там же.
Воркер бота (`app.tgbot_main`) сюда НЕ ходит у него свой процесс без ASGI и
свой экземпляр на всё время жизни поллинга.
"""
from __future__ import annotations
import logging
from app.core.config import settings
from app.services.tgbot.client import TelegramClient
logger = logging.getLogger(__name__)
_client: TelegramClient | None = None
def get_telegram_client() -> TelegramClient:
"""Общий клиент приложения. Ленив: создаётся при первом обращении.
Ленивость (а не «только из lifespan») нужна из-за kill-switch: при пустом
`TELEGRAM_BOT_TOKEN` в lifespan создавать нечего, а тесты ручек поднимают
приложение без прохода через startup.
"""
global _client
if _client is None:
_client = TelegramClient(
settings.telegram_bot_token,
relay_base_url=settings.telegram_relay_base_url,
relay_secret=settings.telegram_relay_secret,
# API-роль (review H2, #3471) — см. докстринг настройки в
# app.core.config: бюджет группы разделён статически между этим
# процессом и app.tgbot_main, суммарно ниже площадочного лимита.
group_rate_limit_per_minute=settings.telegram_group_rate_limit_api_per_minute,
)
return _client
def init_telegram_client() -> TelegramClient:
"""Явное создание на старте приложения (lifespan)."""
return get_telegram_client()
async def close_telegram_client() -> None:
"""Закрывает общий клиент на shutdown. Идемпотентно."""
global _client
client, _client = _client, None
if client is not None:
await client.aclose()
logger.info("tg shared client: пул соединений закрыт")

View file

@ -61,36 +61,6 @@ def get_or_create_thread(db: Session, username: str) -> int:
return int(row[0])
def find_inbound_by_idempotency_key(
db: Session, *, thread_id: int, idempotency_key: str
) -> dict[str, Any] | None:
"""Уже записанное inbound-сообщение с этим ключом идемпотентности в треде,
если есть (#3471). Вызывается ИЗ `app.api.v1.support` ДО похода в Telegram
(`send_support_message` / `send_anon_support_message`) повтор с тем же
ключом не должен создавать второе зеркало в топике, а не только вторую
строку в БД. `thread_id`, а не username/anon-token: таблица не хранит
identity напрямую, а тред уже гарантированно существует к моменту, когда
этот ключ мог быть записан (тред создаётся ДО `record_inbound`, см. H1 в
докстринге `app.api.v1.support`)."""
row = (
db.execute(
text(
"""
SELECT id, direction, text_body, operator_tg_id, created_at
FROM web_support_messages
WHERE thread_id = CAST(:thread_id AS bigint)
AND direction = 'in'
AND idempotency_key = :idempotency_key
"""
),
{"thread_id": thread_id, "idempotency_key": idempotency_key},
)
.mappings()
.one_or_none()
)
return dict(row) if row is not None else None
def record_inbound(
db: Session,
*,
@ -98,7 +68,6 @@ def record_inbound(
text_body: str,
topic_message_id: int | None,
support_chat_id: int | None,
idempotency_key: str | None = None,
) -> dict[str, Any]:
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
@ -106,103 +75,18 @@ def record_inbound(
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
support-группе если группу когда-нибудь сменят/пересоздадут, Telegram
message_id стартует заново с 1 в новом чате и может совпасть с числом из
старого без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188).
`idempotency_key` (#3471, миграция 301) — вызывающая сторона (`app.api.v1.support`)
делает SELECT-затем-действие pre-check ДО Telegram-похода (см.
`find_inbound_by_idempotency_key`), но этот pre-check САМ ПО СЕБЕ гонку не
закрывает (TOCTOU): два запроса с одним ключом могут пройти его одновременно
и оба уйти в Telegram. Последняя линия защиты здесь: `INSERT ... ON
CONFLICT (thread_id, idempotency_key) DO NOTHING` на partial unique индексе
(мигр. 301, тот же predicate). Если конфликт всё же случился, проигравший
НЕ создаёт вторую строку читает уже вставленную и возвращает её, так что
оба запроса-конкурента получают ОДИН и тот же id. `idempotency_key=None`
(дефолт) ведёт себя как раньше: NULL никогда не конфликтует сам с собой в
partial-индексе (WHERE idempotency_key IS NOT NULL), INSERT всегда проходит."""
старого без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188)."""
row = (
db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, idempotency_key, created_at)
support_chat_id, operator_tg_id, created_at)
VALUES
(CAST(:thread_id AS bigint), 'in', :text_body,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint), NULL, :idempotency_key, NOW())
ON CONFLICT (thread_id, idempotency_key)
WHERE idempotency_key IS NOT NULL AND direction = 'in'
DO NOTHING
RETURNING id, direction, text_body, operator_tg_id, created_at
"""
),
{
"thread_id": thread_id,
"text_body": text_body,
"topic_message_id": topic_message_id,
"support_chat_id": support_chat_id,
"idempotency_key": idempotency_key,
},
)
.mappings()
.one_or_none()
)
if row is not None:
return dict(row)
# Конфликт пойман индексом — idempotency_key почти наверняка не NULL здесь
# (при NULL partial-индекс в конфликт не участвует), но НЕ `assert`: это
# прод-путь ПОСЛЕ уже доставленного в Telegram сообщения (см. H1 в
# app.api.v1.support), а `except SQLAlchemyError` в вызывающей стороне
# `AssertionError` не ловит — под `python -O` assert вдобавок исчезает
# молча и `existing` осталось бы `None`, что развалилось бы чуть ниже при
# сборке ответа. Вместо падения — явная ветка с логом и WORKING откатом.
existing = (
find_inbound_by_idempotency_key(db, thread_id=thread_id, idempotency_key=idempotency_key)
if idempotency_key is not None
else None
)
if existing is not None:
# Ожидаемый случай гонки (#3471): проигравший запрос уже отправил своё
# СОБСТВЕННОЕ зеркало в Telegram (свой topic_message_id) до того, как
# обнаружил конфликт здесь — эта копия зеркала теперь осиротела
# (реплай оператора на неё никуда не смаршрутизируется, т.к. строки
# для неё в БД нет). Осознанно не чиним это в этом PR (см. коммент
# выше по коду), но фиксируем в логе, а не молчим — ровно то же самое,
# для чего уже есть `_warn_operator_message_not_recorded` в другом месте.
logger.warning(
"web support: гонка по idempotency_key — зеркало topic_message_id=%s "
"(thread_id=%d) отправлено, но НЕ записано, победила строка id=%d",
topic_message_id,
thread_id,
existing["id"],
)
return existing
# Крайний случай (в норме недостижим при READ COMMITTED, которую использует
# этот сервис): индекс сообщил о конфликте, но повторное чтение строку не
# нашло. Логируем и пишем БЕЗ идемпотентности — NULL-ключ никогда не
# конфликтует сам с собой (partial-индекс его не видит), INSERT гарантированно
# пройдёт; для этого одного сообщения дедупликация отключается, но клиент
# получает корректный ответ вместо 500 после уже состоявшейся доставки.
logger.warning(
"web support: ON CONFLICT сообщил о конфликте (thread_id=%d, "
"idempotency_key=%s), но повторное чтение строки её не нашло — "
"записываем без идемпотентности",
thread_id,
"<set>" if idempotency_key is not None else None,
)
row = (
db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, idempotency_key, created_at)
VALUES
(CAST(:thread_id AS bigint), 'in', :text_body,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint), NULL, NULL, NOW())
CAST(:support_chat_id AS bigint), NULL, NOW())
RETURNING id, direction, text_body, operator_tg_id, created_at
"""
),
@ -222,17 +106,8 @@ def record_inbound(
def find_thread_by_topic_message(
db: Session, topic_message_id: int, support_chat_id: int
) -> int | None:
"""Резолвит id зеркала/ответа (reply_to) в thread_id.
БЕЗ фильтра по direction (#3471 P0, было `AND direction = 'in'`): с тех пор
как `record_outbound` тоже сохраняет `topic_message_id` (id ответа оператора
В ТОПИКЕ), реплай оператора на СВОЙ предыдущий ответ обязан резолвиться так
же, как реплай на inbound-зеркало клиента иначе продолжение диалога без
повторного цитирования клиента тихо проваливалось в orphan-check
(`_handle_group_reply` в bridge.py). Коллизий topic_message_id между
inbound- и outbound-строками одного треда быть не может: Telegram выдаёт
каждому сообщению в чате свой возрастающий id, `web_support_messages_topic_message_id_uq`
(partial unique, 187) это же и гарантирует на уровне БД.
"""Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186).
Скоупим к ТЕКУЩЕМУ `support_chat_id` (#tgsupport-web review M1): строка со
ЧУЖИМ (не NULL и не текущим) support_chat_id это исторический артефакт
@ -245,6 +120,7 @@ def find_thread_by_topic_message(
SELECT thread_id
FROM web_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
AND direction = 'in'
AND (support_chat_id = CAST(:support_chat_id AS bigint) OR support_chat_id IS NULL)
ORDER BY created_at DESC
LIMIT 1
@ -256,40 +132,18 @@ def find_thread_by_topic_message(
def record_outbound(
db: Session,
*,
thread_id: int,
text_body: str,
operator_tg_id: int | None,
topic_message_id: int | None = None,
support_chat_id: int | None = None,
db: Session, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> int | None:
"""Записывает ответ оператора (реплай на веб-зеркало) как direction='out'.
`topic_message_id` (#3471 P0, раньше был безусловно NULL) — id ЭТОГО
сообщения оператора в топике. Раньше маршрутизирующий ключ жил только на
inbound-записи (конвенция 186/187), из-за чего реплай оператора на СВОЙ
предыдущий ответ был нерезолвим искать было нечего, а `reply_to_message_id`
указывал на строку без ключа. `find_thread_by_topic_message` теперь матчит
обе стороны (direction-фильтр там снят).
`support_chat_id` (deep review PR #3479) — ОБЯЗАТЕЛЕН при заполненном
`topic_message_id`: `find_thread_by_topic_message` матчит `support_chat_id
IS NULL` как лениентный wildcard "под любым текущим чатом" (легаси-строки
до 187/188). Без этого поля КАЖДАЯ out-строка была бы таким wildcard при
ротации support-группы новый message_id мог бы совпасть со старой
out-строкой и увести ответ в ЧУЖОЙ тред (ровно то, от чего защищала
скоупинг-миграция 187/188, см. review M1 там же)."""
`topic_message_id` всегда NULL маршрутизирующий ключ живёт только на
inbound-записи (см. tg_support_messages-конвенцию, 186)."""
row = db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, created_at)
(thread_id, direction, text_body, topic_message_id, operator_tg_id, created_at)
VALUES
(CAST(:thread_id AS bigint), 'out', :text_body,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint),
(CAST(:thread_id AS bigint), 'out', :text_body, NULL,
CAST(:operator_tg_id AS bigint), NOW())
RETURNING id
"""
@ -297,8 +151,6 @@ def record_outbound(
{
"thread_id": thread_id,
"text_body": text_body,
"topic_message_id": topic_message_id,
"support_chat_id": support_chat_id,
"operator_tg_id": operator_tg_id,
},
).fetchone()

View file

@ -141,7 +141,7 @@ async def backfill_yandex_addresses(
# нездоровы — НЕ уходим на settings.scraper_proxy_url (см. proxy_egress module
# docstring). Явный пропуск run'а вместо слепого прохода через egress, который
# мог быть источником текущего инцидента.
logger.warning(
logger.error(
"yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run "
"пропущен, ни один листинг не обработан",
exc,

View file

@ -806,7 +806,7 @@ async def run_avito_detail_backfill(
# и mark_failed с текстом про пул, как у домклика после #3283.
if _caused_by_empty_pool(e):
counters.failed += 1
logger.warning(
logger.error(
"avito_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
run_id,
@ -913,7 +913,7 @@ async def run_avito_detail_backfill(
# ratio: прогон 5210 (14 блоков из 20, ровно порог) отпечатал
# "ABORT -- 1 consecutive blocks" -- текущая серия в тот момент
# действительно равнялась единице, но обрыв был не по ней.
logger.warning(
logger.error(
"avito_detail_backfill: run_id=%d ABORT -- %s, "
"частая причина: %s. enriched=%d attempted=%d",
run_id,

View file

@ -1,95 +0,0 @@
"""CLI: макро-ряды ЦБ РФ — ипотека по субъектам + ключевая ставка (#issue см. vault).
Запуск из контейнера:
python -m app.tasks.cbr_macro_pull # всё: ипотека + ставка
python -m app.tasks.cbr_macro_pull --dry-run
python -m app.tasks.cbr_macro_pull --series rate_rub debt_rub
python -m app.tasks.cbr_macro_pull --skip-mortgage --from-date 2024-01-01 --to-date 2024-12-31
python -m app.tasks.cbr_macro_pull --skip-key-rate
Делегирует всю логику app.services.cbr_macro (download parse upsert); этот
модуль только argparse + lifecycle сессии + commit между стадиями.
"""
from __future__ import annotations
import argparse
import logging
from datetime import date
from app.core.db import SessionLocal
from app.services.cbr_macro import (
CBR_MORTGAGE_SERIES,
load_key_rate,
pull_cbr_mortgage,
)
logger = logging.getLogger(__name__)
_SERIES_SLUGS = [s.slug for s in CBR_MORTGAGE_SERIES]
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Загрузка макро-рядов ЦБ РФ")
parser.add_argument("--dry-run", action="store_true", help="только посчитать, без upsert")
parser.add_argument(
"--series",
nargs="*",
choices=_SERIES_SLUGS,
default=None,
help="подмножество серий ипотеки (по умолчанию — все)",
)
parser.add_argument("--skip-mortgage", action="store_true", help="не тянуть ипотечные серии")
parser.add_argument("--skip-key-rate", action="store_true", help="не тянуть ключевую ставку")
parser.add_argument(
"--from-date", type=date.fromisoformat, default=None, help="ключевая ставка: ISO-дата от"
)
parser.add_argument(
"--to-date", type=date.fromisoformat, default=None, help="ключевая ставка: ISO-дата до"
)
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
parser = build_parser()
args = parser.parse_args()
if args.skip_mortgage and args.skip_key_rate:
parser.error("--skip-mortgage и --skip-key-rate вместе не имеют смысла")
mortgage_counts: dict[str, int] = {}
key_rate_counts: dict[str, int] = {}
db = SessionLocal()
try:
if not args.skip_mortgage:
series_list = None
if args.series:
series_list = [s for s in CBR_MORTGAGE_SERIES if s.slug in args.series]
mortgage_counts = pull_cbr_mortgage(
db, series_list=series_list, dry_run=args.dry_run
)
if not args.dry_run:
db.commit()
if not args.skip_key_rate:
key_rate_counts = load_key_rate(
db, from_date=args.from_date, to_date=args.to_date, dry_run=args.dry_run
)
if not args.dry_run:
db.commit()
logger.info(
"cbr_macro_pull DONE: dry_run=%s mortgage=%s key_rate=%s",
args.dry_run,
mortgage_counts,
key_rate_counts,
)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -38,8 +38,6 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
from scraper_kit.orchestration.pipeline import ban_kind_of_exception
from scraper_kit.orchestration.runs import BAN_KIND_UNKNOWN
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.cian.valuation import estimate_via_cian_valuation
from scraper_kit.proxy_errors import caused_by_no_proxy
@ -98,32 +96,18 @@ class CianBackfillResult:
return _dominant_ban_kind(self.ban_kinds)
def _note_refusal(
result: CianBackfillResult, status: int | None, exc: BaseException | None = None
) -> str | None:
"""Записать отказ detail-фетча, если его природа установлена — типом или статусом.
def _note_refusal(result: CianBackfillResult, status: int | None) -> str | None:
"""Записать отказ detail-фетча, если его природа диагностируема по HTTP-статусу (#3196).
Инвариант (#3196) прежний: непустой `ban_kinds` ⟺ отказ был ПОКАЗАН, а не назначен.
Установить его можно двумя способами, и статуса одного мало:
* ТИП исключения (#3402 follow-up) — `CianBlockedError` (капча Циана, отказ
сайдкара `SidecarBanPageError`, WAF-403) наследует `ProxyBanError`, то есть по
построению означает «площадка себя показала»: `ban_kind_of_exception` даёт
'platform'. Капча приезжает с HTTP 200 (свой детект по <title>) или вообще без
статуса (сайдкар не дошёл до навигации), поэтому по статусу она диагностировалась
как «не разобрали»: рос только `listings_failed_fetch`, `ban_kinds` оставался
пустым и следующая капча-волна снова выглядела бы дрейфом нашей разметки;
* HTTP-статус ответа (403/429/5xx) прежний путь для всего остального.
Всё, что не установлено ни тем, ни другим ('unknown' по типу И None по статусу), НЕ
инкрементит ни `listings_blocked`, ни `ban_kinds`: недиагностируемые случаи, записанные
как 'unknown', в scrape_runs.mark_banned (scheduler.py) превращали наши собственные
сбои в фиктивный бан площадки (#2764).
Инвариант: непустой `ban_kinds` мы видели ответ с 403/429/5xx. `ban_kind_from_status`
отвечает диагнозом только на такие статусы; на всё прочее (в т.ч. статуса нет сайдкар
не дошёл до навигации, или это HTTP 200 с промахом нашего парсера дрейф разметки, а
не отказ площадки) возвращает None, и тогда эта функция НЕ инкрементит ни
`listings_blocked`, ни `ban_kinds` только возвращает None вызывающему. Раньше
недиагностируемые случаи писались как 'unknown' и это в scrape_runs.mark_banned
(scheduler.py) превращало наши собственные сбои в фиктивный бан площадки.
"""
kind = ban_kind_of_exception(exc) if exc is not None else BAN_KIND_UNKNOWN
if kind == BAN_KIND_UNKNOWN:
# Тип ничего не доказал — спрашиваем статус (прежнее поведение).
kind = ban_kind_from_status(status)
kind = ban_kind_from_status(status)
if kind is None:
return None
result.listings_blocked += 1
@ -256,7 +240,7 @@ async def backfill_cian_history(
if caused_by_no_proxy(exc):
result.no_proxy_stop = True
result.listings_failed_fetch += 1
logger.warning(
logger.error(
"cian_history_backfill: СТОП — пул прокси пуст, к площадке "
"не ходили. listing_id=%s processed=%d succeeded=%d",
listing_id,
@ -264,11 +248,7 @@ async def backfill_cian_history(
result.listings_succeeded,
)
break
# exc, а не только статус: капча Циана — подтверждённый отказ
# площадки (CianBlockedError), но приходит с HTTP 200/без статуса
# (#3402 follow-up). Без типа волна капчи писалась в счётчик
# «не разобрали» и прогон отдавал пустой ban_kinds.
kind = _note_refusal(result, bf.last_response_status, exc)
kind = _note_refusal(result, bf.last_response_status)
logger.warning(
"cian_detail fetch failed for listing_id=%s url=%s: %s "
"(http=%s ban_kind=%s)",

View file

@ -14,38 +14,18 @@ kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refres
asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды считались по тому же
свежему срезу deals, что и ratio-таблица того же дня.
SQL derivation ниже держит ту же трёхуровневую схему, что seed в
data/sql/298_deal_city_price_bands_region.sql (region_stats / city_stats / tiered:
full N>=30 / rough N 10-29 / region_fallback N 1-9, см. комментарий в 194/298 для
полного обоснования тиров и hard floor'а 8000), но РАСХОДИТСЯ с ним в двух местах
(#3051, округа Москвы): ключ города — COALESCE(NULLIF(raw_payload->>'src_city',''),
city) вместо голого city, и потолок ppm² региональный region_ppm2_max вместо
литерала 800000. Полное обоснование обоих в комментарии над _REDERIVE_SQL.
Для региона 66 обе правки тождественны прежнему поведению (src_city пуст у всех
его сделок, региональный потолок вырождается ровно в 800000) проверено на проде
2026-09-10 пересчётом: 383 строки, все совпадают с текущими.
#3051 «Москва» (298): ключ (region_code, city) вместо (city) — region_stats и
city_stats теперь группируются ПО РЕГИОНУ (region_code), а не по всей таблице
deals целиком. Без этого пул для tier='region_fallback' одного региона
подмешивал бы сделки другого (Москва в deals region_code=77 иначе тянула бы
p1-floor малых городов Свердловской обл. region_code=66 вверх). Для
region_code=66 derivation байт-в-байт прежняя (194): фильтр
NOT (region_code = 66 AND <ключ> = 'Екатеринбург'), где <ключ> то же
выражение, по которому идёт GROUP BY (прод 2026-09-11: у региона 66 ключ == city
у всех 108 623 сделок, обе формы исключают одни и те же 55 749 строк);
region_stats/city_stats для региона 66 видят
ТУ ЖЕ популяцию строк, что видели до появления региона 77 в deals.
SQL derivation ниже БАЙТ-В-БАЙТ та же логика, что seed в
data/sql/194_deal_city_price_bands_tiers.sql (region_stats / city_stats / tiered:
трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см.
комментарий в 194 для полного обоснования тиров и hard floor/ceiling клампов).
Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror
паттерна) множество (region_code, city) монотонно растёт
(rosreestr_dkp_import только INSERT/ON CONFLICT DO UPDATE, никогда не удаляет
сделки), поэтому merge-по-ключу (ON CONFLICT DO UPDATE) достаточен: город,
перешедший в другой tier, просто перезаписывается на следующем refresh.
Екатеринбург НЕ включён для региона 66 (WHERE NOT (region_code = 66 AND
<ключ> = 'Екатеринбург')) estimator.py fallback на глобальные
DEAL_MIN_PPM2/DEAL_MAX_PPM2 для ЕКБ остаётся byte-identical (invariant из
178/194/298 сохранён).
паттерна) множество городов монотонно растёт (rosreestr_dkp_import только
INSERT/ON CONFLICT DO UPDATE, никогда не удаляет сделки), поэтому merge-по-city
(ON CONFLICT DO UPDATE) достаточен: город, перешедший в другой tier, просто
перезаписывается на следующем refresh. Екатеринбург НЕ включён (WHERE city <>
'Екатеринбург') estimator.py fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2
для ЕКБ остаётся byte-identical (invariant из 178/194 сохранён).
"""
from __future__ import annotations
@ -56,189 +36,64 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
from app.services.deal_city_key import deal_city_key_sql
logger = logging.getLogger(__name__)
# Ключ города сделки — ОДНО выражение на derivation и на читающую сторону
# (estimator.py), см. app/services/deal_city_key.py. Здесь alias пустой:
# в запросе ниже `FROM deals` без алиаса.
_CITY_KEY_SQL = deal_city_key_sql(alias="")
# ── Числа формулы регионального потолка ppm² (#3051) ─────────────────────────
# ОДИН источник и для SQL (_REGION_CEILING_SQL ниже), и для питоновского
# эквивалента region_ppm2_max(). Раньше формула жила двумя копиями (SQL +
# локальная копия в тесте): подмена множителя 6 на 3 оставляла ВСЕ тесты
# зелёными, тихо роняя потолок Москвы с 1766742 до 883371 и снова срезая дорогие
# округа. Теперь правка любого из этих чисел автоматически едет в обе стороны.
REGION_CEILING_FLOOR = 800_000 # исторический якорь: ниже потолок не падает нигде
REGION_CEILING_MEDIAN_MULT = 6 # шесть медианных ₽/м² региона — заведомо не рынок
REGION_CEILING_MEDIAN_Q = 0.5 # медиана региона
REGION_CEILING_CAP_Q = 0.9999 # шапка: одиночный мусорный выброс не раздувает потолок
def region_ppm2_max(p_cap: int, p_median: int) -> int:
"""Региональный потолок ppm²: GREATEST(floor, LEAST(p99.99, mult * медиана)).
Питоновский эквивалент _REGION_CEILING_SQL собран из ТЕХ ЖЕ констант, а не
из своих чисел. Замеры прода 2026-09-10: регион 66 (615312, 52706) = 800000
(тот же прежний литерал), регион 77 (1944535, 294457) = 1766742.
"""
return max(REGION_CEILING_FLOOR, min(p_cap, REGION_CEILING_MEDIAN_MULT * p_median))
_REGION_CEILING_CAP_SQL = (
f"round(percentile_cont({REGION_CEILING_CAP_Q}) WITHIN GROUP (ORDER BY price_per_m2))::int"
)
_REGION_CEILING_MEDIAN_SQL = (
f"{REGION_CEILING_MEDIAN_MULT} * "
f"round(percentile_cont({REGION_CEILING_MEDIAN_Q}) WITHIN GROUP (ORDER BY price_per_m2))::int"
)
_REGION_CEILING_SQL = (
f"GREATEST({REGION_CEILING_FLOOR}, "
f"LEAST({_REGION_CEILING_CAP_SQL}, {_REGION_CEILING_MEDIAN_SQL}))"
)
# ── Derivation + re-seed (region-aware; #3051 округа Москвы + региональный потолок) ──
#
# #3051 (округа). Ключ города — COALESCE(NULLIF(raw_payload->>'src_city',''), city)
# вместо голого city: у московских сделок src_city несёт муниципальный округ
# (заполнен у 93.27% из 212 937), и вместо ОДНОЙ полосы 'Москва' 34221..718870
# получается 197 ключей — 152 в тире full, 8 rough, 37 region_fallback. Остаточные
# 6.73% сделок без src_city дают собственную строку 'Москва' (n=14376, tier full,
# 22475..772165) — они не проваливаются в глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2,
# откалиброванные под ЕКБ. Регион 66 не меняется: src_city пуст у всех его сделок
# (прод 2026-09-10: 0 строк, где ключ != city).
#
# #3051 (потолок). Литерал 800000 был калибровкой Свердловской области, а в Москве
# p99 округов доходит до 1 405 882 ₽/м² — 15 округов из 197 упирались в потолок,
# т.е. он резал не опечатки, а легитимный рынок. Потолок стал РЕГИОНАЛЬНЫМ
# (region_ppm2_max в region_stats):
# GREATEST(800000, LEAST(p9999_региона, 6 * медиана_региона))
# Три множителя, каждый со своим смыслом: 800000 — исторический якорь, ниже
# которого потолок не опускается нигде (страхует и от обвала цен); 6 * медиана —
# привязка к масштабу региона (шесть медианных ₽/м² — заведомо не рынок, а
# опечатка или доля); p99.99 — жёсткая шапка, чтобы одиночный мусорный выброс не
# раздул потолок. Замеры: регион 66 → GREATEST(800000, LEAST(615312, 316236)) =
# 800000, тот же литерал; регион 77 → GREATEST(800000, LEAST(1944535, 1766742)) =
# 1766742.
#
# Инвариант региона 66 проверен на проде 2026-09-10 пересчётом по этому же
# выражению: 383 строки против 383 текущих, все совпадают по
# (ppm2_min, ppm2_max, n_deals, tier). Запас прочности: чтобы потолок 66 сдвинулся,
# нужно ОДНОВРЕМЕННО медиане перевалить 133 333 (сейчас 52 706, x2.53) и p99.99
# перевалить 800 000 (сейчас 615 312, x1.3).
#
# Тиры (full N>=30 / rough N 10-29 / region_fallback N<10) и обоснование floor'а
# 8000 — без изменений, см. миграции 194/298.
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 194) ─────────────────────────────────
_REDERIVE_SQL = text(
f"""
"""
WITH region_stats AS (
SELECT
region_code,
GREATEST(
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
8000
) AS region_ppm2_min,
{_REGION_CEILING_SQL} AS region_ppm2_max
SELECT GREATEST(
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
8000
) AS region_ppm2_min
FROM deals
WHERE source = 'rosreestr'
AND doc_type = 'ДКП'
AND price_per_m2 IS NOT NULL
-- #3051: непустоту ключа судим ТЕМ ЖЕ выражением, по которому идут GROUP BY
-- и предикат ЕКБ ниже (было: сырая колонка city). Сделка с непустым src_city
-- и NULL в city даёт ВАЛИДНЫЙ ключ, но сырой фильтр выбрасывал её целиком
-- и из её собственной городской строки, и из региональной статистики, молча
-- занижая n_deals и перцентили региона, в том числе потолок. Прод 2026-09-11:
-- в популяции 321 560 сделок, city IS NULL 0 строк, поэтому обе формы
-- сегодня тождественны: регион 66 52 874 строки, p1=15345, p50=52706,
-- p99.99=615312; регион 77 212 937 строк, 34221 / 294457 / 1944535;
-- расхождение 0 по всем регионам. Совпадение больше не держится на данных.
AND {_CITY_KEY_SQL} IS NOT NULL
AND region_code IS NOT NULL
-- #3051: исключение ЕКБ судится ТЕМ ЖЕ выражением ключа, по которому идёт
-- GROUP BY ниже (было: голая колонка city). Разъехавшиеся предикат и ключ
-- держались на данных: сегодня у региона 66 src_city пуст у всех 108 623
-- сделок, ключ == city, и обе формы дают одни и те же 55 749 исключённых
-- строк (прод 2026-09-11: by_city=55749, by_key=55749, расхождение 0).
-- Появись источник с src_city='Екатеринбург' у сделки с другим city
-- старая форма пропустила бы её в derivation и завела строку полосы
-- 'Екатеринбург', которую ступень 1 нашла бы для настоящих ЕКБ-сделок,
-- сломав намеренное исключение. Теперь исключение и ключ одно выражение.
AND NOT (region_code = 66 AND {_CITY_KEY_SQL} = 'Екатеринбург')
GROUP BY region_code
AND city IS NOT NULL
AND city <> 'Екатеринбург'
),
city_stats AS (
SELECT
region_code,
{_CITY_KEY_SQL} AS city,
city,
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
AS ppm2_p1,
-- p99 сырой: клампится региональным потолком в ветке full ниже,
-- а не литералом 800000 (см. шапку).
round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
AS ppm2_p99,
count(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND doc_type = 'ДКП'
AND price_per_m2 IS NOT NULL
-- #3051: непустоту ключа судим ТЕМ ЖЕ выражением, по которому идут GROUP BY
-- и предикат ЕКБ ниже (было: сырая колонка city). Сделка с непустым src_city
-- и NULL в city даёт ВАЛИДНЫЙ ключ, но сырой фильтр выбрасывал её целиком
-- и из её собственной городской строки, и из региональной статистики, молча
-- занижая n_deals и перцентили региона, в том числе потолок. Прод 2026-09-11:
-- в популяции 321 560 сделок, city IS NULL 0 строк, поэтому обе формы
-- сегодня тождественны: регион 66 52 874 строки, p1=15345, p50=52706,
-- p99.99=615312; регион 77 212 937 строк, 34221 / 294457 / 1944535;
-- расхождение 0 по всем регионам. Совпадение больше не держится на данных.
AND {_CITY_KEY_SQL} IS NOT NULL
AND region_code IS NOT NULL
-- #3051: исключение ЕКБ судится ТЕМ ЖЕ выражением ключа, по которому идёт
-- GROUP BY ниже (было: голая колонка city). Разъехавшиеся предикат и ключ
-- держались на данных: сегодня у региона 66 src_city пуст у всех 108 623
-- сделок, ключ == city, и обе формы дают одни и те же 55 749 исключённых
-- строк (прод 2026-09-11: by_city=55749, by_key=55749, расхождение 0).
-- Появись источник с src_city='Екатеринбург' у сделки с другим city
-- старая форма пропустила бы её в derivation и завела строку полосы
-- 'Екатеринбург', которую ступень 1 нашла бы для настоящих ЕКБ-сделок,
-- сломав намеренное исключение. Теперь исключение и ключ одно выражение.
AND NOT (region_code = 66 AND {_CITY_KEY_SQL} = 'Екатеринбург')
GROUP BY region_code, {_CITY_KEY_SQL}
AND city IS NOT NULL
AND city <> 'Екатеринбург'
GROUP BY city
),
tiered AS (
SELECT c.region_code, c.city, c.ppm2_p1 AS ppm2_min,
LEAST(c.ppm2_p99, r.region_ppm2_max) AS ppm2_max, c.n_deals,
SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
'full'::text AS tier
FROM city_stats c
JOIN region_stats r ON r.region_code = c.region_code
WHERE c.n_deals >= 30
AND c.ppm2_p99 >= 8000
FROM city_stats
WHERE n_deals >= 30
AND ppm2_p99 >= 8000
UNION ALL
SELECT c.region_code, c.city,
LEAST(c.ppm2_p1, r.region_ppm2_max - 100000) AS ppm2_min,
r.region_ppm2_max AS ppm2_max,
c.n_deals, 'rough'::text AS tier
FROM city_stats c
JOIN region_stats r ON r.region_code = c.region_code
WHERE c.n_deals BETWEEN 10 AND 29
SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
'rough'::text AS tier
FROM city_stats
WHERE n_deals BETWEEN 10 AND 29
UNION ALL
SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min,
r.region_ppm2_max AS ppm2_max,
c.n_deals, 'region_fallback'::text AS tier
SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
'region_fallback'::text AS tier
FROM city_stats c
JOIN region_stats r ON r.region_code = c.region_code
CROSS JOIN region_stats r
WHERE c.n_deals < 10
)
INSERT INTO deal_city_price_bands
(region_code, city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
SELECT region_code, city, ppm2_min, ppm2_max, n_deals, tier, now()
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
SELECT city, ppm2_min, ppm2_max, n_deals, tier, now()
FROM tiered
ON CONFLICT (region_code, city) DO UPDATE
ON CONFLICT (city) DO UPDATE
SET ppm2_min = EXCLUDED.ppm2_min,
ppm2_max = EXCLUDED.ppm2_max,
n_deals = EXCLUDED.n_deals,
@ -248,18 +103,13 @@ _REDERIVE_SQL = text(
)
# ── Post-insert counters ──────────────────────────────────────────────────────
# #3051 (298): regions — число различных region_code в таблице после re-derive
# (Свердловская обл. + Москва после включения региона 77). Добавлено в конец
# SELECT-списка, прежние 4 счётчика на тех же местах — контракт _COUNTERS_SQL
# (rows_written/full_rows/rough_rows/region_fallback_rows) не ломается.
_COUNTERS_SQL = text(
"""
SELECT
COUNT(*) AS rows_written,
COUNT(*) FILTER (WHERE tier = 'full') AS full_rows,
COUNT(*) FILTER (WHERE tier = 'rough') AS rough_rows,
COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows,
COUNT(DISTINCT region_code) AS regions
COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows
FROM deal_city_price_bands
"""
)
@ -274,15 +124,13 @@ def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]:
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..,
"regions": ..}.
Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..}.
"""
counters: dict[str, int] = {
"rows_written": 0,
"full_rows": 0,
"rough_rows": 0,
"region_fallback_rows": 0,
"regions": 0,
}
try:
db.execute(_REDERIVE_SQL)
@ -293,19 +141,17 @@ def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]:
counters["full_rows"] = int(row["full_rows"] or 0)
counters["rough_rows"] = int(row["rough_rows"] or 0)
counters["region_fallback_rows"] = int(row["region_fallback_rows"] or 0)
counters["regions"] = int(row["regions"] or 0)
db.commit()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"refresh_deal_city_price_bands run_id=%d done: "
"rows_written=%d full=%d rough=%d region_fallback=%d regions=%d",
"rows_written=%d full=%d rough=%d region_fallback=%d",
run_id,
counters["rows_written"],
counters["full_rows"],
counters["rough_rows"],
counters["region_fallback_rows"],
counters["regions"],
)
return counters
except Exception as exc:

View file

@ -493,7 +493,7 @@ async def run_domclick_detail_backfill(
# ниже. Причина не теряется: в записи прогона стоит
# no_proxy_stop=1 и mark_failed с текстом про пул.
counters.failed += 1
logger.warning(
logger.error(
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
run_id,
@ -572,7 +572,7 @@ async def run_domclick_detail_backfill(
if consecutive_blocks >= max_consecutive_blocks:
# #3196: причину больше не выдумываем и не молчим — печатаем
# перепись диагнозов по HTTP-статусам этого прогона.
logger.warning(
logger.error(
"domclick_detail_backfill: run_id=%d ABORT -- %d consecutive "
"blocks, диагнозы: %s. enriched=%d attempted=%d",
run_id,

View file

@ -1,119 +0,0 @@
"""ДТП (dtp-stat.ru) refresh — CLI + scheduler run-lifecycle wrapper (#3410).
Наполняет `dtp_incidents` (пустая при деплое, миграция 294) через
`app/services/dtp_stat_loader.load_dtp_incidents` (скачивание ZIP + потоковый парс +
TRUNCATE+INSERT). Scheduler source='dtp_stat_refresh' (seed 293, enabled=false
источник заморожен, см. докстринг loader'а; первый прогон ручной).
Запуск из контейнера:
python -m app.tasks.dtp_stat_refresh
python -m app.tasks.dtp_stat_refresh --dry-run
python -m app.tasks.dtp_stat_refresh --src-path /path/to/local.zip
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
"""
from __future__ import annotations
import argparse
import logging
import time
from dataclasses import dataclass, field
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
from app.services.dtp_stat_loader import load_dtp_incidents
logger = logging.getLogger(__name__)
@dataclass
class DtpStatRefreshResult:
parsed: int = 0
inserted: int = 0
duration_sec: float = field(default=0.0)
def to_counters(self) -> dict[str, int]:
return {
"parsed": self.parsed,
"inserted": self.inserted,
"duration_sec": int(self.duration_sec),
}
def run_dtp_stat_refresh(db: Session, *, run_id: int, params: dict) -> DtpStatRefreshResult:
"""Run-lifecycle wrapper для scheduler'а (source='dtp_stat_refresh') и ручного прогона.
params:
dry_run bool, по умолчанию False. True: парсит и считает, БД не трогает.
src_path str, локальный путь к ZIP (для отладки/тестов, минуя скачивание).
Финализирует scrape_runs (mark_done / mark_failed) со счётчиками.
"""
dry_run = bool(params.get("dry_run", False))
src_path = params.get("src_path")
counters: dict[str, int] = {"parsed": 0, "inserted": 0}
start = time.monotonic()
try:
runs_mod.update_heartbeat(db, run_id, counters)
load_counts = load_dtp_incidents(db, src_path=src_path, dry_run=dry_run)
if not dry_run:
db.commit()
result = DtpStatRefreshResult(
parsed=load_counts["parsed"],
inserted=load_counts["inserted"],
duration_sec=time.monotonic() - start,
)
counters = result.to_counters()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"run_dtp_stat_refresh: run_id=%d DONE dry_run=%s parsed=%d inserted=%d duration=%.1fs",
run_id,
dry_run,
result.parsed,
result.inserted,
result.duration_sec,
)
return result
except Exception as exc:
logger.exception("run_dtp_stat_refresh: run_id=%d FAILED", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--src-path", default=None, help="Локальный ZIP вместо скачивания SRC_URL")
parser.add_argument("--dry-run", action="store_true", help="Парсить и считать, не писать в БД")
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
args = build_parser().parse_args()
from app.core.db import SessionLocal
db = SessionLocal()
try:
counts = load_dtp_incidents(db, src_path=args.src_path, dry_run=args.dry_run)
if not args.dry_run:
db.commit()
logger.info("dtp_stat_refresh CLI DONE: dry_run=%s %s", args.dry_run, counts)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -1,71 +0,0 @@
"""CLI: ФНС opendata (revexp/sshr2019/debtam/snr) → fns_legal_entity_facts.
Только загрузка + lookup (`app/services/fns_lookup.py`) потребителя у данных пока
нет, см. docstring `app/services/fns_opendata_loader.py`.
Запуск из контейнера tradein-backend/tradein-scraper (нужен интернет к nalog.gov.ru):
python -m app.tasks.fns_opendata_load # все 4 набора
python -m app.tasks.fns_opendata_load --datasets revexp # один набор
python -m app.tasks.fns_opendata_load --dry-run # без записи, только резолв ссылки
python -m app.tasks.fns_opendata_load --force # перекачать, даже если версия та же
В режиме --dry-run скачивание/парс/запись не происходят только резолв актуальной
ссылки со страницы каталога (проверка доступности + логирование того, что было бы
скачано).
"""
from __future__ import annotations
import argparse
import logging
from app.core.db import SessionLocal
from app.services.fns_opendata_loader import DATASET_SLUGS, load_dataset
logger = logging.getLogger(__name__)
def build_parser() -> argparse.ArgumentParser:
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
parser = argparse.ArgumentParser(
description="ФНС opendata loader: revexp/sshr2019/debtam/snr → fns_legal_entity_facts"
)
parser.add_argument(
"--datasets",
nargs="+",
choices=list(DATASET_SLUGS),
default=list(DATASET_SLUGS),
help="список наборов (по умолчанию — все 4)",
)
parser.add_argument(
"--dry-run", action="store_true", help="без записи в БД — только резолв ссылки/подсчёт"
)
parser.add_argument(
"--force", action="store_true", help="перекачать, даже если версия совпадает с загруженной"
)
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
args = build_parser().parse_args()
db = SessionLocal()
try:
results: dict[str, object] = {}
for slug in args.datasets:
result = load_dataset(db, slug, dry_run=args.dry_run, force=args.force)
if not args.dry_run:
db.commit()
results[slug] = result
logger.info("fns_opendata_load DONE: dry_run=%s %s", args.dry_run, results)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -1,127 +0,0 @@
"""CLI: АИС ППК ФРТ (реестр МКД) → houses.area_land/foundation_type/elevators_total +
добор year_built/material_walls/total_floors/entrances/is_emergency/flat_count/
heat_supply_type/gas_supply_type/hot_water (issue #frt-mkd).
Тянет CSV реестра МКД (export/{node_id}, по умолчанию 110 = region 66) с открытых
данных АИС ППК ФРТ, UPSERT'ит в staging `frt_mkd` (мигр. 290), затем backfill_houses —
COALESCE-добор перечисленных houses.* полей (только NULL, никогда не перезаписывает).
Запуск из контейнера tradein-scraper (у него есть интернет к источнику):
python -m app.tasks.frt_mkd_load # полный прогон (node 110, region 66)
python -m app.tasks.frt_mkd_load --dry-run # без записи, только подсчёт
python -m app.tasks.frt_mkd_load --load-only # только staging, без backfill
python -m app.tasks.frt_mkd_load --backfill-only # staging уже загружена — только backfill
python -m app.tasks.frt_mkd_load --src-path /tmp/frt.csv # локальный CSV, без скачивания
В режиме --dry-run скачивание/парс (если применимо) происходят, но НИ ОДНОЙ записи
в БД не делается.
"""
from __future__ import annotations
import argparse
import logging
from app.core.db import SessionLocal
from app.services.frt_mkd_loader import (
DEFAULT_NODE_ID,
DEFAULT_REGION_CODE,
backfill_houses,
load_frt_mkd,
)
logger = logging.getLogger(__name__)
def build_parser() -> argparse.ArgumentParser:
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
parser = argparse.ArgumentParser(
description=(
"АИС ППК ФРТ loader: реестр МКД → frt_mkd staging → "
"houses.area_land/foundation_type/elevators_total + добор смежных полей"
)
)
parser.add_argument(
"--src-path", default=None, help="локальный CSV реестра МКД (пропустить скачивание)"
)
parser.add_argument(
"--work-dir",
default=None,
help="каталог для скачанного/распакованного CSV (по умолчанию — временный, "
"удаляется после)",
)
parser.add_argument(
"--node-id",
type=int,
default=DEFAULT_NODE_ID,
help=f"id ноды export АИС ФРТ (по умолчанию {DEFAULT_NODE_ID} = реестр МКД region 66)",
)
parser.add_argument(
"--region-code",
type=int,
default=DEFAULT_REGION_CODE,
help=f"код региона выгрузки для тега staging-строк (по умолчанию {DEFAULT_REGION_CODE})",
)
parser.add_argument(
"--dry-run", action="store_true", help="без записи в БД — только скачивание/парс/подсчёт"
)
parser.add_argument(
"--load-only",
action="store_true",
help="только скачать+распарсить+UPSERT staging, без backfill houses",
)
parser.add_argument(
"--backfill-only",
action="store_true",
help="пропустить скачивание/load staging (считаем что уже загружена этим же "
"или предыдущим прогоном) — только backfill houses",
)
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = build_parser()
args = parser.parse_args()
if args.load_only and args.backfill_only:
parser.error("--load-only и --backfill-only взаимоисключающи")
db = SessionLocal()
try:
load_counts: dict[str, int] = {}
if not args.backfill_only:
load_counts = load_frt_mkd(
db,
src_path=args.src_path,
work_dir=args.work_dir,
node_id=args.node_id,
region_code=args.region_code,
dry_run=args.dry_run,
)
if not args.dry_run:
db.commit()
logger.info("frt_mkd_load: load stage done: %s", load_counts)
houses_counts: dict[str, int] = {}
if not args.load_only:
houses_counts = backfill_houses(db, dry_run=args.dry_run)
if not args.dry_run:
db.commit()
logger.info(
"frt_mkd_load DONE: dry_run=%s load=%s houses=%s",
args.dry_run,
load_counts,
houses_counts,
)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -1,105 +0,0 @@
"""Фоновая пересылка GlitchTip-алерта в Telegram после отказа синхронной попытки.
Контекст (#3471, #3157, #3456). GlitchTip-вебхуки НЕ ретраятся — сам GlitchTip
безусловно помечает уведомление ``is_sent`` сразу после HTTP-ответа приёмника
(upstream-поведение, см. #3157), поэтому если синхронная пересылка в Telegram
(``app.api.v1.glitchtip``) не удалась, повторной доставки от GlitchTip не будет
никогда текст алерта исчезает бесследно. Ответ 502 на отказ Telegram остаётся
как есть (задуман осознанно, #3456: честный сигнал отправителю, а не тихий
проглот) меняется то, что происходит С ТЕКСТОМ алерта после этого отказа.
Почему не Celery. В tradein-mvp нет очереди с воркером: ни ``app/celery_app.py``,
ни зависимости ``celery`` в ``backend/pyproject.toml`` не существует (проверено
при работе над #3471) — попытка ``from celery import ...`` здесь упала бы
``ModuleNotFoundError``. Бутстрап полноценного Celery-воркера новый контейнер и
брокер, инфраструктурное решение вне границ этой задачи. Единственный доступный
внутри границ задачи (``app/api/v1/glitchtip.py`` + ``app/tasks/**``) механизм
«не блокировать интерактивный ответ, но не потерять текст» Starlette
``BackgroundTasks``: выполняется ПОСЛЕ отправки HTTP-ответа тем же процессом, вне
узкого интерактивного бюджета (``_INTERACTIVE_SEND_TIMEOUT_S=8s`` в glitchtip.py),
поэтому здесь можно позволить себе штатную "воркерную" ретрай-политику клиента
(``TelegramClient.send_message`` без явных ``timeout``/``max_retries`` 5 попыток,
backoff до 30s, см. ``app.services.tgbot.client``), плюс собственный внешний
потолок ниже.
Компромисс, честно: BackgroundTasks не переживает рестарт процесса (это не
персистентная очередь) если tradein-backend упадёт ровно между отказом
синхронной попытки и завершением фоновой, текст всё-таки потеряется. Событие
редкое (одно с начала эксплуатации, TRADE-IN-3F7, 28.08.2026), а сеть до Telegram
теряет отдельные запросы, а не рвётся на минуты (замер 12.09: 9/12 успешных
``getMe``) штатной ретрай-политики клиента обычно достаточно без внешнего
потолка вовсе. Персистентная очередь (переживающая рестарт) требует
Celery/Redis-воркера отдельное инфраструктурное решение.
Идемпотентность настолько, насколько дёшево. Текст между попытками не
пересобирается (переиспользуется уже отформатированный ``text`` из
``glitchtip.py`` никакого дублирования форматирования). Полной идемпотентности
нет и быть не может дёшево: Telegram ``sendMessage`` не идемпотентен сам по себе
(повтор создаёт НОВОЕ сообщение, не апдейтит старое) именно поэтому внешний
потолок попыток мал (``_MAX_ATTEMPTS``), а не «ретраить пока не получится».
"""
from __future__ import annotations
import asyncio
import logging
from app.services.tgbot.client import TelegramClient, TelegramError
logger = logging.getLogger(__name__)
__all__ = ["retry_forward_alert"]
# Внешний потолок ПОВЕРХ штатной ретрай-политики клиента (5 попыток внутри одного
# send_message с backoff до 30s) — защита от «недоставляемый алерт крутится в фоне
# вечно»: если сеть до Telegram не восстановилась за это время, сдаёмся и логируем
# ERROR с текстом, а не повторяем бесконечно.
_MAX_ATTEMPTS = 3
_RETRY_DELAY_S = 30.0
async def retry_forward_alert(
client: TelegramClient,
*,
chat_id: int,
text: str,
message_thread_id: int | None,
) -> None:
"""Досылает уже отформатированный текст алерта после отказа синхронной попытки.
``client`` ТОТ ЖЕ общий клиент приложения, что и в синхронном пути
(``get_telegram_client()`` в ``glitchtip.py``), а не новый инстанс: он живёт в
lifespan ради keep-alive-соединения (см. docstring ``glitchtip.py``).
"""
for attempt in range(1, _MAX_ATTEMPTS + 1):
try:
await client.send_message(
chat_id=chat_id,
text=text,
message_thread_id=message_thread_id,
# Без явных timeout/max_retries — штатная "воркерная" политика
# клиента (см. докстринг модуля).
)
except TelegramError:
if attempt == _MAX_ATTEMPTS:
logger.error(
"glitchtip alert retry: не удалось доставить алерт в Telegram "
"после %d попыток — текст потерян: %r",
_MAX_ATTEMPTS,
text[:200],
exc_info=True,
)
return
logger.warning(
"glitchtip alert retry: попытка %d/%d не удалась, повтор через %.0fs",
attempt,
_MAX_ATTEMPTS,
_RETRY_DELAY_S,
exc_info=True,
)
await asyncio.sleep(_RETRY_DELAY_S)
else:
logger.info(
"glitchtip alert retry: доставлено фоном с попытки %d/%d", attempt, _MAX_ATTEMPTS
)
return

View file

@ -9,36 +9,26 @@
ПРАВИЛО ОТБОРА ЯВНО И БЕЗ ПОДГОНКИ
------------------------------------
Витрина показывает ПОЛОСУ РАСХОЖДЕНИЯ, а не всю сверку. С 2026-09-12 решением
владельца продукта на витрину попадают только сделки, у которых расхождение
прогноза с ценой ДКП лежит в пределах `BAND_MIN_ERR_PCT`..`BAND_MAX_ERR_PCT`
(5 %..+20 % включительно). Оставшиеся `limit` строк ранжируются ключом::
Отбираем N строк ключом::
(полнота данных , свежесть квартала , id сделки )
ЭТО ОТБОР ПОКАЗАТЕЛЬНЫХ СТРОК, И НАЗЫВАТЬ ЕГО НАДО ТАК. До 2026-09-12 здесь
не было ни фильтра, ни слагаемого ошибки в ключе, и подпись витрины это прямо
утверждала. Теперь утверждать это нельзя: строки с промахом крупнее полосы в
данных есть (на проде 30.08.2026 из показанных двадцати вне полосы было
двенадцать от 27,9 % до +75,7 %), и они не показываются. Поэтому полоса
названа в `REJECTION_RULE`, которое едет на фронт вместе со счётчиками
прогона, и в подписи под таблицей рядом с медианой расхождения ПО ВСЕЙ
СВЕРКЕ: два числа рядом не дают прочитать двадцать отобранных строк как
«вот так МЕРА обычно и попадает».
Величина ошибки в ключе НЕ УЧАСТВУЕТ и участвовать не должна. Отбор по малой
ошибке превращает витрину в рекламу: показанные 20 строк перестают быть
выборкой из работы оценщика и становятся её лучшим хвостом, а посетитель
читает их как «вот так МЕРА обычно и попадает». Это тот самый случай, когда
код формально работает, а продукт врёт. Проверяется тестом
`test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`.
ЧТО ЭТО НЕ ОТМЕНЯЕТ. Внутри полосы отбор по величине ошибки по-прежнему
запрещён иначе витрина показывала бы лучший хвост уже самой полосы
(`test_selection_ignores_error_magnitude`). Счётчики прогона считаются ДО
полосы: `eligible` сколько строк прогон вообще собрал, `written` сколько
из них прошло полосу и поместилось в `limit`. Разница между ними видна
посетителю, и она честная ровно потому, что рядом сказано, чем именно
отобраны показанные. Если в полосу попало меньше `limit` строк показываем
сколько есть; добирать соседями по ошибке нельзя, это вернуло бы отбор по
величине ошибки в обход полосы.
Отбраковка по «данных нет» (нет прогноза, нет квартала, нет площади) осталась
прежней и живёт в `build_row`: строка вне полосы ОСТАЁТСЯ кандидатом и
попадает в счётчик `eligible`, её снимает отбор, а не отбраковка.
ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь
жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ
до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в
ключе и отбор по ошибке в фильтре одно и то же действие, и второе даже
злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение
больше 40% это почти всегда занижение ДКП ради налога» не держится: см.
следующий раздел, грубые занижения вырезаны выше по потоку и по свойству
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
квартала, нет площади) «число некрасивое» причиной не является.
Полнота сколько из полей, которые видит посетитель (район, этаж, этажность,
схема улицы), у строки заполнено. Свежесть порядок квартала сделки.
@ -67,10 +57,8 @@
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
всё, что после этого дало большую ошибку, работа оценщика. С 2026-09-12
такая строка на витрину не выходит (полоса), но остаётся в `eligible` и
в подписи названа отобранной, а не несуществующей. Своей копии диапазона
здесь нет намеренно: прежние
всё, что после этого дало большую ошибку, работа оценщика, и витрина
обязана её показать. Своей копии диапазона здесь нет намеренно: прежние
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k из трёх
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
@ -87,10 +75,7 @@
ошибки, правило отбора выше не нарушено.
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено, сколько строк прогон собрал и по какому правилу из
них отобраны показанные. С появлением полосы это перестало быть
страховкой и стало обязательным: без счётчиков и правила отобранная
двадцатка читается как вся сверка. Поэтому итог
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
ручкой `/api/public/mera/showcase` вместе со строками.
@ -119,47 +104,18 @@ from app.services.street_scheme import StreetIndex, build_street_scheme, load_st
logger = logging.getLogger(__name__)
# ── Полоса расхождения: что показываем и что об этом сказано ─────────────────
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
#
# Границы ВКЛЮЧИТЕЛЬНЫЕ. Полоса несимметрична намеренно: решение владельца от
# 2026-09-12 — показывать сделки, где МЕРА не занизила больше чем на 5 % и не
# завысила больше чем на 20 %.
BAND_MIN_ERR_PCT = -5.0
BAND_MAX_ERR_PCT = 20.0
# Подпись полосы ВЫВОДИТСЯ из границ, а не вписывается рядом: «5 %…+20 %» в
# тексте и `>= -5.0` в коде — две независимые величины, и разъедутся они
# ровно тогда, когда порог однажды подвинут.
BAND_LABEL = f"от {BAND_MIN_ERR_PCT:+.0f} % до {BAND_MAX_ERR_PCT:+.0f} % включительно"
def in_band(err_pct: float) -> bool:
"""Попадает ли расхождение в показываемую полосу (границы включительно)."""
return BAND_MIN_ERR_PCT <= err_pct <= BAND_MAX_ERR_PCT
# ── Правило отбора и отбраковки: одна формулировка, она же едет на фронт ──────
#
# Санитарный диапазон ₽/м² применён выше по потоку, в `_load_sample`;
# дублировать его тут значило бы завести проверку, которая в проде не
# срабатывает никогда.
#
# ТЕКСТ ОБЯЗАН НАЗЫВАТЬ ПОЛОСУ. Пока фильтра не было, здесь стояло «величина
# отклонения на отбор и отбраковку не влияет — иначе витрина показывала бы
# лучший хвост, а не работу расчёта». С фильтром эта фраза стала ложью ровно
# про то, чего опасалась, поэтому она снята, а не смягчена.
# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
# значило бы завести проверку, которая в проде не срабатывает никогда.
REJECTION_RULE = (
f"На витрине — ОТОБРАННАЯ полоса расхождения, а не вся сверка: показаны "
f"только сделки, у которых расхождение прогноза с ценой ДКП лежит {BAND_LABEL}. "
"Промахи крупнее полосы в данных есть, и здесь их не видно — судить по этим "
"строкам о точности расчёта нельзя, для этого есть медиана расхождения по "
"всей сверке. Внутри полосы порядок задают полнота данных и свежесть "
"квартала: величина отклонения на него не влияет, лучший хвост самой полосы "
"витрина тоже не показывает. Кроме полосы строку снимает только отсутствие "
"данных: расчёт МЕРЫ не дал ожидаемой цены продажи (мало аналогов), "
"неизвестен квартал сделки или площадь. Санитарный диапазон цены сделки "
"(30 000600 000 ₽/м² для Екатеринбурга) применён к выборке до расчёта, по "
"цене самой сделки."
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
"диапазон цены сделки (30 000600 000 ₽/м² для Екатеринбурга) применён "
"к выборке до расчёта, по цене самой сделки."
)
NOTE = (
@ -228,7 +184,7 @@ def completeness(row: ShowcaseRow) -> int:
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
"""Ключ ранжирования. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге."""
"""Ключ отбора. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге модуля."""
return (
-completeness(row),
-(row.deal_date or date.min).toordinal(),
@ -237,25 +193,8 @@ def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]:
"""Строки полосы 5 %..+20 %, до `limit` штук, по полноте и свежести.
ДВА ДЕЙСТВИЯ, И ОНИ РАЗНЫЕ. Сначала ФИЛЬТР по величине расхождения
(`in_band`) это и есть «витрина показывает отобранную полосу, а не всю
сверку», названное так же в `REJECTION_RULE` и в подписи под таблицей.
Потом РАНЖИРОВАНИЕ уцелевших по полноте данных и свежести квартала
внутри полосы величина ошибки на порядок не влияет, иначе показывался бы
лучший хвост уже самой полосы.
Фильтр стоит ЗДЕСЬ, а не в `build_row`, намеренно: строка вне полосы
обязана остаться кандидатом и попасть в счётчик `eligible`. Отбраковав её
раньше, мы получили бы «показано 20 из 20 годных» счётчик, из которого
отбор не виден вообще.
В полосе меньше `limit` строк возвращаем сколько есть. Добирать
ближайшими по ошибке нельзя: это тот же отбор по величине ошибки, просто
с другой стороны.
"""
return sorted((r for r in rows if in_band(r.err_pct)), key=_sort_key)[:limit]
"""Отобрать `limit` строк по полноте и свежести (НЕ по величине ошибки)."""
return sorted(rows, key=_sort_key)[:limit]
def build_row(
@ -278,11 +217,8 @@ def build_row(
Причины отказа ИСЧЕРПЫВАЮЩИЕ и все «данных нет»: спайн не дал ожидаемой
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
(делить не на что). Величина отклонения причиной отказа НЕ является ни при
каких значениях: строка с любым промахом становится кандидатом и попадает
в счётчик `eligible`. Полоса, по которой из кандидатов отбираются
показанные, применяется позже и в другом месте `select_rows`; здесь её
нет намеренно, иначе отбор перестал бы быть виден в счётчиках.
(делить не на что). Величина отклонения причиной НЕ является ни при каких
значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
ФАКТ ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
@ -448,14 +384,9 @@ def refresh_landing_showcase_deals(
priced из них оценщик дал ожидаемую цену продажи
no_prediction не дал (мало аналогов / спайн упал)
incomplete цена есть, но нет квартала/площади строку не собрать
eligible строк СОБРАНО всего, ДО полосы (данных хватило)
written из них показано: прошли полосу и поместились в `limit`
eligible годных строк ВСЕГО (никакого отсева по ошибке нет)
written из них показано (обрезано по `limit`)
with_district у скольких показанных удалось определить район
`eligible` минус `written` это НЕ «столько не поместилось»: с 2026-09-12
в разницу входят и строки вне полосы 5 %..+20 %. Поэтому подпись под
таблицей называет `eligible` собранными строками, а чем отобраны
показанные говорит `REJECTION_RULE`, который едет тем же ответом.
"""
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
# его зависимостями, а web-процессу это на импорте приложения не нужно.
@ -513,16 +444,6 @@ def refresh_landing_showcase_deals(
candidates.append(row)
chosen = select_rows(candidates, limit)
# Сколько собранных строк вообще попало в полосу — в лог, а не в счётчики:
# колонки под него в `landing_showcase_runs` нет, а без него по `written`
# не отличить «полоса оставила мало» от «упёрлись в limit».
logger.info(
"в полосе %s: %d из %d собранных, показано %d",
BAND_LABEL,
sum(1 for r in candidates if in_band(r.err_pct)),
len(candidates),
len(chosen),
)
schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
db.execute(_DELETE_SQL)
@ -567,7 +488,7 @@ def refresh_landing_showcase_deals(
logger.info(
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
"собрано=%d записано=%d с_районом=%d",
"годных=%d записано=%d с_районом=%d",
counters["considered"],
counters["priced"],
counters["no_prediction"],

View file

@ -1,910 +0,0 @@
"""Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50).
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`.
Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для
Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
опознаётся байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом
35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox 17 576 (это
Московская область, регион 50); без округа вне bbox 7 337. Отдельно 212
карточек с адресом вида «Екатеринбург (Cian)» артефакт парсера, считаются
своим счётчиком, чтобы не растворяться в «не целевой регион».
Область (регион 50) у Циана в адресе НЕ видна вовсе берём по ПОДДОМЕНУ
`source_url` (`https://<sub>.cian.ru/...`): `sub != "www"` область. Замер по
`msk_raw.cian_latest` 12.09.2026: `www` 38 030 карточек, из них 36 569 с
префиксом округа (это Москва); все прочие поддомены (krasnogorsk 2065,
balashikha 1828, vidnoye 1802, lyubertsy 1498, zvenigorod 1420, khimki 1306,
mytishchi 1288, podolsk 756, odintsovo 738, ) 0 карточек с префиксом округа,
итого не-www 24 784. Поддомен и префикс округа нигде не противоречат друг
другу, поэтому Москва остаётся на префиксе округа (не трогаем), а область
на поддомене. Поддомен не распознался (нет source_url / хост не `*.cian.ru`)
карточка НЕ область (консервативно, счётчик «не целевой регион»).
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес голая улица с
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр
для ЛЮБОГО целевого региона.
Два сигнала, и оба нужны ни один по отдельности не годится.
1. Город по версии самого Авито слаг в `source_url`
(`avito.ru/<slug>/kvartiry/...`). Заполнен у 100% карточек, стоит 0 вызовов:
`moskva` 21 841 карточка, остальное муниципалитеты области (balashiha,
himki, podolsk, ). Это единственный ТОЧНЫЙ признак населённого пункта, но
регион по нему не выводится: Троицк/Щербинка/Коммунарка/Московский/
Зеленоград свои слаги, а регион у них 77 (Новая Москва и ЗелАО).
2. Координаты и регион DaData /suggest/address с `locations`-констрейнтом.
Слаг сужает констрейнт (`moskva` только регион «Москва», иначе «Москва»
И «Московская» разом), но РЕГИОН БЕРЁТСЯ ИЗ ОТВЕТА (`region_kladr_id`), а не
из слага иначе Новая Москва уехала бы в область.
Почему не наоборот (только геокод, без слага). Замер на 200 случайных адресах
Авито с констрейнтом «Москва + Московская»: дом с координатами нашёлся у 184
(92%), но верхний кандидат разошёлся со слагом у 28 из 184 (15%) и почти
всегда в пользу Москвы («пр-т Мира,19» при слаге fryazino «г Москва, пр-кт
Мира, 19»). Голый адрес без города DaData тянет в столицу; 15% чужих домов в
регионе 77 ровно та ошибка, ради которой стоял `--allow-unfiltered`.
С сужением по слагу (`moskva` регион «Москва») резолв 77 из 82 (94%),
qc_geo=0 у 97% найденных.
Что куда едет:
* регион == `--region` в `listings`, С координатами (geom есть сразу,
radius-подбор аналогов работает без ожидания `geocode_missing`);
* адрес разрешился, но регион ответа не совпал с `--region` не пишется,
лежит не в воздухе, а строкой в `msk_raw.avito_geocode` прогон с другим
`--region` подхватит её из кэша без единого внешнего вызова;
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33»)
свой счётчик, карточка не пишется.
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает.
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
то есть по ссылке на ЖК, а не по продаже застройщиком. Замер по всем 60 464:
`raw_payload.is_from_developer` = true у НУЛЯ карточек, false у 29 000,
отсутствует у 31 464 застройщик в этом корпусе не продаёт ничего, это вся
вторичка. В estimator'е стоит гвард (`estimator.py:5992-5995`): в аналоги идут
только строки с `listing_segment IS NULL` или 'vtorichka'. Скопируй мы метку
кита 29 000 карточек выпали бы из подбора. Поэтому метка считается заново:
is_from_developer is True 'novostroyki', иначе 'vtorichka'.
Идемпотентность на стороне `save_listings`: он делает upsert
`ON CONFLICT (dedup_hash) DO UPDATE` плюс reconcile-UPDATE по
`(source, source_id)` на случай дрейфа хеша. `dedup_hash` = sha256(source +
source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), цена в ключ не
входит. Повторный прогон поэтому обновляет те же строки, а не плодит дубли;
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
одинаковы.
Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
вторым компонентом. Замер по 21 393 карточкам первого прохода ровно два
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
Отбор региона (source=domclick) стоит ноль вызовов, но таблица одна на ДВА
РАЗНЫХ прогона сборщика с разными GUID: московский (батч
`msk-serp-domclick-20260912`) и областной (отдельный запуск, батч
`mo-serp-domclick-20260912`) оба пишут в один и тот же
`msk_raw.domclick_cards`. Колонки региона в таблице НЕТ, а вью
`msk_raw.domclick_latest` отдаёт обе партии вперемешку курсор по `id` не
различает, из какого прогона строка. Поэтому фильтр по адресу здесь не
опциональная вторая линия, а единственный способ развести регионы.
Регион читается ПЕРВЫМ компонентом адреса. Замер живьём на API ДомКлика
12.09.2026: московские карточки «Москва, Генерала Дорохова проспект, 49» и
подобные, первый компонент «Москва» у всех 22 836 карточек прод-корпуса;
областные карточки «Московская область, Химки, 7-й м-н, проспект
Мельникова, 33», «Московская область, Одинцовский городской округ,
Звенигород, 3-й м-н, 28» и подобные, первый компонент «Московская область» у
всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и
симметричное `is_moscow_yandex_address`/`is_oblast_yandex_address`. Новая
Москва приходит как «Москва, пос. Птичное, » посёлок стоит вторым
компонентом, первый по-прежнему «Москва», регион 77 не ломается. Координаты
заполнены у 100% карточек в обоих прогонах.
Запуск:
python -m app.tasks.msk_raw_import --dry-run
python -m app.tasks.msk_raw_import --limit 500
python -m app.tasks.msk_raw_import --source yandex
python -m app.tasks.msk_raw_import --source yandex --region 50
python -m app.tasks.msk_raw_import --source cian --region 50
python -m app.tasks.msk_raw_import --source domclick
python -m app.tasks.msk_raw_import --source domclick --region 50
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
python -m app.tasks.msk_raw_import --source avito --geocode --region 50
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import re
from collections.abc import Callable
from dataclasses import dataclass
from urllib.parse import urlsplit
from pydantic import ValidationError
from scraper_kit.base import ScrapedLot, save_listings
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services import dadata
from app.services.geocoder import normalize_address
from app.services.regions import REGIONS, is_within_bbox
from app.services.scraper_adapters import RealMatcherAdapter
logger = logging.getLogger(__name__)
MOSCOW_REGION_CODE = 77
MOSCOW_CITY = "Москва"
# Московская область в реестре `app.services.regions` заведена, но своего
# единого города у неё нет (`canonical_city is None`) — в listings.city для
# неё пишем None (см. `import_msk_raw`, `save_listings` его COALESCE'ит).
OBLAST_REGION_CODE = 50
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
DEFAULT_BATCH_SIZE = 500
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
AVITO_MOSCOW_SLUG = "moskva"
# Имена регионов для DaData-констрейнта `locations`. DaData хранит имя БЕЗ типа
# («Москва», «Московская»), тип лежит отдельно в `region_type` — с типом
# hard-фильтр молча схлопывает выдачу в ноль (та же грабля, что в
# `geocoder.SVERDLOVSK_OBLAST_REGION`).
DADATA_MOSCOW_REGION = "Москва"
DADATA_OBLAST_REGION = "Московская"
# Первые две цифры КЛАДР региона в ответе DaData → код региона.
_KLADR_TO_REGION = {"77": MOSCOW_REGION_CODE, "50": OBLAST_REGION_CODE}
# Дневной потолок внешних вызовов. Free tier DaData /suggest — 10 000/сутки на
# аккаунт, и тот же аккаунт обслуживает автокомплит формы оценки; берём с запасом.
DEFAULT_GEOCODE_LIMIT = 9000
# Сколько запросов к DaData держим в полёте одновременно. Больше смысла нет:
# упираемся не в нас, а в квоту.
_GEOCODE_CONCURRENCY = 6
# Префиксы административных округов Москвы — единственный признак города в адресе
# карточки Циана (сам город в адрес не попадает).
MOSCOW_OKRUGS = (
"ЦАО",
"САО",
"СВАО",
"ВАО",
"ЮВАО",
"ЮАО",
"ЮЗАО",
"ЗАО",
"СЗАО",
"ЗелАО",
"НАО",
"ТАО",
)
# Lookahead вместо \b: следом за округом идёт запятая/пробел, но НЕ буква — иначе
# «ЗАО» матчило бы начало гипотетического «ЗАОзёрная».
_MOSCOW_OKRUG_RE = re.compile(
r"^(?:" + "|".join(MOSCOW_OKRUGS) + r")(?![А-Яа-яЁёA-Za-z])",
)
# Артефакт парсера: адрес вида «Екатеринбург (Cian)» в московском корпусе.
_ARTIFACT_RE = re.compile(r"Екатеринбург", re.IGNORECASE)
# Вью-источники. Только whitelist: имя подставляется в SQL текстом, параметром
# идентификатор не передать.
SOURCE_VIEWS = {
"cian": "msk_raw.cian_latest",
"avito": "msk_raw.avito_latest",
"yandex": "msk_raw.yandex_latest",
"domclick": "msk_raw.domclick_latest",
}
_PAGE_SQL = """
SELECT id, payload
FROM {view}
WHERE id > :after
ORDER BY id
LIMIT :limit
"""
# ── Пред-геокод Авито ────────────────────────────────────────────────────────
@dataclass(frozen=True, slots=True)
class GeoPoint:
"""Разрешённая точка адреса Авито. `region_code` — из ответа, не из слага."""
lat: float
lon: float
region_code: int
full_address: str
@dataclass
class GeocodeBudget:
"""Дневной потолок внешних вызовов на прогон.
Кончился не падаем, а перестаём спрашивать: карточки без точки просто не
пишутся в этот заход, а на следующем подхватятся с того же места (уже
разрешённые адреса лежат в кэше и квоты не стоят).
"""
remaining: int
spent: int = 0
exhausted: bool = False
def take(self, n: int) -> int:
"""Сколько из `n` запросов позволено сделать сейчас."""
allowed = max(0, min(n, self.remaining))
if allowed < n:
self.exhausted = True
self.remaining -= allowed
self.spent += allowed
return allowed
# Кэш пред-геокода. Живёт в `msk_raw` (рядом с сырьём, а не в прикладной схеме):
# это свойство КОРПУСА, а не приложения, и переживает пересбор listings.
# `geocode_cache` приложения сознательно не трогаем — там другой ключ (адрес +
# city_hint) и другой TTL, а нам нужен слаг в ключе и код региона в значении.
# Он же — «полка ожидания» для области: строки с region_code=50 никуда не
# пишутся, но остаются разрешёнными, и когда регион 50 появится в реестре,
# прогон по ним не потратит ни одного внешнего вызова.
#
# Строка с lat IS NULL — ОТРИЦАТЕЛЬНЫЙ результат («DaData дома не знает»), и он
# тоже кэшируется: без этого каждый повторный прогон заново тратил бы квоту на
# те же ~8% неразрешимых адресов (ЖК без улицы, «Мкр-н имени В.Н. Махалина,
# 33»). Передумать можно руками:
# DELETE FROM msk_raw.avito_geocode WHERE lat IS NULL.
_GEO_CACHE_DDL = """
CREATE TABLE IF NOT EXISTS msk_raw.avito_geocode (
cache_key text PRIMARY KEY,
slug text,
address text NOT NULL,
lat double precision,
lon double precision,
region_code smallint,
full_address text,
resolved_at timestamptz NOT NULL DEFAULT NOW()
)
"""
_GEO_CACHE_EXISTS = "SELECT to_regclass('msk_raw.avito_geocode')"
_GEO_CACHE_SELECT = """
SELECT cache_key, lat, lon, region_code, full_address
FROM msk_raw.avito_geocode
WHERE cache_key = ANY(:keys)
"""
_GEO_CACHE_UPSERT = """
INSERT INTO msk_raw.avito_geocode
(cache_key, slug, address, lat, lon, region_code, full_address)
VALUES (:key, :slug, :address, :lat, :lon, :region, :full)
ON CONFLICT (cache_key) DO UPDATE
SET lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
region_code = EXCLUDED.region_code,
full_address = EXCLUDED.full_address,
resolved_at = NOW()
"""
def avito_city_slug(payload: dict) -> str | None:
"""Слаг города из `source_url`: `avito.ru/<slug>/kvartiry/...` → `<slug>`.
Единственный признак населённого пункта, который Авито отдаёт честно и
даром. Регион из него НЕ выводится (см. докстринг модуля) он лишь сужает
констрейнт геокодера.
"""
url = payload.get("source_url")
if not isinstance(url, str) or not url:
return None
parts = [p for p in urlsplit(url).path.split("/") if p]
return parts[0] if parts else None
def geo_cache_key(address: str, slug: str | None) -> str:
"""Ключ кэша.
Слаг ЧАСТЬ ключа, не украшение: один и тот же текст адреса встречается в
разных муниципалитетах (замер: 1110 адресов из 21 570 живут сразу под
несколькими слагами), и это РАЗНЫЕ дома.
"""
return f"{slug or '-'}|{normalize_address(address)}"
def _dadata_regions_for(slug: str | None) -> list[str]:
"""Констрейнт `locations` по слагу.
Москва только столица; иначе оба региона, потому что слаг может оказаться
Новой Москвой (Троицк/Щербинка/Коммунарка/Московский) или Зеленоградом у
них свои слаги, а регион 77.
"""
if slug == AVITO_MOSCOW_SLUG:
return [DADATA_MOSCOW_REGION]
return [DADATA_MOSCOW_REGION, DADATA_OBLAST_REGION]
def _point_from_suggestions(suggestions: list) -> GeoPoint | None:
"""Лучший ДОМ с координатами из выдачи DaData, иначе None.
Берём только house-level (`kind == 'house'`) с координатами: улица/город
дают точку в середине улицы или в центре НП для radius-подбора аналогов
это хуже, чем отсутствие точки (сосед через квартал попадёт в выборку, а
настоящий сосед нет). Регион первые две цифры КЛАДР ответа.
"""
for s in suggestions:
if s.kind != "house" or s.lat is None or s.lon is None:
continue
region = _KLADR_TO_REGION.get((s.kladr_id or "")[:2])
if region is None:
continue # ни 77, ни 50 — констрейнт пробит, такой ответ не берём
return GeoPoint(
lat=float(s.lat),
lon=float(s.lon),
region_code=region,
full_address=s.unrestricted_value,
)
return None
async def _geocode_many(items: list[tuple[str, str, str | None]]) -> dict[str, GeoPoint | None]:
"""`[(cache_key, address, slug)]` → `{cache_key: GeoPoint | None}`.
Параллелим с потолком `_GEOCODE_CONCURRENCY` упираемся в квоту, а не в нас.
Отказ DaData (сеть/429/401) выглядит как пустая выдача: `suggest_addresses`
гасит исключения сам и возвращает []. Такой адрес получит None и он, как и
честное «дома не знаю», уедет в кэш отрицательным. Отсюда правило прогона:
увидел в итоге всплеск `не разрешён` сначала проверь логи DaData, потом
чисти отрицательные строки кэша (SQL выше), иначе разовый 429 замолчит
адреса до ручной чистки.
"""
sem = asyncio.Semaphore(_GEOCODE_CONCURRENCY)
async def one(key: str, address: str, slug: str | None) -> tuple[str, GeoPoint | None]:
async with sem:
found = await dadata.suggest_addresses(
address, limit=10, regions=_dadata_regions_for(slug)
)
return key, _point_from_suggestions(found)
done = await asyncio.gather(*(one(k, a, sl) for k, a, sl in items))
return dict(done)
def _resolve_points(
db: Session,
rows: list,
budget: GeocodeBudget,
*,
dry_run: bool,
) -> dict[str, GeoPoint | None]:
"""Точки для всех адресов страницы: сначала кэш, остаток — у DaData.
Ключ дедуплицируется в пределах страницы: одна и та же связка слаг+адрес
(несколько квартир в одном доме обычное дело, 50 335 карточек на 21 570
адресов) стоит ОДИН внешний вызов.
"""
wanted: dict[str, tuple[str, str | None]] = {}
for row in rows:
payload = row["payload"] or {}
address = payload.get("address")
if not address or is_artifact_address(address):
continue
slug = avito_city_slug(payload)
wanted.setdefault(geo_cache_key(address, slug), (address, slug))
if not wanted:
return {}
points: dict[str, GeoPoint | None] = {}
# Кэш создаётся только боевым прогоном (`geocode and not dry_run`), поэтому на
# первом `--dry-run` таблицы ещё нет и SELECT по ней роняет весь замер — то
# есть ломается ровно та репетиция, ради которой сухой прогон и существует.
# Проверяем наличие отношения, а не ловим исключение: в Postgres упавший
# оператор кладёт транзакцию целиком, и except потребовал бы rollback.
cache_exists = db.execute(text(_GEO_CACHE_EXISTS)).scalar() is not None
cached = (
db.execute(text(_GEO_CACHE_SELECT), {"keys": list(wanted)}).mappings().all()
if cache_exists
else ()
)
for row in cached:
points[row["cache_key"]] = (
None
if row["lat"] is None
else GeoPoint(
lat=row["lat"],
lon=row["lon"],
region_code=row["region_code"],
full_address=row["full_address"] or "",
)
)
misses = [(key, *wanted[key]) for key in wanted if key not in points]
if not misses:
return points
allowed = budget.take(len(misses))
if allowed == 0:
return points
fresh = asyncio.run(_geocode_many(misses[:allowed]))
points.update(fresh)
if dry_run:
return points # замер не пишет даже кэш — прогон остаётся повторяемым
for key, point in fresh.items():
address, slug = wanted[key]
db.execute(
text(_GEO_CACHE_UPSERT),
{
"key": key,
"slug": slug,
"address": address,
"lat": None if point is None else point.lat,
"lon": None if point is None else point.lon,
"region": None if point is None else point.region_code,
"full": None if point is None else point.full_address,
},
)
return points
@dataclass
class ImportCounters:
"""Разбор прогона. Числа обязаны сходиться, см. `check()`."""
read: int = 0
skipped_artifact: int = 0
# Карточка сама говорит про другой регион (префикс округа / поддомен
# Циана / компонент адреса не совпал с целевым `--region`).
skipped_not_target_region: int = 0
skipped_invalid: int = 0
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой
# регион — адрес разрешён, дом реальный, просто регион в ответе DaData не
# совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился
# бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а
# не «в целевом регионе стало меньше домов».
skipped_geo_other_region: int = 0
skipped_ungeocoded: int = 0
selected: int = 0
inserted: int = 0
updated: int = 0
geocode_calls: int = 0 # фактически потраченных внешних вызовов
geocode_budget_exhausted: bool = False
@property
def written(self) -> int:
return self.inserted + self.updated
@property
def writer_skipped(self) -> int:
"""Отобрано, но писатель строку не тронул.
`save_listings` возвращает только (inserted, updated); неизменные строки,
уже виденные сегодня, он пропускает своим гейтом (#2992). Остаток честно
показываем отдельно, а не растворяем в «записано».
"""
return self.selected - self.written
def check(self) -> bool:
return (
self.read
== self.selected
+ self.skipped_artifact
+ self.skipped_not_target_region
+ self.skipped_geo_other_region
+ self.skipped_ungeocoded
+ self.skipped_invalid
)
def is_artifact_address(address: str | None) -> bool:
"""Адрес чужого города в московском корпусе (артефакт парсера)."""
return bool(address) and _ARTIFACT_RE.search(address) is not None
def is_moscow_address(address: str | None) -> bool:
"""Москва опознаётся префиксом административного округа."""
if not address:
return False
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
def cian_subdomain(payload: dict) -> str | None:
"""Поддомен `source_url` Циана: `https://<sub>.cian.ru/...` → `<sub>`.
Адрес карточки регион 50 не выдаёт вовсе (см. докстринг модуля), поэтому
область читается из URL. Поддомен и префикс округа не противоречат друг
другу ни в одной карточке (замер по `msk_raw.cian_latest`, 12.09.2026):
`www` 38 030 карточек (36 569 с префиксом округа, это Москва); все
прочие поддомены (krasnogorsk, balashikha, vidnoye, lyubertsy, zvenigorod,
khimki, mytishchi, podolsk, odintsovo, ) 0 карточек с префиксом округа.
Хост не `*.cian.ru` или `source_url` отсутствует None (консервативно:
региону не сопоставляем).
"""
url = payload.get("source_url")
if not isinstance(url, str) or not url:
return None
# `hostname`, а не `netloc`: он уже без порта и userinfo и в нижнем
# регистре — иначе гипотетический `www.cian.ru:443` промахнулся бы мимо
# суффикса и уехал в «не целевой регион».
host = urlsplit(url).hostname or ""
if not host.endswith(".cian.ru"):
return None
sub = host[: -len(".cian.ru")]
return sub or None
def is_cian_oblast_payload(payload: dict) -> bool:
"""Регион 50 у Циана: любой поддомен, кроме `www` (см. `cian_subdomain`)."""
sub = cian_subdomain(payload)
return sub is not None and sub != "www"
def is_moscow_yandex_address(address: str | None) -> bool:
"""У Яндекса регион — второй компонент полного адреса.
Адрес приходит нормализованным и с городом: «Россия, Москва, Коробейников
переулок, 1». Замер по 21 393 карточкам первого прохода: во втором
компоненте ровно ДВА значения «Москва» 10 610 и «Московская область»
10 783, третьего не встречается. Поэтому ни префикса округа (как у Циана),
ни внешнего геокода (как у Авито) источнику не нужно: разделение 77 и 50
читается из самой карточки и стоит ноль вызовов.
Новая Москва отдельным значением НЕ приходит Троицк и Зеленоград Яндекс
кладёт под «Москва», что совпадает с кодом региона 77.
"""
parts = [part.strip() for part in (address or "").split(",")]
return len(parts) > 1 and parts[1] == "Москва"
def is_oblast_yandex_address(address: str | None) -> bool:
"""Регион 50 у Яндекса: второй компонент адреса — «Московская область».
Симметрично `is_moscow_yandex_address`: во втором компоненте встречаются
ровно два значения (см. докстринг модуля), третьего нет среди карточек
Яндекса «не Москва» и означает «область».
"""
parts = [part.strip() for part in (address or "").split(",")]
return len(parts) > 1 and parts[1] == "Московская область"
def is_moscow_domclick_address(address: str | None) -> bool:
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
Источник собран запросом с GUID-ом Москвы в параметре address и дополнительно
отфильтрован по bbox на стороне сборщика, так что областных карточек в сырье
и не должно быть. Фильтр здесь вторая линия: сменится GUID в сборщике или
появится второй город в той же таблице импорт не потащит его в Москву
молча. Замер по 5 024 карточкам первого прохода: первый компонент имеет ровно
одно значение, «Москва», областных нет ни одной.
Новая Москва отдельным значением НЕ приходит: «Москва, x. Ильичевка, »,
«Москва, пос. Птичное, » посёлок стоит ВТОРЫМ компонентом, первый всегда
город, что совпадает с кодом региона 77.
"""
parts = [part.strip() for part in (address or "").split(",")]
return bool(parts) and parts[0] == "Москва"
def is_oblast_domclick_address(address: str | None) -> bool:
"""Регион 50 у ДомКлика: первый компонент адреса — «Московская область».
Симметрично `is_moscow_domclick_address`. Таблица `msk_raw.domclick_cards`
копит ДВА разных прогона сборщика (московский батч
`msk-serp-domclick-20260912`, областной `mo-serp-domclick-20260912`) без
своей колонки региона, а вью `msk_raw.domclick_latest` отдаёт обе партии
вперемешку фильтр по адресу обязателен, не опционален. Замер живьём на
API ДомКлика 12.09.2026: «Московская область, Химки, 7-й м-н, проспект
Мельникова, 33», «Московская область, Одинцовский городской округ,
Звенигород, 3-й м-н, 28» и подобные первый компонент «Московская
область» у всех 140 карточек выборки с семи разных смещений выдачи.
"""
parts = [part.strip() for part in (address or "").split(",")]
return bool(parts) and parts[0] == "Московская область"
def _payload_point(payload: dict) -> tuple[float, float] | None:
"""(lat, lon) из сырья ДомКлика, если сборщик их положил и они читаются."""
try:
return float(payload["lat"]), float(payload["lon"])
except (KeyError, TypeError, ValueError):
return None
def is_oblast_domclick_payload(payload: dict) -> bool:
"""Область у ДомКлика: префикс адреса ИЛИ координата внутри bbox области.
Одного префикса мало. Замер на собранном корпусе 12.09.2026 (2 961 карточка
областного батча): «Московская область» стоит первым компонентом у 2 960, а
у одной «Можайский муниципальный округ, д. Семёновское, 1», 55.5116/35.8293.
Это настоящая область (Можайск), и строгий префикс выбросил бы её молча.
Московский батч тем же замером даёт «Москва» первым компонентом у ВСЕХ
22 836 карточек, поэтому явный отказ Москве идёт раньше гео-ветки и bbox
Москвы (вложенный в областной) не может протащить столичную карточку в 50.
"""
address = payload.get("address")
if is_oblast_domclick_address(address):
return True
if is_moscow_domclick_address(address):
return False
point = _payload_point(payload)
if point is None:
return False
return is_within_bbox(point[0], point[1], REGIONS[OBLAST_REGION_CODE].bbox_region)
def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]:
"""Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра)."""
return lambda payload: fn(payload.get("address"))
# Реестр (source, целевой регион) → фильтр по ВСЕМУ payload'у, не только
# адресу: у Циана признак региона 50 лежит в `source_url`, адрес про него
# молчит. Ключа нет только для источника, который вообще не умеет отличать
# регион без пред-геокода (avito, для ЛЮБОГО региона) — для всех прочих пар
# фильтр обязан быть в реестре явно.
REGION_FILTERS: dict[tuple[str, int], Callable[[dict], bool]] = {
("cian", MOSCOW_REGION_CODE): _by_address(is_moscow_address),
("cian", OBLAST_REGION_CODE): is_cian_oblast_payload,
("yandex", MOSCOW_REGION_CODE): _by_address(is_moscow_yandex_address),
("yandex", OBLAST_REGION_CODE): _by_address(is_oblast_yandex_address),
("domclick", MOSCOW_REGION_CODE): _by_address(is_moscow_domclick_address),
("domclick", OBLAST_REGION_CODE): is_oblast_domclick_payload,
}
def recompute_listing_segment(payload: dict) -> str:
"""Заново считаем сегмент: 'novostroyki' только при продаже застройщиком.
Обоснование в докстринге модуля: метка кита означает лишь ссылку на ЖК.
"""
raw = payload.get("raw_payload") or {}
if not isinstance(raw, dict):
return "vtorichka"
return "novostroyki" if raw.get("is_from_developer") is True else "vtorichka"
def build_lot(payload: dict) -> ScrapedLot:
"""`payload` → `ScrapedLot` с пересчитанным сегментом.
Ключи, которых в модели нет, отбрасываем явно (по `model_fields`), а не
полагаемся на настройку extra у pydantic-модели.
"""
known = {k: v for k, v in payload.items() if k in ScrapedLot.model_fields}
known["listing_segment"] = recompute_listing_segment(payload)
return ScrapedLot(**known)
def _iter_pages(db: Session, view: str, *, batch_size: int, limit: int | None):
"""Keyset-пагинация по `id` — весь корпус в память не тянем."""
after = 0
taken = 0
sql = text(_PAGE_SQL.format(view=view))
while True:
page_size = batch_size
if limit is not None:
page_size = min(batch_size, limit - taken)
if page_size <= 0:
return
rows = db.execute(sql, {"after": after, "limit": page_size}).mappings().all()
if not rows:
return
after = rows[-1]["id"]
taken += len(rows)
yield rows
def import_msk_raw(
db: Session,
*,
source: str = "cian",
region: int = MOSCOW_REGION_CODE,
batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None,
dry_run: bool = False,
allow_unfiltered: bool = False,
geocode: bool = False,
geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
) -> ImportCounters:
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
if region not in SUPPORTED_REGIONS:
raise SystemExit(f"region={region}: регион не поддержан, доступны {SUPPORTED_REGIONS}")
view = SOURCE_VIEWS[source]
counters = ImportCounters()
matcher = RealMatcherAdapter()
budget = GeocodeBudget(remaining=max(0, geocode_limit))
# Источник, который сам говорит про целевой регион: у Циана это префикс
# округа/поддомен, у Яндекса — второй компонент адреса, у ДомКлика —
# первый. Авито не говорит ничего ни для какого региона, ему нужен
# пред-геокод, поэтому в реестре его нет вовсе.
city_filter = REGION_FILTERS.get((source, region))
if geocode and city_filter is not None:
# Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту
# незачем.
raise SystemExit(f"source={source}: --geocode нужен только для avito")
if city_filter is None and not geocode:
# Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от
# прочих: ни города в адресе, ни координат. Пишем только по явному
# разрешению.
if not (dry_run or allow_unfiltered):
raise SystemExit(
f"source={source}: адрес не содержит признака города, регион "
f"{region} от прочих не отличить. Нужен --geocode (штатный путь), "
"--allow-unfiltered (аварийный) или --dry-run."
)
logger.warning(
"source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет "
"вовсе; строки лягут без geom и вперемешку с прочими регионами",
source,
region,
)
if geocode and not dry_run:
db.execute(text(_GEO_CACHE_DDL))
db.commit()
for rows in _iter_pages(db, view, batch_size=batch_size, limit=limit):
points = _resolve_points(db, rows, budget, dry_run=dry_run) if geocode else {}
lots: list[ScrapedLot] = []
for row in rows:
counters.read += 1
payload = row["payload"] or {}
address = payload.get("address")
if is_artifact_address(address):
counters.skipped_artifact += 1
continue
if city_filter is not None and not city_filter(payload):
counters.skipped_not_target_region += 1
continue
if geocode:
point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
if point is None:
counters.skipped_ungeocoded += 1
continue
if point.region_code != region:
counters.skipped_geo_other_region += 1
continue
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
# трогаем, пересбор корпуса от этого не зависит. geom появляется
# сразу — карточка идёт в radius-подбор аналогов, не дожидаясь
# ночного `geocode_missing`.
payload = {
**payload,
"lat": point.lat,
"lon": point.lon,
"geo_precision": "house",
}
try:
lots.append(build_lot(payload))
except ValidationError as exc:
counters.skipped_invalid += 1
logger.warning("msk_raw id=%s не собрался в ScrapedLot: %s", row["id"], exc)
counters.selected += len(lots)
if dry_run or not lots:
continue
# У региона 50 своего единого города нет (`canonical_city is None` в
# реестре regions) — пишем city=None, `save_listings` его COALESCE'ит
# и существующее значение не затирает. Подбор аналогов не страдает:
# он радиусный (ST_DWithin), а не по городу; ценовая полоса ДКП
# ключуется на `deals.city`, а не на `listings.city`.
city = MOSCOW_CITY if region == MOSCOW_REGION_CODE else None
inserted, updated = save_listings(
db,
lots,
matcher=matcher,
region_code=region,
city=city,
)
counters.inserted += inserted
counters.updated += updated
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
logger.info(
"msk_raw %s region=%d: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
source,
region,
counters.read,
counters.selected,
counters.written,
counters.inserted,
counters.updated,
)
counters.geocode_calls = budget.spent
counters.geocode_budget_exhausted = budget.exhausted
if budget.exhausted:
logger.warning(
"msk_raw %s: дневной бюджет геокода (%d) исчерпан — остаток корпуса "
"подхватит следующий прогон, разрешённые адреса уже в кэше",
source,
geocode_limit,
)
logger.info(
"msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d "
"геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
"геокод-вызовов=%d | сходится=%s",
source,
region,
" (dry-run)" if dry_run else "",
counters.read,
counters.selected,
counters.written,
counters.inserted,
counters.updated,
counters.writer_skipped if not dry_run else 0,
counters.skipped_not_target_region,
counters.skipped_geo_other_region,
counters.skipped_ungeocoded,
counters.skipped_artifact,
counters.skipped_invalid,
counters.geocode_calls,
counters.check(),
)
return counters
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = argparse.ArgumentParser(
description="Импорт сырья msk_raw в listings (регион задаётся --region, по умолчанию 77)"
)
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
parser.add_argument(
"--region",
type=int,
choices=SUPPORTED_REGIONS,
default=MOSCOW_REGION_CODE,
help=f"целевой регион: {MOSCOW_REGION_CODE} — Москва, {OBLAST_REGION_CODE} — область",
)
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
parser.add_argument(
"--allow-unfiltered",
action="store_true",
help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom",
)
parser.add_argument(
"--geocode",
action="store_true",
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
"в listings уходит только целевой регион (--region), прочее пропускается",
)
parser.add_argument(
"--geocode-limit",
type=int,
default=DEFAULT_GEOCODE_LIMIT,
help=f"потолок внешних вызовов за прогон (по умолчанию {DEFAULT_GEOCODE_LIMIT}; "
"free tier DaData — 10 000/сутки на аккаунт, его же ест автокомплит формы)",
)
args = parser.parse_args()
db = SessionLocal()
try:
import_msk_raw(
db,
source=args.source,
region=args.region,
batch_size=args.batch_size,
limit=args.limit,
dry_run=args.dry_run,
allow_unfiltered=args.allow_unfiltered,
geocode=args.geocode,
geocode_limit=args.geocode_limit,
)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -54,50 +54,6 @@ dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблиц
т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал»
имеет смысл после 3 наблюдённых публикаций (ориентир ноябрь 2026).
РЯДОВ ТЕПЕРЬ НЕСКОЛЬКО (#3051). sber_price_index ключуется текстовой колонкой city,
и с #3051 оценщик выбирает ряд по region_code сделки: 66 → «Свердловская область»,
77 «Москва», остальное «Россия» (estimator.SBER_MONITORED_REGIONS). Монитор,
следивший ровно за свердловским рядом, пропустил бы пропажу московского а под
ним 212 937 сделок региона 77. Теперь опрашиваются ВСЕ ряды из того же кортежа.
КОМПРОМИСС, честно. Второго независимого вердикта тут нет и быть не может: stale
считается по такту ЗАГРУЗКИ (sber_index_pull тянет все 3 табло × 3 региона одним
прогоном, errors счётчик по всему прогону), поэтому для всех рядов он ОДИН И ТОТ ЖЕ
по построению. Многорядность ловит другое ПРОПАЖУ ряда. Ранний выход с mark_failed
остался РОВНО за прежним случаем: нет ряда региона по умолчанию (свердловского)
вердикт считать не из чего. Пропажа ЛЮБОГО другого ряда его больше не подавляет:
иначе переименование «Москва» «г. Москва» отключало бы мониторинг Екатеринбурга
(latest_*=0, age_days=0, alert=0 свежесть 66 не считалась вовсе), да ещё и с ложным
текстом «sber_price_index empty», хотя таблица непуста. Теперь пропажа обязательного
ряда 77 свой ERROR с ИМЕНЕМ ряда при done-прогоне; нет фолбэчного «Россия»
WARNING (по нему сегодня не считается ни одна сделка).
ЧЕМ ИМЕННО ЗДЕСЬ АЛЕРТЯТ (честно, не путать со счётчиком). Канал тревоги в проекте
ровно один и тот же у всех соседей ERROR-запись логгера, которую LoggingIntegration
(event_level=ERROR) превращает в событие GlitchTip; это и проверяется в
tests/test_alerts_become_events.py по ФАКТУ СОБЫТИЯ, а не по levelno. Счётчиков
прогона (scrape_runs.counters) не читает ни одно правило алертинга: единственный их
потребитель, стрик-алерт, смотрит на status прогона, а не на ключи counters. Поэтому
`alert_regions_missing` НАБЛЮДЕНИЕ для ретроспективы по scrape_runs (как
regions_missing и age_days_max), а НЕ канал тревоги; обещание «свой алерт по счётчику»
было неправдой и убрано. Тревога по пропавшему ряду держится на ERROR выше, и именно
это проверяется тестом через тот же харнесс событий, что у соседей.
Расхождение latest-периодов между регионами кладётся в counters
(age_days_max) и в лог как НАБЛЮДЕНИЕ, но алертом не становится: источник вправе
публиковать регионы вразнобой, а частоту таких расхождений мы не мерили заводить
порог без замера значит повторить дефект #2846 (порог внутри рабочего диапазона).
Семантика вердикта и ключи counters свердловского ряда не изменились.
Изоляция чужих рядов доведена до конца: не только пустая выборка, но и ИСКЛЮЧЕНИЕ
на запросе чужого ряда (таймаут, обрыв соединения посреди обхода) больше не уходит
во внешний except с mark_failed каждый чужой ряд опрашивается в своём try, сбой
попадает в лог и в regions_missing. Наружу поднимается только сбой на ряде региона
по умолчанию: вердикт всё равно не из чего считать. Свой try без ОТКАТА эту изоляцию
не давал: ошибка драйвера деактивирует транзакцию Session, и следующий же запрос
(за интервалом загрузки) падает с PendingRollbackError исключение не
распространялось, зато сессия оставалась испорченной, и вердикт по свердловскому ряду
терялся ровно как раньше. Поэтому в per-region except стоит db.rollback().
ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с
LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще.
@ -119,12 +75,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
from app.services.estimator import (
SBER_COEFF_DASHBOARDS,
SBER_MONITORED_REGIONS,
SBER_REQUIRED_REGIONS,
SBER_TIME_ADJUST_REGION,
)
from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION
logger = logging.getLogger(__name__)
@ -219,19 +170,16 @@ def evaluate_sber_freshness(
)
def _load_estimator_dashboard(
db: Session, city: str = SBER_TIME_ADJUST_REGION
) -> tuple[str, date] | None:
"""Табло, которое возьмёт оценщик для ряда `city`, и его latest период.
def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None:
"""Табло, которое возьмёт оценщик, и его latest период.
Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло
из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло.
#3051: `city` — имя ряда (sber_price_index.city), дефолт — свердловский, чтобы
вызов без аргумента остался прежним.
"""
for dash in SBER_COEFF_DASHBOARDS:
row = db.execute(_LATEST_PERIOD_SQL, {"city": city, "dash": dash}).first()
row = db.execute(
_LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash}
).first()
latest = row.latest if row is not None else None
if latest is not None:
return dash, latest
@ -282,177 +230,25 @@ def check_sber_freshness(
"pull_lag_days": -1,
"max_pull_lag_days": 0,
"alert": 0,
# #3051: сколько рядов из SBER_MONITORED_REGIONS не нашлось и каков худший
# возраст среди найденных (наблюдение, не критерий тревоги).
"regions_missing": 0,
"age_days_max": 0,
# Обязательный ряд не наблюдался этим прогоном: пропал из таблицы либо запрос
# по нему сорвался. В раннем выходе (нет свердловского) счётчик тоже заполнен.
# НАБЛЮДЕНИЕ, а не канал тревоги (см. «ЧЕМ ИМЕННО ЗДЕСЬ АЛЕРТЯТ» в шапке):
# counters не читает ни одно правило алертинга, тревогу поднимает ERROR-лог.
"alert_regions_missing": 0,
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
# #3051: опрашиваем ВСЕ ряды, которые способен прочитать оценщик, а не один.
# ДЕДУПЛИКАЦИЯ обязательна: SBER_MONITORED_REGIONS — кортеж ИМЁН рядов, а карта
# оценщика вправе свести два region_code на одно имя (заведём регион, чей ряд
# совпал с фолбэчной «Россией» — кортеж станет длиннее на элемент, а РАЗЛИЧНЫХ
# рядов останется столько же). Считать пропажи по длине кортежа значило бы
# залипнуть на regions_missing=1 навсегда при всех живых рядах.
monitored_regions = tuple(dict.fromkeys(SBER_MONITORED_REGIONS))
found_by_region: dict[str, tuple[str, date]] = {}
probe_failed: list[str] = [] # ряд не удалось СПРОСИТЬ (не то же, что «нет ряда»)
for region in monitored_regions:
# КАЖДЫЙ ЧУЖОЙ РЯД — В СВОЁМ try. Раньше весь обход шёл под общим except:
# таймаут или обрыв соединения на запросе московского ряда улетал наружу,
# давал mark_failed и повторный подъём — и вердикт по свердловскому ряду
# снова не считался, хотя сам ряд на месте. Это тот же дефект, что чинили
# ранним выходом по ПУСТОЙ выборке, только по ветке ИСКЛЮЧЕНИЯ.
try:
got = _load_estimator_dashboard(db, region)
except Exception:
if region == SBER_TIME_ADJUST_REGION:
# Ряд региона по умолчанию — единственный источник вердикта:
# его сбой подавлять нечего и незачем, отдаём во внешний except
# (там и откат, и mark_failed).
raise
# ОТКАТ, А НЕ ПРОСТО continue. Прошлый круг изолировал РАСПРОСТРАНЕНИЕ
# исключения, но не ПОРЧУ СЕССИИ — это разные вещи, и второго мало.
# Настоящая ошибка драйвера (таймаут инструкции, обрыв соединения)
# ДЕАКТИВИРУЕТ транзакцию Session: следующий запрос падает с
# PendingRollbackError, даже не дойдя до БД. Без отката изоляция была
# мнимой — цикл шёл дальше, но первый же запрос ЗА ИНТЕРВАЛОМ ЗАГРУЗКИ
# падал на испорченной сессии, улетал во внешний except, давал
# mark_failed и подъём: вердикт по свердловскому ряду опять не доезжал.
# Пустая выборка этого не воспроизводит вовсе — тест обязан имитировать
# именно ошибку драйвера (tests/test_sber_region_series_3051.py).
try:
db.rollback()
except Exception:
# Откат не прошёл — мертво соединение целиком, а не один запрос:
# вердикт всё равно считать не из чего, отдаём наружу.
logger.exception(
"sber freshness: откат сессии после сбоя на ряде %s не прошёл — "
"соединение непригодно, вердикт по %s не считаем",
region,
SBER_TIME_ADJUST_REGION,
)
raise
probe_failed.append(region)
logger.exception(
"sber freshness: запрос ряда СберИндекса %s сорвался — сессия "
"откачена, ряд помечен ненаблюдённым, вердикт по %s считаем дальше",
region,
SBER_TIME_ADJUST_REGION,
)
continue
if got is not None:
found_by_region[region] = got
counters["regions_missing"] = len(monitored_regions) - len(found_by_region)
# Обязательный ряд может быть не наблюдён по двум разным причинам: его нет в
# таблице (переименование в источнике) или запрос по нему сорвался. Причину
# разделяет ЛОГ; в counters она не ветвится — новых ключей не заводим, а для
# тревоги оба случая равнозначны: обязательного ряда за этот прогон нет.
missing_required = [
r for r in SBER_REQUIRED_REGIONS if r not in found_by_region and r not in probe_failed
]
# НЕ то же самое, что missing_required: сюда попадает и ряд, который не удалось
# СПРОСИТЬ. Для тревоги эти случаи равнозначны («обязательного ряда за этот
# прогон нет»), поэтому счётчик считается по required_unseen, а не по
# missing_required; причину разделяет лог (ERROR ниже vs logger.exception в цикле).
required_unseen = [r for r in SBER_REQUIRED_REGIONS if r not in found_by_region]
# ДО РАННЕГО ВЫХОДА, а не после. Раньше этот блок стоял ниже возврата, то есть
# в ветке раннего выхода не выполнялся НИКОГДА: при одновременной пропаже
# свердловского и московского рядов в мониторинг уходило сообщение только про
# свердловский, хотя под московским 212 937 сделок региона 77 и это отдельный
# дефект с отдельной починкой. Теперь про КАЖДЫЙ пропавший обязательный ряд
# сообщение уходит всегда, а ранний выход остаётся только вопросом вердикта.
#
# Почему ERROR при done-прогоне, а не mark_failed (когда свердловский на месте):
# (а) вердикт по 66 уже посчитан и обязан доехать до дашборда, а counters
# упавшего прогона там не читаются — ровно эта подмена и превращала
# пропажу Москвы в отключение мониторинга Екатеринбурга;
# (б) mark_failed виден только стрик-алерту, т.е. на третьи сутки, а ERROR
# уходит в GlitchTip тем же прогоном (#2674);
# (в) отдельный ключ counters не перегружает `alert`, который значит «загрузка
# отстала»: это другой дефект, чинится в другом месте (имя ряда).
# Ряд региона ПО УМОЛЧАНИЮ разбирается отдельной веткой ниже (ранний выход),
# и у неё свой ERROR. Без этого условия одновременная пропажа обоих рядов
# давала ДВА события об одном факте — лишняя issue в GlitchTip, не сигнал.
default_found = found_by_region.get(SBER_TIME_ADJUST_REGION)
if missing_required and default_found is not None:
logger.error(
"sber freshness: пропал обязательный ряд СберИндекса %s%s. "
"По этим регионам есть сделки, а time-поправку взять неоткуда: "
"проверь имя ряда в источнике (переименование city) и карту "
"estimator._SBER_REGION_SERIES",
missing_required,
# «Таблица НЕ пуста» — утверждение о факте, поэтому только когда хоть
# один ряд действительно прочитан: прежний безусловный текст врал.
f"таблица НЕ пуста, остальные ряды на месте ({sorted(found_by_region)})"
if found_by_region
else "ни одного ряда прочитать не удалось",
)
missing_optional = [
r
for r in monitored_regions
if r not in found_by_region and r not in SBER_REQUIRED_REGIONS and r not in probe_failed
]
if missing_optional:
# WARNING (не ERROR): это фолбэчный ряд для региона вне карты оценщика —
# сегодня по нему не считается ни одна сделка, ронять монитор незачем.
logger.warning(
"sber freshness: нет фолбэчной серии %s — регион вне "
"estimator._SBER_REGION_SERIES останется без time-поправки",
missing_optional,
)
# Ранний выход — ТОЛЬКО ради того случая, ради которого он и заводился:
# у оценщика нет серии по региону ПО УМОЛЧАНИЮ, считать вердикт не из чего.
# Пропажа любого другого ряда его больше не подавляет (см. шапку).
if default_found is None:
found = _load_estimator_dashboard(db)
if found is None:
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
# Единственное событие этой ветки: называет и ряд по умолчанию, и все
# прочие пропавшие обязательные ряды — блок missing_required выше
# здесь намеренно молчит, чтобы не дублировать issue.
logger.error(
"sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк "
"для region=%s (вторичка); оценить нечего. Пропавшие обязательные "
"ряды целиком: %s",
"для region=%s (вторичка); оценить нечего",
list(SBER_COEFF_DASHBOARDS),
SBER_TIME_ADJUST_REGION,
missing_required or [SBER_TIME_ADJUST_REGION],
)
# Новые счётчики заполняем и ЗДЕСЬ. Нули по ним делали ранний выход слепым:
# одновременная пропажа свердловского и московского рядов выглядела ровно
# как пропажа одного свердловского (alert_regions_missing=0, age_days_max=0),
# хотя второй дефект — отдельный и по нему 212 937 сделок региона 77.
counters["alert_regions_missing"] = int(bool(required_unseen))
counters["age_days_max"] = max(
((now.date() - d).days for _, d in found_by_region.values()), default=0
)
runs_mod.mark_failed(
db,
run_id,
# Текст называет КОНКРЕТНЫЙ ряд: «таблица пуста» было ложью — в ней
# могут лежать все остальные регионы.
f"sber_price_index: нет серии '{SBER_TIME_ADJUST_REGION}' "
f"в табло {list(SBER_COEFF_DASHBOARDS)}",
counters,
)
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
return counters
# Вердикт — по свердловскому ряду, как и до #3051 (см. КОМПРОМИСС в шапке:
# такт загрузки общий для всех рядов, второго независимого вердикта нет).
dashboard, latest = default_found
dashboard, latest = found
last_pull_row = db.execute(
_LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}
).first()
@ -471,23 +267,8 @@ def check_sber_freshness(
"pull_lag_days": verdict.pull_lag_days,
"max_pull_lag_days": verdict.max_pull_lag_days,
"alert": int(verdict.stale),
"regions_missing": counters["regions_missing"],
"age_days_max": max((now.date() - d).days for _, d in found_by_region.values()),
"alert_regions_missing": int(bool(required_unseen)),
}
# #3051: наблюдение по всем рядам — расхождение latest между регионами видно
# в логе, но алертом не становится (порог без замера = дефект #2846).
logger.info(
"sber freshness: ряды оценщика — %s",
"; ".join(
f"{r}: {found_by_region[r][1]} ({found_by_region[r][0]})"
if r in found_by_region
else (f"{r}: СБОЙ ЗАПРОСА" if r in probe_failed else f"{r}: НЕТ")
for r in monitored_regions
),
)
if verdict.stale:
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR).
logger.error(

View file

@ -427,7 +427,7 @@ async def run_yandex_detail_backfill(
)
if consecutive_blocks >= max_consecutive_blocks:
aborted_by_blocks = True
logger.warning(
logger.error(
"yandex_detail_backfill: run_id=%d ABORT -- %d consecutive "
"non-200 responses. enriched=%d attempted=%d",
run_id,

View file

@ -354,7 +354,7 @@ async def enrich_yandex_newbuilding_sweep(
if caused_by_no_proxy(exc):
result.no_proxy_stop = True
result.failed_resolve += 1
logger.warning(
logger.error(
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
"house_id=%s processed=%d succeeded=%d",
house_id,
@ -419,7 +419,7 @@ async def enrich_yandex_newbuilding_sweep(
if caused_by_no_proxy(exc): # #3197 — см. блок resolve выше
result.no_proxy_stop = True
result.failed_fetch += 1
logger.warning(
logger.error(
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
"house_id=%s processed=%d succeeded=%d",
house_id,

View file

@ -26,12 +26,13 @@ import logging
import os
import signal
from contextlib import suppress
from typing import Any
from app.core.config import settings
from app.core.db import SessionLocal
from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown
from app.services.tgbot.bridge import run_poll_loop
from app.services.tgbot.client import TelegramClient, verify_chat_and_topic
from app.services.tgbot.client import TelegramClient
logging.basicConfig(
level=logging.INFO,
@ -56,20 +57,18 @@ if settings.glitchtip_dsn:
import sentry_sdk
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.types import Event, Hint
from app.observability.sentry_scrub import (
drop_payments_disabled_event,
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_pii_event,
)
def _before_send(event: Event, hint: Hint) -> Event | None:
"""Композиция payments-disabled drop (#3471) + платёжный body-wipe (PR-D2) +
PII-scrub (form-данные) + Telegram bot-токен redaction (#tgsupport review).
Токен утекает ДВУМЯ независимыми векторами, которые
`include_local_variables=False` ниже и этот хук закрывают вместе:
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
"""Композиция платёжный body-wipe (PR-D2) + PII-scrub (form-данные) +
Telegram bot-токен redaction (#tgsupport review). Токен утекает ДВУМЯ
независимыми векторами, которые `include_local_variables=False` ниже и
этот хук закрывают вместе:
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
locals (`self._base`/`url` в `TelegramClient._request`) в traceback
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
@ -79,22 +78,18 @@ if settings.glitchtip_dsn:
belt-and-suspenders на случай #1 (если include_local_variables
случайно вернут) И на span data.
Payments-disabled drop и платёжный body-wipe belt-and-suspenders: этот
процесс не держит ASGI-приложения (нет `request`/HTTPException в event
сегодня, реальный источник 503 app/main.py), но тот же обработчик
передан ОБОИМ каналам ниже (before_send/before_send_transaction) ради
единообразия со всеми точками инициализации sentry_sdk в проекте.
Платёжный body-wipe belt-and-suspenders: этот процесс не держит ASGI-
приложения (нет `request` в event сегодня), но тот же обработчик передан
ОБОИМ каналам ниже (before_send/before_send_transaction) ради единообразия
со всеми точками инициализации sentry_sdk в проекте (см. app/main.py).
"""
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
if dropped is None:
return None
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
scrubbed = scrub_payment_request_body(event, hint)
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
scrubbed = scrub_pii_event(scrubbed, hint)
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
return redact_telegram_bot_token(scrubbed, hint)
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
@ -119,34 +114,8 @@ def _should_run() -> bool:
async def _run_bridge() -> None:
# `async with` — чтобы пул keep-alive соединений закрывался при любом выходе
# из поллинга (кооперативный drain по SIGTERM, hard-cancel, исключение).
# Клиент один на весь процесс: пересоздание на запрос убивало keep-alive и
# заставляло каждый long-poll начинаться с TCP+TLS-хендшейка.
async with TelegramClient(
settings.telegram_bot_token,
relay_base_url=settings.telegram_relay_base_url,
relay_secret=settings.telegram_relay_secret,
# Бот-роль (review H2, #3471) — своя, меньшая доля общего бюджета
# группы; см. докстринг настройки в app.core.config.
group_rate_limit_per_minute=settings.telegram_group_rate_limit_bot_per_minute,
) as client:
# Startup-проверка (#3471): убеждаемся ОДИН раз, что чат/тема живы,
# прежде чем уходить в бесконечный poll loop. Не блокирует и не роняет
# запуск при неудаче — см. докстринг `verify_chat_and_topic`.
await verify_chat_and_topic(
client,
chat_id=settings.telegram_support_chat_id,
topic_id=settings.telegram_support_topic_id,
label="support",
)
await verify_chat_and_topic(
client,
chat_id=settings.telegram_alerts_chat_id,
topic_id=settings.telegram_alerts_topic_id,
label="alerts",
)
await run_poll_loop(client, SessionLocal)
client = TelegramClient(settings.telegram_bot_token)
await run_poll_loop(client, SessionLocal)
async def _await_bridge(task: asyncio.Task[None]) -> None:

View file

@ -1,67 +0,0 @@
-- 288_deals_doc_type.sql
-- deals.doc_type — тип документа сделки Росреестра (#3051 п.3, п.6).
--
-- Dependencies: 002_core_tables.sql (deals), 015_scrape_runs.sql + 072_scrape_schedules_seed_cian_rosreestr.sql
-- (scrape_schedules, строка source='rosreestr_dkp_import')
-- Apply after: 287_proxy_run_attribution.sql
--
-- ЧАСТЬ A — WHY:
-- Импорт Росреестра до сих пор ронял тип документа на пол: фильтр doc_type = 'ДКП'
-- стоял литералом в WHERE, а в deals не приезжало ничего. Пока скоуп был один
-- (Свердловская обл., только вторичка) это было безобидно — все строки источника
-- 'rosreestr' по построению ДКП. С расширением на Москву безобидность кончается:
-- в источнике за 2024 по region_code=77 лежит 30 627 ДДУ с медианой 112 743 ₽/м²
-- против 107 005 ДКП с медианой 256 250 ₽/м² — это цены котлована, и смешать их
-- в одной таблице без различимого признака значит развалить любую оценку.
-- Колонка нужна ДО того, как импорт начнёт тянуть больше одного типа.
--
-- ЧАСТЬ A — WHAT:
-- doc_type text NULLable — источник (foreign table gendesign_rosreestr_deals.doc_type)
-- тоже text и NULL допускает; строгий NOT NULL сломал бы не-росреестровые источники
-- (etazhi / domklik_history), у которых понятия «тип документа» нет вовсе.
-- Индекс НЕ добавляем: селективность низкая (2-3 значения), а все живые выборки
-- по deals идут по region_code/deal_date/geom — doc_type там в лучшем случае
-- довесок к уже отобранному диапазону. Появится запрос, который реально режет
-- по doc_type на большом наборе — заведём частичный индекс тогда, по EXPLAIN.
--
-- ЧАСТЬ B — бэкфилл:
-- Всё, что лежит в deals с source='rosreestr', прошло через WHERE doc_type = 'ДКП'
-- (и в scheduler.import_rosreestr_dkp, и в deploy/import-rosreestr.sh, и в
-- backfill-миграции 077) — других типов там физически быть не может. Поэтому
-- проставить 'ДКП' задним числом корректно, а не эвристика.
-- Остальные источники остаются NULL осознанно.
--
-- ЧАСТЬ C — расписание:
-- scrape_schedules.default_params для 'rosreestr_dkp_import' получает явный
-- region_code=66. Раньше регион был неявным дефолтом в коде; после параметризации
-- (params.get('region_code', 66)) неявность становится ловушкой — прод-строка должна
-- сама говорить, какой регион она тянет. doc_types в default_params НЕ пишем:
-- дефолт ['ДКП'] в коде и есть текущее поведение, а запись его в расписание
-- создала бы второе место, где надо не забыть поменять.
--
-- Идемпотентна, безопасна к повторному запуску.
BEGIN;
-- #2752: блокирующий DDL не должен вставать в очередь за чужой сессией и уводить
-- за собой запросы приложения — лучше упасть по таймауту и повторить деплой.
SET LOCAL lock_timeout = '5s';
-- A: колонка
ALTER TABLE deals ADD COLUMN IF NOT EXISTS doc_type text;
COMMENT ON COLUMN deals.doc_type IS
'Тип документа сделки Росреестра (ДКП / ДДУ). NULL для источников без этого понятия.';
-- B: бэкфилл — все rosreestr-строки прошли фильтр ДКП на импорте
UPDATE deals
SET doc_type = 'ДКП'
WHERE source = 'rosreestr'
AND doc_type IS NULL;
-- C: явный регион в расписании импорта вместо неявного дефолта в коде
UPDATE scrape_schedules
SET default_params = default_params || '{"region_code": 66}'::jsonb
WHERE source = 'rosreestr_dkp_import';
COMMIT;

View file

@ -1,60 +0,0 @@
-- 289_rosreestr_fdw_msk_columns_seed77.sql
-- gendesign_rosreestr_deals: колонки okato/quarter_cad_number/district + disabled
-- seed-строка scrape_schedules для региона 77 (Москва) — #3051 п.3.
--
-- Dependencies: 072_scrape_schedules_seed_cian_rosreestr.sql (gendesign_rosreestr_deals,
-- scrape_schedules).
-- Apply after: 288_deals_doc_type.sql
--
-- WHY:
-- Трек 2 подготовки Mera к Москве — импорт сделок Росреестра параметризуется по
-- региону (66 Свердловская обл. / 77 Москва, code-часть в scheduler.py). Для
-- региона С canonical_city (Москва) исходные city/okato/quarter_cad_number/district
-- уходят в deals.raw_payload (jsonb), т.к. city источника там — муниципальный
-- округ/поселение, не город, и обычный city/address его не покрывают. Foreign
-- table расширена тремя колонками источника; существование их в
-- public.rosreestr_deals на gendesign-стороне проверено live (2026-09-08, prod psql).
--
-- Seed-строка rosreestr_dkp_import_77 — ВЫКЛЮЧЕНА (enabled=false): миграция
-- только заводит расписание, включение и первый прогон по Москве — отдельное
-- решение main-сессии после ревью кода-части.
--
-- ИДЕМПОТЕНТНОСТЬ: ADD COLUMN IF NOT EXISTS × 3, ON CONFLICT (source) DO NOTHING
-- для seed — повторный прогон no-op.
BEGIN;
SET LOCAL lock_timeout = '5s';
-- ── gendesign_rosreestr_deals: колонки под региональный raw_payload (#3051) ────
-- ALTER FOREIGN TABLE ADD COLUMN — только локальные метаданные (не трогает
-- реальную remote-таблицу), безопасно как ALTER TABLE ADD COLUMN без DEFAULT.
ALTER FOREIGN TABLE gendesign_rosreestr_deals
ADD COLUMN IF NOT EXISTS okato text;
ALTER FOREIGN TABLE gendesign_rosreestr_deals
ADD COLUMN IF NOT EXISTS quarter_cad_number text;
ALTER FOREIGN TABLE gendesign_rosreestr_deals
ADD COLUMN IF NOT EXISTS district text;
-- ── Seed: rosreestr_dkp_import_77 (выключено) ──────────────────────────────────
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
default_params
)
VALUES (
'rosreestr_dkp_import_77',
false,
4,
6,
'{"region_code": 77, "since": "2024-01-01", "batch_size": 2000}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -1,100 +0,0 @@
-- 290_frt_mkd_staging.sql
-- АИС ППК «ФРТ» (бывш. Реформа ЖКХ) — реестр МКД, обогащение houses (issue #frt-mkd).
--
-- КОНТЕКСТ: houses.area_land / foundation_type отсутствуют вовсе; elevators_count в
-- источнике — ОБЩЕЕ число лифтов, а в houses раздельно passenger/cargo — отдельная
-- колонка elevators_total, НЕ маппинг на passenger_elevators (иначе задвоение/потеря
-- типа). year_built/material_walls/total_floors/entrances/is_emergency/flat_count/
-- heat_supply_type/gas_supply_type/hot_water уже существуют — этот источник их
-- ДОБИРАЕТ (COALESCE, только NULL), не переопределяет.
--
-- ИСТОЧНИК: https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/110 (node 110 =
-- реестр МКД региона 66), zip → CSV `export-reestrmkd-66-YYYYMMDD.csv`, UTF-8 BOM,
-- разделитель ';', 60 колонок, ~41.8k строк по СО. Проверено живьём 08.09.2026.
--
-- ЧТО НЕ ДЕЛАЕМ (осознанно, см. промпт задачи):
-- * project_type НЕ мапим в houses.series_name — свободный текст, дубль материала
-- стен (топ: пусто, «кирпичный», «нет данных», «панельный», разнобой регистра).
-- * energy_efficiency НЕ добавляем в houses — миграция 284 уже решила НЕ заводить
-- это поле; у ~61% домов значение «Не присвоен».
-- * playground/sportsground — id справочника (498/499/500), НЕ булев флаг; в staging
-- как есть, в houses.has_playground не пишем.
--
-- Идемпотентно: CREATE TABLE/ADD COLUMN IF NOT EXISTS, повторный прогон — no-op.
-- lock_timeout (#2752): ALTER houses берёт ACCESS EXCLUSIVE — SET LOCAL только внутри BEGIN.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS frt_mkd (
houseguid text PRIMARY KEY, -- ФИАС GUID дома (АИС ФРТ houseguid)
region_code smallint NOT NULL, -- код региона выгрузки (66 = Свердловская обл.)
address text,
built_year smallint,
exploitation_start_year smallint,
project_type text, -- сырьё; НЕ мапится в houses (см. шапку)
house_type text,
is_alarm boolean, -- «Да»/«Нет» из источника
floor_count_max smallint,
floor_count_min smallint,
entrance_count smallint,
elevators_count smallint, -- ОБЩЕЕ число лифтов (не passenger/cargo)
energy_efficiency text, -- сырьё; НЕ мапится в houses (мигр. 284)
quarters_count integer,
living_quarters_count integer,
unliving_quarters_count integer,
area_total numeric(12, 2),
area_residential numeric(12, 2),
area_non_residential numeric(12, 2),
area_common_property numeric(12, 2),
area_land numeric(12, 2),
parking_square numeric(12, 2),
playground integer, -- id справочника благоустройства, НЕ булев флаг
sportsground integer, -- id справочника благоустройства, НЕ булев флаг
other_beautification text,
foundation_type text,
floor_type text,
wall_material text,
basement_area numeric(12, 2),
chute_type text,
chute_count smallint,
heating_type text,
hot_water_type text,
cold_water_type text,
sewerage_type text,
gas_type text,
ventilation_type text,
firefighting_type text,
drainage_type text,
management_organization_id bigint,
method_of_forming_overhaul_fund text,
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE frt_mkd IS
'АИС ППК ФРТ (бывш. Реформа ЖКХ) — реестр МКД, open data export/110, region 66. '
'Ключ houseguid (ФИАС GUID). Заполняется app/services/frt_mkd_loader.py, '
'мигр. 290. Матчится к houses через COALESCE(gar_house_guid, house_fias_id, '
'zhkh_house_guid).';
ALTER TABLE houses ADD COLUMN IF NOT EXISTS area_land numeric(12, 2);
COMMENT ON COLUMN houses.area_land IS
'мигр. 290: площадь земельного участка, м2, источник АИС ППК ФРТ (frt_mkd.area_land).';
ALTER TABLE houses ADD COLUMN IF NOT EXISTS foundation_type text;
COMMENT ON COLUMN houses.foundation_type IS
'мигр. 290: тип фундамента, источник АИС ППК ФРТ (frt_mkd.foundation_type).';
ALTER TABLE houses ADD COLUMN IF NOT EXISTS elevators_total integer;
COMMENT ON COLUMN houses.elevators_total IS
'мигр. 290: ОБЩЕЕ число лифтов (без разбивки passenger/cargo), источник АИС ППК ФРТ '
'(frt_mkd.elevators_count). НЕ путать с passenger_elevators/cargo_elevators — те '
'раздельные и из других источников.';
ALTER TABLE houses ADD COLUMN IF NOT EXISTS frt_matched_at timestamptz;
COMMENT ON COLUMN houses.frt_matched_at IS
'мигр. 290: момент, когда backfill_houses() из frt_mkd_loader.py нашёл матч по '
'houseguid для этого дома (проставляется один раз, даже если полей для дозаполнения '
'уже не осталось).';
COMMIT;

View file

@ -1,37 +0,0 @@
-- 291_scrape_schedules_seed_frt_mkd_load.sql
-- Расписание для frt_mkd_load (АИС ППК ФРТ, реестр МКД region 66, issue #frt-mkd).
--
-- Источник обновляется нерегулярно (реестр МКД, не суточный поток) — interval_days=30
-- по аналогии с ДОМ.РФ капремонт (мигр. 176/... каденция месячная). node_id/region_code
-- в default_params — параметры handler'а (app/services/product_handlers.py
-- _job_frt_mkd_load), сам loader их же принимает как аргументы load_frt_mkd().
--
-- ENABLED=false — как у domrf_kapremont_load и domclick_detail_backfill: включение
-- отдельным осознанным шагом после деплоя и дымовой пробы (ZIP 3.2 МБ / CSV 41 МБ —
-- смотрим таймауты и нагрузку на первом ручном прогоне, не в фоне).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 290_frt_mkd_staging.sql.
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'frt_mkd_load',
false,
2,
5,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"node_id": 110, "region_code": 66, "interval_days": 30}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -1,87 +0,0 @@
-- 292_cbr_macro_series.sql
-- Макро-ряды ЦБ РФ: ипотека по субъектам + ключевая ставка.
--
-- Context:
-- ЦБ РФ публикует месячную статистику по ипотечным жилищным кредитам (объём выдач,
-- ставки, задолженность) в разрезе субъектов РФ — три XLSX-дашборда на cbr.ru
-- (см. app/services/cbr_macro.py::CBR_MORTGAGE_SERIES) — и дневную ключевую ставку
-- через SOAP-сервис DailyInfoWebServ. Обе серии нужны как макро-контекст для
-- продукта (региональная динамика ипотечного рынка); загрузку в estimator этот
-- PR не делает — только данные и их сбор (app/tasks/cbr_macro_pull.py).
--
-- Schema notes:
-- cbr_mortgage_series
-- * region — территория как в источнике (РФ целиком / федеральный округ /
-- субъект), без нормализации кодов ОКАТО.
-- * period_month — первое число отчётного месяца (шапка XLSX «Месяц Год»).
-- * series — slug серии (rate_rub / new_loans_rub / debt_rub, конфиг —
-- CBR_MORTGAGE_SERIES в коде).
-- * PRIMARY KEY (region, period_month, series) — одно значение на территорию×месяц×серию.
-- * fetched_at — «когда мы ВПЕРВЫЕ увидели этот период» (не время последней
-- загрузки — источник даёт всю серию целиком, отсечки по
-- периоду нет). Урок #2846 у sber_price_index: НЕ обновлять
-- fetched_at в ON CONFLICT DO UPDATE, иначе один прогон
-- перезаписывает метку у всех периодов, включая 2019-01.
-- cbr_key_rate
-- * rate_date — дата действия ключевой ставки (PRIMARY KEY).
-- * rate — значение ставки, % годовых.
-- * fetched_at — по той же логике, что и выше: не трогаем в DO UPDATE.
--
-- Idempotent: CREATE TABLE IF NOT EXISTS — безопасно переприменять. Только ADDITIVE.
--
-- Dependencies: нет (самостоятельные таблицы).
-- Deploy order: применить до app/services/cbr_macro.py + app/tasks/cbr_macro_pull.py
-- + миграции 291 (сид scrape_schedules).
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS cbr_mortgage_series (
region text NOT NULL,
period_month date NOT NULL,
series text NOT NULL,
value double precision NOT NULL,
source text NOT NULL DEFAULT 'cbr',
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (region, period_month, series)
);
COMMENT ON TABLE cbr_mortgage_series IS
'Месячные ряды ипотечной статистики ЦБ РФ по территориям (РФ / ФО / субъекты): '
'объём выдач, ставки, задолженность. Источник — XLSX-дашборды cbr.ru/vfs/statistics/'
'BankSector/Mortgage/. Загружается app/tasks/cbr_macro_pull.py через in-app '
'планировщик (scrape_schedules.source=''cbr_macro_pull'').';
COMMENT ON COLUMN cbr_mortgage_series.region IS
'Территория как в источнике (например «РОССИЙСКАЯ ФЕДЕРАЦИЯ», «ЦЕНТРАЛЬНЫЙ '
'ФЕДЕРАЛЬНЫЙ ОКРУГ», «Свердловская область»). Без нормализации ОКАТО/ISO.';
COMMENT ON COLUMN cbr_mortgage_series.period_month IS
'Первое число отчётного месяца (нормализовано из шапки «Месяц Год» XLSX).';
COMMENT ON COLUMN cbr_mortgage_series.series IS
'Slug серии, см. CBR_MORTGAGE_SERIES в app/services/cbr_macro.py '
'(rate_rub, new_loans_rub, debt_rub).';
COMMENT ON COLUMN cbr_mortgage_series.value IS
'Значение серии в единицах источника (% годовых для ставки, млн руб для объёмов).';
COMMENT ON COLUMN cbr_mortgage_series.fetched_at IS
'Когда мы ВПЕРВЫЕ увидели этот период (не время последней загрузки). '
'НЕ обновляется в ON CONFLICT DO UPDATE — см. урок #2846 у sber_price_index.';
CREATE INDEX IF NOT EXISTS idx_cbr_mortgage_series_region_series_period
ON cbr_mortgage_series (region, series, period_month DESC);
CREATE TABLE IF NOT EXISTS cbr_key_rate (
rate_date date PRIMARY KEY,
rate double precision NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE cbr_key_rate IS
'Ключевая ставка ЦБ РФ по датам действия. Источник — SOAP-метод KeyRate '
'сервиса https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx. Загружается '
'app/tasks/cbr_macro_pull.py.';
COMMENT ON COLUMN cbr_key_rate.rate_date IS 'Дата, с которой действует значение ставки.';
COMMENT ON COLUMN cbr_key_rate.rate IS 'Ключевая ставка, % годовых.';
COMMENT ON COLUMN cbr_key_rate.fetched_at IS
'Когда мы ВПЕРВЫЕ увидели эту дату. НЕ обновляется в ON CONFLICT DO UPDATE.';
COMMIT;

View file

@ -1,41 +0,0 @@
-- 293_scrape_schedules_seed_cbr_macro_pull.sql
-- Сид расписания для нового продуктового источника cbr_macro_pull (ипотека по
-- субъектам + ключевая ставка ЦБ РФ, см. 292_cbr_macro_series.sql).
--
-- Такт: interval_days=7 — источник обновляет XLSX-дашборды ипотеки раз в месяц,
-- ключевую ставку — по решениям Совета директоров (раз в 6 недель), недельный
-- такт достаточен и не создаёт нагрузки (три XLSX ~800 КБ суммарно + один SOAP-запрос).
--
-- Окно 07:0008:00 UTC — свободно на момент миграции (проверены соседние сиды:
-- 180 sber_freshness_monitor 09-10, 275 landing_stats_refresh 05-06,
-- 289 rosreestr_dkp_import_77 04-06); до утра по Екатеринбургу (UTC+5 → 12:00).
--
-- ENABLED=false — как и у прочих новых источников (домрф/sber): включение —
-- отдельный осознанный шаг после деплоя и дымовой пробы.
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 290 (таблицы данных).
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
BEGIN;
SET LOCAL lock_timeout = '5s';
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'cbr_macro_pull',
false,
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"interval_days": 7}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -1,79 +0,0 @@
-- 294_dtp_incidents.sql
-- ДТП (dtp-stat.ru, первоисточник — открытые данные ГИБДД), Свердловская обл. (#3410).
--
-- WHY: слой ДТП нужен как радиусный "фактор безопасности" района (по образцу
-- osm_poi_ekb_local / 169_osm_poi_ekb_local.sql — та же архитектура: пустая таблица +
-- индексы на деплое, наполнение отдельным рефреш-джобом, никакого чтения внешнего
-- источника при прогоне миграций).
--
-- ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip — открытый
-- агрегатор данных ГИБДД, без auth. Дамп заморожен (Last-Modified 26.02.2026 на момент
-- проверки 08.09.2026) — см. докстринг app/services/dtp_stat_loader.py, пустая дельта на
-- рефреше это НЕ баг, а факт источника.
--
-- ПДн (важно): properties.vehicles[].participants[] источника содержит пол/роль/нарушения
-- физлиц-участников ДТП. Эта таблица их СОЗНАТЕЛЬНО НЕ хранит — ни колонкой, ни в jsonb.
-- Загрузчик (dtp_stat_loader.py) участников/транспорт из парсинга просто не извлекает.
-- Берём только агрегаты (dead/injured count) и не-персональные атрибуты происшествия.
--
-- ИНДЕКСЫ (урок 270_houses_geog_gist_idx.sql / 134_listings_geom_geography_gist.sql): весь
-- код МЕРЫ ходит радиусными запросами через ST_DWithin(geom::geography, …) — обычный GIST
-- по geometry планировщик под такой предикат не берёт (прод-замер houses: 70.7 мс на
-- bitmap-фильтр всех строк; после функционального индекса по geography — 38 мс на listings).
-- Поэтому сразу оба индекса: geometry (на будущее/для прочих операторов) и функциональный
-- по (geom::geography) — под фактический предикат dtp_index.py.
--
-- Идемпотентно: CREATE TABLE/INDEX IF NOT EXISTS. Безопасно перезапускать.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS dtp_incidents (
id bigserial PRIMARY KEY,
source_id text UNIQUE,
dtp_at timestamptz,
category text,
severity text,
dead integer,
injured integer,
address text,
light text,
weather text[],
nearby text[],
lat double precision,
lon double precision,
geom geometry(Point, 4326),
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE dtp_incidents IS
'ДТП по Свердловской обл. (dtp-stat.ru, первоисточник — открытые данные ГИБДД, #3410). '
'Пишет app/tasks/dtp_stat_refresh.py (TRUNCATE+INSERT, scheduler source=dtp_stat_refresh, '
'seed 293, enabled=false — включение вручную). Читает app/services/dtp_index.py — '
'радиусные агрегаты (число ДТП/тяжёлых/погибших/раненых) для карточки района. '
'ПДн участников (vehicles/participants: пол, роль, нарушения физлиц) осознанно НЕ '
'хранятся — ни колонкой, ни в jsonb; источник даёт их построчно на транспорт/участника, '
'загрузчик их не извлекает вовсе.';
COMMENT ON COLUMN dtp_incidents.source_id IS 'properties.id из dtp-stat GeoJSON — ключ идемпотентности (UNIQUE).';
COMMENT ON COLUMN dtp_incidents.dtp_at IS 'properties.datetime, распарсено в timestamptz.';
COMMENT ON COLUMN dtp_incidents.category IS 'properties.category — тип ДТП ("Столкновение" и т.п.).';
COMMENT ON COLUMN dtp_incidents.severity IS 'properties.severity — тяжесть ("Тяжёлый"/"Лёгкий"/…).';
COMMENT ON COLUMN dtp_incidents.dead IS 'properties.dead_count — погибшие, агрегат, без ФИО/пола участников.';
COMMENT ON COLUMN dtp_incidents.injured IS 'properties.injured_count — раненые, агрегат.';
COMMENT ON COLUMN dtp_incidents.weather IS 'properties.weather — список погодных условий на момент ДТП.';
COMMENT ON COLUMN dtp_incidents.nearby IS 'properties.nearby — список объектов УДС/притяжения рядом с местом ДТП.';
COMMENT ON COLUMN dtp_incidents.geom IS 'ST_SetSRID(ST_MakePoint(lon, lat), 4326) — строится явно в загрузчике, без триггера (как osm_poi_ekb_local).';
-- GIST по geometry — на будущее / прочие geometry-операторы.
CREATE INDEX IF NOT EXISTS dtp_incidents_geom_idx
ON dtp_incidents USING GIST (geom);
-- Функциональный GIST по (geom::geography) — под фактический предикат
-- ST_DWithin(geom::geography, …) в app/services/dtp_index.py (см. WHY выше).
CREATE INDEX IF NOT EXISTS dtp_incidents_geog_idx
ON dtp_incidents USING GIST ((geom::geography));
CREATE INDEX IF NOT EXISTS dtp_incidents_dtp_at_idx
ON dtp_incidents (dtp_at);
COMMIT;

View file

@ -1,52 +0,0 @@
-- 295_scrape_schedules_seed_dtp_stat_refresh.sql
-- Scheduler seed for the ДТП local-mirror refresh job (#3410).
--
-- WHAT (source='dtp_stat_refresh'):
-- trigger_dtp_stat_refresh_run (scheduler.py) → run_dtp_stat_refresh
-- (app/tasks/dtp_stat_refresh.py) → app/services/dtp_stat_loader.py: download ZIP
-- (dtp-stat.ru), потоковый парс GeoJSON, TRUNCATE dtp_incidents; INSERT — построчно
-- строим geom из lon/lat явно (ST_SetSRID(ST_MakePoint(...))).
--
-- ПОЧЕМУ enabled=false (по образцу house_dedup_merge, seed 135): источник — открытый
-- годовой дамп, а не живой поток; на момент проверки 08.09.2026 Last-Modified
-- 26.02.2026 — дельта между рефрешами пустая по построению, пустая дельта НЕ баг
-- (см. докстринг app/services/dtp_stat_loader.py). Первый прогон — вручную/через
-- orchestrator, включение регулярного расписания — отдельное решение продукта.
--
-- interval_days=30 (default_params, НЕ отдельная колонка — compute_next_run_at читает
-- именно default_params.interval_days, ровно как в 135/212): месячный дамп двигается
-- редко, ежемесячная проверка — с запасом.
--
-- Window 11:00-12:00 UTC (14:00-15:00 МСК): после osm_poi_ekb_refresh (10:00-11:00
-- UTC, 170), не пересекает другие DB-only/сетевые джобы. Внешний HTTP на dtp-stat.ru,
-- но разовый один ZIP-запрос ~5 МБ — не конкурирует за прокси-пул (обычные площадки
-- не трогает).
--
-- next_run_at = завтра 11:00 UTC (NOT NULL — конвенция сидов; безвредно при
-- enabled=false, т.к. get_due_schedules сначала фильтрует enabled=true).
--
-- DEPENDENCIES: 052_scrape_schedules.sql (table + UNIQUE(source)), 294_dtp_incidents.sql.
-- Idempotent: ON CONFLICT (source) DO NOTHING.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'dtp_stat_refresh',
false,
11,
12,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
'{"interval_days": 30}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

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