Compare commits

..

No commits in common. "main" and "fix/3451-tvf-deals-rooms" have entirely different histories.

286 changed files with 2470 additions and 23387 deletions

View file

@ -219,34 +219,6 @@ jobs:
- '.forgejo/workflows/deploy.yml' - '.forgejo/workflows/deploy.yml'
- '.forgejo/workflows/deploy-tradein.yml' - '.forgejo/workflows/deploy-tradein.yml'
- '.forgejo/workflows/ci.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'
# #3486: гейт test_metrics_single_file_mounts.py читает и
# compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у
# экспортёра) без этой строки прошла бы мимо гейта.
- 'docker-compose.metrics-agent.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:
- 'frontend/**' - 'frontend/**'
- '.forgejo/workflows/ci.yml' - '.forgejo/workflows/ci.yml'

View file

@ -90,13 +90,8 @@ jobs:
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }} METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }} METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }} 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: 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 }} host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
@ -193,14 +188,6 @@ jobs:
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)." echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
fi 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 if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо." echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
else else
@ -263,19 +250,6 @@ jobs:
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078." echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
fi 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) ──────────────────────── # ── Цели file_sd для Prometheus (#3155) ────────────────────────
# Включатель профиля и цель для Prometheus обязаны стоять в ОДНОМ # Включатель профиля и цель для Prometheus обязаны стоять в ОДНОМ
# условии. Пока они жили порознь, вышло так: 27.08 профиль alerts # условии. Пока они жили порознь, вышло так: 27.08 профиль alerts
@ -294,27 +268,19 @@ jobs:
AM_TARGETS_FILE=ops/metrics/prometheus/alertmanager_targets.gen.yml AM_TARGETS_FILE=ops/metrics/prometheus/alertmanager_targets.gen.yml
: > "$AM_TARGETS_FILE" : > "$AM_TARGETS_FILE"
echo "# Файл рендерится деплоем (deploy-metrics.yml), правки руками затрутся." >> "$AM_TARGETS_FILE" echo "# Файл рендерится деплоем (deploy-metrics.yml), правки руками затрутся." >> "$AM_TARGETS_FILE"
# Сравнение через case, а не "=": PROFILES теперь может быть if [ "$PROFILES" = "alerts" ]; then
# комбинацией через запятую ("alerts,relay") с тех пор, как #3471
# завёл независимый профиль relay — точное равенство строке
# "alerts" сломалось бы молча в тот момент, когда оба профиля
# включены разом.
case ",$PROFILES," in
*,alerts,*)
echo '- targets: ["alertmanager:9093"]' >> "$AM_TARGETS_FILE" echo '- targets: ["alertmanager:9093"]' >> "$AM_TARGETS_FILE"
echo " labels:" >> "$AM_TARGETS_FILE" echo " labels:" >> "$AM_TARGETS_FILE"
echo " host: infra" >> "$AM_TARGETS_FILE" echo " host: infra" >> "$AM_TARGETS_FILE"
echo "Prometheus: приёмник alertmanager:9093 прописан в целях." echo "Prometheus: приёмник alertmanager:9093 прописан в целях."
;; else
*)
# Пустой список, а НЕ отсутствующий файл: одиночный бинд-маунт # Пустой список, а НЕ отсутствующий файл: одиночный бинд-маунт
# несуществующего пути docker подменяет каталогом, и Prometheus # несуществующего пути docker подменяет каталогом, и Prometheus
# не стартует вовсе. # не стартует вовсе.
echo "# Профиль alerts выключен — приёмников нет." >> "$AM_TARGETS_FILE" echo "# Профиль alerts выключен — приёмников нет." >> "$AM_TARGETS_FILE"
echo "[]" >> "$AM_TARGETS_FILE" echo "[]" >> "$AM_TARGETS_FILE"
echo "Prometheus: профиль alerts выключен — целей нет, это штатно." echo "Prometheus: профиль alerts выключен — целей нет, это штатно."
;; fi
esac
# ── read-only роль для датасорса GlitchTip ───────────────────── # ── read-only роль для датасорса GlitchTip ─────────────────────
# Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana # Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
@ -326,43 +292,6 @@ jobs:
COMPOSE_PROFILES="$PROFILES" \ COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans 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: пересоздать, если конфиг перерисовали ───────── # ── Alertmanager: пересоздать, если конфиг перерисовали ─────────
# `up -d` выше СЧИТАЕТ alertmanager неизменившимся: он сравнивает # `up -d` выше СЧИТАЕТ alertmanager неизменившимся: он сравнивает
# описание сервиса, а содержимое бинд-маунта в это сравнение не # описание сервиса, а содержимое бинд-маунта в это сравнение не
@ -388,15 +317,6 @@ jobs:
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду." echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
fi fi
# ── Loki: конфиг — бинд-маунт ОДНОГО файла (#3467) ─────────────
# Та же ловушка инода, что у Alertmanager выше, а перезагрузки
# основного конфига у Loki нет вовсе: правка loki-config.yml ложилась
# на диск и не вступала в силу до случайного пересоздания.
# Пересоздаём только при расхождении инода.
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
gendesign-metrics docker-compose.metrics.yml \
loki ops/metrics/loki/loki-config.yml /etc/loki/loki-config.yml
# ── Caddy: СНАЧАЛА проверить, потом применять ────────────────── # ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian. # На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
# Синтаксическая ошибка в infra.caddy положила бы их все, включая # Синтаксическая ошибка в infra.caddy положила бы их все, включая
@ -415,98 +335,14 @@ jobs:
fi fi
fi fi
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не # ── Приёмка ────────────────────────────────────────────────────
# перечитывает ──────────────────────────────────────────────── for i in $(seq 1 30); do
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше: if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
# docker compose сравнивает описание сервиса, а НЕ содержимое break
# бинд-маунта, поэтому уже работающий контейнер продолжает жить
# со старым конфигом сколько угодно — на проде дошло до 16 суток
# незамеченными (#3467): lastConfigTime совпадал со startTime
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
#
# /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый
# файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО
# файла: после `git reset --hard` за путём в контейнере остаётся
# СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и
# новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09).
# Поэтому при расхождении инода контейнер пересоздаётся (#3467).
# --web.enable-lifecycle включён в compose ради этого шага.
#
# promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО
# любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым
# контейнером того же образа. `docker exec` в работающий проверял
# бы тот самый старый инод, а не то, что сейчас применится.
# Синтаксически верное правило может врать по смыслу —
# `count(x == 1) == 0` от пустого вектора не срабатывает никогда, —
# отсюда test rules.
if docker run --rm --entrypoint sh \
-v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
-c 'promtool check config /etc/prometheus/prometheus.yml && promtool check rules /etc/prometheus/rules/*.yml && promtool test rules /etc/prometheus/tests/infra_test.yml'; then
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
gendesign-metrics docker-compose.metrics.yml \
prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml
# ── Приёмка ────────────────────────────────────────────────
# После возможного пересоздания выше — ждём оба процесса,
# которые дальше перечитывают конфиг по HTTP.
for i in $(seq 1 30); do
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null \
&& docker exec gendesign-grafana wget -q --spider http://localhost:3000/api/health 2>/dev/null; then
break
fi
sleep 3
done
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
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 fi
echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)." sleep 3
else done
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool (проверка конфига, правил или их юнит-тестов — смотри вывод выше) — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге." docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
exit 1
fi
# ── Grafana: датасорсы применяются только при старте (#3467) ────
# Каталог provisioning смонтирован целиком, новый datasources.yml
# контейнер видит сразу. Дашборды провайдер пересканирует сам
# (updateIntervalSeconds: 30), а датасорсы — нет: на стенде
# grafana:11.5.1 изменённый url не применился и через 75 с, POST
# на ручку ниже применил сразу (замер в PR #3475). Без этого шага
# правка датасорса лежала бы на диске без эффекта при зелёном деплое.
#
# Пароль раскрывается ВНУТРИ контейнера: в argv хоста и в лог деплоя
# он не попадает. wget отдаёт rc≠0 на 401/5xx; текст ответа сверяем
# отдельно, чтобы «200 не от той ручки» не сошло за успех.
GRAFANA_RELOAD="$(docker exec gendesign-grafana sh -c \
'wget -q -O- --post-data="" --header="Authorization: Basic $(printf "%s:%s" "${GF_SECURITY_ADMIN_USER:-admin}" "${GF_SECURITY_ADMIN_PASSWORD}" | base64 -w0)" http://localhost:3000/api/admin/provisioning/datasources/reload' \
2>&1)" || GRAFANA_RELOAD="rc=$? $GRAFANA_RELOAD"
case "$GRAFANA_RELOAD" in
*"Datasources config reloaded"*)
echo "Grafana: датасорсы перечитаны."
;;
*)
echo "ОШИБКА: Grafana не перечитала датасорсы ($GRAFANA_RELOAD) — правка provisioning/datasources осталась бы без эффекта."
exit 1
;;
esac
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════ # ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
agent-apps: agent-apps:
@ -599,21 +435,6 @@ jobs:
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \ [ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; } || { echo "::error::alloy читает старый инод конфига"; exit 1; }
# ── postgres-экспортёры: queries.yml — бинд-маунт ОДНОГО файла (#3486) ──
# PG_EXPORTER_EXTEND_QUERY_PATH читается только при старте, а
# `git reset --hard` пишет правку новым инодом: без пересоздания
# экспортёр продолжает отдавать старые запросы при зелёном деплое.
# Пересоздаём только при расхождении инода. Под гейтом профиля:
# `up` с явным именем сервиса включает его профиль сам.
if [ -n "$EXPORTER_PROFILE" ]; then
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
postgres-exporter-gendesign ops/metrics/postgres/queries.yml /etc/pg-queries.yml
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
postgres-exporter-tradein ops/metrics/postgres/queries.yml /etc/pg-queries.yml
fi
agent-infra: agent-infra:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: server needs: server
@ -683,10 +504,3 @@ jobs:
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \ [ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; } || { echo "::error::alloy читает старый инод конфига"; exit 1; }
# queries.yml экспортёра инфраструктурной БД — см. agent-apps (#3486).
if [ -n "$EXPORTER_PROFILE" ]; then
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
postgres-exporter-infra ops/metrics/postgres/queries.yml /etc/pg-queries.yml
fi

View file

@ -511,36 +511,23 @@ jobs:
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js # NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит, # инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG). # см. frontend/Dockerfile комментарий у соответствующих ARG).
# NEXT_PUBLIC_YM_ID — счётчик Яндекс.Метрики публичного контура. # NEXT_PUBLIC_YM_ID/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION —
# Живёт на корпоративном аккаунте info@meraocenka.ru, там же, где # ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и
# Вебмастер: счётчик и права на сайт на одном аккаунте связываются # мета-теги верификации поисковых консолей. Пустая строка = скрипт
# автоматически, на разных — только через письмо-подтверждение # счётчика НЕ рендерится вообще (контракт фронта, см. тот же
# владельцу счётчика. Прежний номер 112563104 был заведён на личном # Dockerfile-комментарий). Когда номера появятся — вписать
# аккаунте подрядчика и на сайт не попал ни разу, данных не потерял. # литералом сюда И в retry-блок ниже (оба обязательны, иначе
# NEXT_PUBLIC_YANDEX_VERIFICATION — токен подтверждения прав в # ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки
# Яндекс.Вебмастере, отдаётся мета-тегом (mera-public/layout.tsx). # образа (build-time bake, не runtime-правка на проде).
# Токен привязан к аккаунту: у другого аккаунта он будет свой.
# Удалять его после подтверждения НЕЛЬЗЯ — Вебмастер перепроверяет
# права и снимает их, если тег исчез.
# Литералы, а не secret'ы: оба значения и так уходят в разметку
# каждой страницы, прятать нечего, а secret'ом их не проверить
# глазами в диффе.
# NEXT_PUBLIC_GA_ID/GOOGLE_VERIFICATION — ПОКА ПУСТЫЕ: ресурс GA4 и
# права в Search Console ещё не заведены. Пустая строка =
# соответствующий тег НЕ рендерится вообще (контракт фронта, см.
# тот же Dockerfile-комментарий).
# ЛЮБОЕ из этих значений вписывать литералом СЮДА И в retry-блок
# ниже — оба обязательны, иначе ретрай без кеша уедет без счётчика.
# Требует пересборки образа: build-time bake, не runtime-правка.
build-args: | build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
NEXT_PUBLIC_YM_ID=112672927 NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_GA_ID= NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1 NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_GOOGLE_VERIFICATION= NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
@ -565,9 +552,9 @@ jobs:
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
NEXT_PUBLIC_YM_ID=112672927 NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_GA_ID= NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1 NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_GOOGLE_VERIFICATION= NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: | tags: |

View file

@ -77,7 +77,7 @@ on:
# исполнял бы старую версию — молча и без единого сигнала. # исполнял бы старую версию — молча и без единого сигнала.
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт, # Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают # запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
# backup.sh, restore-drill.sh, restore.sh — точечное # backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это # перечисление пришлось бы дополнять при каждом новом скрипте, и про это
# снова забыли бы (см. как этот самый комментарий выше был точечным про # снова забыли бы (см. как этот самый комментарий выше был точечным про
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком. # docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.
@ -121,98 +121,37 @@ jobs:
infra: ${{ steps.filter.outputs.infra }} infra: ${{ steps.filter.outputs.infra }}
# #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он # #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он
# включает и compose, и сам workflow, где полный деплой обязателен. # включает и 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
# ── #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)
id: filter id: filter
env: with:
BEFORE: ${{ github.event.before }} filters: |
EVENT: ${{ github.event_name }} backend:
run: | - 'backend/**'
set -eu - 'data/sql/**'
NULL_SHA=0000000000000000000000000000000000000000 frontend:
BASE="" - 'frontend/**'
if [ "$EVENT" = "push" ] && [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "$NULL_SHA" ]; then infra:
git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \ - 'docker-compose.prod.yml'
|| git fetch --depth=1 --no-tags origin "$BEFORE" >/dev/null 2>&1 \ - 'Caddyfile'
|| true - 'caddy/**'
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then - '.forgejo/workflows/deploy.yml'
BASE="$BEFORE" # Пара фильтров для «правка ТОЛЬКО прокси» (#2916). Одного `caddy`
else # мало: он true и когда вместе с конфигом приехал бэкенд — тогда
echo "::warning::коммит $BEFORE недоступен в клоне — деплой будет полным" # нужен обычный полный деплой. `non_caddy` матчит ВСЁ остальное,
fi # и быстрый путь включается лишь когда он false.
fi caddy:
- 'Caddyfile'
if [ -n "$BASE" ]; then - 'caddy/**'
FILES=$(git -c core.quotePath=false diff --no-renames --name-only "$BASE" HEAD) non_caddy:
N=$(printf '%s\n' "$FILES" | grep -c . || true) - '**'
echo "База: $BASE → $(git rev-parse HEAD); изменённых файлов: $N" - '!Caddyfile'
printf '%s\n' "$FILES" | sed 's/^/ /' - '!caddy/**'
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"
build-backend: build-backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -1058,17 +997,12 @@ jobs:
docker compose -p gendesign -f docker-compose.prod.yml up -d \ docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps $WORKER_SERVICES --force-recreate --no-deps $WORKER_SERVICES
# Caddy: пересоздание ТОЛЬКО когда без него правка не доедет (#3443). # Caddy: force-recreate чтобы подхватить изменения в Caddyfile
# Здесь стоял безусловный `up -d --force-recreate --no-deps caddy` — # И в особенности новые volume mounts из docker-compose.prod.yml
# то есть КАЖДЫЙ полный деплой сносил единственный процесс, слушающий # (`reload` не пересоздаёт container, поэтому новые binds не появляются —
# 80/443, и все домены хоста отдавали `code=000` (замер 05.09: 67 с). # был случай 2026-05-17 с PR #268 preview/ — потребовался manual SSH fix).
# Довод той правки (17.05, 11e78d73 — «иначе новые volume mounts не docker compose -p gendesign -f docker-compose.prod.yml up -d \
# появляются») не подтвердился: `up -d` БЕЗ флага пересоздаёт --force-recreate --no-deps caddy
# контейнер сам, как только меняется описание сервиса или образ.
# Разбор и проверки — в шапке ops/caddy-apply.sh; там же сверка
# пофайловых bind-маунтов (Caddyfile + 4 сниппета держат инод) и
# `caddy validate` до применения.
sh ops/caddy-apply.sh
# Forwarder: force-recreate чтобы новый image / новые env подхватывались. # Forwarder: force-recreate чтобы новый image / новые env подхватывались.
# Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild # Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild
@ -1295,13 +1229,14 @@ jobs:
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая # Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
# страница = новая строка allowlist'а. # страница = новая строка allowlist'а.
# #
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Опечатка в конфиге на # ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой
# пересоздании уводит контейнер в crash-loop и роняет ВСЕ домены сразу, а # осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы
# `caddy reload` её просто не принимает: job краснеет, домены продолжают # принять битый конфиг и оставил бы работать старый — на общем деплое это
# обслуживаться прежним конфигом. С #3443 ровно тот же порядок действует и на # скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ
# полном деплое — оба пути зовут ops/caddy-apply.sh, который сперва проверяет # применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой
# конфиг одноразовым контейнером и пересоздаёт Caddy, только если правка иначе # код → job краснеет, а домены продолжают обслуживаться старым конфигом.
# не доедет (пофайловый bind-маунт держит инод). # Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop
# и роняет ВСЕ домены сразу.
# #
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг — # Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
# вторая, уже против боевого файла после `git reset`. # вторая, уже против боевого файла после `git reset`.
@ -1347,73 +1282,14 @@ jobs:
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
script: | script: |
set -euo pipefail 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 cd /opt/gendesign
git fetch origin main 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 git reset --hard origin/main
# #3443: тот же скрипт, что и в полном деплое. Голый `exec caddy # Конфиг примонтирован read-only с хоста, пересборка не нужна —
# reload` здесь был ВЕРЕН только для каталогов (caddy/sites/**, # контейнер читает тот же файл, что только что обновил git.
# caddy/local/**). Caddyfile и четыре сниппета смонтированы docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
# ПОФАЙЛОВО, а `git reset --hard` выше пишет новый инод — контейнер caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
# остаётся на прежнем, и reload перечитывает СТАРЫЙ текст. Отказ echo "✓ конфиг прокси перезагружен без пересборки и без миграций"
# беззвучный: джоба зелёная, конфиг на диске новый, прокси работает
# по старому. Скрипт сверяет, что именно видит контейнер, и
# пересоздаёт его только в этом случае.
sh ops/caddy-apply.sh
echo "✓ быстрый путь завершён: без пересборки образов и без миграций"
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ────────────────── # ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
# #

View file

@ -1910,19 +1910,17 @@ def trigger_ekburg_permits(
return {"task_id": result.id, "scope": scope, "queued_at": "now"} return {"task_id": result.id, "scope": scope, "queued_at": "now"}
# Текст отказа guard'а блокировки DOM.РФ (#2443, #2445 D1). Зонды 20.08, 27.08 и # WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
# 01.09: наш.дом.рф за StormWall отдаёт серверу «Доступ заблокирован [403]», таймера # после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
# у этой блокировки нет. Beat-записи catalog-object и catalog-flat scrape'ов # и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
# выключены по той же причине (см. beat_schedule.py); оба ad-hoc эндпоинта ниже # оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
# ходят тем же /сервисы/* BrowserSession path family. Оператор решает по этому # path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
# тексту, ставить ли флаг, поэтому он называет реальное условие (#3307). _WAF_COOLDOWN_GUARD_MSG = (
_DOMRF_BLOCK_GUARD_MSG = ( "Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
"Ad-hoc catalog-scrape заблокирован guard'ом: наш.дом.рф за StormWall отдаёт " "2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
"этому серверу «Доступ заблокирован [403]» (зонды 20.08-01.09, issue #2443), " "причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
"beat schedule для этого таска выключен по той же причине. Ожидание блокировку " "принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
"не снимает, запуск отсюда даст только отказы. Передавай " "передай i_understand_waf_risk=true в теле запроса."
"i_understand_waf_risk=true, только когда сбор идёт через прокси и kn-прогон "
"принят по числу строк (#3307)."
) )
@ -1954,20 +1952,20 @@ def trigger_kn_catalog_objects(
"""Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff, """Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__). ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py). Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint для ad-hoc
Этот endpoint для ad-hoc запуска (smoke-тест или повторный pass для запуска (smoke-тест после деплоя или повторный pass для свежесозданных
свежесозданных объектов). объектов до next beat fire).
- max_objects=None дефолтный лимит таска (300). - max_objects=None дефолтный лимит таска (300).
- max_objects=3 smoke-тест. - max_objects=3 smoke-тест.
- force=True "Загрузить все": игнорирует skip-today, грузит всё подряд. - force=True "Загрузить все": игнорирует skip-today, грузит всё подряд.
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true — WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
запуск без явного подтверждения оператора запрещён (условие #3307). re-trigger без явного подтверждения оператора запрещён.
""" """
if not payload.i_understand_waf_risk: if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG) raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
@ -2024,12 +2022,12 @@ def trigger_kn_catalog_flats(
- max_flats=3 smoke-тест. - max_flats=3 smoke-тест.
- force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash. - force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true — WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
блокировкой StormWall (условие #3307). того же WAF-бана.
""" """
if not payload.i_understand_waf_risk: if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG) raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats

View file

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

View file

@ -30,15 +30,11 @@ _EXCERPT_RADIUS = 100 # символов вокруг первого кад-н
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр. # 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b") _RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон # Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП».
# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга _RE_ACT_NUMBER = re.compile(
# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не r"\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
# даёт обрезать чужой номер: из «№ 60/1» не берётся «60», из «№ 12-ПППП» — «12». re.UNICODE | re.IGNORECASE,
_ACT_NUM = r"(\d+(?:\s*[-]\s*[А-Я]{1,3})?)(?![\w/\-])" )
_RE_ACT_NUMBER = re.compile(rf"\s*{_ACT_NUM}")
# Номер того же акта, что и выбранная дата: «от DD.MM.YYYY № N» либо «№ N от DD.MM.YYYY».
_RE_ACT_NUMBER_AFTER_DATE = re.compile(rf"\s*№\s*{_ACT_NUM}")
_RE_ACT_NUMBER_BEFORE_DATE = re.compile(rf"\s*{_ACT_NUM}\s*$")
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})") _RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не # Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
@ -237,9 +233,7 @@ def extract_izyatie_records(
normalized = _normalize_ocr_text(ocr_text) normalized = _normalize_ocr_text(ocr_text)
# Реквизиты акта из заголовка или текста. # Реквизиты акта из заголовка или текста.
act_number = _extract_act_number(doc_title) or _extract_act_number( act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
normalized, require_act_context=True
)
act_date = _extract_act_date(doc_title) or _extract_act_date( act_date = _extract_act_date(doc_title) or _extract_act_date(
normalized, require_act_context=True normalized, require_act_context=True
) )
@ -295,26 +289,9 @@ def extract_izyatie_records(
# ── Вспомогательные функции ──────────────────────────────────────────────────── # ── Вспомогательные функции ────────────────────────────────────────────────────
def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None: def _extract_act_number(text: str) -> str | None:
"""Извлекает номер акта. """Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»)."""
m = _RE_ACT_NUMBER.search(text)
Без require_act_context (заголовок) первое « N» в тексте.
С require_act_context (тело документа, #2982) — номер ТОГО ЖЕ акта, чью дату
выбирает `_extract_act_date`: «» вплотную после даты, иначе вплотную перед
ней. Первое «» в теле это «Решение Думы 60/1» (Генплан) или «Приказ
Министерства 746-П», а не основание изъятия. Номера рядом с датой нет
None, а не номер соседнего документа.
"""
if not require_act_context:
m = _RE_ACT_NUMBER.search(text)
else:
date_m = _act_date_match(text, require_act_context=True)
if date_m is None:
return None
m = _RE_ACT_NUMBER_AFTER_DATE.match(text, date_m.end()) or (
_RE_ACT_NUMBER_BEFORE_DATE.search(text, max(0, date_m.start() - 30), date_m.start())
)
if not m: if not m:
return None return None
return re.sub(r"\s+", "", m.group(1)) return re.sub(r"\s+", "", m.group(1))
@ -326,17 +303,6 @@ def _act_context_matches(text: str, pos: int) -> bool:
return all(word in ctx for word in _ACT_CONTEXT_WORDS) return all(word in ctx for word in _ACT_CONTEXT_WORDS)
def _act_date_match(text: str, *, require_act_context: bool) -> re.Match[str] | None:
"""Первое «от DD.MM.YYYY» с допустимой датой (и контекстом постановления, если нужен)."""
for m in _RE_ACT_DATE.finditer(text):
if require_act_context and not _act_context_matches(text, m.start()):
continue
d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
return m
return None
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None: def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE. """Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
@ -349,10 +315,18 @@ def _extract_act_date(text: str, *, require_act_context: bool = False) -> str |
даты честнее, чем дата чужого документа по ней нельзя ни отфильтровать даты честнее, чем дата чужого документа по ней нельзя ни отфильтровать
актуальные изъятия, ни сверить срок. актуальные изъятия, ни сверить срок.
""" """
m = _act_date_match(text, require_act_context=require_act_context) for m in _RE_ACT_DATE.finditer(text):
if m is None: if require_act_context and not _act_context_matches(text, m.start()):
return None continue
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}" day, month, year = m.group(1), m.group(2), m.group(3)
try:
# Валидируем диапазоны.
d, mo, y = int(day), int(month), int(year)
except ValueError:
continue
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
return f"{y:04d}-{mo:02d}-{d:02d}"
return None
def _extract_purpose(text: str) -> str | None: def _extract_purpose(text: str) -> str | None:

View file

@ -31,13 +31,8 @@ _RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b")
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п. # Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы. # Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»),
# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий
# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает
# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ»,
# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60».
_RE_ACT_NUMBER = re.compile( _RE_ACT_NUMBER = re.compile(
r"\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])", r"\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
re.UNICODE, re.UNICODE,
) )

View file

@ -1,17 +1,12 @@
"""Загрузчик OSM POI из Overpass API для site-finder. """Загрузчик OSM POI из Overpass API для site-finder.
Запускается раз в неделю через Celery beat (регион по умолчанию ЕКБ, см. DEFAULT_REGION). Запускается раз в неделю через Celery beat. Поддерживает фильтр
Поддерживает фильтр "не старше 2 лет" (требование Максима) last_osm_edit_date. "не старше 2 лет" (требование Максима) last_osm_edit_date.
Параметризован регионом (REGION_BBOX) sync_poi_to_db(region=...) может грузить любой
зарегистрированный bbox, не только ЕКБ; большие bbox автоматически режутся на тайлы
(_bbox_tiles), чтобы не упереться в лимиты одного Overpass-запроса.
""" """
import asyncio import asyncio
import json import json
import logging import logging
import math
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import httpx import httpx
@ -24,31 +19,6 @@ logger = logging.getLogger(__name__)
OVERPASS_URL = "https://overpass-api.de/api/interpreter" OVERPASS_URL = "https://overpass-api.de/api/interpreter"
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east) EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
# Прямоугольники продуктовых ядер по региону — источник загрузки POI больше не зашит
# в одну константу (было: только ЕКБ, блок «что рядом» молчал для остальных регионов).
# Site Finder — независимая половина монорепо со своим окружением/БД и НЕ импортирует
# tradein-mvp (у того свой реестр `app/services/regions.py`), поэтому bbox для Москвы
# продублирован явно, а не через кросс-импорт. Значение — bbox_product_core региона 77
# (tradein-mvp/backend/app/services/regions.py REGIONS[77]), пересчитанное в тот же
# (south, west, north, east) порядок, что EKB_BBOX выше. "ekb" остаётся значением по
# умолчанию ВЕЗДЕ (sync_poi_to_db / fetch_overpass) — существующее weekly-расписание
# (tasks.poi_sync.sync_osm_poi_ekb) не передаёт region и не должно молча сменить город.
REGION_BBOX: dict[str, tuple[float, float, float, float]] = {
"ekb": EKB_BBOX,
"msk": (55.55, 37.30, 55.95, 37.90), # (south, west, north, east)
}
DEFAULT_REGION = "ekb"
# Максимальный размер стороны ОДНОГО Overpass-запроса в градусах. У ЕКБ обе стороны
# bbox — ровно 0.25° (проверенный на практике размер: per-category запрос укладывается
# в timeout:30 без 504). Для региона с большей стороной bbox запрос режется на грид
# тайлов такого же порядка вместо одного большого — иначе на плотном городе (Москва на
# порядок плотнее ЕКБ по числу POI) Overpass либо отдаёт 504, либо (хуже) частично
# посчитанный ответ без явной ошибки, и загрузка молча обрежется. Для ЕКБ (0.25×0.25)
# тайлинг даёт РОВНО один тайл, совпадающий с EKB_BBOX бит-в-бит — поведение дефолтного
# региона не меняется.
MAX_TILE_SIDE_DEG = 0.25
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная # Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query); # category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на # несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
@ -84,119 +54,17 @@ OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
} }
def _bbox_tiles( def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
bbox: tuple[float, float, float, float], max_side_deg: float = MAX_TILE_SIDE_DEG """Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
) -> list[tuple[float, float, float, float]]:
"""Разбить bbox на равномерный грид тайлов со стороной ≤ max_side_deg.
(south, west, north, east) список тайлов того же формата. Для bbox, у которого обе
стороны уже max_side_deg (текущий EKB_BBOX: 0.25×0.25), возвращает список ровно из
ОДНОГО тайла, идентичного входному bbox тайлинг не меняет поведение для ЕКБ.
"""
south, west, north, east = bbox
rows = max(1, math.ceil(round((north - south) / max_side_deg, 6)))
cols = max(1, math.ceil(round((east - west) / max_side_deg, 6)))
lat_step = (north - south) / rows
lon_step = (east - west) / cols
tiles = []
for r in range(rows):
for c in range(cols):
tiles.append(
(
south + r * lat_step,
west + c * lon_step,
south + (r + 1) * lat_step,
west + (c + 1) * lon_step,
)
)
return tiles
def _split_bbox_quadrants(
bbox: tuple[float, float, float, float],
) -> list[tuple[float, float, float, float]]:
"""Разбить bbox на 4 равные четверти (2×2) — используется адаптивным ретраем
_fetch_category, когда сам тайл всё равно оказался слишком тяжёлым для Overpass."""
south, west, north, east = bbox
mid_lat = (south + north) / 2
mid_lon = (west + east) / 2
return [
(south, west, mid_lat, mid_lon),
(south, mid_lon, mid_lat, east),
(mid_lat, west, north, mid_lon),
(mid_lat, mid_lon, north, east),
]
# Живой замер 2026-09-13: uniform-тайл 0.25×0.25 (размер ЕКБ) для category=bus_stop в
# Москве отдал 504 Gateway Timeout ОДНИМ тайлом (12 181 bus_stop во всём продуктовом
# ядре — уже больше, чем ВСЕ 14 категорий ЕКБ вместе, 4 850). Единый "правильный" размер
# тайла под все 14 категорий Москвы заранее не подобрать — плотность по городу сильно
# неравномерна (плотный центр / разреженная периферия), а у Overpass нет заголовка с
# "это частичный ответ" — единственный надёжный сигнал перегруза — HTTP-ошибка/таймаут.
# Поэтому вместо фиксированного маленького тайла — АДАПТИВНОЕ дробление: тайл, на
# котором per-category запрос дважды падает, дробится на 4 четверти и каждая
# перезапрашивается рекурсивно (до RECURSIVE_SPLIT_MAX_DEPTH). Для ЕКБ recursion
# НИКОГДА не срабатывает (единственный тайл исторически всегда отвечал 200) — поведение
# дефолтного региона не меняется.
RECURSIVE_SPLIT_MAX_DEPTH = 3
# Дробить тайл имеет смысл ТОЛЬКО когда сервер отказал из-за тяжести запроса:
# 504/429/503 и таймаут чтения — «я не успел посчитать», четверть посчитается.
# Отказ на уровне транспорта (connection refused / network unreachable) про размер
# запроса не говорит ВООБЩЕ: хост нас не принимает, и дробление превращает один
# отказ в 4, 16, 64 повторных стука. Живой случай 15.09.2026: загрузка Москвы
# поймала блокировку overpass-api.de по IP и за три минуты выдала 58 отказов на
# 4 успеха — ровно этот механизм.
_SPLIT_WORTHY_STATUS = frozenset({429, 503, 504})
# Подряд идущие транспортные отказы = хост нас не принимает. Продолжать прогон
# бессмысленно и вредно (углубляем блокировку), поэтому после порога — стоп всего
# прогона с явной ошибкой, а не тихий пропуск категорий.
_MAX_CONSECUTIVE_TRANSPORT_ERRORS = 5
class OverpassUnreachableError(RuntimeError):
"""Overpass отказывает на уровне соединения подряд — прогон остановлен."""
class _RunState:
"""Счётчик подряд идущих транспортных отказов в рамках одного fetch_overpass."""
__slots__ = ("consecutive_transport_errors",)
def __init__(self) -> None:
self.consecutive_transport_errors = 0
def _is_overload(exc: Exception) -> bool:
"""True, если отказ говорит «запрос слишком тяжёлый» (есть смысл дробить)."""
if isinstance(exc, httpx.TimeoutException):
return True
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in _SPLIT_WORTHY_STATUS
return False
def _is_transport_error(exc: Exception) -> bool:
"""True для отказа на уровне соединения (хост не принимает), не про размер запроса."""
return isinstance(exc, httpx.TransportError) and not isinstance(exc, httpx.TimeoutException)
def _build_overpass_query(
tag_filters: tuple[tuple[str, str], ...], bbox: tuple[float, float, float, float]
) -> str:
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND)
в ОДНОМ тайле bbox (south, west, north, east) см. _bbox_tiles.
Раньше делали один большой запрос на все 14 категорий Overpass возвращал Раньше делали один большой запрос на все 14 категорий Overpass возвращал
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт 504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
быстрые запросы вместо одного 60+ секундного. быстрые запросы вместо одного 60+ секундного.
""" """
south, west, north, east = bbox south, west, north, east = EKB_BBOX
bbox_str = f"({south},{west},{north},{east})" bbox = f"({south},{west},{north},{east})"
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters) filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
return f"[out:json][timeout:30];(node{filt}{bbox_str};way{filt}{bbox_str};);out center meta;" return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
def _classify(tags: dict[str, str]) -> str | None: def _classify(tags: dict[str, str]) -> str | None:
@ -212,117 +80,55 @@ def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
async def _fetch_category( async def _fetch_category(
client: httpx.AsyncClient, client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
tag_filters: tuple[tuple[str, str], ...],
category: str,
bbox: tuple[float, float, float, float],
state: _RunState,
depth: int = 0,
) -> list[dict]: ) -> list[dict]:
"""Один per-category Overpass-запрос (для ОДНОГО тайла bbox) с ОДНИМ повтором при """Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке.
транзиентной ошибке; если тайл падает оба раза адаптивно дробится на 4 четверти
(см. RECURSIVE_SPLIT_MAX_DEPTH) и перезапрашивается рекурсивно, вместо того чтобы
тихо потерять весь тайл.
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
сбоев без риска зациклиться (Overpass rate-limit max 2 concurrent, поэтому не более сбоев без риска зациклиться (Overpass rate-limit max 2 concurrent, поэтому не более
2 попыток на тайл ДО дробления). 2 попыток на категорию).
""" """
tag_desc = _tag_filters_desc(tag_filters) tag_desc = _tag_filters_desc(tag_filters)
query = _build_overpass_query(tag_filters, bbox) query = _build_overpass_query(tag_filters)
for attempt in (1, 2): for attempt in (1, 2):
try: try:
r = await client.post(OVERPASS_URL, data={"data": query}) r = await client.post(OVERPASS_URL, data={"data": query})
r.raise_for_status() r.raise_for_status()
elements: list[dict] = r.json().get("elements", []) elements: list[dict] = r.json().get("elements", [])
logger.info( logger.info(
"Overpass: %s (%s) bbox=%s depth=%d%d [attempt %d]", "Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt
tag_desc,
category,
bbox,
depth,
len(elements),
attempt,
) )
# Привязываем category именно к тому per-category запросу, под который # Привязываем category именно к тому per-category запросу, под который
# элемент реально пришёл. Элемент с двумя целевыми тегами (например # элемент реально пришёл. Элемент с двумя целевыми тегами (например
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия # amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
# несёт свою category. Иначе _classify по dict-порядку молча терял бы # несёт свою category. Иначе _classify по dict-порядку молча терял бы
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372. # вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
state.consecutive_transport_errors = 0
for el in elements: for el in elements:
el["_gd_category"] = category el["_gd_category"] = category
return elements return elements
except Exception as e: except Exception as e:
if _is_transport_error(e):
state.consecutive_transport_errors += 1
if state.consecutive_transport_errors >= _MAX_CONSECUTIVE_TRANSPORT_ERRORS:
raise OverpassUnreachableError(
f"Overpass отказывает на уровне соединения "
f"{state.consecutive_transport_errors} раз подряд ({e}) — прогон "
f"остановлен, чтобы не стучаться в блокирующий хост"
) from e
logger.warning(
"Overpass transport error for %s bbox=%s (подряд %d) — тайл пропущен "
"без дробления: %s",
tag_desc,
bbox,
state.consecutive_transport_errors,
e,
)
return []
state.consecutive_transport_errors = 0
if attempt == 1: if attempt == 1:
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e) logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
await asyncio.sleep(3.0) await asyncio.sleep(3.0)
continue continue
if _is_overload(e) and depth < RECURSIVE_SPLIT_MAX_DEPTH:
logger.warning(
"Overpass failed for %s bbox=%s twice — splitting into 4 quadrants "
"(depth %d%d) instead of dropping the tile: %s",
tag_desc,
bbox,
depth,
depth + 1,
e,
)
combined: list[dict] = []
for quadrant in _split_bbox_quadrants(bbox):
combined.extend(
await _fetch_category(
client, tag_filters, category, quadrant, state, depth + 1
)
)
await asyncio.sleep(1.0)
return combined
logger.warning( logger.warning(
"Overpass failed for %s bbox=%s at max split depth %d — tile skipped this run: %s", "Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e
tag_desc,
bbox,
depth,
e,
) )
return [] return []
async def fetch_overpass(region: str = DEFAULT_REGION) -> list[dict]: async def fetch_overpass() -> list[dict]:
"""Запросить Overpass API per category × per tile, вернуть combined список elements. """Запросить Overpass API per category, вернуть combined список elements.
Делаем отдельные запросы вместо одного гигантского большой запрос Делаем отдельные запросы вместо одного гигантского большой запрос
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
policy: max 2 concurrent, лучше 1 req/s). bbox региона режется на тайлы policy: max 2 concurrent, лучше 1 req/s).
MAX_TILE_SIDE_DEG (_bbox_tiles) для "ekb" это ровно один тайл (без
изменения поведения), для регионов с большим bbox (напр. "msk") несколько,
чтобы не поймать 504 или тихо обрезанный ответ на плотном городе.
Overpass блокирует default `python-httpx/*` User-Agent (406) поэтому Overpass блокирует default `python-httpx/*` User-Agent (406) поэтому
явный UA с контактом проекта. явный UA с контактом проекта.
""" """
bbox = REGION_BBOX[region]
tiles = _bbox_tiles(bbox)
state = _RunState()
headers = { headers = {
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)", "User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
"Accept": "application/json", "Accept": "application/json",
@ -330,34 +136,24 @@ async def fetch_overpass(region: str = DEFAULT_REGION) -> list[dict]:
all_elements: list[dict] = [] all_elements: list[dict] = []
async with httpx.AsyncClient(timeout=60, headers=headers) as client: async with httpx.AsyncClient(timeout=60, headers=headers) as client:
for tag_filters, category in OSM_CATEGORIES.items(): for tag_filters, category in OSM_CATEGORIES.items():
for tile in tiles: elements = await _fetch_category(client, tag_filters, category)
elements = await _fetch_category(client, tag_filters, category, tile, state) all_elements.extend(elements)
all_elements.extend(elements) await asyncio.sleep(1.0)
await asyncio.sleep(1.0)
logger.info( logger.info(
"Overpass region=%s: total %d elements across %d category-queries × %d tiles", "Overpass: total %d elements across %d category-queries",
region,
len(all_elements), len(all_elements),
len(OSM_CATEGORIES), len(OSM_CATEGORIES),
len(tiles),
) )
return all_elements return all_elements
def sync_poi_to_db(region: str = DEFAULT_REGION) -> dict[str, int]: def sync_poi_to_db() -> dict[str, int]:
"""Синхронизирует POI из Overpass в osm_poi_ekb для одного региона. """Синхронизирует POI из Overpass в osm_poi_ekb.
region ключ REGION_BBOX ("ekb" по умолчанию, сохраняет старое поведение
weekly-расписания). Имя таблицы osm_poi_ekb историческое (изначально
ЕКБ-only); таблица читается ещё в двух местах вне Site Finder (FDW-таблица
gendesign_osm_poi_ekb + локальное зеркало osm_poi_ekb_local в tradein), поэтому
НЕ переименована: переименование потянуло бы миграции в обеих половинах
монорепо (FDW-объект + зеркало + их индексы) ради косметики.
UPSERT по UNIQUE(osm_type, osm_id, category). UPSERT по UNIQUE(osm_type, osm_id, category).
Returns: counters {fetched, inserted, updated, skipped_old}. Returns: counters {fetched, inserted, updated, skipped_old}.
""" """
elements = asyncio.run(fetch_overpass(region)) elements = asyncio.run(fetch_overpass())
# 730 дней ≈ 2 года: избегаем ValueError 29 февраля (year-2 не високосный → нет 29.02). # 730 дней ≈ 2 года: избегаем ValueError 29 февраля (year-2 не високосный → нет 29.02).
# Точность ±1 день несущественна для фильтра "не старше 2 лет" (требование Максима). См. #1232. # Точность ±1 день несущественна для фильтра "не старше 2 лет" (требование Максима). См. #1232.
two_years_ago = date.today() - timedelta(days=730) two_years_ago = date.today() - timedelta(days=730)

View file

@ -346,13 +346,11 @@ def build_beat_schedule() -> dict:
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта # Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
# за ~5 недель полного обновления. # за ~5 недель полного обновления.
# #
# DISABLED с 2026-05-24. Исходная причина «hard-ban, cooldown 24-48h» оказалась # DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
# догадкой: kn-прогоны 29-33 (03.06-28.06) шли успешно уже после 24.05. Зонды # extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
# 20.08, 27.08 и 01.09 (#2443, #3307): наш.дом.рф стоит за StormWall (куки # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
# spid/spjs/spsc), IP Poincare получает «Доступ заблокирован [403]» — это # 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
# блокировка, ожидание её не снимает. Каталог не собирался с 19.05. # cooldown 24-48h (проверить через targeted test).
# Включать только после решения по прокси и принятого по числу строк
# kn-прогона (#3307). Выключенность держит test_beat_schedule_domrf_catalog.py.
# schedule["scrape-kn-catalog-objects-weekly"] = { # schedule["scrape-kn-catalog-objects-weekly"] = {
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects", # "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК # "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
@ -364,11 +362,11 @@ def build_beat_schedule() -> dict:
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из # (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL. # SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
# #
# DISABLED (как и object-level выше): 1) та же блокировка StormWall на IP # DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
# Poincare — каталог-квартиры используют тот же BrowserSession + /сервисы/* paths. # каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и # 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет. # свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
# Включать после прокси и принятого kn-прогона с hash (#3307), не по таймеру. # Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку — # Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
# напр. четверг 04:00 МСК. # напр. четверг 04:00 МСК.
# schedule["scrape-kn-catalog-flats-weekly"] = { # schedule["scrape-kn-catalog-flats-weekly"] = {

View file

@ -110,25 +110,6 @@ _UPSERT_NO_ACT_SQL = text(
) )
# Прежний разбор того же участка из ТОГО ЖЕ документа, но с другим номером акта (#2982).
#
# act_number входит в ключ конфликта, поэтому, когда разбор номера меняется, новая
# строка с ним не конфликтует со старой и ложится рядом. Так и было бы при починке
# регекса номера: 27 строк на проде записаны с act_number IS NULL, следующий прогон
# добавил бы 27 таких же с номером, а reservation_lookup показал бы каждое изъятие
# дважды. Один документ = один акт (номер разбирается один раз на весь PDF), значит
# строка с тем же (cad_num, doc_url) и другим номером — устаревший разбор той же
# записи, а не второе основание. Участок в двух РАЗНЫХ документах не затрагивается.
_DELETE_STALE_PARSE_SQL = text(
"""
DELETE FROM land_reservation
WHERE cad_num = CAST(:cad_num AS text)
AND doc_url = CAST(:doc_url AS text)
AND act_number IS DISTINCT FROM CAST(:act_number AS text)
"""
)
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int: def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк.""" """UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
count = 0 count = 0
@ -136,7 +117,6 @@ def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
try: try:
with db.begin_nested(): # SAVEPOINT per-row with db.begin_nested(): # SAVEPOINT per-row
db.execute(_DELETE_STALE_PARSE_SQL, row)
db.execute(upsert_sql, row) db.execute(upsert_sql, row)
count += 1 count += 1
except Exception as exc: except Exception as exc:

View file

@ -24,9 +24,8 @@ Sequencing dependency (важно): эта задача НЕ дублирует
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И 1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах. 2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level): наш.дом.рф за ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) WAF-cooldown на
StormWall отдаёт «Доступ заблокирован [403]» (#2443), ожидание это не снимает; VPS IP; включается вручную после проверки targeted-тестом.
включать после прокси и принятого kn-прогона (#3307).
""" """
from __future__ import annotations from __future__ import annotations
@ -117,7 +116,7 @@ def scrape_kn_catalog_flats(
Concurrency: Concurrency:
No Redis lock consistent с sibling tasks (scrape_kn_catalog_objects и т.д.). No Redis lock consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
Beat отключён (блокировка StormWall, #2443); при ручном/ad-hoc запуске пересечение крайне Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне
маловероятно. Если случится: маловероятно. Если случится:
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id). - UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch. - Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.

View file

@ -9,7 +9,7 @@ Selector logic:
- DATE(catalog_scraped_at) = CURRENT_DATE уже сегодня обновлён, пропускаем - DATE(catalog_scraped_at) = CURRENT_DATE уже сегодня обновлён, пропускаем
- force=True игнорирует фильтр, загружает все объекты последнего snapshot - force=True игнорирует фильтр, загружает все объекты последнего snapshot
Beat-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443). Beat schedule: вторник 04:00 UTC (в beat_schedule.py).
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -17,22 +17,12 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим (``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
per-test ``patch`` он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так 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 __future__ import annotations
from collections.abc import Iterator from collections.abc import Iterator
from unittest.mock import MagicMock, patch from unittest.mock import patch
import pytest 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): with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
yield 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,11 +1,10 @@
"""Тесты guard'а блокировки DOM.РФ (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах. """Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
/сервисы/* BrowserSession path family, что закрыт StormWall (#2443). Beat schedule /сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
для обоих тасков отключён по этой причине эти эндпоинты должны отказывать в (#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
запуске БЕЗ явного i_understand_waf_risk=true. Текст отказа то, по чему оператор должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
решает, ставить ли флаг, поэтому он обязан называть реальное условие (#3307), а не smoke-тест/re-trigger не углубил бан.
ожидание таймера: зонды 20.08-01.09 показали, что ждать нечего.
""" """
from __future__ import annotations from __future__ import annotations
@ -20,15 +19,6 @@ OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects"
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats" FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
def _assert_refusal_names_real_condition(detail: str) -> None:
assert "2443" in detail
assert "i_understand_waf_risk" in detail
# Условие снятия — прокси и принятый kn-прогон (#3307). Обещание «cooldown
# прошёл» подсказывало оператору обойти блокировку, которую ожидание не снимает.
assert "3307" in detail
assert "cooldown" not in detail.lower()
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock: def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
result = MagicMock() result = MagicMock()
result.id = task_id result.id = task_id
@ -43,7 +33,9 @@ def test_kn_catalog_objects_refuses_without_override() -> None:
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66}) resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
assert resp.status_code == 400 assert resp.status_code == 400
_assert_refusal_names_real_condition(resp.json()["detail"]) detail = resp.json()["detail"]
assert "2443" in detail
assert "i_understand_waf_risk" in detail
mock_apply_async.assert_not_called() mock_apply_async.assert_not_called()
@ -76,7 +68,9 @@ def test_kn_catalog_flats_refuses_without_override() -> None:
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66}) resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
assert resp.status_code == 400 assert resp.status_code == 400
_assert_refusal_names_real_condition(resp.json()["detail"]) detail = resp.json()["detail"]
assert "2443" in detail
assert "i_understand_waf_risk" in detail
mock_apply_async.assert_not_called() mock_apply_async.assert_not_called()

View file

@ -1,4 +1,4 @@
"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164). """Оповещения уходят в тему «алерты», а не в общую (#2203/#3078).
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал и алерты Прод-факт, 27.08. Канал доставки включили, `notify()` заработал и алерты
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
@ -16,7 +16,9 @@
реально ушло бы в сеть. реально ушло бы в сеть.
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
бы с оригиналом на первой же правке. бы с оригиналом на первой же правке. Сорсить файл целиком нельзя у
`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы
настоящие сетевые проверки.
""" """
from __future__ import annotations from __future__ import annotations
@ -32,6 +34,7 @@ import pytest
REPO_ROOT = Path(__file__).resolve().parents[3] REPO_ROOT = Path(__file__).resolve().parents[3]
SENDERS = { SENDERS = {
"lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh", "lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh",
"uptime-healthcheck.sh": REPO_ROOT / "ops" / "uptime-healthcheck.sh",
} }
FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n' FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n'
@ -102,7 +105,7 @@ def _run_notify(tmp_path: Path, script: Path, *, topic: str | None) -> list[str]
f'ARGS_DUMP="{_posix(dump)}"', f'ARGS_DUMP="{_posix(dump)}"',
'TELEGRAM_BOT_TOKEN="123:FAKE"', 'TELEGRAM_BOT_TOKEN="123:FAKE"',
'TELEGRAM_CHAT_ID="-1004443088679"', 'TELEGRAM_CHAT_ID="-1004443088679"',
f'BACKUP_ENV_FILE="{_posix(tmp_path / "missing.env")}"', f"BACKUP_ENV_FILE=\"{_posix(tmp_path / 'missing.env')}\"",
] ]
if topic is not None: if topic is not None:
prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"') prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"')
@ -150,73 +153,3 @@ def test_adres_i_tekst_na_meste(tmp_path: Path, name: str) -> None:
args = _run_notify(tmp_path, SENDERS[name], topic="158") args = _run_notify(tmp_path, SENDERS[name], topic="158")
assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id" assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id"
assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст" assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст"
# #3164. Решение владельца в #3163: инфраструктура — в тему «Metrics» (245),
# клиентские инциденты — в «алерты» (158). Бэкапы — инфраструктура. Тема живёт
# только в env-файле на хосте, поэтому образец — единственное место в репозитории,
# откуда следующий, кто заводит хост, узнает правильный адрес.
INFRA_TOPIC = "245"
BACKUP_ENV_EXAMPLES = [
"gendesign-backup.default.example",
"gendesign-backup-couchdb.default.example",
"gendesign-backup-forgejo.default.example",
]
@pytest.mark.parametrize("example", BACKUP_ENV_EXAMPLES)
def test_host_po_obraztsu_shlyot_bekapy_v_metriki(tmp_path: Path, example: str) -> None:
"""Хост, заведённый по образцу, шлёт алерт бэкапа в «Metrics», а не мимо.
Исполняется настоящий сторож `check-backup-staleness.sh` с отсутствующим
маркером, а env-файл образец, заполненный так, как он велит человеку:
раскомментированы строки `TELEGRAM_*`, подставлены токен и чат.
Ловит и второе: тема обязана лежать В ТОМ ЖЕ файле, что токен и чат.
`notify()` читает `BACKUP_ENV_FILE`, только если токена или чата нет в
окружении, тема из другого файла молча не подхватится.
"""
text = (REPO_ROOT / "ops" / example).read_text(encoding="utf-8")
filled = re.sub(r"(?m)^#(TELEGRAM_\w+=)", r"\1", text)
filled = re.sub(r"(?m)^TELEGRAM_BOT_TOKEN=.*$", "TELEGRAM_BOT_TOKEN=123:FAKE", filled)
filled = re.sub(r"(?m)^TELEGRAM_CHAT_ID=.*$", "TELEGRAM_CHAT_ID=-1004443088679", filled)
env_file = tmp_path / "backup.env"
env_file.write_text(filled, encoding="utf-8", newline="\n")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
fake = bin_dir / "curl"
fake.write_text(FAKE_CURL, encoding="utf-8", newline="\n")
fake.chmod(0o755)
dump = tmp_path / "args.txt"
# Пустые TELEGRAM_* префиксом: переменные из окружения разработчика иначе
# перебили бы файл, и тест проверял бы не образец.
prefix = [
f'PATH="{_posix(bin_dir)}:$PATH"',
f'ARGS_DUMP="{_posix(dump)}"',
'TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" TELEGRAM_TOPIC_ID=""',
f'BACKUP_ENV_FILE="{_posix(env_file)}"',
f'BACKUP_STALENESS_STATE_FILE="{_posix(tmp_path / "state")}"',
]
script = REPO_ROOT / "ops" / "check-backup-staleness.sh"
proc = subprocess.run(
[
"bash",
"-c",
" ".join(prefix)
+ f' bash "{_posix(script)}" "{_posix(tmp_path / "no-sentinel")}" 26 "topic probe"',
],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=30,
env=dict(os.environ),
)
args = dump.read_text(encoding="utf-8").splitlines() if dump.exists() else []
assert args, f"{example}: curl не был вызван.\n{proc.stdout}\n{proc.stderr}"
threads = [a for a in args if a.startswith("message_thread_id=")]
assert threads == [f"message_thread_id={INFRA_TOPIC}"], (
f"{example}: алерт бэкапа адресован {threads or 'в общую тему'}, "
f"а по #3163 инфраструктура идёт в «Metrics» ({INFRA_TOPIC})"
)

View file

@ -14,9 +14,7 @@ Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.15
Как это ломало алерты: Как это ломало алерты:
* `ops/uptime-healthcheck.sh` (удалён в #3164: ни на одном хосте не был * `ops/uptime-healthcheck.sh` ОДИН `curl`, дальше `|| log WARN`. Каждый
запущен, доступность сайтов сторожат uptime-мониторы GlitchTip) ОДИН
`curl`, дальше `|| log WARN`. Каждый
отказ терял уведомление целиком. Watchdog, который не может дозваться, отказ терял уведомление целиком. Watchdog, который не может дозваться,
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс, худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
@ -53,6 +51,7 @@ import pytest
# backend/tests/ops/<этот файл> → корень репозитория # backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3] REPO_ROOT = Path(__file__).resolve().parents[3]
UPTIME = "ops/uptime-healthcheck.sh"
LIB_BACKUP = "ops/lib-backup.sh" LIB_BACKUP = "ops/lib-backup.sh"
# См. подробное обоснование shutil.which в # См. подробное обоснование shutil.which в
@ -66,7 +65,8 @@ if BASH is None: # pragma: no cover - окружение без bash не за
def _extract_function(script_rel: str, name: str = "notify") -> str: def _extract_function(script_rel: str, name: str = "notify") -> str:
"""Достаёт тело одной функции из скрипта — не весь файл. """Достаёт тело одной функции из скрипта — не весь файл.
Весь скрипт source'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh. Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально
ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh.
Сопоставление точное (`name() {`), иначе `notify` поймал бы Сопоставление точное (`name() {`), иначе `notify` поймал бы
`notify_fallback_mail` соседнюю функцию в том же файле. `notify_fallback_mail` соседнюю функцию в том же файле.
@ -140,7 +140,7 @@ def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]:
return calls, out, err return calls, out, err
@pytest.mark.parametrize("script_rel", [LIB_BACKUP]) @pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
def test_transient_failure_is_retried_not_lost(script_rel: str) -> None: def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
"""Один отказ — алерт всё равно доставляется со второй попытки. """Один отказ — алерт всё равно доставляется со второй попытки.
@ -153,7 +153,7 @@ def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным" assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным"
@pytest.mark.parametrize("script_rel", [LIB_BACKUP]) @pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
def test_gives_up_after_three_attempts(script_rel: str) -> None: def test_gives_up_after_three_attempts(script_rel: str) -> None:
"""Повторы ограничены: три попытки, а не бесконечный цикл. """Повторы ограничены: три попытки, а не бесконечный цикл.
@ -164,6 +164,12 @@ def test_gives_up_after_three_attempts(script_rel: str) -> None:
assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз" assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз"
def test_uptime_reports_undelivered_alert_loudly() -> None:
"""Когда все три попытки провалились — это видно в логе, а не молча."""
_calls, _out, err = _run_notify(UPTIME, fail_times=99)
assert "НЕ ДОСТАВЛЕН" in err, f"недоставленный алерт должен логироваться громко.\n{err}"
def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None: def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None:
"""Транзиентный таймаут не должен трогать запасной канал. """Транзиентный таймаут не должен трогать запасной канал.

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,460 +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
import sys
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",
}
# Эти кейсы гоняют `bash -c <скрипт>` во временном git-репозитории (POSIX shell +
# /dev/null + жёсткий PATH=/usr/bin:/bin:/usr/local/bin). На Windows дочерний
# процесс не стартует (нет POSIX shell на этом PATH); покрытие обеспечивается в
# CI на Linux. Кейсы, которые читают только YAML/строки без subprocess, не
# помечены и обязаны выполняться локально.
posix_only = pytest.mark.skipif(
sys.platform == "win32",
reason="запускает bash-скрипт дочерним процессом; POSIX-only, покрытие в CI (Linux)",
)
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)
@posix_only
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"
@posix_only
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"
@posix_only
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
@posix_only
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},
)
@posix_only
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}"
@posix_only
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, "лок открывается, но не захватывается"
@posix_only
@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}"
@posix_only
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
@posix_only
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
@posix_only
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}"
@posix_only
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,176 +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. `POST /-/reload` лечит правила они подключены
КАТАЛОГОМ. prometheus.yml же бинд-маунт ОДНОГО файла: reload перечитывает
за тем же путём СТАРЫЙ инод (prom/prometheus:v3.1.0, 17.09: файл подменён,
reload rc=0, в /api/v1/status/config прежний job). Поэтому при расхождении
инода контейнер пересоздаётся (ops/metrics/recreate-stale-mount.sh, его
поведение test_metrics_single_file_mounts.py), а promtool проверяет файлы С
ДИСКА: `docker exec` в работающий контейнер проверял бы старый инод.
Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска,
(2) reload и пересоздание ТОЛЬКО после успешной валидации, (3) шаг обязан
упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы
Grafana перечитываются, и отказ этого шага роняет деплой.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml"
def _text() -> str:
return WORKFLOW.read_text(encoding="utf-8")
def _guard_start(text: str) -> int:
"""Начало `if <проверка promtool>; then` — ближайший `if` перед check config."""
guard = text.rfind("\n if ", 0, text.index(CHECK_CONFIG))
assert guard != -1, "проверка promtool не стоит под `if` — reload ничем не защищён"
return guard
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 = _guard_start(text)
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 = _guard_start(text)
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 не изменился, шаг обязан падать, а не считаться успешным"
)
def test_promtool_checks_files_from_disk_not_the_running_container() -> None:
"""Проверяется то, что сейчас применится, а не то, что держит контейнер.
prometheus.yml смонтирован одним файлом: `docker exec promtool check config`
в работающем контейнере читает СТАРЫЙ инод битая правка прошла бы проверку
и уехала бы в пересоздание.
"""
text = _text()
guard = text[_guard_start(text) : text.index(CHECK_CONFIG)]
assert "docker run --rm" in guard, "promtool запускается не одноразовым контейнером"
assert "/opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro" in guard, (
"одноразовый promtool смотрит не на файлы с диска по боевым путям"
)
assert not re.search(r"docker exec gendesign-prometheus[^\n]*promtool", text), (
"promtool всё ещё проверяет работающий контейнер — то есть старый инод"
)
def test_stale_prometheus_yml_is_recreated_between_check_and_reload() -> None:
"""Пересоздание по иноду — после проверки promtool и до reload."""
text = _text()
check = text.index("promtool check config /etc/prometheus/prometheus.yml")
recreate = re.search(
r"recreate-stale-mount\.sh \\\s+gendesign-metrics docker-compose\.metrics\.yml \\\s+"
r"prometheus ops/metrics/prometheus/prometheus\.yml /etc/prometheus/prometheus\.yml",
text,
)
assert recreate, "prometheus.yml не сверяется по иноду — правка конфига не доедет"
else_pos = text.index("else", _guard_start(text))
assert check < recreate.start() < text.index("localhost:9090/-/reload") < else_pos, (
"пересоздание Prometheus стоит не между успешной проверкой и reload"
)
def test_grafana_datasources_are_reloaded_and_failure_is_red() -> None:
"""Датасорсы Grafana применяются только при старте — их надо перечитать явно.
Замер в PR #3475 (grafana:11.5.1): изменённый url не применился и через 75 с,
POST /api/admin/provisioning/datasources/reload применил сразу. Ответ ручки в
v11.5.1 «Datasources config reloaded» (pkg/api/admin_provisioning.go).
"""
text = _text()
call = text.find("http://localhost:3000/api/admin/provisioning/datasources/reload")
assert call != -1, "деплой не перечитывает датасорсы Grafana"
block = re.search(r'case "\$GRAFANA_RELOAD" in(.+?)\besac\b', text[call:], re.S)
assert block, "результат перезагрузки датасорсов не разбирается"
ok, _, failed = block.group(1).partition(";;")
assert "Datasources config reloaded" in ok, "успех не сверяется по тексту ответа ручки"
assert "exit 1" in failed and "exit 1" not in ok, "отказ перезагрузки не роняет деплой"

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,310 +0,0 @@
"""Правка пофайлово смонтированного конфига метрик обязана доезжать до контейнера.
ЧТО НЕ ТАК. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
одного файла держит прежний. `up -d` этого не видит (сравнивает описание сервиса),
перезагрузка по API тоже: на prom/prometheus:v3.1.0 (17.09) prometheus.yml подменён,
POST /-/reload rc=0, lastConfigTime новый, а /api/v1/status/config отдаёт прежний
job. Деплой зелёный, конфиг старый у отказа нет отрицательного признака (#3467).
Так смонтированы prometheus.yml и loki-config.yml на сервере и queries.yml у трёх
postgres-экспортёров (#3486).
ЧТО ПРОВЕРЯЕТСЯ.
1. ops/metrics/recreate-stale-mount.sh ИСПОЛНЯЕТСЯ с подставным `docker`: смотрим
на совершённые действия (пересоздал / не тронул / упал), а не на текст.
2. Гейт по compose-файлам метрик: у КАЖДОГО пофайлового маунта есть путь доезда в
той джобе деплоя, что поднимает сервис. Список маунтов берётся ИЗ compose:
добавят новый тест покраснеет, пока путь не появится.
"""
from __future__ import annotations
import os
import re
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "ops" / "metrics" / "recreate-stale-mount.sh"
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
SRC = "ops/metrics/loki/loki-config.yml"
DST = "/etc/loki/loki-config.yml"
# Подставной `docker`. Каждый вызов пишется в $FAKE_LOG.
# compose … ps -q — id контейнера из $FAKE_CID_FILE (пусто = не запущен);
# код из $FAKE_PS_RC;
# compose … up … — «пересоздание»: новый id cid-new, и контейнер видит
# инод $FAKE_NEW_VIEW (пусто — не видит ничего);
# exec ID stat -c %i … — инод, который видит контейнер ID ($FAKE_VIEW/ID).
FAKE_DOCKER = r"""#!/bin/bash
printf '%s\n' "$*" >> "$FAKE_LOG"
cmd="$1"; shift
case "$cmd" in
exec)
view="$FAKE_VIEW/$1"
[ -f "$view" ] || exit 1
cat "$view"
;;
compose)
case " $* " in
*" ps "*)
[ "${FAKE_PS_RC:-0}" = "0" ] || { echo "compose error" >&2; exit "$FAKE_PS_RC"; }
cat "$FAKE_CID_FILE"
;;
*" up "*)
printf 'cid-new\n' > "$FAKE_CID_FILE"
[ -z "${FAKE_NEW_VIEW:-}" ] || printf '%s\n' "$FAKE_NEW_VIEW" > "$FAKE_VIEW/cid-new"
;;
esac
;;
esac
exit 0
"""
# На раннере и на хостах stat — GNU/busybox (`-c %i`); на macOS — BSD (`-f %i`).
# Шим ставится только там, где `-c` не понимают, иначе гейт не запускался бы локально.
STAT_SHIM = (
'#!/bin/sh\n[ "$1" = "-c" ] && [ "$2" = "%i" ] && exec /usr/bin/stat -f %i "$3"\n'
'exec /usr/bin/stat "$@"\n'
)
NO_SLEEP = "#!/bin/sh\nexit 0\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)
def _gnu_stat() -> bool:
return subprocess.run(["stat", "-c", "%i", "/"], capture_output=True).returncode == 0
@pytest.fixture
def host(tmp_path: Path) -> Path:
"""Боевое дерево: скрипт + конфиг; контейнер cid-old видит ТЕКУЩИЙ инод."""
tree = tmp_path / "opt" / "gendesign"
(tree / "ops" / "metrics" / "loki").mkdir(parents=True)
shutil.copy(SCRIPT, tree / "ops" / "metrics" / SCRIPT.name)
(tree / SRC).write_text("auth_enabled: false\n", encoding="utf-8")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_write_exec(bin_dir / "docker", FAKE_DOCKER)
_write_exec(bin_dir / "sleep", NO_SLEEP)
if not _gnu_stat():
_write_exec(bin_dir / "stat", STAT_SHIM)
view = tmp_path / "view"
view.mkdir()
(view / "cid-old").write_text(f"{_ino(tree)}\n", encoding="utf-8")
(tmp_path / "cid").write_text("cid-old\n", encoding="utf-8")
(tmp_path / "log").write_text("", encoding="utf-8")
return tree
def _ino(tree: Path) -> int:
return os.stat(tree / SRC).st_ino
def _stale(tree: Path) -> None:
"""Контейнер держит прежний инод — как после `git reset --hard`."""
(tree.parent.parent / "view" / "cid-old").write_text(f"{_ino(tree) + 1}\n", encoding="utf-8")
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_VIEW": str(root / "view"),
**env_extra,
}
proc = subprocess.run(
[
"sh",
str(tree / "ops" / "metrics" / SCRIPT.name),
"gendesign-metrics",
"docker-compose.metrics.yml",
"loki",
SRC,
DST,
],
cwd="/", # скрипт обязан сам встать в корень дерева
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 _recreates(calls: list[str]) -> list[str]:
return [c for c in calls if "--force-recreate" in c]
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
def test_fresh_inode_touches_nothing(host: Path) -> None:
"""Контейнер видит текущий файл — пересоздавать нельзя: это рвёт работу зря."""
rc, out, calls = _run(host)
assert rc == 0, out
assert not _recreates(calls), f"пересоздан без расхождения инода: {calls}"
def test_stale_inode_recreates_that_service_and_confirms(host: Path) -> None:
"""Старый инод → пересоздать ИМЕННО этот сервис и убедиться, что доехало."""
_stale(host)
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
assert rc == 0, out
recreates = _recreates(calls)
assert len(recreates) == 1, f"ожидалось одно пересоздание, было: {calls}"
assert recreates[0].split()[-1] == "loki", f"пересоздан не тот сервис: {recreates[0]}"
assert "--no-deps" in recreates[0], f"пересоздание тянет зависимости: {recreates[0]}"
assert "-p gendesign-metrics -f docker-compose.metrics.yml" in recreates[0], recreates[0]
assert any(c.startswith("exec cid-new ") for c in calls), (
f"после пересоздания инод НОВОГО контейнера не сверен: {calls}"
)
def test_still_stale_after_recreate_fails_the_deploy(host: Path) -> None:
"""Пересоздание не помогло — красный деплой, а не зелёный со старым конфигом."""
_stale(host)
rc, out, _calls = _run(host, FAKE_NEW_VIEW=str(_ino(host) + 1))
assert rc != 0, f"контейнер всё ещё на старом иноде, а скрипт вышел с 0:\n{out}"
assert SRC in out, f"в логе не назван файл, который не доехал:\n{out}"
def test_unreadable_container_view_counts_as_stale(host: Path) -> None:
"""Инод в контейнере не прочитался — это расхождение, а не «доехало»."""
(host.parent.parent / "view" / "cid-old").unlink()
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
assert rc == 0, out
assert _recreates(calls), f"непрочитанный инод сочтён доехавшим: {calls}"
def test_not_running_container_is_left_alone(host: Path) -> None:
"""Контейнера нет — старый инод держать некому; поднимать сервис тут нельзя."""
(host.parent.parent / "cid").write_text("", encoding="utf-8")
rc, out, calls = _run(host)
assert rc == 0, out
assert not _recreates(calls), f"поднят незапущенный сервис: {calls}"
def test_compose_failure_is_not_read_as_not_running(host: Path) -> None:
"""Упавший `compose ps` — не «контейнер не запущен»: иначе зелёный выход вслепую."""
_stale(host)
rc, out, calls = _run(host, FAKE_PS_RC="1")
assert rc != 0, f"ошибка compose проглочена, скрипт вышел с 0:\n{out}"
assert not _recreates(calls), calls
def test_missing_host_file_is_not_success(host: Path) -> None:
"""Файла на хосте нет — сверять не с чем; молча выйти с 0 нельзя."""
(host / SRC).unlink()
rc, out, _calls = _run(host)
assert rc != 0, f"отсутствующий файл сочтён доехавшим:\n{out}"
# ── Гейт: у каждого пофайлового маунта есть путь доезда ──────────────────────
# Производные файлы: их рендерит сам деплой, в git их нет.
# alertmanager.yml — rm + рендер, пересоздание по ALERTMANAGER_RERENDERED;
# alertmanager_targets.gen.yml — усечение на месте (`: >`), инод сохраняется.
RENDERED = {
"ops/metrics/alertmanager/alertmanager.yml",
"ops/metrics/prometheus/alertmanager_targets.gen.yml",
}
# Источник маунта: ./путь[:${VAR:?…}]:/куда[:ro]. `${…}` внутри источника — файл,
# выбираемый ролью (alloy-apps.alloy / alloy-infra.alloy).
MOUNT = re.compile(r"^\./(?P<src>(?:\$\{[^}]*\}|[^:])+):(?P<dst>/[^:]+)")
COMPOSE_FILES = {
"docker-compose.metrics.yml": "gendesign-metrics",
"docker-compose.metrics-agent.yml": "gendesign-metrics-agent",
}
def _jobs_for(compose_name: str, profiles: list[str]) -> list[str]:
"""Джобы деплоя, поднимающие сервис: сервер — одна; агенты — по профилю."""
if compose_name == "docker-compose.metrics.yml":
return ["server"]
return [f"agent-{p}" for p in profiles] or ["agent-apps", "agent-infra"]
def _commands(job: str) -> str:
"""ssh-скрипт джобы: комментарии выкинуты, продолжения строк склеены.
Комментарии потому что разбор дефекта содержит его формулировку дословно.
"""
spec = yaml.safe_load(WORKFLOW.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} не ровно один ssh-шаг — гейт ослеп"
kept = [ln for ln in steps[0]["with"]["script"].splitlines() if not ln.lstrip().startswith("#")]
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
def _file_mounts(compose_name: str) -> list[tuple[str, str, str, list[str]]]:
spec = yaml.safe_load((REPO_ROOT / compose_name).read_text(encoding="utf-8"))
found = []
for service, body in spec["services"].items():
for vol in body.get("volumes") or []:
m = MOUNT.match(vol) if isinstance(vol, str) else None
if not m or (REPO_ROOT / m["src"]).is_dir() or m["src"] in RENDERED:
continue
found.append((service, m["src"], m["dst"], body.get("profiles") or []))
return found
def _arrival(commands: str, project: str, compose_name: str, service: str, src: str, dst: str):
script_call = re.search(
rf"recreate-stale-mount\.sh\s+{re.escape(project)}\s+{re.escape(compose_name)}"
rf"\s+{re.escape(service)}\s+{re.escape(src)}\s+{re.escape(dst)}[^\n]*",
commands,
)
forced = re.search(
rf"--force-recreate(?:\s+--no-deps)?\s+{re.escape(service)}(?![\w-])[^\n]*", commands
)
return script_call or forced
@pytest.mark.parametrize("compose_name", sorted(COMPOSE_FILES))
def test_every_single_file_mount_has_a_way_to_arrive(compose_name: str) -> None:
project = COMPOSE_FILES[compose_name]
mounts = _file_mounts(compose_name)
assert mounts, f"в {compose_name} не найдено ни одного пофайлового маунта — маска ослепла"
missing = []
for service, src, dst, profiles in mounts:
for job in _jobs_for(compose_name, profiles):
hit = _arrival(_commands(job), project, compose_name, service, src, dst)
if not hit:
missing.append(f"{job}: {service} {src}{dst}")
elif re.search(r"\|\|\s*(true|:)\s*$", hit.group(0)):
missing.append(f"{job}: {service} — отказ проглочен: {hit.group(0).strip()}")
assert not missing, (
"пофайловые маунты без пути доезда: правка файла ляжет на диск, контейнер "
"останется на старом иноде, деплой зелёный:\n " + "\n ".join(missing)
)
def test_gate_sees_the_known_mounts() -> None:
"""Проверка на себя: маска обязана находить маунты, ради которых гейт заведён."""
server = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics.yml")}
assert ("prometheus", "ops/metrics/prometheus/prometheus.yml") in server, server
assert ("loki", "ops/metrics/loki/loki-config.yml") in server, server
agent = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics-agent.yml")}
for exporter in ("gendesign", "tradein", "infra"):
assert (f"postgres-exporter-{exporter}", "ops/metrics/postgres/queries.yml") in agent, agent
def test_gate_would_notice_a_mount_without_arrival() -> None:
"""Сервис без пути доезда гейт обязан НЕ найти — иначе он зелёный по построению."""
commands = _commands("server")
assert not _arrival(
commands, "gendesign-metrics", "docker-compose.metrics.yml", "nosuch", SRC, DST
), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая"

View file

@ -1,103 +0,0 @@
"""act_number извлекается у постановлений Администрации Екатеринбурга (#2982).
Регекс номера требовал суффикс областных актов (`-ПП/-ПА/-РП/-ПГ/-ГП/-МО`). У
постановлений Администрации города его нет, поэтому на проде `act_number` пуст у
всех 27 строк `land_reservation` (замер 17.09.2026: 27 строк, заполнено 0).
Фрагменты дословный OCR пяти прод-документов из test_2464_act_date_citation
(распознаны `ocr_pdf_text` в прод-контейнере). Ожидаемые номера из самих PDF:
1413, 259, 2687, 863, 1504. Помехи в тех же текстах «Решение Думы 60/1»
(Генплан) и «Приказ Министерства 746-П»: их номер браться не должен.
Тест зовёт `extract_izyatie_records` путь загрузчика и краснеет на origin/main
неверным значением (None вместо номера), а не ошибкой сигнатуры.
"""
from __future__ import annotations
import pytest
from app.services.scrapers.izyatie_ocr import extract_izyatie_records
from app.services.scrapers.page_reservation_parser import extract_reservations
from tests.services.scrapers.test_2464_act_date_citation import (
ДОКЕРЕМЕШАННЫЙ,
ДОКЕРЕМЕШАННЫЙ_2,
ДОКРЯМОЙ,
ДОК_РАЗВЯЗКА,
ДОКНЕРГОСТРОИТЕЛЕЙ,
ЗАГОЛОВОК,
КАД,
)
ПЯТЬОКУМЕНТОВ = [
pytest.param(ДОК_РАЗВЯЗКА, "1413", "2022-05-27", id="развязка"),
pytest.param(ДОКНЕРГОСТРОИТЕЛЕЙ, "259", "2020-02-12", id="энергостроителей"),
pytest.param(ДОКЕРЕМЕШАННЫЙ, "2687", "2015-09-29", id="павлодарская"),
pytest.param(ДОКЕРЕМЕШАННЫЙ_2, "863", "2019-04-19", id="иркутская"),
pytest.param(ДОКРЯМОЙ, "1504", "2019-06-24", id="татищева"),
]
def апись(текст: str) -> dict:
записи = extract_izyatie_records(f"{текст}\n{КАД}", ЗАГОЛОВОК, "http://x/y")
assert записи, "кад-номер не разобран — тест смотрел бы не туда"
return записи[0]
@pytest.mark.parametrize(("текст", "номер", "дата"), ПЯТЬОКУМЕНТОВ)
def test_act_number_of_ekb_administration_resolution(текст: str, номер: str, дата: str) -> None:
"""Головной: номер постановления без суффикса извлекается, и это номер того же акта,
чья дата стоит в act_date, а не Решения Думы или приказа Министерства."""
запись = апись(текст)
assert запись["act_number"] == номер
assert запись["act_date"] == дата
@pytest.mark.parametrize(("текст", "номер", "ата"), ПЯТЬОКУМЕНТОВ)
def test_page_parser_takes_the_same_numbers(текст: str, номер: str, ата: str) -> None:
"""Второе место того же дефекта — page_reservation_parser (pravo.gov66).
Там номер первое «» в тексте. В двух документах первыми идут « 60/1» и
« 746-П»: их нельзя ни взять, ни обрезать до «60»/«746».
"""
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number == номер
def test_citations_alone_give_no_number() -> None:
"""Контроль: без постановления — None, а не номер Генплана или приказа."""
текст = (
"Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»;\n"
"Приказ Министерства строительства и развития инфраструктуры Свердловской "
"области от 30.12.2021 № 746-П"
)
assert апись(текст)["act_number"] is None
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number is None
def test_number_belongs_to_the_act_whose_date_is_taken() -> None:
"""Контроль связки: номер берётся у того же акта, что и дата, а не первое «№».
Тот же документ «развязка», но у приказа Министерства номер без суффикса.
Первое подходящее «» в тексте «746», дата при этом у постановления 1413:
в строке оказалась бы пара реквизитов от двух разных документов.
"""
текст = ДОК_РАЗВЯЗКА.replace("№ 746-П", "№ 746")
запись = апись(текст)
assert (запись["act_date"], запись["act_number"]) == ("2022-05-27", "1413")
def test_page_parser_prefers_own_number_over_cited_regional_act() -> None:
"""Контроль порядка: собственный номер в шапке без суффикса не пропускается ради
следующего « -ПП» номера цитируемого постановления Правительства."""
текст = (
"ПРИКАЗ\nот 12.03.2024 № 1234\nОб изъятии земельных участков\n"
"В соответствии с постановлением Правительства Свердловской области "
f"от 26.02.2020 № 100-ПП\n{КАД}"
)
assert extract_reservations(текст)[0].act_number == "1234"
def test_regional_suffix_still_extracted() -> None:
"""Контроль от переусердствования: областной номер с суффиксом не потерян."""
assert апись("Постановление № 509-ПП от 12.03.2024")["act_number"] == "509-ПП"
assert апись("Постановление Правительства от 12.03.2024 № 509-ПП")["act_number"] == "509-ПП"

View file

@ -176,29 +176,3 @@ tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
# ── POSIX-only: тесты запускают дочерний bash-скрипт ───────────────────────────
# На Windows нет POSIX shell на PATH, которым эти тесты исполняют /bin/bash -c
# <скрипт> или сам shell-скрипт напрямую (шебанг #!/usr/bin/env bash) — процесс
# не стартует. В CI ЭТИ ТЕСТЫ ИДУТ (ubuntu-раннер). Записи нужны только для
# локального прогона на Windows; чисто-логические проверки в тех же файлах
# (читают YAML/строки без subprocess) в этот список НЕ входят и обязаны
# исполняться локально.
tests/ops/test_3448_caddy_only_detection.py::test_merge_with_only_caddy_file_takes_the_fast_path
tests/ops/test_3448_caddy_only_detection.py::test_caddy_plus_backend_is_a_full_deploy
tests/ops/test_3448_caddy_only_detection.py::test_missing_base_falls_back_to_full_deploy
tests/ops/test_3448_caddy_only_detection.py::test_decision_is_visible_in_the_log
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_allowed_when_prod_lags_only_by_proxy_config
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_refuses_when_prod_lags_by_code
tests/ops/test_3448_caddy_only_detection.py::test_paths_that_merely_start_with_caddy_are_not_the_fast_path
tests/ops/test_3448_caddy_only_detection.py::test_empty_diff_is_not_the_fast_path
tests/ops/test_3448_caddy_only_detection.py::test_data_sql_counts_as_backend
tests/ops/test_3448_caddy_only_detection.py::test_non_ascii_path_is_classified
tests/ops/test_3448_caddy_only_detection.py::test_multi_commit_push_is_not_truncated
tests/test_2950_latest_image_revision_gate.py::test_fresh_latest_passes
tests/test_2950_latest_image_revision_gate.py::test_newer_revision_passes
tests/test_2950_latest_image_revision_gate.py::test_stale_latest_fails_after_timeout
tests/test_2950_latest_image_revision_gate.py::test_revision_from_other_component_does_not_cover
tests/test_2950_latest_image_revision_gate.py::test_missing_label_fails
tests/test_2950_latest_image_revision_gate.py::test_registry_error_fails
tests/test_2950_latest_image_revision_gate.py::test_usage_errors_are_distinct

View file

@ -1,134 +0,0 @@
"""Починка разбора номера не удваивает land_reservation (#2982).
act_number входит в ключ `UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`. На
проде 27 строк записаны с `act_number IS NULL`. Когда номер начинает извлекаться,
строка (участок, «1413») с (участок, NULL) не конфликтует без очистки прогон
кладёт вторую строку рядом, и reservation_lookup показывает изъятие дважды.
Проверяется `_upsert_records` загрузчика целиком на временной таблице той же
формы: боевые данные не читаются и не меняются.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.workers.tasks.izyatie_ocr_ingest import _upsert_records
def _dsn() -> str:
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
)
return (
raw
if raw.startswith("postgresql+")
else raw.replace("postgresql://", "postgresql+psycopg://")
)
def _db_reachable() -> tuple[bool, str]:
try:
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
with eng.connect() as c:
c.execute(text("SELECT 1"))
return True, ""
except Exception as exc:
return False, str(exc)
_DB_OK, _DB_ERR = _db_reachable()
pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}")
_TABLE = """
CREATE TEMP TABLE land_reservation (
id bigserial PRIMARY KEY,
cad_num text NOT NULL,
reservation_kind text NOT NULL,
basis_act text NOT NULL,
act_number text,
act_date date,
purpose text,
doc_url text,
source text NOT NULL DEFAULT 'page_pdf',
is_active boolean NOT NULL DEFAULT true,
raw_excerpt text,
fetched_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_t UNIQUE NULLS NOT DISTINCT (cad_num, act_number)
) ON COMMIT DROP;
"""
КАД = "66:41:0303004:22"
ДОК = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8"
def _row(cad: str = КАД, act: str | None = "1413", url: str = ДОК) -> dict:
return {
"cad_num": cad,
"reservation_kind": "изъятие",
"basis_act": "Сообщение о планируемом изъятии",
"act_number": act,
"act_date": "2022-05-27",
"purpose": None,
"doc_url": url,
"source": "izyatie_ekb_ocr",
"raw_excerpt": None,
}
@pytest.fixture
def db():
engine = create_engine(_dsn())
session = sessionmaker(bind=engine)()
try:
session.execute(text(_TABLE))
n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar()
assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)"
yield session
finally:
session.rollback()
session.close()
engine.dispose()
def _rows(db) -> list[tuple[str, str | None, str]]:
return [
(r[0], r[1], r[2])
for r in db.execute(
text("SELECT cad_num, act_number, doc_url FROM land_reservation ORDER BY 1, 2, 3")
).all()
]
def test_numbered_parse_replaces_numberless_row_of_same_document(db) -> None:
"""Головной: прод-состояние (номер NULL) + прогон с номером → одна строка с номером."""
_upsert_records(db, [_row(act=None)])
assert _rows(db) == [(КАД, None, ДОК)]
assert _upsert_records(db, [_row(act="1413")]) == 1
assert _rows(db) == [(КАД, "1413", ДОК)], "строка без номера осталась рядом с новой"
_upsert_records(db, [_row(act="1413")])
assert _rows(db) == [(КАД, "1413", ДОК)], "повторный прогон не идемпотентен"
def test_same_parcel_in_another_document_is_kept(db) -> None:
"""Контроль ширины: участок в ДРУГОМ документе — другое основание, его не трогаем."""
другой = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5"
_upsert_records(db, [_row(act="259", url=другой)])
_upsert_records(db, [_row(act=None)])
_upsert_records(db, [_row(act="1413")])
assert _rows(db) == [(КАД, "1413", ДОК), (КАД, "259", другой)]
def test_other_parcels_of_same_document_are_kept(db) -> None:
"""Контроль: чистка идёт по участку, а не по всему документу."""
_upsert_records(db, [_row(cad="66:41:0303004:23", act=None)])
_upsert_records(db, [_row(act="1413")])
assert _rows(db) == [("66:41:0303004:22", "1413", ДОК), ("66:41:0303004:23", None, ДОК)]

View file

@ -15,20 +15,10 @@ from __future__ import annotations
import os import os
import stat import stat
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
# Каждый тест в файле запускает scripts/check-latest-image-revision.sh (POSIX shell,
# shebang #!/usr/bin/env bash) дочерним процессом. На Windows нет POSIX shell на PATH,
# чтобы это исполнить, — покрытие обеспечивается в CI на Linux.
pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="запускает bash-скрипт (check-latest-image-revision.sh) дочерним процессом; "
"POSIX-only, покрытие в CI (Linux)",
)
КОРЕНЬ = Path(__file__).resolve().parents[2] КОРЕНЬ = Path(__file__).resolve().parents[2]
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh" СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"

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,257 +0,0 @@
"""Unit tests для poi_loader — региональный bbox + адаптивное дробление Overpass-тайлов.
Mock-based / pure БЕЗ живых походов в Overpass и без БД (правило: реальные запросы
к Overpass в тестах недопустимы). Покрывает:
- REGION_BBOX / DEFAULT_REGION дефолт остаётся "ekb", не меняется молча.
- _bbox_tiles для ЕКБ ровно один тайл, идентичный EKB_BBOX; для Москвы несколько
тайлов, покрывающих исходный bbox без дыр/нахлёста (по площади).
- _build_overpass_query bbox теперь параметр, а не глобальная константа.
- _fetch_category retry (было и раньше) + НОВОЕ: адаптивное дробление тайла на 4
четверти при устойчивом провале (вместо тихой потери тайла), с остановкой на
RECURSIVE_SPLIT_MAX_DEPTH (без бесконечной рекурсии).
"""
from __future__ import annotations
from types import SimpleNamespace
import httpx
import pytest
from app.services.site_finder.poi_loader import (
_MAX_CONSECUTIVE_TRANSPORT_ERRORS,
DEFAULT_REGION,
EKB_BBOX,
RECURSIVE_SPLIT_MAX_DEPTH,
REGION_BBOX,
OverpassUnreachableError,
_bbox_tiles,
_build_overpass_query,
_fetch_category,
_RunState,
_split_bbox_quadrants,
)
class _FakeResponse:
"""Ответ Overpass. ok=False отдаёт 504 — «запрос слишком тяжёлый», единственный
класс отказа, ради которого тайл вообще осмысленно дробить."""
def __init__(self, ok: bool, elements: list[dict] | None = None) -> None:
self._ok = ok
self._elements = elements or []
def raise_for_status(self) -> None:
if not self._ok:
raise httpx.HTTPStatusError(
"simulated Overpass overload",
request=httpx.Request("POST", "https://overpass-api.de/api/interpreter"),
response=httpx.Response(504),
)
def json(self) -> dict:
return {"elements": self._elements}
@pytest.fixture
def instant_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
"""Подменяет asyncio.sleep внутри poi_loader на no-op — тесты дробления тайлов иначе
реально спали бы минуты (retry-пауза 3с + 1с между каждой из 4 четвертей на каждом
уровне рекурсии)."""
async def _instant(_seconds: float) -> None:
return None
monkeypatch.setattr("app.services.site_finder.poi_loader.asyncio.sleep", _instant)
# ── REGION_BBOX / дефолт ──────────────────────────────────────────────────────
def test_default_region_is_ekb_unchanged() -> None:
assert DEFAULT_REGION == "ekb"
assert REGION_BBOX["ekb"] == EKB_BBOX
def test_region_bbox_has_msk_product_core() -> None:
assert "msk" in REGION_BBOX
south, west, north, east = REGION_BBOX["msk"]
assert south < north
assert west < east
# ── _bbox_tiles ────────────────────────────────────────────────────────────────
def test_bbox_tiles_ekb_is_single_tile_identical_to_ekb_bbox() -> None:
"""Дефолтный регион не должен молча поменять поведение — один тайл, байт-в-байт EKB_BBOX."""
tiles = _bbox_tiles(EKB_BBOX)
assert tiles == [EKB_BBOX]
def test_bbox_tiles_msk_splits_into_multiple_tiles_without_gaps() -> None:
bbox = REGION_BBOX["msk"]
tiles = _bbox_tiles(bbox)
assert len(tiles) > 1
south, west, north, east = bbox
total_area = (north - south) * (east - west)
tiles_area = sum((t[2] - t[0]) * (t[3] - t[1]) for t in tiles)
assert tiles_area == pytest.approx(total_area, rel=1e-9)
# ── _split_bbox_quadrants ────────────────────────────────────────────────────────
def test_split_bbox_quadrants_covers_original_area() -> None:
bbox = (55.55, 37.30, 55.95, 37.90)
quads = _split_bbox_quadrants(bbox)
assert len(quads) == 4
south, west, north, east = bbox
total_area = (north - south) * (east - west)
quads_area = sum((q[2] - q[0]) * (q[3] - q[1]) for q in quads)
assert quads_area == pytest.approx(total_area, rel=1e-9)
# ── _build_overpass_query ─────────────────────────────────────────────────────
def test_build_overpass_query_uses_given_bbox_not_global_constant() -> None:
q = _build_overpass_query((("amenity", "pharmacy"),), (1.0, 2.0, 3.0, 4.0))
assert "(1.0,2.0,3.0,4.0)" in q
assert '["amenity"="pharmacy"]' in q
# ── _fetch_category: retry (существующее поведение) ──────────────────────────────
async def test_fetch_category_retries_then_succeeds(instant_sleep: None) -> None:
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
if calls["n"] == 1:
return _FakeResponse(ok=False)
return _FakeResponse(ok=True, elements=[{"type": "node", "id": 1, "lat": 1, "lon": 2}])
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0, 0, 1, 1), _RunState()
)
assert calls["n"] == 2
assert len(result) == 1
assert result[0]["_gd_category"] == "pharmacy"
# ── _fetch_category: адаптивное дробление (НОВОЕ) ─────────────────────────────────
async def test_fetch_category_splits_into_quadrants_on_persistent_failure(
instant_sleep: None,
) -> None:
"""Тайл, где оба attempt проваливаются, дробится на 4 четверти вместо потери данных."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
query = data["data"]
if "(0.0,0.0,1.0,1.0)" in query: # верхнеуровневый тайл всегда 504
return _FakeResponse(ok=False)
return _FakeResponse(
ok=True, elements=[{"type": "node", "id": calls["n"], "lat": 0.1, "lon": 0.1}]
)
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
)
# верхний тайл: 2 неудачных attempt, затем 4 успешных запроса по четвертям
assert calls["n"] == 2 + 4
assert len(result) == 4
async def test_fetch_category_gives_up_at_max_depth_without_infinite_recursion(
instant_sleep: None,
) -> None:
"""Тайл, падающий на ЛЮБОМ размере, останавливает дробление на RECURSIVE_SPLIT_MAX_DEPTH
и возвращает пустой список не зацикливается и не падает."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
assert data # параметр используется — сигнатура должна совпадать с client.post
return _FakeResponse(ok=False)
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
)
assert result == []
# sum_{d=0}^{max_depth} 4^d узлов, каждый по 2 attempt — рекурсия конечна
expected_nodes = sum(4**d for d in range(RECURSIVE_SPLIT_MAX_DEPTH + 1))
assert calls["n"] == expected_nodes * 2
# ── отказ транспорта: НЕ дробим и не стучимся дальше ──────────────────────────
async def test_transport_error_does_not_split_the_tile(instant_sleep: None) -> None:
"""Connection refused говорит «хост нас не принимает», а не «запрос тяжёлый».
Дробление тут превращает один отказ в 4, 16, 64 повторных стука ровно это
случилось 15.09.2026 на загрузке Москвы (58 отказов на 4 успеха за три минуты).
"""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
assert data
raise httpx.ConnectError("[Errno 101] Network is unreachable")
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
)
assert result == []
assert calls["n"] == 1 # ни retry, ни четвертей
async def test_consecutive_transport_errors_abort_the_run(instant_sleep: None) -> None:
"""Порог подряд идущих транспортных отказов останавливает ВЕСЬ прогон явной ошибкой."""
async def fake_post(_url: str, data: dict) -> _FakeResponse:
assert data
raise httpx.ConnectError("connection refused")
client = SimpleNamespace(post=fake_post)
state = _RunState()
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS - 1):
assert (
await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
)
== []
)
with pytest.raises(OverpassUnreachableError):
await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
)
async def test_successful_tile_resets_transport_error_streak(instant_sleep: None) -> None:
"""Одиночные сетевые икоты вперемешку с успехами не должны копиться до аварии."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
assert data
if calls["n"] % 2:
raise httpx.ConnectError("hiccup")
return _FakeResponse(ok=True, elements=[{"type": "node", "id": calls["n"]}])
client = SimpleNamespace(post=fake_post)
state = _RunState()
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS * 2):
await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
)
assert state.consecutive_transport_errors < _MAX_CONSECUTIVE_TRANSPORT_ERRORS

View file

@ -1,38 +0,0 @@
"""#2443: каталожные DOM.РФ-скраперы не должны попасть в beat, пока стоит блокировка.
С 01.09 наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]».
Решение «выключено до прокси» раньше жило только в комментарии с обещанием
«cooldown 24-48h» и месяцами читалось как таймер. Здесь оно проверяется по
значению: включить запись = осознанно поправить этот тест после #3307.
"""
from __future__ import annotations
from unittest.mock import patch
_BLOCKED_TASKS = (
"tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
"tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
)
def test_domrf_catalog_scrapers_not_scheduled_while_stormwall_blocks() -> None:
from app.workers import beat_schedule
with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}):
schedule = beat_schedule.build_beat_schedule()
# Контроль: статическая часть расписания реально построилась, иначе
# отсутствие задач ниже было бы зелёным по построению.
assert schedule["refresh-quarter-price-index"]["task"] == (
"tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain"
)
# По task, а не по ключу: переименованный ключ не должен обойти проверку.
scheduled = {
name: entry["task"] for name, entry in schedule.items() if entry["task"] in _BLOCKED_TASKS
}
assert scheduled == {}, (
f"каталог DOM.РФ включён в beat: {scheduled}. IP Poincare заблокирован "
"StormWall (#2443); включать только после прокси и принятого kn-прогона (#3307)"
)

View file

@ -133,35 +133,6 @@ gendsgn.ru {
format json 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 { route {
# `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078). # `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078).
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только # Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
@ -184,13 +155,10 @@ gendsgn.ru {
} }
# Static HTML mockups для review (audit alternatives). # Static HTML mockups для review (audit alternatives).
# Public access — без auth (по запросу 2026-05-17) — это намеренно и # Public access — без auth (по запросу 2026-05-17).
# менять НЕ надо. Но каталог не должен листингом отдавать список
# файлов случайному посетителю: без `browse` file_server продолжает
# отдавать сами файлы по прямой ссылке, просто без index-листинга.
handle_path /preview/* { handle_path /preview/* {
root * /srv/preview root * /srv/preview
file_server file_server browse
} }
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру # Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
@ -403,58 +371,10 @@ www.gendsgn.ru {
meraocenka.ru { meraocenka.ru {
encode zstd gzip encode zstd gzip
# #3471 (security-headers follow-up): публичный B2C-домен без базовых
# security-заголовков. Позиция директивы в файле не важна — Caddyfile
# применяет директивы в фиксированном порядке (`header` идёт раньше
# `reverse_proxy`), не по строке в исходнике.
#
# Проверено: ни tradein-mvp/frontend (обслуживает этот домен, отдаёт
# `/trade-in/mera-public*`), ни его next.config.ts/middleware не
# выставляют ни один из этих заголовков — дублирования нет.
header {
# 180 суток (15552000s). НЕ ставить includeSubDomains и НЕ ставить
# preload — оба флага практически необратимы (браузеры кэшируют
# preload-список надолго, includeSubDomains ломает любой будущий
# HTTP-поддомен), а не все поддомены meraocenka.ru гарантированно
# под нашим контролем. Если это когда-нибудь понадобится — отдельное
# осознанное решение, не «улучшение по умолчанию».
Strict-Transport-Security "max-age=15552000"
# Запрет встраивания во фрейм: X-Frame-Options — для старых
# клиентов, которые не понимают CSP; frame-ancestors — современный
# механизм. Оба вместе, не один вместо другого.
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
# Не даём стеку светиться в ответе. Полноценный CSP
# (default-src/script-src/...) сюда намеренно НЕ входит — отдельная
# работа с риском сломать фронт, здесь только периметр вокруг фрейма.
-Server
-X-Powered-By
}
log { log {
output file /var/log/caddy/meraocenka.ru.log 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). Здесь действует белый список и # `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и
# финальный `handle { respond 404 }`, так что путь и без этой строки не # финальный `handle { respond 404 }`, так что путь и без этой строки не
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента # проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
@ -518,7 +438,7 @@ meraocenka.ru {
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next — # Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
# robots.txt существует только в корне app/, не под mera-public/), эта # robots.txt существует только в корне app/, не под mera-public/), эта
# форма под `{path}` не попадает — отсюда отдельный handle у него. # форма под `{path}` не попадает — отсюда отдельный handle у него.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /articles/skolko-stoit-metr-v-ekaterinburge /articles/pochemu-kvartira-ne-prodaetsya /docs /business /sitemap.xml @meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml
handle @meraPages { handle @meraPages {
rewrite * /trade-in/mera-public{path} rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 { reverse_proxy tradein-frontend:3000 {
@ -549,7 +469,7 @@ meraocenka.ru {
# переносит query как есть. Обёртка `route` обязательна по той же # переносит query как есть. Обёртка `route` обязательна по той же
# причине, что у соседей — внутри `handle` порядок директив определяет # причине, что у соседей — внутри `handle` порядок директив определяет
# Caddy, и без неё `redir` выполнился бы раньше `uri`. # Caddy, и без неё `redir` выполнился бы раньше `uri`.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|articles/skolko-stoit-metr-v-ekaterinburge|articles/pochemu-kvartira-ne-prodaetsya|docs|business)/$ @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
handle @meraShortSlash { handle @meraShortSlash {
route { route {
uri strip_suffix / uri strip_suffix /
@ -637,7 +557,7 @@ meraocenka.ru {
# порядок директив внутри `handle` определяет Caddy, и без неё `redir` # порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу # выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
# (бесконечный цикл; поймано на локальном стенде). # (бесконечный цикл; поймано на локальном стенде).
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge/ /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/ @meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
handle @meraLongPages { handle @meraLongPages {
route { route {
uri strip_prefix /trade-in/mera-public uri strip_prefix /trade-in/mera-public

View file

@ -56,28 +56,6 @@ errors.gendsgn.ru {
git.gendsgn.ru { git.gendsgn.ru {
encode zstd gzip encode zstd gzip
# Генерация архивов закрыта — она съедала диск (16.09.2026).
#
# Forgejo на КАЖДЫЙ GET /<owner>/<repo>/archive/<ref>.{zip,tar.gz,bundle}
# собирает снапшот репозитория заново (20-30 МБ, несколько секунд CPU) и
# кладёт результат в кэш repo-archive. Штатная уборка archive_cleanup
# работает, но раз в сутки — а внешний обходчик (73 из 78 обращений за 6ч
# с одного адреса 216.73.216.17) дёргал архив для каждого коммита и держал
# генерацию на ~100 файлах в час. Кэш вырос до 55 ГБ при диске 145 ГБ и ел
# 2 ГБ/ч: до нуля оставалось ~12 часов.
#
# Ограничение скорости тут не лечит: даже один запрос в минуту — это
# 30 МБ/мин новых файлов при суточной уборке. Нужен именно отказ.
# На `git clone`, `git fetch` и работу CI это НЕ влияет: смарт-протокол
# ходит по /<owner>/<repo>/info/refs и /git-upload-pack, другие пути.
# Цена: анонимная кнопка «скачать архив» в вебе больше не работает —
# сознательный размен, вернуть можно снятием этих двух правил.
@repo_archive path_regexp repo_archive ^/[^/]+/[^/]+/archive/.+$
respond @repo_archive "Архивы отключены: используйте git clone" 403
@api_archive path_regexp api_archive ^/api/v1/repos/[^/]+/[^/]+/archive/.+$
respond @api_archive "Архивы отключены: используйте git clone" 403
reverse_proxy forgejo:3000 reverse_proxy forgejo:3000
log { log {
@ -127,44 +105,11 @@ metrics.gendsgn.ru {
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её # угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
# поставил не человек. Прав в системе токен не даёт никаких. # поставил не человек. Прав в системе токен не даёт никаких.
# #
# Сервис отвечает только на /ack/*, /glitchtip и /healthz; всё прочее — 404. # Сервис отвечает только на /ack/* и /healthz; всё прочее — 404.
handle /ack/* { handle /ack/* {
reverse_proxy alert-ack:8080 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 (#3078). Внешний basic_auth снят по
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет # решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`. # собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.

View file

@ -231,77 +231,6 @@ services:
mem_limit: 128m mem_limit: 128m
logging: *default-logging 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 + что-то ещё для остальных двух чисел.
#
# ТЕГ И ФЛАГИ СВЕРЕНЫ С ИСХОДНИКОМ ОБРАЗА, а не с памятью (#3493). Прежняя
# запись `0.13.0` + `--queue` не существовала вовсе: такого тега нет на Docker
# Hub (`pull` → `not found`), а в `src/cli.py` версии 0.12.2 опция зовётся
# `--queues` (список через запятую), `--queue` click отвергает при старте.
# Под `set -e` упавший `pull` обрывал ВСЮ джобу `agent-apps` деплоя метрик:
# с 12.09 до этой правки на Poincare не доезжало ничего из агента — ни этот
# экспортер, ни redis-exporter, ни новый конфиг Alloy, а NoActiveCeleryWorkers
# горел по ветке absent() при живом воркере.
#
# Имена метрик 0.12.2 (src/exporter.py, metric_prefix="celery_"):
# celery_worker_up{hostname}, celery_queue_length{queue_name},
# celery_task_failed_total — совпадают с ops/metrics/prometheus/rules/infra.yml.
celery-exporter:
image: danihodovic/celery-exporter:0.12.2
container_name: gendesign-celery-exporter
restart: unless-stopped
profiles: ["apps"]
command:
- "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}"
- "--queues=celery"
expose:
- "9808"
networks:
- shared
mem_limit: 128m
logging: *default-logging
# ── postgres-exporter: инфраструктурная БД (только на Beget) ───────────────── # ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
# forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip # forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
# ничем не ограничен — политики ретенции у GlitchTip нет вообще. # ничем не ограничен — политики ретенции у GlitchTip нет вообще.

View file

@ -196,12 +196,6 @@ services:
# Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки, # Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки,
# но уйдёт: алерт важнее подтверждения. # но уйдёт: алерт важнее подтверждения.
ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru} 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: volumes:
- ./ops/metrics/alert-ack/app.py:/app/app.py:ro - ./ops/metrics/alert-ack/app.py:/app/app.py:ro
expose: expose:
@ -216,74 +210,7 @@ services:
timeout: 10s timeout: 10s
retries: 5 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: витрина ─────────────────────────────────────────────────────────
# 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: grafana:
image: grafana/grafana:11.5.1 image: grafana/grafana:11.5.1
container_name: gendesign-grafana container_name: gendesign-grafana
@ -303,12 +230,6 @@ services:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/ GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
GF_SERVER_SERVE_FROM_SUB_PATH: "false" 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" GF_ANALYTICS_REPORTING_ENABLED: "false"

View file

@ -1919,17 +1919,17 @@ export interface paths {
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff, * @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__). * ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
* *
* Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py). * Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint для ad-hoc
* Этот endpoint для ad-hoc запуска (smoke-тест или повторный pass для * запуска (smoke-тест после деплоя или повторный pass для свежесозданных
* свежесозданных объектов). * объектов до next beat fire).
* *
* - max_objects=None дефолтный лимит таска (300). * - max_objects=None дефолтный лимит таска (300).
* - max_objects=3 smoke-тест. * - max_objects=3 smoke-тест.
* - force=True "Загрузить все": игнорирует skip-today, грузит всё подряд. * - force=True "Загрузить все": игнорирует skip-today, грузит всё подряд.
* *
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true * WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true
* beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc * beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
* запуск без явного подтверждения оператора запрещён (условие #3307). * re-trigger без явного подтверждения оператора запрещён.
*/ */
post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"]; post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"];
delete?: never; delete?: never;
@ -1960,9 +1960,9 @@ export interface paths {
* - max_flats=3 smoke-тест. * - max_flats=3 smoke-тест.
* - force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash. * - force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
* *
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true * WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true
* same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же * same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
* блокировкой StormWall (условие #3307). * того же WAF-бана.
*/ */
post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"]; post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"];
delete?: never; delete?: never;

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

@ -10,8 +10,9 @@
# script says so loudly instead of the silence that let backups break for # script says so loudly instead of the silence that let backups break for
# weeks undetected before (#71). # weeks undetected before (#71).
# #
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), so an # Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same
# hourly cron doesn't spam Telegram once a backup is already known to be stale. # discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam
# Telegram once a backup is already known to be stale.
# #
# Usage (cron — one line per sentinel, run more often than the backup itself # Usage (cron — one line per sentinel, run more often than the backup itself
# so a stale state is caught promptly; hourly is a reasonable default for a # so a stale state is caught promptly; hourly is a reasonable default for a
@ -26,10 +27,9 @@
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \ # /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
# >> /var/log/gendesign-backup-staleness.log 2>&1 # >> /var/log/gendesign-backup-staleness.log 2>&1
# #
# Alert channel: notify() in ops/lib-backup.sh — creds in # Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see
# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/ # notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup,
# TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID (245 «Metrics», #3164). Without the first # TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only.
# two, logs only.
# #
# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be # Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be
# used as a plain healthcheck by anything that just wants the exit code). # used as a plain healthcheck by anything that just wants the exit code).

View file

@ -22,11 +22,7 @@
15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1 15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1
# Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч. # Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч.
# Канал оповещения сторожей и бэкапа волта — /opt/gendesign/secrets/backup-notify.env 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# (TELEGRAM_BOT_TOKEN/CHAT_ID/TOPIC_ID, тема 245 «Metrics», #3164). Без
# BACKUP_ENV_FILE notify() ищет /etc/default/gendesign-backup, где канала на
# этом хосте нет, — алерты молча не уходят никуда.
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# ── Бэкап волта Obsidian (CouchDB) ────────────────────────────────────────── # ── Бэкап волта Obsidian (CouchDB) ──────────────────────────────────────────
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись # Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
@ -37,11 +33,11 @@
# file-level копия работающего узла может оказаться рассогласованной. Время — # file-level копия работающего узла может оказаться рассогласованной. Время —
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс), # 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
# чтобы три задачи не дрались за диск и сеть одновременно. # чтобы три задачи не дрались за диск и сеть одновременно.
45 4 * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1 45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных: # Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных:
# сутки плюс запас на сдвиг прогона. # сутки плюс запас на сдвиг прогона.
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# ── Уборка докера ─────────────────────────────────────────────────────────── # ── Уборка докера ───────────────────────────────────────────────────────────
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их # Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их

View file

@ -51,10 +51,5 @@ COUCHDB_S3_SECRET_KEY=
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат: # Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
#TELEGRAM_BOT_TOKEN= #TELEGRAM_BOT_TOKEN=
#TELEGRAM_CHAT_ID= #TELEGRAM_CHAT_ID=
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
# чат: notify() читает env-файл, только если их нет в окружении.
#TELEGRAM_TOPIC_ID=245
# Либо укажи другой файл, откуда notify() возьмёт настройки канала: # Либо укажи другой файл, откуда notify() возьмёт настройки канала:
#BACKUP_ENV_FILE=/etc/default/gendesign-backup #BACKUP_ENV_FILE=/etc/default/gendesign-backup

View file

@ -27,17 +27,6 @@
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED #FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED #FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED
# --- оповещения (Telegram) ---
# backup-forgejo.sh читает этот файл сам, поэтому канал задаётся здесь: без
# токена и чата notify() ищет их в /etc/default/gendesign-backup.
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
#TELEGRAM_CHAT_ID=123456789
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
# чат: notify() читает env-файл, только если их нет в окружении.
#TELEGRAM_TOPIC_ID=245
# --- optional overrides (defaults are sensible; uncomment only to change) --- # --- optional overrides (defaults are sensible; uncomment only to change) ---
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match #FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
# the policy's Resource path exactly # the policy's Resource path exactly

View file

@ -24,8 +24,13 @@
# #
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron # Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
# entry, see its header) alerts when a backup's sentinel goes stale. It reads # entry, see its header) alerts when a backup's sentinel goes stale. It reads
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID from THIS file. # TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from
# Without the first two set, ops/check-backup-staleness.sh still logs, just # /etc/default/gendesign-uptime — deliberately a separate config so backup
# alerting doesn't depend on the uptime watchdog's env file existing).
# SAME variable names as ops/gendesign-uptime.default.example — point both
# files at the same bot/chat if you want one Telegram destination for
# everything, or use different bots/chats if you'd rather split the noise.
# Without these two set, ops/check-backup-staleness.sh still logs, just
# doesn't send a Telegram alert. # doesn't send a Telegram alert.
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) — # --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
@ -36,14 +41,9 @@
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY #S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY #S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
# --- missed-run alerting (Telegram) --- # --- missed-run alerting (Telegram, shared bot with uptime watchdog) ---
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN #TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
#TELEGRAM_CHAT_ID=123456789 #TELEGRAM_CHAT_ID=123456789
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
# чат: notify() читает env-файл, только если их нет в окружении.
#TELEGRAM_TOPIC_ID=245
# --- optional overrides (defaults are sensible; uncomment only to change) --- # --- optional overrides (defaults are sensible; uncomment only to change) ---
#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and #BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and

View file

@ -0,0 +1,28 @@
# Environment file for ops/uptime-healthcheck.sh (external uptime watchdog, #75).
#
# Install as a ROOT-OWNED, chmod-600 file that is NOT in git, on whatever host
# runs the cron (ideally a host OTHER than the prod VM, so it survives a full
# VPS outage):
# sudo cp ops/gendesign-uptime.default.example /etc/default/gendesign-uptime
# sudo chmod 600 /etc/default/gendesign-uptime
# sudo $EDITOR /etc/default/gendesign-uptime # fill in real Telegram creds
#
# uptime-healthcheck.sh sources this file if present. With NO Telegram vars set,
# it still logs up/down but sends no alert (useful for a dry run first).
#
# Get a bot token from @BotFather; get your chat_id by messaging the bot then
# GET https://api.telegram.org/bot<TOKEN>/getUpdates and reading message.chat.id.
# --- Telegram alerting (both required to enable alerts) ---
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
#TELEGRAM_CHAT_ID=123456789
# --- optional overrides (defaults are sensible; uncomment only to change) ---
#BASE_URL=https://gendsgn.ru
#STATE_FILE=/var/tmp/gendesign-uptime-state
#CURL_TIMEOUT=15
#RETRIES=2
#RETRY_SLEEP=5
# Custom check list (newline-separated "label|path|expected_status"):
#CHECKS="health|/health|200
#market-pulse|/api/v1/analytics/market-pulse|200"

View file

@ -5,7 +5,9 @@
# SOURCED, not executed directly — no shebang execution of its own. Inherits # SOURCED, not executed directly — no shebang execution of its own. Inherits
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins + # the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by # coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
# notify() when Telegram vars are actually set). # notify() when Telegram vars are actually set — curl is already a hard
# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no
# new dependency).
# #
# Load with (script computes its own dir so this works regardless of cron's # Load with (script computes its own dir so this works regardless of cron's
# CWD or which repo subdir the caller lives in): # CWD or which repo subdir the caller lives in):
@ -16,8 +18,14 @@
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; } log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# --- notify ------------------------------------------------------------ # --- notify ------------------------------------------------------------
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env # Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) —
# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164. # this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN /
# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file
# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so
# backup alerting doesn't depend on the uptime watchdog's config being
# present, and vice versa. Point both files at the same bot/chat if you want
# one Telegram destination for everything — that's an ops choice, not this
# script's concern.
# #
# No-op (logs only) when unset — this is the extension point: to wire a # No-op (logs only) when unset — this is the extension point: to wire a
# different channel later, edit ONLY this function; every caller in this repo # different channel later, edit ONLY this function; every caller in this repo
@ -145,9 +153,12 @@ sentinel_age_hours() {
} }
# --- transition-tracked alert state -------------------------------------- # --- transition-tracked alert state --------------------------------------
# A flat "<label> <status>" file, one line per label, so repeated runs alert # Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
# flat "<label> <status>" file, one line per label, so repeated runs alert
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every # only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
# single run — avoids Telegram spam from an hourly staleness-check cron. # single run — avoids Telegram spam from an hourly staleness-check cron.
# Intentionally a separate, independent implementation (not shared code with
# uptime-healthcheck.sh) — that script is out of scope for this change.
backup_prev_status() { backup_prev_status() {
local state_file="$1" label="$2" v local state_file="$1" label="$2" v
[[ -f "$state_file" ]] || { echo "unknown"; return; } [[ -f "$state_file" ]] || { echo "unknown"; return; }

View file

@ -27,28 +27,6 @@
METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна) METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна)
ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку
ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440) 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 from __future__ import annotations
@ -73,7 +51,6 @@ TOPIC_ID = os.environ.get("METRICS_TELEGRAM_TOPIC_ID", "")
ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "") ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "")
PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/") PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/")
TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60 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{}/{}" API = "https://api.telegram.org/bot{}/{}"
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None} # 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) _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 = ( _PAGE = (
"<!doctype html><meta charset=utf-8>" "<!doctype html><meta charset=utf-8>"
"<title>{t}</title>" "<title>{t}</title>"
@ -339,24 +243,11 @@ class Handler(BaseHTTPRequestHandler):
self._reply(code, page.encode()) self._reply(code, page.encode())
def do_POST(self) -> None: # noqa: N802 — имя из stdlib def do_POST(self) -> None: # noqa: N802 — имя из stdlib
# Тело читается ДО любой развилки и ветки отказа. protocol_version = if self.path != "/alertmanager":
# 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":
self._reply(404, b"not found", "text/plain; charset=utf-8") self._reply(404, b"not found", "text/plain; charset=utf-8")
return return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try: try:
payload = json.loads(raw.decode() or "{}") payload = json.loads(raw.decode() or "{}")
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
@ -370,36 +261,6 @@ class Handler(BaseHTTPRequestHandler):
self._reply(200, b"accepted", "text/plain; charset=utf-8") self._reply(200, b"accepted", "text/plain; charset=utf-8")
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start() 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: def main() -> None:
missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v] 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)}") raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}")
if not PUBLIC_URL: if not PUBLIC_URL:
log.warning("ALERT_ACK_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")) port = int(os.environ.get("ALERT_ACK_PORT", "8080"))
log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "", ONCALL or "") log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "", ONCALL or "")
ThreadingHTTPServer(("", port), Handler).serve_forever() 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 repeat_interval: 3h
inhibit_rules: inhibit_rules:
# Если хост целиком недоступен, не сыпать отдельно про каждый его # Если хост целиком недоступен, не сыпать отдельно про каждый его сервис.
# warning-сервис. `target_matchers` НАМЕРЕННО ограничен одним warning
# (#3471): раньше сюда попадал и critical, и падение node-exporter молча
# гасило заодно PostgresLongTransactionCritical и все critical cAdvisor-
# алерты того же хоста — самое важное сообщение исчезало вместе с шумом,
# который оно должно было подавить. Warning того же хоста подавлять по-
# прежнему стоит (диск/память/своп неотличимы от «нет данных»), а critical
# обязан пережить это подавление и дойти до дежурного отдельно.
- source_matchers: [alertname = "HostAgentDown"] - source_matchers: [alertname = "HostAgentDown"]
target_matchers: [severity = "warning"] target_matchers: [severity =~ "warning|critical"]
equal: ["host"] equal: ["host"]
receivers: receivers:

View file

@ -119,27 +119,6 @@ prometheus.scrape "postgres" {
scrape_interval = "60s" 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` — и это // Эндпоинты появляются в части 3. До этого скрейп просто отдаёт `up 0` — и это
// правильно: цель видна как недоступная, а не отсутствует молча. // правильно: цель видна как недоступная, а не отсутствует молча.

View file

@ -57,13 +57,9 @@ prometheus.scrape "cadvisor" {
prometheus.relabel "cadvisor_trim" { prometheus.relabel "cadvisor_trim" {
forward_to = [prometheus.remote_write.central.receiver] forward_to = [prometheus.remote_write.central.receiver]
// `up`/`scrape_samples_scraped` — служебные ряды самого скрейпа, не
// container_*-метрики. Без явного допуска этот keep-фильтр резал их вместе
// с прочим шумом, и у cAdvisor как job'а не было своей серии `up` вообще —
// его смерть выглядела так же, как «ничего не изменилось» (#3471).
rule { rule {
source_labels = ["__name__"] 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" action = "keep"
} }
@ -74,15 +70,9 @@ prometheus.relabel "cadvisor_trim" {
// //
// NB: на пустые панели это правило НЕ влияло. Причина была в cAdvisor 0.52 на // NB: на пустые панели это правило НЕ влияло. Причина была в cAdvisor 0.52 на
// Docker 29 — до сюда доезжал ровно один ряд, корневой. Лечится версией 0.55.1. // Docker 29 — до сюда доезжал ровно один ряд, корневой. Лечится версией 0.55.1.
//
// `up`/`scrape_samples_scraped` лейбла `name` не несут вовсе (они не про
// конкретный контейнер, а про сам скрейп) — фильтр по нему вырезал бы и их.
// Поэтому здесь смотрим на пару (__name__, name): для служебных рядов
// достаточно самого __name__, для контейнерных метрик по-прежнему обязателен
// непустой name.
rule { rule {
source_labels = ["__name__", "name"] source_labels = ["name"]
regex = "up;.*|scrape_samples_scraped;.*|container_[^;]*;.+" regex = ".+"
action = "keep" action = "keep"
} }
} }

View file

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

View file

@ -1,184 +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": "timeseries",
"title": "Конверсия оценка → заявка, скользящие 7 суток",
"description": "Доля успешных оценок (outcome=ok), после которых оставили заявку (#1971): лиды за 7 суток / оценки ok за 7 суток. Знаменатель — только ok, потому что форма заявки показывается только при посчитанной оценке (insufficient_data формы не видит). Счёт по событиям, а не по людям: одна заявка после трёх пересчётов того же адреса даёт 33 %. Пусто вместо 0 % — за 7 суток не было ни одной успешной оценки.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 },
"targets": [
{
"refId": "A",
"expr": "sum(increase(mera_leads_total[7d])) / sum(increase(mera_estimates_total{outcome=\"ok\"}[7d]))",
"legendFormat": "заявки / оценки"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": []
}
},
{ "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 минут не приходят метрики" summary: "С продуктового хоста 15 минут не приходят метрики"
description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write." 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 - name: host
interval: 60s interval: 60s
@ -140,225 +125,17 @@ groups:
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса." description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
# Подошёл к своему mem_limit — следующий шаг OOM-kill. # Подошёл к своему 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)`
# выбрасывает серии без лимита ДО деления.
#
# ВТОРОЕ УСЛОВИЕ — АНОНИМНАЯ ПАМЯТЬ (#3493). working_set = usage inactive_file,
# то есть в нём остаётся АКТИВНЫЙ кэш страниц, а его ядро вытесняет само и
# OOM из-за него не наступает. У контейнера с базой ночные сканы поднимают
# активный кэш к потолку каждую ночь: tradein-postgres за 14 суток дал 5
# эпизодов (все 01:0903:44 UTC) при анонимной памяти не выше 11.4 % лимита и
# oom_kill = 0 — текст «дальше OOM-kill» был ложью. Калибровка этим самым
# выражением по истории 14 суток: tradein-postgres 5 → 0, tradein-browser
# 2 → 2 (06.09 и 12.09, rss до 95 % — настоящие), остальные 0 → 0.
# `and` без `on()`: у working_set и rss один и тот же набор меток cAdvisor,
# совпадение проверено на проде (913 из 915 серий); слева по-прежнему доля.
- alert: ContainerNearMemoryLimit - alert: ContainerNearMemoryLimit
expr: | expr: |
container_memory_working_set_bytes{name!=""} container_spec_memory_limit_bytes{name!=""} > 0
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90 and container_memory_working_set_bytes{name!=""}
and / container_spec_memory_limit_bytes{name!=""} > 0.90
(container_memory_rss{name!=""}
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.50)
for: 15m for: 15m
labels: labels:
severity: warning severity: warning
annotations: annotations:
summary: "Контейнер у своего потолка памяти" summary: "Контейнер у своего потолка памяти"
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit, и больше половины лимита — анонимная память процессов, которую ядро не вытеснит как кэш. Дальше OOM-kill." 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:
# Бэкенд «Меры» лежит или завис (#2214). До этого правила такой отказ не
# давал ни одного сигнала вне Poincare. GlitchTip-монитор 6 смотрит на
# лэндинг meraocenka.ru, а его отдаёт tradein-frontend из пререндер-кэша
# (x-nextjs-cache: HIT) — при мёртвом бэкенде там по-прежнему 200.
# Монитор 2 (gendsgn.ru/health) — это бэкенд «Птицы». Два правила ниже
# считают метрики, которые отдаёт сам бэкенд: мёртвый не отдаёт, и они
# молчат по построению.
#
# Скрейп идёт с Poincare (Alloy), но решение и доставка — здесь, на Beget.
# Зависший event loop даёт таймаут скрейпа (20s), удалённый контейнер —
# ошибку DNS; в обоих случаях up=0. Смерть всего хоста ловят
# HostAgentDown/RemoteWriteStalled; `absent()` — на случай, когда цель
# тихо пропала из alloy-apps.alloy.
#
# for: 5m — по истории up{app="mera"} 26.0817.09 (61 857 точек): 140
# провалов, все короткие (окна деплоя), самый длинный — 4 нулевые точки
# подряд, ~2 минуты до восстановления. 5 минут — запас в 2.5 раза.
#
# Чего правило НЕ видит: публичный путь до бэкенда (DNS, TLS, Caddy).
# Для лэндинга его проверяет монитор 6; монитора на публичную ручку
# бэкенда (meraocenka.ru/trade-in/api/public/mera/stats) пока нет — он
# заводится в UI GlitchTip, а не кодом.
- alert: MeraBackendDown
expr: |
up{job="app", app="mera"} == 0
or absent(up{job="app", app="mera"})
for: 5m
labels:
severity: critical
host: apps
annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
# Гейт по 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 0.12.2 (src/exporter.py) — см.
# комментарий у сервиса в docker-compose.metrics-agent.yml (#3493).
- 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."
# ДВЕ РАЗНЫЕ НОВОСТИ — ДВА ПРАВИЛА (#3493). Прежнее
# `count(celery_worker_up == 1) == 0 or absent(celery_worker_up)` смешивало
# «воркер мёртв» с «экспортёр не поднят» под текстом первой: с 12.09 по 17.09
# оно горело без перерыва по ветке absent() при живом воркере (экспортёр не
# стартовал), повторяясь каждые ~3 часа. А первая ветка не сработала бы
# НИКОГДА: `count()` от пустого вектора возвращает пустой вектор, а не 0,
# поэтому `== 0` сравнивать не с чем — мёртвый воркер она бы пропустила.
#
# Здесь — только когда экспортёр ЖИВ (`up == 1`), но живых воркеров нет:
# либо все серии в 0, либо серий нет (экспортёр вычищает отвалившихся).
# Отсутствие самого экспортёра — QueueExporterDown ниже, со своим текстом.
- alert: NoActiveCeleryWorkers
expr: |
(sum(celery_worker_up) == 0 or absent(celery_worker_up))
and on() (up{job="celery"} == 1)
for: 5m
labels:
severity: critical
annotations:
summary: "Ни одного живого воркера Celery"
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
# Экспортёр очереди не отдаёт метрики. `absent(up{job="X"})` переносит
# job в метки результата, поэтому текст знает, какой из двух.
- alert: QueueExporterDown
expr: |
up{job=~"redis|celery"} == 0
or absent(up{job="redis"})
or absent(up{job="celery"})
for: 10m
labels:
severity: warning
annotations:
summary: "Метрики очереди не собираются"
description: "{{ $labels.job }}-экспортёр на Poincare не отдаёт метрики (up=0 или серии нет вовсе). Это авария наблюдаемости, а не продукта: Redis и воркеры могут быть живы — проверь `docker ps`. Пока она горит, RedisDown и NoActiveCeleryWorkers молчат по построению."
# Порог 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 ──────────────────────────────────────────────────────────────── # ── Postgres ────────────────────────────────────────────────────────────────
- name: postgres - name: postgres
@ -395,20 +172,12 @@ groups:
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие # Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
# позволило 91 день не замечать 198 апдейтов на строку. # позволило 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 - alert: PostgresLowHotUpdateRatio
expr: | 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_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 for: 6h
labels: labels:
severity: warning severity: warning
@ -416,11 +185,6 @@ groups:
summary: "Обновления идут мимо HOT" summary: "Обновления идут мимо HOT"
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие." description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
# Третье правило того же семейства `A and B` — и единственное, где текст
# верен: `$value` тут печатается без humanize, а слева стоит ровно то, что
# описание и называет («N мёртвых»). Совпадение, а не заслуга формы: если
# когда-нибудь захочется печатать здесь ДОЛЮ, отношение придётся вынести
# влево, как в двух правилах выше.
- alert: PostgresDeadTuplesHigh - alert: PostgresDeadTuplesHigh
expr: | expr: |
pg_table_write_amplification_dead_tup > 1000000 pg_table_write_amplification_dead_tup > 1000000

View file

@ -1,172 +0,0 @@
# Юнит-тесты правил `promtool test rules` (#3493). Исполняются деплоем метрик
# перед reload Prometheus (.forgejo/workflows/deploy-metrics.yml): упавший тест
# оставляет работающий Prometheus на прежних правилах.
#
# Каждый случай — ровно тот, что уже случился на проде, а не придуманный.
rule_files:
- ../rules/infra.yml
evaluation_interval: 1m
tests:
# tradein-postgres, ночь 17.09: рабочий набор 98 % от лимита, но почти весь —
# кэш страниц; анонимная память ~1 %. OOM не грозит — тревоги быть не должно.
- interval: 1m
input_series:
- series: 'container_memory_working_set_bytes{host="apps",name="tradein-postgres"}'
values: '2940x40'
- series: 'container_memory_rss{host="apps",name="tradein-postgres"}'
values: '40x40'
- series: 'container_spec_memory_limit_bytes{host="apps",name="tradein-postgres"}'
values: '3000x40'
alert_rule_test:
- eval_time: 30m
alertname: ContainerNearMemoryLimit
exp_alerts: []
# tradein-browser, 12.09: у потолка, и это анонимная память незакрытых
# инстансов браузера. Тревога обязана прийти, и в тексте — доля, а не байты.
- interval: 1m
input_series:
- series: 'container_memory_working_set_bytes{host="apps",name="tradein-browser"}'
values: '1960x40'
- series: 'container_memory_rss{host="apps",name="tradein-browser"}'
values: '1900x40'
- series: 'container_spec_memory_limit_bytes{host="apps",name="tradein-browser"}'
values: '2000x40'
alert_rule_test:
- eval_time: 30m
alertname: ContainerNearMemoryLimit
exp_alerts:
- exp_labels:
severity: warning
host: apps
name: tradein-browser
exp_annotations:
summary: "Контейнер у своего потолка памяти"
description: "apps / tradein-browser: 98% от mem_limit, и больше половины лимита — анонимная память процессов, которую ядро не вытеснит как кэш. Дальше OOM-kill."
# 12.0917.09: экспортёр Celery не поднят, серий нет вовсе, воркер при этом жив.
# Должна гореть QueueExporterDown про celery — и НЕ должна NoActiveCeleryWorkers.
- interval: 1m
input_series:
- series: 'up{job="redis",host="apps"}'
values: '1x40'
alert_rule_test:
- eval_time: 30m
alertname: NoActiveCeleryWorkers
exp_alerts: []
- eval_time: 30m
alertname: QueueExporterDown
exp_alerts:
- exp_labels:
severity: warning
job: celery
exp_annotations:
summary: "Метрики очереди не собираются"
description: "celery-экспортёр на Poincare не отдаёт метрики (up=0 или серии нет вовсе). Это авария наблюдаемости, а не продукта: Redis и воркеры могут быть живы — проверь `docker ps`. Пока она горит, RedisDown и NoActiveCeleryWorkers молчат по построению."
# Нормальная работа: экспортёр жив, воркер шлёт heartbeat — тишина.
- interval: 1m
input_series:
- series: 'up{job="celery",host="apps"}'
values: '1x40'
- series: 'up{job="redis",host="apps"}'
values: '1x40'
- series: 'celery_worker_up{hostname="celery@worker",host="apps"}'
values: '1x40'
alert_rule_test:
- eval_time: 30m
alertname: NoActiveCeleryWorkers
exp_alerts: []
- eval_time: 30m
alertname: QueueExporterDown
exp_alerts: []
# Воркер умер: экспортёр жив, серия воркера в 0. Прежнее правило
# (`count(x == 1) == 0`) этот случай пропускало — count() от пустого вектора пуст.
- interval: 1m
input_series:
- series: 'up{job="celery",host="apps"}'
values: '1x40'
- series: 'celery_worker_up{hostname="celery@worker",host="apps"}'
values: '0x40'
alert_rule_test:
- eval_time: 30m
alertname: NoActiveCeleryWorkers
exp_alerts:
- exp_labels:
severity: critical
exp_annotations:
summary: "Ни одного живого воркера Celery"
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
# Воркер умер давно: экспортёр вычистил его серию, осталось только `up`.
- interval: 1m
input_series:
- series: 'up{job="celery",host="apps"}'
values: '1x40'
alert_rule_test:
- eval_time: 30m
alertname: NoActiveCeleryWorkers
exp_alerts:
- exp_labels:
severity: critical
exp_annotations:
summary: "Ни одного живого воркера Celery"
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
# #2214: tradein-backend умер, хост и агент живы — агент шлёт up=0.
# До MeraBackendDown этот случай не давал ни одного сигнала вне Poincare.
- interval: 30s
input_series:
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
values: '1x9 0x40'
alert_rule_test:
- eval_time: 15m
alertname: MeraBackendDown
exp_alerts:
- exp_labels:
severity: critical
host: apps
job: app
app: mera
instance: tradein-backend:8000
exp_annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
# Окно деплоя: самый длинный провал за 26.0817.09 — 4 нулевые точки подряд
# (~2 минуты). Тревоги быть не должно. Заодно упавший бэкенд «Птицы» не должен
# поднимать тревогу про «Меру».
- interval: 30s
input_series:
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
values: '1x19 0 0 0 0 1x40'
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
values: '0x64'
alert_rule_test:
- eval_time: 11m
alertname: MeraBackendDown
exp_alerts: []
- eval_time: 30m
alertname: MeraBackendDown
exp_alerts: []
# Цель пропала из скрейпа (агент жив, «Птица» снимается), серии «Меры» нет.
- interval: 30s
input_series:
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
values: '1x40'
alert_rule_test:
- eval_time: 10m
alertname: MeraBackendDown
exp_alerts:
- exp_labels:
severity: critical
host: apps
job: app
app: mera
exp_annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."

View file

@ -1,65 +0,0 @@
#!/bin/sh
# Пересоздать контейнер, если он читает СТАРЫЙ инод пофайлового bind-маунта
# (#3467, #3486).
#
# sh ops/metrics/recreate-stale-mount.sh ПРОЕКТ COMPOSE-ФАЙЛ СЕРВИС ФАЙЛ-НА-ХОСТЕ ПУТЬ-В-КОНТЕЙНЕРЕ
#
# ЗАЧЕМ. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
# одного файла держит прежний: контейнер продолжает читать старый текст, `up -d`
# этого не видит (сравнивает описание сервиса, а не содержимое маунта), и
# перезагрузка по API тоже — процесс перечитывает путь, за которым в контейнере
# всё тот же старый инод. Проверено на prom/prometheus:v3.1.0 (17.09): файл
# подменён, POST /-/reload → rc=0, а в /api/v1/status/config остался прежний
# job. Лечит только пересоздание контейнера — его и делаем, но лишь при
# расхождении, чтобы не рвать работу на каждом деплое.
#
# Та же ловушка уже ловилась на Alertmanager (27.08), Alloy (#3380) и Caddy
# (#3443, ops/caddy-apply.sh). Контейнер не запущен — старого инода держать
# некому (при старте маунт берётся с хоста заново), выходим с 0. Сервис
# выключенного профиля сюда передавать нельзя: `up` с явным именем включает
# профиль и поднял бы сервис без окружения — гейт по профилю у вызывающего.
# Сверка не прочиталась — считаем расхождением. После пересоздания сверяем
# снова: не сошлось — exit 1, а не зелёный деплой со старым конфигом.
set -eu
if [ "$#" -ne 5 ]; then
echo "usage: $0 PROJECT COMPOSE_FILE SERVICE HOST_FILE CONTAINER_FILE" >&2
exit 2
fi
project=$1 compose_file=$2 service=$3 src=$4 dst=$5
cd "$(dirname "$0")/../.."
# Ошибку `ps` НЕ глушим: пустой ответ от упавшего compose читался бы как
# «контейнер не запущен» — и зелёный выход со старым конфигом.
cid() { docker compose -p "$project" -f "$compose_file" ps -q "$service"; }
seen() { docker exec "$1" stat -c %i "$dst" 2>/dev/null || true; }
id=$(cid)
if [ -z "$id" ]; then
echo "$service: контейнер не запущен — старый инод $src держать некому."
exit 0
fi
want=$(stat -c %i "$src")
if [ "$(seen "$id")" = "$want" ]; then
echo "$service: $src доехал до контейнера (инод $want)."
exit 0
fi
echo "$service: контейнер не видит текущий $src (инод на хосте $want) — пересоздаю."
docker compose -p "$project" -f "$compose_file" up -d --no-deps --force-recreate "$service"
i=0
while [ "$i" -lt 15 ]; do
id=$(cid)
if [ -n "$id" ] && [ "$(seen "$id")" = "$want" ]; then
echo "$service: пересоздан, $src доехал (инод $want)."
exit 0
fi
i=$((i + 1))
sleep 2
done
echo "::error::$service: после пересоздания контейнер всё ещё не видит $src (инод $want)"
exit 1

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

@ -216,7 +216,7 @@ log "11. Закрепление рабочего адреса api.telegram.org"
# #
# Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для # Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для
# tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает: # tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает:
# ops/lib-backup.sh (уведомления о бэкапах). # ops/lib-backup.sh (уведомления о бэкапах) и ops/uptime-healthcheck.sh.
# Без этого они молча перестают слать алерты — а это ровно тот канал, которым # Без этого они молча перестают слать алерты — а это ровно тот канал, которым
# мы узнали бы о любой другой поломке. # мы узнали бы о любой другой поломке.
TELEGRAM_API_IP="${TELEGRAM_API_IP:-149.154.167.220}" TELEGRAM_API_IP="${TELEGRAM_API_IP:-149.154.167.220}"

171
ops/uptime-healthcheck.sh Executable file
View file

@ -0,0 +1,171 @@
#!/usr/bin/env bash
# External uptime watchdog for gendesign (#75 B6-1, lightweight fallback).
#
# WHY THIS EXISTS alongside Uptime Kuma (docker-compose.uptime.yml): Kuma runs
# ON the prod VM, so if the whole VM dies it can't alert. This script is meant to
# run from cron on a DIFFERENT host (your laptop, a tiny free-tier box, Beget
# shared-host cron) and hit the PUBLIC URLs over the internet — last-resort
# "весь хост лёг" detection. Kuma covers rich per-endpoint/SSL/latency monitoring;
# this covers the case Kuma structurally can't.
#
# Self-contained: only needs `curl` + `bash`. No docker, no repo checkout.
# State (last-known status per check) lives in a file so we alert on TRANSITIONS
# (up→down, down→up) — not every run — to avoid Telegram spam.
#
# Usage (cron — note `bash`, not a bare path, so a missing +x bit can't break it):
# * * * * * bash /path/to/uptime-healthcheck.sh >> /var/log/gendesign-uptime.log 2>&1
#
# Telegram alerting — set these in an env file (NOT in git, chmod 600):
# TELEGRAM_BOT_TOKEN=123456:ABC...
# TELEGRAM_CHAT_ID=123456789
# Default env path: /etc/default/gendesign-uptime (override via UPTIME_ENV_FILE).
# A redacted template lives at ops/gendesign-uptime.default.example.
# Without a token set, the script still logs up/down but sends no alert.
set -euo pipefail
# --- config (env-overridable) ---
UPTIME_ENV_FILE="${UPTIME_ENV_FILE:-/etc/default/gendesign-uptime}"
# shellcheck source=/dev/null
[[ -f "$UPTIME_ENV_FILE" ]] && source "$UPTIME_ENV_FILE"
BASE_URL="${BASE_URL:-https://gendsgn.ru}"
STATE_FILE="${STATE_FILE:-/var/tmp/gendesign-uptime-state}"
CURL_TIMEOUT="${CURL_TIMEOUT:-15}" # seconds per request (connect+read)
RETRIES="${RETRIES:-2}" # extra attempts before declaring DOWN
RETRY_SLEEP="${RETRY_SLEEP:-5}" # seconds between attempts
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
# Checks to probe. Format per line: "<label>|<path>|<expected_http_status>".
# Override the whole list via CHECKS env (same newline-separated format).
#
# DEFAULT = /health ONLY. WHY: this watchdog is UNAUTHENTICATED, and in the
# Caddyfile only /health (and /preview/*) are public — ALL /api/* sits behind
# the Basic-Auth gate (import caddy/users.caddy.snippet) and returns 401 to an
# anonymous client. So the issue's /api/v1/analytics/market-pulse and
# /parcels/{cad}/analyze monitors CANNOT be probed anonymously — they belong in
# Uptime Kuma, which can attach the Basic-Auth header (see README "Kuma monitors"
# in docker-compose.uptime.yml). Probing them here would false-alarm forever.
CHECKS="${CHECKS:-health|/health|200}"
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# --- telegram (no-op if token/chat unset) ---
notify() {
local text="$1"
if [[ -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then
log "NOTIFY (telegram disabled — no token/chat): $text"
return 0
fi
# #3059: путь до Telegram теряет соединения. Замер 26.08 с Poincare — 40
# подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.154.167.220: 3 отказа (7.5%), все
# таймаутом на установке соединения; успешные при этом стабильны (0.14-0.17 с).
# Три остальных дата-центра Telegram с Selectel недостижимы вовсе, так что
# запасного адреса нет — потери на единственном рабочем неустранимы сетью.
#
# Раньше здесь был ОДИН curl, и `|| log WARN` означал, что каждый такой отказ
# ТЕРЯЕТ алерт целиком: уведомление о падении прода не приходит, остаётся
# строка в логе, который читают уже после аварии. Watchdog, который сам себя
# не может дозваться, — худший вид самоскрывающейся поломки: чем хуже дела,
# тем вероятнее, что о них не сообщат.
#
# Цикл, а не `curl --retry`: ниже в этом же файле проверки уже повторяются
# ровно такой конструкцией (см. `for attempt in $(seq 1 ...)`), и семантика
# `--max-time` при ретраях curl зависит от версии. Здесь таймаут заведомо
# применяется к КАЖДОЙ попытке.
#
# Дубль вместо потери — осознанный размен: sendMessage не идемпотентен, но
# замер показал, что отказы происходят на СТАДИИ ПОДКЛЮЧЕНИЯ, до отправки
# запроса, так что повтор почти никогда не дублирует уже доставленное
# сообщение. А продублированный алерт безвреден, пропущенный — нет.
local attempt
for attempt in 1 2 3; do
if curl -fsS --max-time "$CURL_TIMEOUT" \
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
${TELEGRAM_TOPIC_ID:+-d "message_thread_id=${TELEGRAM_TOPIC_ID}"} \
-d "disable_web_page_preview=true" \
--data-urlencode "text=${text}" \
>/dev/null 2>&1; then
[[ "$attempt" -gt 1 ]] && log "telegram sendMessage: доставлено с попытки ${attempt}"
return 0
fi
[[ "$attempt" -lt 3 ]] && sleep "${NOTIFY_RETRY_DELAY:-2}"
done
log "WARN: telegram sendMessage failed — 3 попытки подряд, алерт НЕ ДОСТАВЛЕН"
}
# --- state helpers (last status per check) ---
prev_status() {
local label="$1"
[[ -f "$STATE_FILE" ]] || { echo "unknown"; return; }
# Line format: "<label> <status>". grep the latest for this label.
local v
v="$(grep -E "^${label} " "$STATE_FILE" 2>/dev/null | tail -1 | awk '{print $2}')"
echo "${v:-unknown}"
}
set_status() {
local label="$1" status="$2" tmp
tmp="$(mktemp)"
# Drop any prior line for this label, then append the fresh one.
if [[ -f "$STATE_FILE" ]]; then
grep -vE "^${label} " "$STATE_FILE" > "$tmp" 2>/dev/null || true
fi
echo "${label} ${status}" >> "$tmp"
mv "$tmp" "$STATE_FILE"
}
# --- probe one URL with retries; echoes "up" or "down code=NNN" ---
probe() {
local url="$1" expect="$2" attempt code
# NB: NO `-f` here. `-w %{http_code}` always prints exactly a 3-digit status
# (or 000 on connect/timeout failure), so we judge by the code ourselves and
# never need a `|| echo` fallback (which previously concatenated → "401000").
for attempt in $(seq 1 "$((RETRIES + 1))"); do
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" "$url" 2>/dev/null)"
code="${code:-000}"
if [[ "$code" == "$expect" ]]; then
echo "up"
return 0
fi
[[ "$attempt" -le "$RETRIES" ]] && sleep "$RETRY_SLEEP"
done
# Report the last code seen for the alert body.
echo "down code=${code}"
return 0
}
# --- run ---
overall_rc=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
label="${line%%|*}"
rest="${line#*|}"
path="${rest%%|*}"
expect="${rest##*|}"
url="${BASE_URL}${path}"
result="$(probe "$url" "$expect")"
now="up"
[[ "$result" == up ]] || now="down"
prev="$(prev_status "$label")"
if [[ "$now" == "up" ]]; then
log "OK $label ($url)"
if [[ "$prev" == "down" ]]; then
notify "✅ RECOVERED: gendesign $label is back UP ($url)"
fi
else
overall_rc=1
log "DOWN $label ($url) — $result"
# Alert on transition into down (or first-ever observation that is down).
if [[ "$prev" != "down" ]]; then
notify "🔴 DOWN: gendesign $label${url} (${result#down }). $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
fi
fi
set_status "$label" "$now"
done <<< "$CHECKS"
exit "$overall_rc"

View file

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

View file

@ -63,7 +63,6 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper
from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper
from scraper_kit.providers.yandex.serp import YandexRealtyScraper from scraper_kit.providers.yandex.serp import YandexRealtyScraper
from scraper_kit.providers.yandex.valuation import YandexValuationScraper from scraper_kit.providers.yandex.valuation import YandexValuationScraper
from scraper_kit.proxy_errors import NoProxyAvailableError
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -517,13 +516,16 @@ async def cian_auto_login(
) )
try: try:
# #3410: логин идёт через узел пула, как debug-карточка DomClick ниже. `/login` # #3197 (хвост): через фабрику (endpoint/environment из одного места), но
# сайдкара принимает proxy в теле, `BrowserFetcher.login` кладёт туда узел аренды. # НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
# До этого (#3197) провайдер сюда не передавали, и логин шёл с env-узла сайдкара — # (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам
# на проде это выключенный узел 9, camoufox `InvalidIP`, ручка не работала вовсе. # `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) —
async with build_browser_fetcher( # логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле
RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider() # блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError →
) as fetcher: # 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара.
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
raw_cookies = await fetcher.login( raw_cookies = await fetcher.login(
url=settings.cian_login_url, url=settings.cian_login_url,
email=email, email=email,
@ -535,13 +537,6 @@ async def cian_auto_login(
pre_click_selectors=settings.cian_login_pre_click_selectors, pre_click_selectors=settings.cian_login_pre_click_selectors,
wait_ms=settings.cian_login_wait_ms, wait_ms=settings.cian_login_wait_ms,
) )
except NoProxyAvailableError as exc:
# Пул пуст для cian (прод, #2616): на мёртвый env-узел не идём, говорим прямо.
logger.warning("cian auto-login: пул прокси пуст для cian — логин не запускался")
raise HTTPException(
status_code=503,
detail="Нет свободного узла прокси для cian в пуле — логин не запускался",
) from exc
except Exception as exc: except Exception as exc:
logger.error("cian auto-login failed: %s", type(exc).__name__) logger.error("cian auto-login failed: %s", type(exc).__name__)
raise HTTPException( raise HTTPException(
@ -1257,11 +1252,8 @@ async def start_avito_city_sweep(
request_delay_sec=payload.request_delay_sec, request_delay_sec=payload.request_delay_sec,
enrich_imv=payload.enrich_imv, enrich_imv=payload.enrich_imv,
) )
except Exception as exc: except Exception:
logger.exception("city-sweep background task run_id=%d crashed", run_id) logger.exception("city-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally: finally:
sweep_db.close() sweep_db.close()
@ -1352,11 +1344,8 @@ async def start_cian_city_sweep(
detail_top_n=payload.detail_top_n, detail_top_n=payload.detail_top_n,
enrich_houses=payload.enrich_houses, enrich_houses=payload.enrich_houses,
) )
except Exception as exc: except Exception:
logger.exception("cian-sweep background task run_id=%d crashed", run_id) logger.exception("cian-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally: finally:
sweep_db.close() sweep_db.close()
@ -1498,11 +1487,8 @@ async def start_cian_full_load(
resume_run_id=payload.resume_run_id, resume_run_id=payload.resume_run_id,
secondary_only=payload.secondary_only, secondary_only=payload.secondary_only,
) )
except Exception as exc: except Exception:
logger.exception("cian-full-load background task run_id=%d crashed", run_id) logger.exception("cian-full-load background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(task_db, run_id, exc)
finally: finally:
task_db.close() task_db.close()
@ -1604,11 +1590,8 @@ async def start_yandex_full_load(
concurrency=payload.concurrency, concurrency=payload.concurrency,
resume_run_id=payload.resume_run_id, resume_run_id=payload.resume_run_id,
) )
except Exception as exc: except Exception:
logger.exception("yandex-full-load background task run_id=%d crashed", run_id) logger.exception("yandex-full-load background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(task_db, run_id, exc)
finally: finally:
task_db.close() task_db.close()
@ -1675,11 +1658,8 @@ async def start_yandex_city_sweep(
request_delay_sec=payload.request_delay_sec, request_delay_sec=payload.request_delay_sec,
enrich_address=payload.enrich_address, enrich_address=payload.enrich_address,
) )
except Exception as exc: except Exception:
logger.exception("yandex-sweep background task run_id=%d crashed", run_id) logger.exception("yandex-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally: finally:
sweep_db.close() sweep_db.close()

View file

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

View file

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

View file

@ -43,7 +43,6 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import secrets import secrets
@ -51,14 +50,11 @@ from datetime import UTC, datetime
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Header, HTTPException, Query, Request from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, ValidationError from pydantic import BaseModel, ConfigDict, ValidationError
from starlette.background import BackgroundTask
from app.core.config import settings from app.core.config import settings
from app.services.tgbot.client import TelegramError from app.services.tgbot.client import TelegramError
from app.services.tgbot.shared import get_telegram_client from app.services.tgbot.shared import get_telegram_client
from app.tasks.glitchtip_alert_retry import retry_forward_alert
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -75,14 +71,6 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)"
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты. # timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
_INTERACTIVE_SEND_TIMEOUT_S = 8.0 _INTERACTIVE_SEND_TIMEOUT_S = 8.0
_INTERACTIVE_SEND_MAX_RETRIES = 1 _INTERACTIVE_SEND_MAX_RETRIES = 1
# Потолок на ВСЮ синхронную попытку (#3157). Таймаут выше — на один HTTP-запрос,
# а попыток больше одной: ретранслятор, при его отказе прямой путь, пауза, повтор
# (#3471). 16.09.2026 16:42 UTC отказ шёл медленно (8 с relay + 5 с напрямую +
# 2 с паузы), GlitchTip на 10-й секунде оборвал соединение (Caddy: status=0),
# а хендлер досчитал себе 200 уже в пустоту — uvicorn ответ разорванному
# клиенту молча выбрасывает вместе со строкой access-log. Отправитель и
# приёмник расходились в исходе. Запас 3 с от 10 с GlitchTip — на Caddy и тело.
_INTERACTIVE_SEND_DEADLINE_S = 7.0
class GlitchTipAttachment(BaseModel): class GlitchTipAttachment(BaseModel):
@ -196,28 +184,16 @@ def _verify_secret(provided: str) -> None:
raise HTTPException(status_code=401, detail="invalid or missing secret") 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( async def glitchtip_webhook(
request: Request, request: Request,
secret: Annotated[str, Query()] = "", secret: Annotated[str, Query()] = "",
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "", header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
) -> dict[str, str] | JSONResponse: ) -> dict[str, str]:
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в """Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID`` Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля). ОТДЕЛЬНАЯ тема от 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``) этот Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) этот
хендлер сам делает единственную проверку секрета. хендлер сам делает единственную проверку секрета.
@ -243,41 +219,20 @@ async def glitchtip_webhook(
# до api.telegram.org перед каждой отправкой. Живёт в lifespan. # до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client() client = get_telegram_client()
try: try:
async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S): await client.send_message(
await client.send_message( chat_id=settings.telegram_alerts_chat_id,
chat_id=settings.telegram_alerts_chat_id, text=text,
text=text, message_thread_id=settings.telegram_alerts_topic_id or None,
message_thread_id=settings.telegram_alerts_topic_id or None, # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не # может легально висеть воркерные минуты ретраев.
# может легально висеть воркерные минуты ретраев. timeout=_INTERACTIVE_SEND_TIMEOUT_S,
timeout=_INTERACTIVE_SEND_TIMEOUT_S, max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES, )
) except TelegramError:
except (TelegramError, TimeoutError):
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram — # Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не # тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
# 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок # 500 из необработанного исключения (#3456).
# `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
# (#3471, см. app.tasks.glitchtip_alert_retry).
#
# `raise HTTPException` здесь НЕ подходит: FastAPI прикрепляет
# background-задачи только к ответу, который вернул сам хендлер, а
# исключение строит СВОЙ отдельный JSONResponse в exception-мидлваре —
# задача, поставленная до `raise`, никогда бы не выполнилась. Поэтому
# 502 собран и возвращён вручную, с задачей на этом же объекте ответа.
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram") logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
return JSONResponse( raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
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,
),
)
return {"status": "ok"} return {"status": "ok"}

View file

@ -2,16 +2,9 @@
POST /api/v1/trade-in/lead контактная заявка с результата оценки: POST /api/v1/trade-in/lead контактная заявка с результата оценки:
телефон + явное согласие на обработку персональных данных. Persist в телефон + явное согласие на обработку персональных данных. Persist в
trade_in_leads. trade_in_leads. Notification (Telegram/email) вне scope: нет существующей
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
Уведомление ответственному (#1971): после ответа клиенту (BackgroundTasks) лид persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
уходит сообщением в support-топик Telegram тот же бот и топик, что у веб-чата
поддержки (`app.api.v1.support`). До этого заявки только ложились в таблицу, и
никто о них не узнавал. Успешная отправка проставляет `notified_at`; отказ
Telegram не трогает ни ответ, ни лид остаётся лог и пустой `notified_at` как
видимый след недоставки. Телефона в сообщении НЕТ: копия в Telegram не стирается
механизмом удаления ПДн (`data_erasure.py`), поэтому туда идут только id
телефон оператор берёт из trade_in_leads.
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца любой СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца любой
@ -32,17 +25,14 @@ from datetime import UTC, datetime, timedelta
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request from fastapi import APIRouter, Depends, Header, HTTPException, Request
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.v1.support import _bot_configured
from app.api.v1.trade_in import _assert_estimate_access from app.api.v1.trade_in import _assert_estimate_access
from app.core.config import settings from app.core.config import settings
from app.core.db import SessionLocal, get_db from app.core.db import get_db
from app.observability.metrics import LEADS
from app.services.tgbot.shared import get_telegram_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -108,49 +98,11 @@ class TradeInLeadInput(BaseModel):
return value return value
async def _notify_new_lead(lead_id: str, estimate_id: UUID | None, username: str | None) -> None:
"""Сообщает о заявке в support-топик и отмечает `notified_at` (#1971).
Идёт после ответа клиенту, поэтому ретраи клиента штатные воркерные, без
интерактивного бюджета (как `app.tasks.glitchtip_alert_retry`). Сессия своя:
сессия запроса к этому моменту уже закрыта. Любой отказ только в лог.
"""
message = (
"Новая заявка на трейд-ин\n"
f"id: {lead_id}\n"
f"пользователь: {username or ''}\n"
f"оценка: {estimate_id or 'без привязки'}\n"
"Телефон — в trade_in_leads по id."
)
try:
await get_telegram_client().send_message(
chat_id=settings.telegram_support_chat_id,
text=message,
message_thread_id=settings.telegram_support_topic_id or None,
)
except Exception:
logger.exception("trade_in_lead: уведомление не доставлено id=%s", lead_id)
return
db = SessionLocal()
try:
db.execute(
text("UPDATE trade_in_leads SET notified_at = now() WHERE id = CAST(:id AS uuid)"),
{"id": lead_id},
)
db.commit()
except Exception:
logger.exception("trade_in_lead: уведомление ушло, notified_at не записан id=%s", lead_id)
finally:
db.close()
@router.post("/lead") @router.post("/lead")
async def create_trade_in_lead( async def create_trade_in_lead(
payload: TradeInLeadInput, payload: TradeInLeadInput,
request: Request, request: Request,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
background_tasks: BackgroundTasks,
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Сохраняет лид (телефон + согласие) в trade_in_leads.""" """Сохраняет лид (телефон + согласие) в trade_in_leads."""
@ -221,7 +173,6 @@ async def create_trade_in_lead(
) )
db.commit() db.commit()
LEADS.inc()
logger.info( logger.info(
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s", "trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
@ -232,15 +183,6 @@ async def create_trade_in_lead(
_CONSENT_POLICY_VERSION, _CONSENT_POLICY_VERSION,
) )
if _bot_configured():
background_tasks.add_task(
_notify_new_lead, row["id"], payload.estimate_id, x_authenticated_user
)
else:
logger.warning(
"trade_in_lead: бот не настроен, уведомление о id=%s не отправлено", row["id"]
)
return { return {
"id": row["id"], "id": row["id"],
"created_at": row["created_at"].isoformat(), "created_at": row["created_at"].isoformat(),

View file

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

View file

@ -62,7 +62,6 @@ import hashlib
import logging import logging
import re import re
import secrets import secrets
import time
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Literal from typing import Annotated, Literal
@ -74,7 +73,6 @@ from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.db import get_db from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter, _client_ip 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 import web_support_storage as storage
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
from app.services.tgbot.client import TelegramError from app.services.tgbot.client import TelegramError
@ -171,14 +169,6 @@ _SEND_UNAVAILABLE_DETAIL = "Telegram сейчас недоступен. Попр
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`. # треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
_LIST_MESSAGES_LIMIT = 200 _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: def _require_username(request: Request) -> str:
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его """Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
@ -354,51 +344,8 @@ def _rollback_quietly(db: Session) -> None:
logger.warning("web support: rollback после сбоя БД тоже не удался", exc_info=True) 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) @router.post("/support/messages", response_model=SupportMessageOut)
async def send_support_message( async def send_support_message(
request: Request,
payload: SupportMessageInput, payload: SupportMessageInput,
username: Annotated[str, Depends(_require_username)], username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
@ -432,37 +379,6 @@ async def send_support_message(
if cooldown is not None: if cooldown is not None:
raise _too_many_failures_error(cooldown) 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): на каждый запрос # Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк # свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan. # до api.telegram.org перед каждой отправкой. Живёт в lifespan.
@ -490,7 +406,6 @@ async def send_support_message(
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет. # Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
_send_limiter.record(username) _send_limiter.record(username)
SUPPORT_MESSAGES.labels(channel="web").inc()
# Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы. # Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы.
_send_failure_limiter.reset(username) _send_failure_limiter.reset(username)
@ -521,7 +436,6 @@ async def send_support_message(
text_body=payload.text, text_body=payload.text,
topic_message_id=topic_message_id, topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id, support_chat_id=settings.telegram_support_chat_id,
idempotency_key=idempotency_key,
) )
db.commit() db.commit()
except SQLAlchemyError: except SQLAlchemyError:
@ -692,31 +606,6 @@ async def send_anon_support_message(
raise _too_many_failures_error(cooldown) raise _too_many_failures_error(cooldown)
display_id = _anon_display_id(token) 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): на каждый запрос # Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк # свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan. # до api.telegram.org перед каждой отправкой. Живёт в lifespan.
@ -742,7 +631,6 @@ async def send_anon_support_message(
_send_limiter.record(thread_key) _send_limiter.record(thread_key)
_anon_ip_limiter.record(ip) _anon_ip_limiter.record(ip)
SUPPORT_MESSAGES.labels(channel="anon").inc()
# Канал жив — счётчики отказов считают именно ПОДРЯД идущие отказы. # Канал жив — счётчики отказов считают именно ПОДРЯД идущие отказы.
_send_failure_limiter.reset(thread_key) _send_failure_limiter.reset(thread_key)
_anon_ip_failure_limiter.reset(ip) _anon_ip_failure_limiter.reset(ip)
@ -766,7 +654,6 @@ async def send_anon_support_message(
text_body=payload.text, text_body=payload.text,
topic_message_id=topic_message_id, topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id, support_chat_id=settings.telegram_support_chat_id,
idempotency_key=idempotency_key,
) )
db.commit() db.commit()
except SQLAlchemyError: except SQLAlchemyError:

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.config import settings
from app.core.db import get_db from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter, _client_ip from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.observability.metrics import ESTIMATES, REPORTS_EXPORTED
from app.schemas.trade_in import ( from app.schemas.trade_in import (
AggregatedEstimate, AggregatedEstimate,
AnalogLot, AnalogLot,
@ -561,13 +560,6 @@ async def estimate(
# #3082: слот возвращаем сразу после дорогой части — инкремент квоты и # #3082: слот возвращаем сразу после дорогой части — инкремент квоты и
# сериализация ответа ниже дёшевы и слот держать не должны. # сериализация ответа ниже дёшевы и слот держать не должны.
_estimate_slots.release() _estimate_slots.release()
# #3471: считаем оценку "успешно посчитанной" здесь — до квоты и до 429
# ниже, потому что расчёт (дорогая часть) уже прошёл. insufficient_data —
# тоже успех расчёта: медиана не нашлась не потому что что-то сломалось, а
# потому что аналогов не было, это отдельный, а не ошибочный исход.
ESTIMATES.labels(outcome="insufficient_data" if result.insufficient_data else "ok").inc()
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise # #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение # выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
# тут: при гонке двух /estimate на used=lim-1 второй получит False. # тут: при гонке двух /estimate на used=lim-1 второй получит False.
@ -718,7 +710,6 @@ def load_estimate(
_qc_geo_to_precision, _qc_geo_to_precision,
_resolve_target_city, _resolve_target_city,
_source_counts, _source_counts,
analog_tier_from_explanation,
rehydrate_search_radius_m, rehydrate_search_radius_m,
) )
@ -897,10 +888,6 @@ def load_estimate(
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить # осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
# сюда дефолт значило бы выдать догадку за то, что просил пользователь. # сюда дефолт значило бы выдать догадку за то, что просил пользователь.
search_radius_m=search_radius_m, search_radius_m=search_radius_m,
# #3466: якорный тир (same_building/micro_radius) восстанавливается из подписи
# якорного блока в confidence_explanation; радиусный — null, колонки нет.
# Без него ссылка ?id= теряла признак «коридор в цену не вошёл» (Tier A).
analog_tier=analog_tier_from_explanation(row.confidence_explanation),
) )
@ -1026,7 +1013,6 @@ def estimate_pdf(
brand_obj = _resolve_brand(owner_brand_slug, db) brand_obj = _resolve_brand(owner_brand_slug, db)
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj) pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf" filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
REPORTS_EXPORTED.inc()
logger.info( logger.info(
"PDF generated estimate_id=%s brand=%s size=%d", "PDF generated estimate_id=%s brand=%s size=%d",
estimate_id, estimate_id,
@ -2215,7 +2201,6 @@ def get_street_deals(
_percentile, _percentile,
_resolve_target_city, _resolve_target_city,
extract_street_name, extract_street_name,
region_code_for_address,
) )
now = datetime.now(tz=UTC) now = datetime.now(tz=UTC)
@ -2259,17 +2244,6 @@ def get_street_deals(
target_city = _resolve_target_city(address) target_city = _resolve_target_city(address)
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else "" 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 = ( rows = (
db.execute( db.execute(
text( text(
@ -2281,7 +2255,6 @@ def get_street_deals(
AND address ILIKE :street_pattern AND address ILIKE :street_pattern
AND address ~* :street_regex AND address ~* :street_regex
{city_filter} {city_filter}
AND region_code = CAST(:region_code AS integer)
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади -- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй -- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто -- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто
@ -2296,7 +2269,6 @@ def get_street_deals(
"street_pattern": "%" + street_name + "%", "street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M", "street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None, "target_city": target_city.lower() if target_city else None,
"region_code": region_code,
"area_min": area_min, "area_min": area_min,
"area_max": area_max, "area_max": area_max,
"period_months": period_months, "period_months": period_months,
@ -2531,12 +2503,7 @@ def get_sales_vs_listings(
Per-street view: Росреестр open dataset агрегирует адреса до улицы. Per-street view: Росреестр open dataset агрегирует адреса до улицы.
""" """
from app.services.estimator import ( from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse: def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
return SalesVsListingsResponse( return SalesVsListingsResponse(
@ -2566,36 +2533,16 @@ def get_sales_vs_listings(
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205). # известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
target_city = _resolve_target_city(address) 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 = ( rows = (
db.execute( db.execute(
text( text(
""" """
SELECT SELECT
sv.deal_id, sv.deal_date, sv.deal_price_rub, sv.deal_price_per_m2, deal_id, deal_date, deal_price_rub, deal_price_per_m2,
sv.deal_area_m2, sv.deal_rooms, sv.deal_floor, sv.deal_address, deal_area_m2, deal_rooms, deal_floor, deal_address,
sv.listing_id, sv.listing_source, sv.listing_source_url, listing_id, listing_source, listing_source_url,
sv.listing_date, sv.listing_price_rub, sv.listing_price_per_m2, listing_date, listing_price_rub, listing_price_per_m2,
sv.listing_area_m2, sv.days_listing_to_deal, sv.discount_pct listing_area_m2, days_listing_to_deal, discount_pct
FROM street_sales_vs_listings( FROM street_sales_vs_listings(
CAST(:street_pattern AS text), CAST(:street_pattern AS text),
CAST(:area_m2 AS numeric), CAST(:area_m2 AS numeric),
@ -2604,9 +2551,7 @@ def get_sales_vs_listings(
CAST(:area_tolerance AS numeric), CAST(:area_tolerance AS numeric),
CAST(:period_months AS integer), CAST(:period_months AS integer),
CAST(:target_city AS text) CAST(:target_city AS text)
) sv )
JOIN deals d ON d.id = sv.deal_id
WHERE d.region_code = CAST(:region_code AS integer)
""" """
), ),
{ {
@ -2617,7 +2562,6 @@ def get_sales_vs_listings(
"area_tolerance": area_tolerance, "area_tolerance": area_tolerance,
"period_months": period_months, "period_months": period_months,
"target_city": target_city, "target_city": target_city,
"region_code": region_code,
}, },
) )
.mappings() .mappings()
@ -2849,46 +2793,6 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
COVERAGE_GREEN_MIN_N = 8 COVERAGE_GREEN_MIN_N = 8
COVERAGE_YELLOW_MIN_N = 12 COVERAGE_YELLOW_MIN_N = 12
# Московская область (регион 50) добавлена 2026-09-16 — все 22 города сразу в
# жёлтый тир, той же метрикой, которой выбирали тир Москве. Замер: 120 проб на
# город, радиус 8 км от центра, когорта = is_active + точные комнаты + площадь
# +-15% + scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты по
# городам области 17-51, доля проб с когортой >= 12 равна 0.63-0.92 (Видное
# 51/0.83, Балашиха 40/0.88, Железнодорожный 39/0.92, Люберцы 37/0.87,
# Электросталь 35/0.83, Химки 30/0.74, Домодедово 30/0.79, Ногинск 29/0.85,
# Реутов 28/0.82, Долгопрудный 27/0.72, Красногорск 27/0.77, Одинцово 27/0.78,
# Подольск 26/0.83, Сергиев Посад 25/0.79, Королёв 24/0.79, Мытищи 23/0.73,
# Орехово-Зуево 22/0.73, Серпухов 22/0.66, Раменское 21/0.68,
# Наро-Фоминск 20/0.73, Щёлково 19/0.73, Коломна 17/0.63). Контроль той же
# метрикой: Москва (уже жёлтая) — медиана 14, доля 0.57; Нижний Тагил (тоже
# жёлтый) — медиана 11, доля 0.473. То есть КАЖДЫЙ город области плотнее уже
# принятого жёлтого эталона (Москвы) — зелёный тир не рассматривался, порог
# 12 достаточен и единообразен. Порядок — по убыванию медианы когорты.
COVERAGE_MO_CITIES = (
"Видное",
"Балашиха",
"Железнодорожный",
"Люберцы",
"Электросталь",
"Химки",
"Домодедово",
"Ногинск",
"Реутов",
"Долгопрудный",
"Красногорск",
"Одинцово",
"Подольск",
"Сергиев Посад",
"Королёв",
"Мытищи",
"Орехово-Зуево",
"Серпухов",
"Раменское",
"Наро-Фоминск",
"Щёлково",
"Коломна",
)
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в # Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше — # COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется # это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
@ -3085,7 +2989,6 @@ def _fold_city(name: str) -> str:
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = { _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_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(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_MO_CITIES},
**{ **{
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N) _fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
for k in COVERAGE_MOSCOW_CENTROID_KEYS for k in COVERAGE_MOSCOW_CENTROID_KEYS
@ -3132,36 +3035,6 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Первоуральск": (56.9083, 59.9483), "Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298), "Ревда": (56.7986, 59.9298),
"Серов": (59.6047, 60.5772), "Серов": (59.6047, 60.5772),
# Московская область (регион 50), 22 города — добавлены 2026-09-16 вместе с
# COVERAGE_MO_CITIES (см. обоснование порога там же). Координаты для 12 из
# 22 перенесены как есть из `_COVERAGE_NEGATIVE_CENTROIDS_DEG` (эти города
# раньше были ТОЛЬКО отрицательными точками московской сетки — конкурс
# ближайшего центроида без порога, теперь у них есть и собственный порог).
# Оставшиеся 10 взяты из `MOSCOW_OBLAST_CITY_ANCHORS` в
# packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py — те же
# координаты, что использует скрейпер (резолв Nominatim).
"Видное": (55.5519, 37.7133),
"Балашиха": (55.7969, 37.9386),
"Железнодорожный": (55.7444, 38.0128),
"Люберцы": (55.6767, 37.8931),
"Электросталь": (55.7845, 38.4449),
"Химки": (55.8894, 37.4450),
"Домодедово": (55.4368, 37.7681),
"Ногинск": (55.8554, 38.4412),
"Реутов": (55.7614, 37.8564),
"Долгопрудный": (55.9386, 37.5100),
"Красногорск": (55.8317, 37.3300),
"Одинцово": (55.6789, 37.2639),
"Подольск": (55.4312, 37.5450),
"Сергиев Посад": (56.3153, 38.1360),
"Королёв": (55.9142, 37.8256),
"Мытищи": (55.9116, 37.7308),
"Орехово-Зуево": (55.8062, 38.9810),
"Серпухов": (54.9155, 37.4196),
"Раменское": (55.5709, 38.2282),
"Наро-Фоминск": (55.3843, 36.7235),
"Щёлково": (55.9206, 37.9916),
"Коломна": (55.0939, 38.7670),
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все. # Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
# Как сетка получена, почему точек именно столько, где проходит граница # Как сетка получена, почему точек именно столько, где проходит граница
# с областью и какова остаточная цена — см. большой комментарий над # с областью и какова остаточная цена — см. большой комментарий над
@ -3203,18 +3076,30 @@ _COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Поварово": (56.0667, 37.0667), "Поварово": (56.0667, 37.0667),
"Дедовск": (55.8672, 37.1200), "Дедовск": (55.8672, 37.1200),
"Нахабино": (55.8500, 37.1833), "Нахабино": (55.8500, 37.1833),
"Реутов": (55.7614, 37.8564),
"Подольск": (55.4312, 37.5450),
"Апрелевка": (55.5500, 37.0700), "Апрелевка": (55.5500, 37.0700),
"Немчиновка": (55.7050, 37.3450), "Немчиновка": (55.7050, 37.3450),
"Химки": (55.8894, 37.4450),
"Одинцово": (55.6789, 37.2639),
"Лобня": (56.0100, 37.4750), "Лобня": (56.0100, 37.4750),
"Мытищи": (55.9116, 37.7308),
"Котельники": (55.6553, 37.8619), "Котельники": (55.6553, 37.8619),
"Красногорск": (55.8317, 37.3300),
"Люберцы": (55.6767, 37.8931),
"Дзержинский": (55.6294, 37.8500), "Дзержинский": (55.6294, 37.8500),
"Развилка": (55.5842, 37.7392), "Развилка": (55.5842, 37.7392),
"Климовск": (55.3667, 37.5333), "Климовск": (55.3667, 37.5333),
"Балашиха": (55.7969, 37.9386),
"Истра": (55.9167, 36.8667), "Истра": (55.9167, 36.8667),
"Долгопрудный": (55.9386, 37.5100),
"Королёв": (55.9142, 37.8256),
"Видное": (55.5519, 37.7133),
"Селятино": (55.5081, 36.9825), "Селятино": (55.5081, 36.9825),
"Томилино": (55.6528, 37.9472), "Томилино": (55.6528, 37.9472),
"Некрасовский": (56.0500, 37.5500), "Некрасовский": (56.0500, 37.5500),
"Барвиха": (55.7333, 37.2333), "Барвиха": (55.7333, 37.2333),
"Железнодорожный": (55.7444, 38.0128),
} }

View file

@ -47,7 +47,6 @@ from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings from app.core.config import settings
from app.core.db import DB_CONNECT_ARGS
class AuthDatabaseNotConfiguredError(RuntimeError): class AuthDatabaseNotConfiguredError(RuntimeError):
@ -102,18 +101,6 @@ def _build() -> tuple[Engine, sessionmaker[Session]]:
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением) # НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс. # и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True, 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): except (ArgumentError, ValueError):
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до # ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до

View file

@ -44,12 +44,6 @@ _AUTH_DB_DEFAULT_USER = "auth_app"
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк). # никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
# #1795 шаг 1 / #3452: минимум ДКП-сделок, с которого коридор держит цену — soft-кламп
# headline сверху и radius-floor снизу (estimator.py). Живёт здесь, а не в estimator.py:
# тот же порог читает схема (DkpCorridor.advisory_only), а schemas остаётся import-light.
# Бывшее поле Settings estimate_corridor_clamp_min_n (#2380), на проде не переопределялось.
CORRIDOR_CLAMP_MIN_N = 10
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@ -384,25 +378,36 @@ class Settings(BaseSettings):
dadata_api_token: str | None = None dadata_api_token: str | None = None
dadata_api_secret: str | None = None dadata_api_secret: str | None = None
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на # ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
# данные ЭТОГО адреса, а не на среднее по городу. # Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного # (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
# объявления. Число приходило из ДКП-коридора Росреестра, а тот при тонкой # факт ~100М). Если внешний якорь (Avito IMV recommended_price из
# улице (n < DKP_CORRIDOR_CITY_WIDE_MIN_N = 3) расширяется до всего города и # house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
# помечает себя scope="city_wide". Проверено на проде: у Янтарной ОДНА сделка, # `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
# по Красногорску 4770 с медианой 200 676 ₽/м² — пользователю показали среднюю # расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
# цену города как оценку его квартиры. Доля таких ответов 3.8% по ЕКБ, 4.8% по # При отсутствии IMV/Yandex no-op (медиана не меняется).
# 77/50, то есть дефект продукта, а не регионального покрытия. estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
# Флаг делает две вещи, обе откатываются одним переключателем: estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
# 1) общегородской коридор больше не может стать headline (уличный — может:
# Нижний Тагил с 12 сделками по улице это настоящий сигнал, ради него
# #oblast-D и делали, иначе туда протекал екатеринбургский asking вшестеро
# выше);
# 2) если headline так и не появился, ответ уходит честной карточкой
# «недостаточно данных», а не нулями.
# False ⇒ прежнее поведение целиком, без релиза.
estimate_require_local_evidence: bool = True
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
# комфорт 15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
# и tighter FSD-диапазон.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
estimate_sb_floor_sigma: float = 0.25
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# ── #1966: honest calibrated prediction-interval для expected_sold range ───── # ── #1966: honest calibrated prediction-interval для expected_sold range ─────
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio): # Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й # ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
@ -412,24 +417,6 @@ class Settings(BaseSettings):
# (проверено: 80.0% coverage на тех же 2366). # (проверено: 80.0% coverage на тех же 2366).
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366) estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366) estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
# ── региональные множители PI (замер 2026-09-16) ──────────────────────────
# Скаляры выше сняты ТОЛЬКО на Екатеринбурге (обл.66, #1966) и молча
# применялись ко всем регионам — прогон `--engine full --sample 2000
# --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id
# --pi-report` (гедоническая коррекция #2002 уже выключена в main) показал,
# что достигнутое покрытие «80%-й» вилки уезжает по региону:
# регион n p10 p50 p90 факт. покрытие
# 77 1969 0.448 1.043 1.440 71.2%
# 50 1632 0.703 1.117 1.500 74.8%
# 66 1360 0.649 1.143 1.584 66.2%
# Воспроизведено на независимой выборке (seed 777): 77 → 0.453/1.040/1.455,
# 50 → 0.701/1.115/1.514 — числа устойчивы, это не шум сэмпла.
# ВАЖНО: p50 (медиана actual_sold/expected_sold) > 1 во ВСЕХ трёх регионах —
# точка expected_sold систематически занижена на 4-14%. Эта правка чинит
# только ШИРИНУ/покрытие вилки по региону, а НЕ смещение точки. Калибровка
# asking→sold на более коротком (свежем) окне — отдельная незакрытая задача.
estimate_pi_low_mult_by_region: dict[int, float] = {77: 0.448, 50: 0.703, 66: 0.649}
estimate_pi_high_mult_by_region: dict[int, float] = {77: 1.440, 50: 1.500, 66: 1.584}
# ── #2002: hedonic year+area correction на точку expected_sold ───────────── # ── #2002: hedonic year+area correction на точку expected_sold ─────────────
# Диагноз: estimator систематически промахивается по эре дома + размеру — # Диагноз: estimator систематически промахивается по эре дома + размеру —
# недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366 # недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366
@ -443,27 +430,7 @@ class Settings(BaseSettings):
# эконом/комфорт/премиум лучше, элит без изменений (no harm). # эконом/комфорт/премиум лучше, элит без изменений (no harm).
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking). # После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
# OFF ⇒ точно старое поведение expected_sold. # OFF ⇒ точно старое поведение expected_sold.
# ВЫКЛЮЧЕНА 2026-09-16. Замер на проде показал, что коррекция превратилась в estimate_hedonic_correction_enabled: bool = True
# чистое занижение цены: она двигает точку вниз и НЕ покупает за это точности.
# Контролируемый эксперимент — одна и та же выборка (--engine full --sample 2000
# --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id), меняется
# ровно эта переменная:
# регион ВКЛ bias/MAPE/покрытие ВЫКЛ bias/MAPE/покрытие
# 77 Москва -9.1 / 20.5 / 69.0 -4.1 / 19.7 / 71.2
# 50 область -12.1 / 19.4 / 75.4 -10.4 / 19.2 / 74.8
# 66 обл. целиком -17.1 / 23.0 / 66.7 -12.4 / 23.2 / 66.2
# 66 только ЕКБ -17.4 / 21.2 / 69.8 -14.2 / 21.3 / 69.9
# Последняя строка решающая: ЕКБ — ровно та выборка, на которой коэффициенты
# перефитили 30.08.2026 (#3248, n=1269). Даже там выключение снимает 3.2 пункта
# занижения при неизменных MAPE и покрытии. Механика понятна из самих
# коэффициентов: b_larea=0, поэтому фактор = exp(-0.0140 + 0.0769*(year-2000)/20
# - 0.0745*[floor==1]), и для дома 1980 года это 0.913, то есть -8.7%. Жилой фонд
# в выборке преимущественно старый → фактор systematically < 1 → минус к цене.
# Коэффициенты фитили на остатке log(actual_sold/expected_sold) ПРИ ТОЙ базе,
# какая была тогда; база с тех пор уехала (area-бакетный ratio #2620, коэффициент
# asking->sold пересчитан по регионам 15.09), а хедонику не пересчитывали.
# ВКЛЮЧАТЬ ОБРАТНО только вместе с перефитом и замером всех четырёх строк выше.
estimate_hedonic_correction_enabled: bool = False
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок). # #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
# #
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены # Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
@ -497,13 +464,128 @@ class Settings(BaseSettings):
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7% estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
estimate_hedonic_factor_min: float = 0.75 estimate_hedonic_factor_min: float = 0.75
estimate_hedonic_factor_max: float = 1.30 estimate_hedonic_factor_max: float = 1.30
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
# Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение.
#
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
estimate_corridor_clamp_min_n: int = 10
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
estimate_corridor_clamp_slack: float = 0.40
# Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
# (избегаем ложных подъёмов) и ловит явный undershoot.
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
estimate_radius_floor_factor: float = 0.8
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
# Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
# сделки выше прайса) -> expected_sold = headline x ratio > headline.
# При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
# к point И range (expected_sold_low/high/price) консистентно.
# False -> старое поведение без clamp (backward-compat).
# ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
estimate_expected_sold_le_asking: bool = Field(
default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
)
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
estimate_sb_mad_k_small_n: float = 2.5
estimate_sb_small_n_threshold: int = 10
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
estimate_anchor_tier_c_corridor_mult: float = 1.5
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold
# k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение).
estimate_outlier_small_n_threshold: int = 15
estimate_outlier_tukey_k_small: float = 1.0
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
# ratio — не затрагиваются.
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
estimate_sb_gate_min_n: int = 3
estimate_sb_gate_max_fsd: float = 0.20
# ── #audit-3: price_trend freshness filter ──────────────────────────────── # ── #audit-3: price_trend freshness filter ────────────────────────────────
# Исключать items старше N месяцев из price_trend (house_placement_history). # Исключать items старше N месяцев из price_trend (house_placement_history).
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо. # Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
estimate_price_trend_max_age_months: int = 6 estimate_price_trend_max_age_months: int = 6
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
# аналога → headline выглядит достоверным при нулевой реальной базе.
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
# (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
estimate_manual_review_elite_ppm2: int = 250000
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
# (freshest scraped_at на (source, source_id|source_url|ctid)).
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
# Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
# source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
# раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
# #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
# True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
# building (building_cadastral_number | нормализованный address)
# + floor + area_bucket (round(area_m2), ~±0.5 м²)
# + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
# Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
# n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
# («лот считается один раз»).
#
# Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
# 13.89%, coverage 83.33% → 83.33%, bias 3.83% → 3.83%, median width 0.743 →
# 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
# 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%, median width/cv
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
estimate_dedup_analogs_enabled: bool = True
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ── # ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки # Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 / # estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
@ -572,16 +654,6 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED. # ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
estimate_unknown_attr_penalty_enabled: bool = False estimate_unknown_attr_penalty_enabled: bool = False
estimate_unknown_attr_penalty_min_n: int = 5 estimate_unknown_attr_penalty_min_n: int = 5
# #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно
# этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты
# через env без правки кода. Дефолты = прежнее поведение байт-в-байт.
# Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из
# них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста.
# Ручки временные: после решения по бэктесту (#3234, шаг 4) вернуть их в
# константы движка, как #1970 сделал с остальными неменявшимися estimate_*.
# ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL.
estimate_tier_h_min_comps: int = 5
estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol)
# #
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая # is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard # корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
@ -596,6 +668,20 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED. # ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
estimate_is_apartments_filter_enabled: bool = False estimate_is_apartments_filter_enabled: bool = False
# ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
# растягивается через несколько ценовых режимов → коридор range_low/high
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
# disclosure в explanation. НЕ трогает point/median/range.
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
# Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
# экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
estimate_wide_corridor_threshold: float = 1.2
# ── Mera-audit fix-1: Cian valuation sanity bounds ──────────────────────── # ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999). # API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется). # sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
@ -620,7 +706,26 @@ class Settings(BaseSettings):
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER). # datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
avito_imv_use_browser_fetcher: bool = False avito_imv_use_browser_fetcher: bool = False
# ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ── # ── #764: per-cadastral-quarter price index correction ───────────────────
# Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
# (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
estimate_quarter_index_min_n_deals: int = 10
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
# аналоги уже несут локацию квартала → skip (location in median).
estimate_quarter_match_skip_ratio: float = 0.6
# Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
# дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
estimate_quarter_index_max_for_small_n: float = 2.0
estimate_quarter_index_small_n_threshold: int = 50
# Sanity-clamp на factor = target_index / avg_analog_index (#859).
# Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local), # Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде. # когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED). # ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
@ -799,27 +904,6 @@ class Settings(BaseSettings):
# ENV: ASKING_RATIO_PPM2_MAX. # ENV: ASKING_RATIO_PPM2_MAX.
asking_ratio_ppm2_max: int = 1_200_000 asking_ratio_ppm2_max: int = 1_200_000
# Time-adjust для SOLD-стороны asking→sold ratio (rollback-флаг, без релиза).
# ПРОБЛЕМА: числитель ratio (deals, trailing 12 months) — сырые цены сделок,
# знаменатель (listings, LISTINGS_FRESH_DAYS) — сегодняшний рынок. На растущем
# рынке медиана сделок системно отстаёт от медианы объявлений, т.е. ratio занижен
# ровно на дрейф рынка за половину окна. Прод-замер 2026-09-16 (окно сделок
# 2025-10..2026-04, ряд real_estate_deals своего региона, clamp 0.7/1.6):
# регион 77 n=68 731 sold_median 323 529 → 360 330 (×1.1137, factor 1.077..1.147)
# регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138)
# регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062)
# Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50),
# 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают.
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
# При True каждая сделка домножается на sber-фактор приведения (тот же
# ряд/карту, что estimator._SBER_REGION_SERIES, см. app.tasks.asking_to_sold_ratio)
# к последнему доступному месяцу серии перед расчётом медианы. False — байт-в-байт
# прежнее поведение (для быстрого отката без деплоя, сравнения до/после).
# ENV: ASKING_RATIO_TIME_ADJUST_ENABLED.
asking_ratio_time_adjust_enabled: bool = True
# SSRF-защита для admin scrape endpoints (#756). # SSRF-защита для admin scrape endpoints (#756).
# Список хостов которым разрешено передавать абсолютные URL в параметрах *_url. # Список хостов которым разрешено передавать абсолютные URL в параметрах *_url.
# Относительные пути (без netloc) проходят без проверки — хост подставляется # Относительные пути (без netloc) проходят без проверки — хост подставляется
@ -1032,6 +1116,14 @@ class Settings(BaseSettings):
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY. # False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY") avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
# ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при
# Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
# Пусто / файл не найден = запросы без cookies (не падаем, только warning).
# ENV: YANDEX_COOKIES_FILE.
yandex_cookies_file: str | None = None
# ── #639: Cian browser auto-login (Variant B) ──────────────────────────── # ── #639: Cian browser auto-login (Variant B) ────────────────────────────
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый # Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable. # (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
@ -1051,19 +1143,25 @@ class Settings(BaseSettings):
cian_login_success_cookie: str = "DMIR_AUTH" cian_login_success_cookie: str = "DMIR_AUTH"
cian_login_wait_ms: int = 4000 cian_login_wait_ms: int = 4000
# detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL. # detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
# False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046). # SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
# True = curl_cffi через settings.scraper_proxy_url. # (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
# 1 коннект/запрос) развязывает прокси-аккаунты.
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
# #
# Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который # ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
# curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4 # отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
# обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование # 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
# curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован # браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
# BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только # браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
# environment'ом сервиса scraper, а tradein-backend (ручка админки # Прод переведён на браузер через docker-compose.prod.yml (environment
# scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом. # перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
# других окружений вслепую.
avito_detail_backfill_use_curl: bool = Field( avito_detail_backfill_use_curl: bool = Field(
default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL" default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
) )
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший # #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
@ -1189,69 +1287,6 @@ class Settings(BaseSettings):
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID") 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") 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 ────────── # ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит # См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch. # роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.

View file

@ -1,68 +1,14 @@
import asyncio from collections.abc import Generator
import logging
from collections.abc import Callable, Generator
from typing import Any
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import settings 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( engine = create_engine(
settings.database_url, settings.database_url,
pool_pre_ping=True, pool_pre_ping=True,
future=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 — # #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
# через них в GlitchTip уезжали ключ шифрования кук и сами куки # через них в GlitchTip уезжали ключ шифрования кук и сами куки
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все # (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
@ -119,39 +65,3 @@ def get_db() -> Generator[Session, None, None]:
yield db yield db
finally: finally:
db.close() 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

@ -41,84 +41,17 @@ def scrub_query_secrets(text: str) -> str:
class QuerySecretFilter(logging.Filter): class QuerySecretFilter(logging.Filter):
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан. """Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан."""
Скрабит `record.msg` и КАЖДЫЙ элемент `record.args` по отдельности НЕ схлопывает
их в единую строку через `record.getMessage()` с последующим `args = ()`.
Прод-баг (#3471): схлопывание ломало `uvicorn.access` — там `record.args` это
структурный 5-tuple `(client_addr, method, full_path, http_version, status_code)`,
который `uvicorn.logging.AccessFormatter.formatMessage()` распаковывает напрямую
(`a, b, c, d, e = record.args`), в обход `record.getMessage()`. Как только фильтр
находил секрет (например `?secret=` в webhook-пути) и обнулял `args`, форматтер падал
с `ValueError: not enough values to unpack (expected 5, got 0)` сама попытка
заскрабить секрет ломала запись лога целиком (`--- Logging error ---` в докер-логах).
"""
def filter(self, record: logging.LogRecord) -> bool: def filter(self, record: logging.LogRecord) -> bool:
args = record.args message = record.getMessage()
if isinstance(args, tuple) and args: scrubbed = scrub_query_secrets(message)
# Есть позиционные args — скрабим КАЖДЫЙ элемент отдельно, arity не трогаем. if scrubbed != message:
# `record.msg` (шаблон вида `"%s ..."`) не трогаем вовсе: в реальных вызовах record.msg = scrubbed
# этого кодбейза секрет+query-контекст лежат САМОДОСТАТОЧНО внутри одного record.args = ()
# аргумента (например body_preview в app/services/dadata.py), а не расщеплены
# между текстом шаблона и голым значением — трогать msg тут не нужно и опасно
# (шаблонный `%s` сам по себе мог бы ложно совпасть с чувствительным именем
# параметра прямо перед ним).
scrubbed_args = tuple(
scrub_query_secrets(a) if isinstance(a, str) else a for a in args
)
if scrubbed_args != args:
record.args = scrubbed_args
elif isinstance(record.msg, str):
# Args нет — вся запись уже готовым текстом в msg (f-string и т.п.).
scrubbed_msg = scrub_query_secrets(record.msg)
if scrubbed_msg != record.msg:
record.msg = scrubbed_msg
return True return True
# ── Секреты в произвольном тексте (тело ответа внешнего API) — #3471 ──────────
#
# DaData на HTTP 403 возвращает диагностику вида:
# "Feature 'CLEAN' disabled for token '<действующий 40-символьный токен>'. See ..."
# body_preview из этого текста уходит в WARNING/ERROR лог (app/services/dadata.py) →
# docker logs → потенциально breadcrumb к любой последующей ошибке в GlitchTip.
# Маскируем ДО логирования. Не завязываемся на конкретную формулировку вендора —
# она может измениться (см. generic-слой ниже).
_TOKEN_QUOTED = re.compile(r"(\btoken\s*['\"])([^'\"]+)(['\"])", re.IGNORECASE)
# Любая hex/base64-подобная последовательность 24+ символов — ловит секрет независимо
# от контекста (Authorization/X-Secret значения, если когда-нибудь попадут в текст как есть).
_LONG_SECRET_LIKE = re.compile(r"[A-Za-z0-9+/_-]{24,}")
def _mask_value(value: str) -> str:
"""`8d4e…(40)` — первые 4 символа + длина в скобках, остальное скрыто."""
if len(value) <= 4:
return "***"
return f"{value[:4]}…({len(value)})"
def scrub_body_secrets(text: str | None) -> str:
"""Маскирует токены/секреты в произвольном тексте (тело ответа внешнего API и т.п.).
Двухслойно: 1) явный ``token '<value>'`` (текущая формулировка DaData на 403),
2) generic любая hex/base64-подобная последовательность 24+ символов, чтобы защита
не зависела от того, как вендор сформулирует сообщение завтра.
"""
if not text:
return text or ""
def _replace_quoted(m: re.Match[str]) -> str:
return f"{m.group(1)}{_mask_value(m.group(2))}{m.group(3)}"
scrubbed = _TOKEN_QUOTED.sub(_replace_quoted, text)
scrubbed = _LONG_SECRET_LIKE.sub(lambda m: _mask_value(m.group(0)), scrubbed)
return scrubbed
def install_query_secret_filter(*logger_names: str) -> None: def install_query_secret_filter(*logger_names: str) -> None:
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера. """Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.

View file

@ -19,7 +19,6 @@ from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration 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.public import mera as public_mera
from app.api.v1 import ( from app.api.v1 import (
@ -80,43 +79,31 @@ install_query_secret_filter()
# frontend), отдельного broker нет → мониторить нечего. # frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn: if settings.glitchtip_dsn:
from app.observability.sentry_scrub import ( from app.observability.sentry_scrub import (
drop_payments_disabled_event,
redact_telegram_bot_token, redact_telegram_bot_token,
scrub_payment_request_body, scrub_payment_request_body,
scrub_public_address, scrub_public_address,
stabilize_retry_error_fingerprint, stabilize_retry_error_fingerprint,
) )
def _before_send(event: Event, hint: Hint) -> Event | None: def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция payments-disabled drop + платёжный body-wipe + PII-scrub + """Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
Telegram bot-токен redaction + RetryError fingerprint-стабилизация RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
(#tgsupport-web, PR-D2, glitchtip-noise, #3471) — см. см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
app/tgbot_main.py._before_send (идентичная композиция без последнего тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
шага, тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже TelegramClient в стек-фреймах при ошибке sendMessage, а
держит TelegramClient в стек-фреймах при ошибке sendMessage, а
include_local_variables=False ниже первый рубеж защиты. include_local_variables=False ниже первый рубеж защиты.
#3471: payments-disabled drop идёт ПЕРВЫМ шагом — это единственный PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные
процесс из трёх entrypoint'ов, который реально держит ASGI-роут режет `request.data` целиком только для `/payments/*`, остальные пути
`/api/v1/payments/*`, поэтому именно здесь события возникают; ранний (extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
return None экономит остальную композицию на заведомо отбрасываемом 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/* RetryError-стабилизация этот процесс обслуживает /api/v1/geocode/*
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см. (suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint.""" sentry_scrub.stabilize_retry_error_fingerprint."""
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type] scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
if dropped is None:
return None
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
if scrubbed is None: if scrubbed is None:
return None return None
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а # Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а

View file

@ -99,47 +99,6 @@ BUILD_INFO = Gauge(
# смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно. # смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно.
BUILD_INFO.labels(app="mera", release=f"{APP_VERSION}+{BUILD_SHA}").set(1) 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: def route_label(scope: Scope) -> str:
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``. """Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.

View file

@ -25,7 +25,6 @@ import re
from typing import Any from typing import Any
from sentry_sdk.types import Event from sentry_sdk.types import Event
from starlette.exceptions import HTTPException as _StarletteHTTPException
from tenacity import RetryError from tenacity import RetryError
_REDACTED = "[REDACTED]" _REDACTED = "[REDACTED]"
@ -257,41 +256,6 @@ def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | N
return event 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/" _PUBLIC_API_URL_SEGMENT = "/api/public/"
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query. #: Хосты геокодеров: их URL несёт введённый адрес прямо в query.

View file

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

View file

@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно). Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а
(см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на (см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
ценовой шкале. None если для дома нет свежей IMV-записи. ценовой шкале. None если для дома нет свежей IMV-записи.
""" """
@ -158,18 +158,10 @@ class DkpCorridor(BaseModel):
"""Коридор реальных ДКП-сделок Росреестра для target (#652). """Коридор реальных ДКП-сделок Росреестра для target (#652).
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице + Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
rooms + площади ±15% за период. Показывается как тонкая референсная линия rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
«коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит за линия «коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит
[low,high]×slack добавляется текстовая пометка. за [low,high]×slack добавляется текстовая пометка. НЕ хард-клампит оценку.
None / count=0 если по улице нет сопоставимых сделок. None / count=0 если по улице нет сопоставимых сделок.
#3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
порога не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
#3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier
== "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не
входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится
из коридора, а поле обязано быть верным и на GET-rehydrate.
""" """
count: int # число ДКП-сделок в выборке count: int # число ДКП-сделок в выборке
@ -191,33 +183,6 @@ class DkpCorridor(BaseModel):
# None = сделки без даты (в проде не встречается) — потребитель молчит. # None = сделки без даты (в проде не встречается) — потребитель молчит.
latest_deal_date: date | None = None latest_deal_date: date | None = None
@computed_field # type: ignore[prop-decorator]
@property
def advisory_only(self) -> bool:
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ
путей коридора: soft-кламп headline сверху и radius-floor снизу
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
цену зона n=1..9 на экране была неотличима от работающей.
False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building"
обе страховки выключены якорем, а не порогом (#3466).
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
deals-headline-fallback берёт медиану коридора начиная с трёх сделок.
Потребителю (витрине) поэтому корректно говорить про РАЗМЕР ВЫБОРКИ, а
не про то, что цену коридор не трогал.
Производное от count, поэтому верно во ВСЕХ конструкторах DkpCorridor
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
вторым числом.
"""
from app.core.config import CORRIDOR_CLAMP_MIN_N # локально: schemas import-light
return self.count < CORRIDOR_CLAMP_MIN_N
class PriceTrendPoint(BaseModel): class PriceTrendPoint(BaseModel):
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart). """Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).
@ -351,9 +316,6 @@ class AggregatedEstimate(BaseModel):
# "city" — radius Tier W: широкий fallback # "city" — radius Tier W: широкий fallback
# null — нет данных / оценка не построена # null — нет данных / оценка не построена
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
# #3466: на GET-rehydrate колонки нет — якорные тиры (same_building/micro_radius)
# восстанавливаются из подписи якорного блока в confidence_explanation
# (estimator.analog_tier_from_explanation), радиусные остаются null.
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
# search_radius_m — фактический радиус (метры), по которому реально отбирались # search_radius_m — фактический радиус (метры), по которому реально отбирались
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m: # listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:

View file

@ -67,12 +67,8 @@ class BlockRatioBreaker:
ratio_threshold: float ratio_threshold: float
safety_min: int safety_min: int
snapshot_size: int snapshot_size: int
# #3439: сколько отказов НАШЕЙ стороны (kind != platform) подряд, без единого ответа
# площадки, рвут прогон. 0 — критерий выключен.
infra_streak_limit: int = 0
_window: deque[bool] = field(init=False, repr=False) _window: deque[bool] = field(init=False, repr=False)
_consecutive_blocks: int = field(default=0, init=False) _consecutive_blocks: int = field(default=0, init=False)
_consecutive_infra: int = field(default=0, init=False)
_pure_block_run: bool = field(default=True, init=False) _pure_block_run: bool = field(default=True, init=False)
streak_histogram: Counter[int] = field(default_factory=Counter, init=False) streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
@ -110,16 +106,10 @@ class BlockRatioBreaker:
короче окна рвал бы прогон по тем же infra-отказам, только другим короче окна рвал бы прогон по тем же infra-отказам, только другим
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
не считают. не считают.
Свой предохранитель у такого отказа серия infra_streak_limit (#3439): без
него серия любой длины не рвала прогон ничем (7302: 1598 отказов сайдкара из
1600, status='done'). Серию гасит любой ответ площадки успех, её блок, 404.
""" """
if kind != BAN_KIND_PLATFORM: if kind != BAN_KIND_PLATFORM:
self._consecutive_infra += 1
self.record_failure() self.record_failure()
return return
self._consecutive_infra = 0
self._consecutive_blocks += 1 self._consecutive_blocks += 1
self._window.append(True) self._window.append(True)
@ -127,7 +117,6 @@ class BlockRatioBreaker:
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя """Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
бы одного успеха -- уже не "чистый с рождения прогона" burst).""" бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
self._flush_streak() self._flush_streak()
self._consecutive_infra = 0
self._pure_block_run = False self._pure_block_run = False
self._window.append(False) self._window.append(False)
@ -142,11 +131,8 @@ class BlockRatioBreaker:
AvitoListingGoneError). Класс написан источник-агностично на будущее (не AvitoListingGoneError). Класс написан источник-агностично на будущее (не
только avito), но сейчас единственный вызывающий -- avito_detail_backfill только avito), но сейчас единственный вызывающий -- avito_detail_backfill
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой (#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
задачи -- своя калибровка, свои ограничения прокси-пула). задачи -- своя калибровка, свои ограничения прокси-пула)."""
return
Единственное, что 404 двигает, -- серия infra (#3439): площадка ответила,
значит наш тракт жив."""
self._consecutive_infra = 0
def _flush_streak(self) -> None: def _flush_streak(self) -> None:
if self._consecutive_blocks: if self._consecutive_blocks:
@ -156,11 +142,9 @@ class BlockRatioBreaker:
def abort_reason(self) -> str | None: def abort_reason(self) -> str | None:
"""Какой критерий требует обрыва прямо сейчас, или None. """Какой критерий требует обрыва прямо сейчас, или None.
Возвращает "infra_streak" / "safety_net" / "ratio" / None. Состояние не меняет, Возвращает "safety_net" / "ratio" / None. Состояние не меняет, поэтому
поэтому вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога. вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
""" """
if self.infra_streak_limit > 0 and self._consecutive_infra >= self.infra_streak_limit:
return "infra_streak"
# Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот # Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот
# короче окна), иначе пачка safety_min блоков в начале длинного прогона # короче окна), иначе пачка safety_min блоков в начале длинного прогона
# абортила бы его так же, как до правки (#3184 review MAJOR 2). # абортила бы его так же, как до правки (#3184 review MAJOR 2).
@ -192,11 +176,6 @@ class BlockRatioBreaker:
should_abort(), так что в логи не попадает. should_abort(), так что в логи не попадает.
""" """
reason = self.abort_reason() reason = self.abort_reason()
if reason == "infra_streak":
return (
f"{self._consecutive_infra} отказов нашего тракта (сайдкар/пул) подряд "
"без единого ответа площадки"
)
if reason == "ratio": if reason == "ratio":
return ( return (
f"доля блоков {self.window_blocks}/{self.window_len} в окне " f"доля блоков {self.window_blocks}/{self.window_len} в окне "

View file

@ -131,7 +131,7 @@ async def backfill_cian_price_history(
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги # Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с # упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
# логом, который читается как «Циан нас блокирует». # логом, который читается как «Циан нас блокирует».
logger.warning( logger.error(
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван " "cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
"на listing_id=%s (обработано %d из %d)", "на listing_id=%s (обработано %d из %d)",
exc, exc,

View file

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

View file

@ -29,7 +29,6 @@ from typing import Any
import httpx import httpx
from app.core.config import settings from app.core.config import settings
from app.core.log_scrub import scrub_body_secrets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -174,7 +173,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)") logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
return None return None
if status in (401, 403): if status in (401, 403):
body_preview = scrub_body_secrets(response.text)[:200] body_preview = (response.text or "")[:200]
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден, # 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ # но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback # поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
@ -214,7 +213,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
logger.warning("dadata: HTTP %d — transient server error", status) logger.warning("dadata: HTTP %d — transient server error", status)
return None return None
if status >= 400: if status >= 400:
body_preview = scrub_body_secrets(response.text)[:200] body_preview = (response.text or "")[:200]
logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview) logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
return None return None
@ -437,7 +436,7 @@ async def suggest_addresses(
logger.warning("dadata suggest: HTTP %d — transient server error", status) logger.warning("dadata suggest: HTTP %d — transient server error", status)
return [] return []
if status >= 400: if status >= 400:
body_preview = scrub_body_secrets(response.text)[:200] body_preview = (response.text or "")[:200]
logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview) logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
return [] return []

File diff suppressed because it is too large Load diff

View file

@ -46,8 +46,6 @@ from lxml import etree
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.regions import REGIONS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Глобы файлов ГАР (матчим case-insensitive: .XML/.xml). # Глобы файлов ГАР (матчим case-insensitive: .XML/.xml).
@ -522,62 +520,6 @@ def upsert_gar_houses(
return upserted return upserted
# ─────────────────────────────────────────────────────────────────────────────
# Город-фильтр матча: выбирается ПО РЕГИОНУ, а не задаётся руками на каждый запуск
# ─────────────────────────────────────────────────────────────────────────────
# Раньше был жёсткой CLI-константой "Екатеринбург" (годилось только для region 66).
# Регионы принципиально разные по форме коллизии одноимённых улиц:
# - 66 (область): ОДИН доминирующий город в инвентаре (истор. запуск продукта был
# ЕКБ-only) + >20 сопоставимых по названиям городов-соседей в REGIONS[66].cities —
# без фильтра «Машиностроителей 6» из 4 городов сливается в одну строку. Фильтр
# нужен и достаточен.
# - 77 (Москва): город ровно один — фильтровать нечем и незачем (см. REGIONS[77]).
# - 50 (область): НЕТ доминирующего города (20 сопоставимых по объёму городов-спутников,
# см. docstring REGIONS[50]) — фильтр по ОДНОМУ городу был бы не защитой, а порчей
# знаменателя (отрежет почти весь регион). Риск коллизии одноимённых улиц РАЗНЫХ
# городов при отсутствии фильтра закрыт не им, а guard'ом на стороне SQL
# (см. _MATCH_SQL: canon_hits) — неоднозначный canon не матчится вовсе, а не
# угадывается по max(flat_count).
# REGIONS_REQUIRING_CITY_FILTER — явный, единственный источник этого продуктового
# решения (какие регионы НУЖДАЮТСЯ в one-city ограничении); САМО значение фильтра
# берётся из REGIONS[<code>].city_token (реестр regions.py, ЕДИНСТВЕННОЕ место границ
# покрытия) — не второй раз хардкодится строкой "Екатеринбург".
REGIONS_REQUIRING_CITY_FILTER: frozenset[int] = frozenset({66})
class CityFilterAutoType:
"""Маркер «city_filter не передан явно» — резолвится по региону в match_houses_to_gar."""
__slots__ = ()
def __repr__(self) -> str:
return "CITY_FILTER_AUTO"
# Сентинел default'а (не None — None остаётся легитимным явным «фильтр отключён»).
CITY_FILTER_AUTO = CityFilterAutoType()
def default_city_filter_for_region(region_code: str | None) -> str | None:
"""Город-фильтр GAR-матча по умолчанию для региона (см. REGIONS_REQUIRING_CITY_FILTER).
region 66 "Екатеринбург" (byte-for-byte прежнее поведение, значение из
REGIONS[66].city_token). Любой другой/неизвестный/отсутствующий регион None
(без ограничения). region_code принимает и None, и нечисловую строку не
ошибка, просто «не знаем региона» без фильтра.
"""
try:
code = int(region_code) if region_code is not None else None
except (TypeError, ValueError):
return None
if code is None or code not in REGIONS_REQUIRING_CITY_FILTER:
return None
region = REGIONS.get(code)
if region is None:
return None
return region.city_token.capitalize()
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Матчер ГАР → houses # Матчер ГАР → houses
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -587,26 +529,16 @@ def default_city_filter_for_region(region_code: str | None) -> str | None:
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию # «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted). # (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
# #
# На каждый canon берём ОДНУ ГАР-строку (ROW_NUMBER … ORDER BY flat_count DESC, house_guid) — # На каждый canon берём ОДНУ ГАР-строку (DISTINCT ON … ORDER BY flat_count DESC, house_guid) —
# детерминированный tie-break: максимальный flat_count, при равенстве — лексикографически # детерминированный tie-break: максимальный flat_count, при равенстве — лексикографически
# меньший house_guid. Houses-сторона: tradein_canon_addr(COALESCE(short/full/address)) — тот же # меньший house_guid. Houses-сторона: tradein_canon_addr(COALESCE(short/full/address)) — тот же
# канон, что и в gar_pick. С city-фильтром (:city IS NOT NULL, region 66) поведение # канон, что и в gar_pick. ЕКБ-ограничение (:city ILIKE по full_address) обязательно: без него
# byte-for-byte прежнее — ILIKE по full_address обязателен: без него «Машиностроителей 6» в # «Машиностроителей 6» в 4 городах region 66 даёт ложные коллизии. Предикат
# 4 городах region 66 даёт ложные коллизии. # `gar_flat_count IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие строки.
#
# БЕЗ city-фильтра (:city IS NULL — многогородские регионы без одного доминирующего города,
# напр. область 50) canon НЕ несёт населённый пункт (tradein_canon_addr режет всё, кроме улицы
# и номера дома — см. мигр. 144) — «Ленина 5» существует в десятках городов области. Молча
# брать «лучший по flat_count» здесь означало бы РАНДОМНО пришить дом одного города к дому
# другого. Вместо этого canon_hits (COUNT(*) OVER PARTITION BY canon в рамках уже
# region/city-отфильтрованной выборки) — если у canon >1 разных GAR-домов, ambiguity
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` →
# повторный прогон не трогает уже совпавшие строки.
_MATCH_SQL = text( _MATCH_SQL = text(
""" """
WITH gar_scope AS ( WITH gar_pick AS (
SELECT SELECT DISTINCT ON (canon)
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
FROM gar_house_flats FROM gar_house_flats
WHERE flat_count > 0 WHERE flat_count > 0
@ -619,21 +551,7 @@ _MATCH_SQL = text(
CAST(:city AS text) IS NULL CAST(:city AS text) IS NULL
OR full_address ILIKE '%' || CAST(:city AS text) || '%' OR full_address ILIKE '%' || CAST(:city AS text) || '%'
) )
), ORDER BY canon, flat_count DESC, house_guid
gar_ranked AS (
SELECT
canon, house_guid, flat_count,
ROW_NUMBER() OVER (
PARTITION BY canon ORDER BY flat_count DESC, house_guid
) AS rn,
COUNT(*) OVER (PARTITION BY canon) AS canon_hits
FROM gar_scope
),
gar_pick AS (
SELECT canon, house_guid, flat_count
FROM gar_ranked
WHERE rn = 1
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
) )
UPDATE houses h UPDATE houses h
SET gar_house_guid = gp.house_guid, SET gar_house_guid = gp.house_guid,
@ -653,19 +571,15 @@ def match_houses_to_gar(
db: Session, db: Session,
*, *,
region_code: str | None = None, region_code: str | None = None,
city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO, city_filter: str | None = "Екатеринбург",
) -> int: ) -> int:
"""Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller). """Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).
city_filter: CITY_FILTER_AUTO (умолч.) резолвится по region_code через city_filter (умолч. «Екатеринбург») ограничивает ГАР-сторону по full_address ILIKE
default_city_filter_for_region (region 66 «Екатеринбург», иначе None). Явный защита от cross-town коллизий внутри region 66; None отключает фильтр (city-aware матч
None отключает фильтр НЕЗАВИСИМО от региона; явная строка ILIKE-override за пределами ЕКБ future work). Возвращает число обновлённых домов. Идемпотентно
(любой регион). Без фильтра ambiguity одноимённых улиц разных городов закрыта (plain UPDATE, IS DISTINCT FROM gate).
отдельно см. _MATCH_SQL (canon_hits). Возвращает число обновлённых домов.
Идемпотентно (plain UPDATE, IS DISTINCT FROM gate).
""" """
if isinstance(city_filter, CityFilterAutoType):
city_filter = default_city_filter_for_region(region_code)
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter}) result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
matched = result.rowcount matched = result.rowcount
logger.info( logger.info(

View file

@ -26,7 +26,6 @@ from sqlalchemy.orm import Session
from tenacity import retry, stop_after_attempt, wait_exponential from tenacity import retry, stop_after_attempt, wait_exponential
from app.core.config import settings from app.core.config import settings
from app.core.db import run_db_thread
from app.services import dadata from app.services import dadata
from app.services.regions import REGIONS as _ALL_REGIONS from app.services.regions import REGIONS as _ALL_REGIONS
from app.services.regions import Region, is_within_bbox from app.services.regions import Region, is_within_bbox
@ -37,11 +36,7 @@ _REGION_66 = _ALL_REGIONS[66]
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы # в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
# без записи здесь получают `marker=None` → cross-check пропускается # без записи здесь получают `marker=None` → cross-check пропускается
# (fallback на bbox-only, прежнее поведение). # (fallback на bbox-only, прежнее поведение).
# 50 → "московская", а НЕ "москва": Nominatim отдаёт `state="Московская _REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
# область"` для области и `state="Москва"` для города. Маркер "московская" не
# матчит "Москва" (подстроки нет) — результат внутри города Москвы, который
# щедрый `bbox_region` области накрывает целиком, будет честно отвергнут.
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва", 50: "московская"}
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -966,12 +961,7 @@ _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов # «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится, # хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim. # поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
_DADATA_REGION_NAMES: dict[int, str] = { _DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"}
66: SVERDLOVSK_OBLAST_REGION,
77: "Москва",
# Без типа — DaData хранит `region="Московская"`, `region_type="обл"`.
50: "Московская",
}
def _dadata_region_name(region_code: int) -> str: def _dadata_region_name(region_code: int) -> str:
@ -1115,14 +1105,7 @@ async def _nominatim_query_city_aware(
) )
if city_specified: if city_specified:
return await _nominatim_query_multi(client, query, limit, region_code=region_code) return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Город неизвестен. У региона БЕЗ города-ядра (`has_city_core=False`, # Город неизвестен — dual-query с суффиксом главного города региона
# реестр регионов; на сегодня это 50 — Московская область) суффикс главного
# города подставлять НЕЛЬЗЯ: «Луговая» есть и в Красногорске, и в Сабурово,
# и суффикс уверенно притянет чужой город — ровно ловушка #2576, только
# уровнем выше. Такому региону остаётся честный bare-запрос по viewbox.
if not _ALL_REGIONS[region_code].has_city_core:
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Дальше — регион с ядром: dual-query с суффиксом главного города
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`). # (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
default_city = _region_default_city(region_code) default_city = _region_default_city(region_code)
city_data = await _nominatim_query_multi( city_data = await _nominatim_query_multi(
@ -1901,11 +1884,11 @@ async def suggest(
parsed = _parse_street_house(query.strip()) parsed = _parse_street_house(query.strip())
if parsed is not None: if parsed is not None:
street, house = parsed 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: if hit is not None:
return [hit] return [hit]
# 1b. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм) # 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: if cad_results:
return cad_results return cad_results
@ -2012,7 +1995,7 @@ async def _geocode_resolve(
addr_norm = _cache_key(normalize_address(address), city_hint) addr_norm = _cache_key(normalize_address(address), city_hint)
# 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop) # 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: if cached is not None:
logger.info("geocode cache hit: %s", addr_norm) logger.info("geocode cache hit: %s", addr_norm)
return replace(cached, city_ambiguous=city_ambiguous) return replace(cached, city_ambiguous=city_ambiguous)
@ -2045,7 +2028,7 @@ async def _geocode_resolve(
if use_local_ekb and parsed is not None: if use_local_ekb and parsed is not None:
street, house = parsed street, house = parsed
try: 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: except Exception:
logger.warning("geoportal house-match raised — fall through", exc_info=True) logger.warning("geoportal house-match raised — fall through", exc_info=True)
hit = None hit = None
@ -2058,7 +2041,7 @@ async def _geocode_resolve(
confidence="exact", confidence="exact",
city_ambiguous=city_ambiguous, 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( logger.info(
"geocode geoportal house-match: %s → (%.5f, %.5f)", "geocode geoportal house-match: %s → (%.5f, %.5f)",
addr_norm, addr_norm,
@ -2073,7 +2056,7 @@ async def _geocode_resolve(
# (литеральная подстрока не совпадает). # (литеральная подстрока не совпадает).
if use_local_ekb and parsed is not None: if use_local_ekb and parsed is not None:
street, house = parsed 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: if hit is not None:
result = GeocodeResult( result = GeocodeResult(
lat=hit.lat, lat=hit.lat,
@ -2083,7 +2066,7 @@ async def _geocode_resolve(
confidence="exact", confidence="exact",
city_ambiguous=city_ambiguous, 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( logger.info(
"geocode cadastral house-match: %s → (%.5f, %.5f)", "geocode cadastral house-match: %s → (%.5f, %.5f)",
addr_norm, addr_norm,
@ -2094,7 +2077,9 @@ async def _geocode_resolve(
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм) # 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
if use_local_ekb: 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: if cad_suggestions:
s = cad_suggestions[0] s = cad_suggestions[0]
result = GeocodeResult( result = GeocodeResult(
@ -2105,7 +2090,7 @@ async def _geocode_resolve(
confidence="exact", confidence="exact",
city_ambiguous=city_ambiguous, 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( logger.info(
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon "geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
) )
@ -2116,7 +2101,7 @@ async def _geocode_resolve(
result = await _nominatim_lookup(address, city_hint, region_code) result = await _nominatim_lookup(address, city_hint, region_code)
if result is not None: if result is not None:
result = replace(result, city_ambiguous=city_ambiguous) 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) logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result return result
except Exception: except Exception:
@ -2135,7 +2120,7 @@ async def _geocode_resolve(
if use_local_ekb and parsed is not None: if use_local_ekb and parsed is not None:
local_street, _parsed_house = parsed local_street, _parsed_house = parsed
local_house = _extract_local_house_token(address) or _parsed_house 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: if hit is not None:
result = GeocodeResult( result = GeocodeResult(
lat=hit.lat, lat=hit.lat,
@ -2344,7 +2329,7 @@ async def reverse_geocode(
""" """
# 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI) # 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI)
if db is not None: 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: if cad is not None:
address, snap_lat, snap_lon = cad address, snap_lat, snap_lon = cad
return ReverseGeocodeResult( return ReverseGeocodeResult(

View file

@ -142,8 +142,7 @@ class LocationIndexResult(BaseModel):
sample_size: int sample_size: int
radius_m: int radius_m: int
nearby_poi: list[NearbyPoi] nearby_poi: list[NearbyPoi]
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена)
# ДЛЯ ПРОДУКТ-ЯДРА РЕГИОНА точки — не глобальный count(*) по таблице)
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
@ -208,27 +207,9 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок: # упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246. # insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
# #
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО в продукт-ядре региона, # bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
# даже если сам продукт уже скрейпит соседние города/области (city-sweep): географию # сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
# location_index явно ограничил владелец продукта. # явно ограничил владелец продукта.
#
# region_code (дефект Москвы, прод-замер 13.09.2026): bbox продукт-ядра — геометрический
# прямоугольник, а границы регионов НЕ прямоугольны — в московское ядро (55.55..55.95 /
# 37.30..37.90) попадают Химки/Реутов/Котельники/Люберцы (region_code=50). Внутри ядра 77
# лежало 16 801 лот региона 50 (медиана 251 295 ₽/м²) и 82 719 лотов региона 77 (медиана
# 458 208) — примесь заниженная городскую медиану до 422 222 (7.9%). У областных строк
# `city IS NULL`, фильтровать по имени города нельзя — только по region_code.
#
# СТРОГОЕ равенство, БЕЗ `OR region_code IS NULL`: строка без региона не считается ни за
# один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active,
# price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66): 15 858 строк
# проходят фильтры, из них 3 с NULL region_code; медиана с ними 151 101 ₽/м², без них
# 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), статистически ничто для 66. Взамен —
# `region_code IS NULL` НЕ постоянная лазейка того же класса, что и сам дефект: в
# `listings` 16 930 NULL-region строк Avito, 24 из них лежат в bbox Москвы и сегодня не
# проходят по свежести — но могут завтра. Мягкий `OR NULL` тихо открыл бы им дорогу
# обратно в московскую медиану ровно тем же путём, каким туда попадала Московская
# область. Три рубля на 66 — цена закрытия дыры, а не повод её оставить.
_MEDIAN_PPM2_LOCAL_SQL = text( _MEDIAN_PPM2_LOCAL_SQL = text(
""" """
SELECT SELECT
@ -242,7 +223,6 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city') AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS integer)
AND lat BETWEEN CAST(:bbox_south AS double precision) AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision) AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -268,7 +248,6 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city') AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS integer)
AND lat BETWEEN CAST(:bbox_south AS double precision) AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision) AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -276,17 +255,6 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
""" """
) )
_POI_COUNT_IN_REGION_SQL = text(
"""
SELECT count(*) AS n
FROM osm_poi_ekb_local
WHERE 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)
"""
)
_NEAREST_POI_SQL = text( _NEAREST_POI_SQL = text(
""" """
SELECT SELECT
@ -325,7 +293,6 @@ def _local_median_ppm2(
"fresh_days": LISTINGS_FRESH_DAYS, "fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN, "price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX, "price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min, "bbox_south": lat_min,
"bbox_north": lat_max, "bbox_north": lat_max,
"bbox_west": lon_min, "bbox_west": lon_min,
@ -350,7 +317,6 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
"fresh_days": LISTINGS_FRESH_DAYS, "fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN, "price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX, "price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min, "bbox_south": lat_min,
"bbox_north": lat_max, "bbox_north": lat_max,
"bbox_west": lon_min, "bbox_west": lon_min,
@ -367,38 +333,18 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
def _fetch_nearby_poi( def _fetch_nearby_poi(
db: Any, lat: float, lon: float, radius_m: int, top_n: int, region: regions_mod.Region db: Any, lat: float, lon: float, radius_m: int, top_n: int
) -> tuple[list[NearbyPoi], str]: ) -> tuple[list[NearbyPoi], str]:
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель. """Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
Graceful fallback ([], "unavailable") вместо 500 или сфабрикованного списка. Дефект Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
(прод 13.09.2026): гейт раньше смотрел на count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850 ([], "unavailable") вместо 500 или сфабрикованного списка.
строк, все екатеринбургские) для Москвы это count > 0 (таблица не пуста), и статус
молча становился "ok" с пустым списком, хотя внутри московского продукт-ядра лежит 0
POI. Гейт обязан смотреть на наличие POI В РЕЛЕВАНТНОЙ области (bbox_product_core
региона точки), а не на размер таблицы целиком. Загрузка московских POI отдельная
задача; здесь только честная деградация тем же статусом "unavailable" (терминология
не расширяется trade_in.py и так трактует его как «нет данных», см. #2131).
""" """
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
in_region = ( if total == 0:
db.execute(
_POI_COUNT_IN_REGION_SQL,
{
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
"bbox_east": lon_max,
},
).scalar()
or 0
)
if in_region == 0:
logger.warning( logger.warning(
"location_index: osm_poi_ekb_local has no rows in region=%s product core " "location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
"(empty or refresh job not yet run for this region) — nearby_poi unavailable, " "environment) — nearby_poi unavailable, no fabricated factors"
"no fabricated factors",
region.code,
) )
return [], "unavailable" return [], "unavailable"
@ -477,7 +423,7 @@ def compute_location_index(
poi_status="unavailable", poi_status="unavailable",
) )
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n, region) nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
city_median, city_n = _citywide_median_ppm2(db, region) city_median, city_n = _citywide_median_ppm2(db, region)
if city_median is None or city_n < MIN_SAMPLE_SIZE: if city_median is None or city_n < MIN_SAMPLE_SIZE:

View file

@ -39,9 +39,6 @@ from scraper_kit.orchestration.scheduler import (
from scraper_kit.orchestration.scheduler import ( from scraper_kit.orchestration.scheduler import (
_pick_resume as kit_pick_resume, _pick_resume as kit_pick_resume,
) )
from scraper_kit.orchestration.scheduler import (
_resolve_region_code as kit_resolve_region_code,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from scraper_kit.orchestration.scheduler import SchedulerContext from scraper_kit.orchestration.scheduler import SchedulerContext
@ -239,12 +236,6 @@ async def _job_deactivate_stale(
listing_source: str = params.get("listing_source", "avito") listing_source: str = params.get("listing_source", "avito")
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days) ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
segments: list[str] | None = params.get("segments") segments: list[str] | None = params.get("segments")
# Регионы снятия. None (ключа нет в default_params) -> дефолт самой джобы,
# DEFAULT_DEACTIVATION_REGION_CODES=(66,): снимать устаревшее можно только там,
# где идёт регулярный пересбор -- см. развёрнутый комментарий у константы в
# deactivate_stale_avito.py. Расширять список ВМЕСТЕ с запуском сбора по
# региону, ключом "region_codes" (jsonb-массив кодов) в строке расписания.
region_codes: list[int] | None = params.get("region_codes")
staleness_column: str = params.get("staleness_column", "last_seen_at") staleness_column: str = params.get("staleness_column", "last_seen_at")
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание # Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по # получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
@ -284,7 +275,6 @@ async def _job_deactivate_stale(
listing_source=listing_source, listing_source=listing_source,
ttl_days=ttl_days, ttl_days=ttl_days,
segments=segments, segments=segments,
region_codes=region_codes,
staleness_column=staleness_column, staleness_column=staleness_column,
min_confirmations=min_confirmations, min_confirmations=min_confirmations,
revisit_floor_quantile=revisit_floor_quantile, revisit_floor_quantile=revisit_floor_quantile,
@ -335,50 +325,6 @@ async def _job_landing_stats(
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params) 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)
)
# #3511: пустая витрина — не успех. Результатного ключа кита
# (`_RESULT_COUNTER_KEYS`) у счётчиков витрины нет, сводка просроченных
# судит её по статусу, и `done` с written=0 гасил часы свежести так же,
# как удачный прогон, — а страница тем временем теряла таблицу целиком.
if not counters.get("written"):
logger.error(
"landing_showcase_deals run_id=%d: витрина пуста (written=0, "
"considered=%s, eligible=%s) — прогон неуспешен",
run_id,
counters.get("considered"),
counters.get("eligible"),
)
ctx.runs.mark_failed(
db, run_id, "витрина пуста: прогон не записал ни одной строки (written=0)", counters
)
else:
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 ────────── # ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
async def _job_sber_freshness_monitor( async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -541,7 +487,6 @@ async def _job_domclick_city_sweep(
rooms=params.get("rooms"), rooms=params.get("rooms"),
pages=int(params.get("pages_per_anchor", 5)), pages=int(params.get("pages_per_anchor", 5)),
request_delay_sec=float(params.get("request_delay_sec", 6.0)), request_delay_sec=float(params.get("request_delay_sec", 6.0)),
region_code=kit_resolve_region_code(params),
resume_run_id=kit_pick_resume(db, run_id), resume_run_id=kit_pick_resume(db, run_id),
cookies=cookies, cookies=cookies,
) )
@ -966,7 +911,6 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"), "deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"), "sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"), "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"), "newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler( "yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep" _job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
@ -1024,12 +968,6 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
), ),
# Override kit-native (#3264) — инъекция куки сессии, см. докстринг job'а выше. # Override kit-native (#3264) — инъекция куки сессии, см. докстринг job'а выше.
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"), "domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
# Вне ЕКБ каждый регион получает СВОЙ source ("domclick_city_sweep_moskva"),
# потому что scrape_schedules.source UNIQUE. Wildcard обязан стоять и здесь,
# а не только в _default_kit_handlers: kit-native тело ходит БЕЗ кук сессии,
# и московская строка молча резолвилась бы на версию без инъекции (#3264),
# то есть на ту, что упирается в QRATOR. Ключ продукта перекрывает kit-native.
"domclick_city_sweep_*": Handler(_job_domclick_city_sweep, "domclick_city_sweep_*"),
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"), "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"), "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"), "dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),

View file

@ -302,7 +302,7 @@ def resolve_proxy_url(db: Session, source: str) -> str | None:
# Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного # Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного
# здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env. # здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env.
logger.warning( logger.error(
"proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН " "proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН "
"не прошёл фильтр для этого источника (banned_for_source=%d, " "не прошёл фильтр для этого источника (banned_for_source=%d, "
"unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через " "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) - "timeout" сеть недоступна/медленная (httpx.TimeoutException)
- "connect_error" прокси не поднят/не слушает/DNS (httpx.ConnectError) - "connect_error" прокси не поднят/не слушает/DNS (httpx.ConnectError)
- "proxy_error" сам прокси отверг соединение (httpx.ProxyError, напр. 407 от
провайдера это состояние пула, а не инцидент; #3471)
- "http_error" ipify ответил ошибкой через прокси (auth/upstream) - "http_error" ipify ответил ошибкой через прокси (auth/upstream)
- "other" прочее - "other" прочее
@ -1330,14 +1328,6 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
except httpx.ConnectError: except httpx.ConnectError:
logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url)) logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url))
return False, None, None, "connect_error" 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: except httpx.HTTPStatusError as exc:
logger.warning( logger.warning(
"proxy_pool: health probe http_error proxy=%s status=%s", "proxy_pool: health probe http_error proxy=%s status=%s",

File diff suppressed because one or more lines are too long

View file

@ -10,23 +10,13 @@
Регион 50 (Московская область) отложен сознательно обоснование в #2996: Регион 50 (Московская область) отложен сознательно обоснование в #2996:
10 121 текстовое имя города против 612 у Москвы, вся мина имён в области. 10 121 текстовое имя города против 612 у Москвы, вся мина имён в области.
#3052: `region_for_point` резолвит по НАСТОЯЩЕЙ границе региона (полигон
OSM/Nominatim, `region_boundaries/boundaries.geojson.json`), не по
прямоугольнику `bbox_region` прямоугольники Москвы и области пересекались
(Химки/Реутов/Котельники/Люберцы уходили в Москву), полигоны нет.
Модуль ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают Модуль ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают
geocoder / location_index / matching.normalize, циклы недопустимы). json/ geocoder / location_index / matching.normalize, циклы недопустимы).
pathlib стандартная библиотека, листовость не нарушают.
""" """
from __future__ import annotations from __future__ import annotations
import json
import math
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import NamedTuple
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический # bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
# geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже). # geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже).
@ -38,18 +28,9 @@ class Region:
"""Один регион покрытия продукта. """Один регион покрытия продукта.
bbox_tight ядро города: geocoder-фильтрация фуззи-матчей провайдеров bbox_tight ядро города: geocoder-фильтрация фуззи-матчей провайдеров
(не принять соседний город за совпадение по опечатке). У (не принять соседний город за совпадение по опечатке).
региона БЕЗ одного центрального города (50 область, много
сопоставимых по объёму городов, ни один не «ядро») равен
bbox_product_core: эмпирический пояс, где данные РЕАЛЬНО
наблюдались (перцентили 0.5..99.5 координат сырья), а не
административная граница см. REGIONS[50] и обоснование там.
bbox_wide город + легитимное приграничье: ingest-guard координат, bbox_wide город + легитимное приграничье: ingest-guard координат,
ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит tight. ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит tight.
У 50 полный наблюдённый диапазон координат (min..max, без
перцентильной обрезки) вместо «город + отступ»: без своего
города отступать не от чего, поэтому граница «легитимности»
здесь тоже эмпирическая, просто менее обрезанная, чем tight.
bbox_region генеральный bbox региона: fallback-accept для провайдеров без bbox_region генеральный bbox региона: fallback-accept для провайдеров без
структурного region-поля. Содержит wide. структурного region-поля. Содержит wide.
bbox_product_core гео-охват ПРОДУКТА в этом регионе: location_index bbox_product_core гео-охват ПРОДУКТА в этом регионе: location_index
@ -57,29 +38,15 @@ class Region:
out_of_coverage. У 66 УЖЕ (не равен) tight: исторический bbox out_of_coverage. У 66 УЖЕ (не равен) tight: исторический bbox
location_index (56.70..56.95/60.50..60.75), синхронизирован с location_index (56.70..56.95/60.50..60.75), синхронизирован с
EKB_BBOX Overpass-загрузчика POI основного gendesign-бэкенда EKB_BBOX Overpass-загрузчика POI основного gendesign-бэкенда
(комментарий в обе стороны, см. site_finder/poi_loader.py). У (комментарий в обе стороны, см. site_finder/poi_loader.py).
50 намеренно НЕ административный bbox (обещать охват там,
где нет ни одного объявления, нельзя) эмпирический пояс
фактических данных, см. REGIONS[50].
city_token нормализованный токен главного города (нижний регистр, е==ё city_token нормализованный токен главного города (нижний регистр, е==ё
нормализует потребитель matching.normalize). У региона без нормализует потребитель matching.normalize).
единого центра (50) самый объёмный по данным город, который
ОДНОВРЕМЕННО де-факто административный: см. REGIONS[50].
cities узнаваемые города региона (для city_hint / prefix-логики cities узнаваемые города региона (для city_hint / prefix-логики
геокодера). НЕ исчерпывающий список основные центры. геокодера). НЕ исчерпывающий список основные центры.
enrichment_tiers какие тиры обогащения РЕАЛЬНО доступны региону. enrichment_tiers какие тиры обогащения РЕАЛЬНО доступны региону.
Регион без тира должен деградировать ЯВНО (потребитель Регион без тира должен деградировать ЯВНО (потребитель
спрашивает unsupported_tier_reason и логирует/маркирует), спрашивает unsupported_tier_reason и логирует/маркирует),
а не молча считать дальше без источника. а не молча считать дальше без источника.
has_city_core есть ли у региона ОДИН город-ядро, имя которого допустимо
молча подставлять в запрос геокодера, когда город не назван
(dual-query `_nominatim_query_city_aware`, ветка «город
неизвестен»). True у 66/77 (Екатеринбург / Москва там это
majority-трафик). False у 50: у области 20 сопоставимых
городов и ~970 населённых пунктов в сырье, подстановка
«Красногорск» к «Сабурово, Луговая» ровно та же ловушка
одноимённых улиц, от которой отказались в #2576.
canonical_city #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city` canonical_city #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
строк, приходящих из источника без надёжного city-поля строк, приходящих из источника без надёжного city-поля
(Росреестр по Москве отдаёт муниципальный округ/поселение (Росреестр по Москве отдаёт муниципальный округ/поселение
@ -101,7 +68,6 @@ class Region:
cities: frozenset[str] cities: frozenset[str]
enrichment_tiers: frozenset[str] enrichment_tiers: frozenset[str]
canonical_city: str | None = None canonical_city: str | None = None
has_city_core: bool = True
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool: def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
@ -110,213 +76,6 @@ def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
# ── #3052: настоящая граница региона (point-in-polygon вместо bbox) ─────────
#
# Источник — region_boundaries/boundaries.geojson.json: полигоны OSM/Nominatim
# (см. поля _source/_license внутри файла, лицензия ODbL требует их хранить).
# Парсим один раз на импорте модуля — 27 точек лишний JSON-parse на каждый
# вызов region_for_point был бы дороже самого ray casting.
#
# ТОЧНОСТЬ (правка регрессии первой версии #3052). Полигоны берутся БЕЗ
# упрощения (`polygon_threshold=0`, 42 755 вершин), а не с `0.005` (~500 м,
# 2 388 вершин), как в первой версии. Полукилометровое упрощение спрямляет
# ровно то, ради чего полигоны и заводились — изрезанную границу Москвы с
# областью. Сверка резолва против сохранённого region_code по проду (277 582
# объявления + 48 143 дома) на упрощённой геометрии дала: 69 точек НИ в одном
# полигоне (агломерация → None → вызывающий код `trade_in.py` подставляет
# DEFAULT_REGION_CODE, и адрес в Куркино считается по коэффициентам
# Екатеринбурга), Митино/Косино-Ухтомский/Можайский вне Москвы (27 домов),
# 731 объявление области внутри Москвы. Без упрощения этих спрямлений нет.
#
# Ring — одно кольцо GeoJSON (lon, lat) точек: внешний контур ИЛИ дыра.
# Полигон/мультиполигон региона хранится как ПЛОСКИЙ список всех его колец
# (для MultiPolygon 77 — кольца всех 10 частей вперемешку, у Polygon 50 —
# внешнее кольцо + 9 дыр). Плоский список работает благодаря even-odd
# правилу: точка внутри региона ⟺ она попадает внутрь НЕЧЁТНОГО числа колец
# из списка. Это ОДНОВРЕМЕННО корректно обрабатывает дыры (Москва — дыра в
# кольцах региона 50: попадание в кольцо-дыру снимает чётность, снятую
# внешним кольцом) и непересекающиеся части мультиполигона (попадание ровно
# в одно кольцо — нечётность не портится соседними частями, которые точка не
# задевает) — без явного разделения "внешний контур минус дыры".
Ring = list[tuple[float, float]]
_BOUNDARIES_PATH = Path(__file__).parent / "region_boundaries" / "boundaries.geojson.json"
def _flatten_rings(geometry: dict[str, object]) -> list[Ring]:
"""Все кольца геометрии (Polygon или MultiPolygon) одним плоским списком."""
coords = geometry["coordinates"]
if geometry["type"] == "Polygon":
polygons = [coords]
elif geometry["type"] == "MultiPolygon":
polygons = coords # type: ignore[assignment]
else:
raise ValueError(f"неподдержанный тип геометрии: {geometry['type']}")
rings: list[Ring] = []
for polygon in polygons:
for ring in polygon: # type: ignore[union-attr]
rings.append([(pt[0], pt[1]) for pt in ring]) # type: ignore[index]
return rings
def _load_region_boundaries() -> dict[int, list[Ring]]:
"""code региона → плоский список колец его полигона/мультиполигона."""
with _BOUNDARIES_PATH.open(encoding="utf-8") as f:
payload = json.load(f)
return {
int(code): _flatten_rings(entry["geometry"]) for code, entry in payload["regions"].items()
}
_REGION_BOUNDARIES: dict[int, list[Ring]] = _load_region_boundaries()
class _IndexedRing(NamedTuple):
"""Кольцо + предпосчёт, которым точная геометрия остаётся дешёвой.
Наивный ray casting линеен по числу вершин, а вершин после отказа от
упрощения в 18 раз больше (42 755 против 2 388) прямым перебором это
десятки-сотни микросекунд на вызов. Два предпосчёта снимают почти всё:
lat_min/lat_max/lon_min/lon_max bbox САМОГО кольца (не региона): у
Москвы мультиполигон из 10 частей, у области внешний контур + 9 дыр,
и точка почти всегда вне bbox большинства колец такое кольцо
отвергается четырьмя сравнениями вместо обхода вершин.
band_h/bands кольцо нарезано на горизонтальные полосы по широте; в
полосе лежат ТОЛЬКО те рёбра, чей диапазон широт её задевает (ребро
попадает во все свои полосы, поэтому пересечения не теряются).
Луч ray casting горизонтален, значит пересекать его могут лишь рёбра
своей полосы обход сокращается с «все вершины кольца» до «рёбра
одной полосы» (для внешнего контура области 23 511 максимум 724,
типично десятки).
points те же вершины ОДНИМ объектом с _REGION_BOUNDARIES (не копия):
нужны fallback'у по расстоянию до границы в region_for_point.
"""
lat_min: float
lat_max: float
lon_min: float
lon_max: float
band_h: float
bands: tuple[tuple[tuple[float, float, float, float], ...], ...]
points: Ring
# Целевая плотность полосы: ~4 вершины кольца на полосу. Мельче — полосы
# начинают стоить памяти и промахов кэша больше, чем экономят на обходе;
# крупнее — в полосу попадает слишком много рёбер. Потолок числа полос
# ограничивает память на самых длинных кольцах (внешний контур области).
_RING_BAND_DENSITY = 4
_RING_MAX_BANDS = 1024
def _index_ring(points: Ring) -> _IndexedRing:
"""Посчитать bbox и полосы кольца (один раз на импорте модуля)."""
lat_min = min(p[1] for p in points)
lat_max = max(p[1] for p in points)
lat_span = lat_max - lat_min
n_bands = max(1, min(_RING_MAX_BANDS, len(points) // _RING_BAND_DENSITY))
# Вырожденное по широте кольцо (lat_span == 0) в полосы не режется:
# одна полоса, band_h произвольный ненулевой — делить на 0 нельзя.
band_h = lat_span / n_bands if lat_span > 0.0 else 1.0
buckets: list[list[tuple[float, float, float, float]]] = [[] for _ in range(n_bands)]
x_prev, y_prev = points[-1]
for x, y in points:
edge = (x, y, x_prev, y_prev)
lo = int((min(y, y_prev) - lat_min) / band_h)
hi = int((max(y, y_prev) - lat_min) / band_h)
lo = 0 if lo < 0 else min(lo, n_bands - 1)
hi = 0 if hi < 0 else min(hi, n_bands - 1)
for band in range(lo, hi + 1):
buckets[band].append(edge)
x_prev, y_prev = x, y
return _IndexedRing(
lat_min=lat_min,
lat_max=lat_max,
lon_min=min(p[0] for p in points),
lon_max=max(p[0] for p in points),
band_h=band_h,
bands=tuple(tuple(b) for b in buckets),
points=points,
)
_REGION_RING_INDEX: dict[int, tuple[_IndexedRing, ...]] = {
code: tuple(_index_ring(ring) for ring in rings) for code, rings in _REGION_BOUNDARIES.items()
}
def _point_in_ring(ring: _IndexedRing, lon: float, lat: float) -> bool:
"""PNPOLY (W. R. Franklin) ray casting по рёбрам ОДНОЙ полосы кольца."""
lat_min, lat_max, lon_min, lon_max, band_h, bands, _points = ring
if lat < lat_min or lat > lat_max or lon < lon_min or lon > lon_max:
return False
band = int((lat - lat_min) / band_h)
if band >= len(bands): # lat == lat_max попадает ровно на верхнюю кромку
band = len(bands) - 1
inside = False
for xi, yi, xj, yj in bands[band]:
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
inside = not inside
return inside
def _point_in_region_polygon(code: int, lat: float, lon: float) -> bool:
"""True если (lat, lon) внутри настоящей границы региона `code`.
even-odd по всем кольцам сразу (см. комментарий выше про плоский список).
Региона без загруженной границы граничит только по bbox (см. вызов в
region_for_point), сюда такой код не попадает."""
inside = False
for ring in _REGION_RING_INDEX[code]:
if _point_in_ring(ring, lon, lat):
inside = not inside
return inside
def _distance_to_boundary(code: int, lat: float, lon: float) -> float:
"""Расстояние от точки до ближайшего ребра границы региона `code`.
В градусах широты (1° 111 км) по ПЛОСКОМУ приближению с поправкой
cos(lat) на сжатие долготы: значение сравнивается только с таким же
значением другого региона-кандидата на тех же ~десятках метров, где
геодезия и плоскость расходятся на доли процента.
Путь редкий (только щель между полигонами соседних регионов, см.
region_for_point), поэтому обход честный по всем рёбрам, без полос.
Стоимость срезается оценкой снизу по bbox кольца: кольцо, чей bbox уже
дальше найденного минимума, не может дать ребра ближе.
"""
lat_scale = math.cos(math.radians(lat))
px = lon * lat_scale
best = math.inf
ranked: list[tuple[float, _IndexedRing]] = []
for ring in _REGION_RING_INDEX[code]:
dx = max(ring.lon_min - lon, 0.0, lon - ring.lon_max) * lat_scale
dy = max(ring.lat_min - lat, 0.0, lat - ring.lat_max)
ranked.append((dx * dx + dy * dy, ring))
for lower_bound, ring in sorted(ranked, key=lambda item: item[0]):
if lower_bound >= best:
break
ax, ay = ring.points[-1]
ax *= lat_scale
for bx_raw, by in ring.points:
bx = bx_raw * lat_scale
dx, dy = bx - ax, by - ay
if dx or dy:
t = ((px - ax) * dx + (lat - ay) * dy) / (dx * dx + dy * dy)
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
ex = ax + t * dx - px
ey = ay + t * dy - lat
else: # выродившееся в точку ребро (дубль вершины в OSM)
ex, ey = ax - px, ay - lat
dist = ex * ex + ey * ey
if dist < best:
best = dist
ax, ay = bx, by
return math.sqrt(best)
# Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator). # Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator).
TIER_AVITO_IMV = "avito_imv" TIER_AVITO_IMV = "avito_imv"
TIER_YANDEX_VALUATION = "yandex_valuation" TIER_YANDEX_VALUATION = "yandex_valuation"
@ -400,183 +159,27 @@ REGIONS: dict[int, Region] = {
# import_rosreestr_dkp подставляет каноничное имя вместо city источника. # import_rosreestr_dkp подставляет каноничное имя вместо city источника.
canonical_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,
# Города-ядра нет (см. bbox_tight выше) — подставлять «Красногорск» в
# запрос геокодера, когда город не назван, НЕЛЬЗЯ: «Луговая» есть и в
# Красногорске, и в Сабурово, и ещё в десятке НП области.
has_city_core=False,
),
} }
DEFAULT_REGION_CODE = 66 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) по числовому коду.
#
# #3052: сам резолв региона идёт по настоящему полигону (see
# _point_in_region_polygon), не по прямоугольнику — реальные админ-границы
# 50 и 77 не пересекаются (Москва вырезана дырой из полигона области), так
# что для КОНЕЧНОГО результата порядок обхода больше не обязателен: у точки
# есть ровно один полигон-кандидат, bbox какого региона ни проверяй первым.
# Порядок остаётся не как костыль корректности, а как ДЕШЁВЫЙ предварительный
# отсев: 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 < 77 по числовому коду.
# Если проверять регионы в порядке `sorted(REGIONS)`, для точки в центре
# Москвы bbox-отсев региона 50 пройдёт ПЕРВЫМ и завернёт в дорогой
# point-in-polygon по 1 227 точкам области раньше, чем дело дойдёт до
# компактного полигона Москвы (386 точек) — лишняя работа на каждый вызов,
# не баг результата (полигон 50 всё равно отвергнет точку — она в дыре), но
# systematic overhead на самом частом случае (Москва/область — соседи).
#
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает и это без
# ручного списка: чем компактнее регион, тем раньше его bbox-отсев и (при
# совпадении) полигон проверяют, поэтому вложенный по bbox регион (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: def region_for_point(lat: float, lon: float) -> Region | None:
"""Регион покрытия, которому принадлежит точка, или None (вне охвата). """Регион покрытия, которому принадлежит точка (по bbox_region), или None.
Два уровня отсева на каждого кандидата (порядок `_POINT_LOOKUP_ORDER`, Регионы географически не пересекаются; порядок обхода детерминирован кодом
компактный bbox_region раньше обширного, см. комментарий там): региона на случай, если когда-нибудь пересекутся (первый по коду выигрывает
1. `is_within_bbox` по `bbox_region` дешёвый прямоугольный предфильтр, и это станет видно в тестах реестра, а не в проде).
НЕ финальный ответ (прямоугольники Москвы и области пересекаются).
2. Полигон (`_point_in_region_polygon`) настоящая граница, решает
результат. Если bbox прошёл, а полигон точку не принял (точка в
прямоугольнике области, но не в её реальных границах то есть,
например, внутри вырезанной дыры Москвы), идём к следующему
кандидату, а не возвращаем None сразу.
None ТОЛЬКО когда точка вне bbox ВСЕХ регионов (Пермь, Таллин). Если
хоть один прямоугольник её принял, а все полигоны отвергли, ответом
становится кандидат, к ГРАНИЦЕ которого точка ближе
(`_distance_to_boundary`). Иначе такая точка досталась бы вызывающему коду
как None, а тот (`trade_in.py`: `target_region.code if target_region else
DEFAULT_REGION_CODE`) молча подставил бы регион по умолчанию адрес в
агломерации посчитался бы по коэффициентам Екатеринбурга. Щели между
полигонами соседних регионов неизбежны (у Москвы и области это независимо
оцифрованные в OSM линии, совпадающие с точностью до метров, а не одна
общая), и закрывать их возвратом к прямоугольнику нельзя прямоугольники
перекрываются и врут ровно там, ради чего #3052 и делался.
Региона без загруженного полигона в реестре нет (все REGIONS покрыты
boundaries.geojson.json) на практике до fallback-ветки дело не доходит,
но она есть, чтобы новый регион без границы не «пропадал» молча, а
работал по старому bbox-поведению до того, как для него добавят полигон.
""" """
near_miss: list[int] = [] for code in sorted(REGIONS):
for code in _POINT_LOOKUP_ORDER: if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
region = REGIONS[code] return REGIONS[code]
if not is_within_bbox(lat, lon, region.bbox_region): return None
continue
if code not in _REGION_RING_INDEX or _point_in_region_polygon(code, lat, lon):
return region
near_miss.append(code)
if not near_miss:
return None
if len(near_miss) == 1:
return REGIONS[near_miss[0]]
nearest = min(near_miss, key=lambda code: _distance_to_boundary(code, lat, lon))
return REGIONS[nearest]
def region_by_city(city: str | None) -> Region | None: def region_by_city(city: str | None) -> Region | None:
"""Регион, в чьём списке городов есть `city` (нормализованный нижний """Регион, в чьём списке городов есть `city` (нормализованный нижний
регистр, е/ё не различаются). None город не узнан ни одним регионом. регистр, е/ё не различаются). 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).
"""
if not city: if not city:
return None return None
token = " ".join(city.lower().replace("ё", "е").split()) 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 77 = Москва verified 2026-05-31 brute-force against /api/sowa
(region name from ref_area field of response); (region name from ref_area field of response);
real_estate_deals: 2017-01 148 471 2026-04 309 510 руб/м². 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 from __future__ import annotations
@ -83,7 +71,6 @@ SBER_REF_AREAS: dict[str, str] = {
"643": "Россия", "643": "Россия",
"66": "Свердловская область", "66": "Свердловская область",
"77": "Москва", "77": "Москва",
"50": "Московская область",
} }

View file

@ -147,21 +147,6 @@ _NOTIFY_SEND_TIMEOUT_S = 5.0
_NOTIFY_SEND_MAX_RETRIES = 3 _NOTIFY_SEND_MAX_RETRIES = 3
_NOTIFY_SEND_MAX_BACKOFF_S = 1.0 _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 на транзиентных сетевых отказах # Потолок переигрываний ОДНОГО update_id на транзиентных сетевых отказах
# (#tg-connection-resilience). # (#tg-connection-resilience).
# #
@ -238,13 +223,7 @@ class BridgeStorage(Protocol):
) -> int | None: ... ) -> int | None: ...
def record_web_out_message( def record_web_out_message(
self, self, *, thread_id: int, text_body: str, operator_tg_id: int | None
*,
thread_id: int,
text_body: str,
operator_tg_id: int | None,
topic_message_id: int | None = None,
support_chat_id: int | None = None,
) -> None: ... ) -> None: ...
@ -397,20 +376,14 @@ class SqlBridgeStorage:
со ЧУЖИМ (не NULL, не текущим) support_chat_id исторический артефакт со ЧУЖИМ (не NULL, не текущим) support_chat_id исторический артефакт
ротации support-группы, не валидный маршрут сегодня. NULL (строки до ротации support-группы, не валидный маршрут сегодня. NULL (строки до
миграции 188, если есть) лениентный wildcard-матч (единственный миграции 188, если есть) лениентный wildcard-матч (единственный
действовавший чат на тот момент). действовавший чат на тот момент)."""
БЕЗ фильтра по direction (#3471 P0, было `AND direction = 'in'`): с тех
пор как `record_message` на исходящем ответе тоже сохраняет
`topic_message_id` (id сообщения оператора В ТОПИКЕ), реплай оператора
на СВОЙ предыдущий ответ обязан резолвиться так же, как реплай на
зеркало клиента иначе продолжение диалога без повторного цитирования
клиента тихо проваливалось в orphan-check."""
row = self._db.execute( row = self._db.execute(
text( text(
""" """
SELECT chat_id SELECT chat_id
FROM tg_support_messages FROM tg_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint) WHERE topic_message_id = CAST(:topic_message_id AS bigint)
AND direction = 'in'
AND (support_chat_id = CAST(:support_chat_id AS bigint) AND (support_chat_id = CAST(:support_chat_id AS bigint)
OR support_chat_id IS NULL) OR support_chat_id IS NULL)
ORDER BY created_at DESC ORDER BY created_at DESC
@ -440,21 +413,13 @@ class SqlBridgeStorage:
) )
def record_web_out_message( def record_web_out_message(
self, self, *, thread_id: int, text_body: str, operator_tg_id: int | None
*,
thread_id: int,
text_body: str,
operator_tg_id: int | None,
topic_message_id: int | None = None,
support_chat_id: int | None = None,
) -> None: ) -> None:
web_support_storage.record_outbound( web_support_storage.record_outbound(
self._db, self._db,
thread_id=thread_id, thread_id=thread_id,
text_body=text_body, text_body=text_body,
operator_tg_id=operator_tg_id, operator_tg_id=operator_tg_id,
topic_message_id=topic_message_id,
support_chat_id=support_chat_id,
) )
@ -483,7 +448,7 @@ async def _notify_topic(
text: str, text: str,
reply_to_message_id: int | None, reply_to_message_id: int | None,
context: str, context: str,
) -> bool: ) -> None:
"""Служебное уведомление оператору в support-топик (вторичное действие). """Служебное уведомление оператору в support-топик (вторичное действие).
Два свойства, которых не было у прямых `client.send_message` вызовов: Два свойства, которых не было у прямых `client.send_message` вызовов:
@ -494,12 +459,6 @@ async def _notify_topic(
и не решает судьбу апдейта. и не решает судьбу апдейта.
`context` только технические идентификаторы (chat_id/thread_id), НЕ текст `context` только технические идентификаторы (chat_id/thread_id), НЕ текст
переписки: логи моста принципиально не содержат ПДн. переписки: логи моста принципиально не содержат ПДн.
Возвращает True, если уведомление реально ушло, False если само
уведомление тоже упало (напр. Telegram недоступен). Вызывающий, для
которого проваленное уведомление означает ПОЛНУЮ тишину (ни клиенту, ни
оператору), обязан на False залогировать `logger.error` с идентификаторами
(#3471 P0) — иначе единственный след остаётся только в этом WARNING.
""" """
try: try:
await client.send_message( await client.send_message(
@ -511,7 +470,6 @@ async def _notify_topic(
max_retries=_NOTIFY_SEND_MAX_RETRIES, max_retries=_NOTIFY_SEND_MAX_RETRIES,
max_backoff=_NOTIFY_SEND_MAX_BACKOFF_S, max_backoff=_NOTIFY_SEND_MAX_BACKOFF_S,
) )
return True
except Exception: except Exception:
logger.warning( logger.warning(
"tgbot bridge: не удалось отправить уведомление оператору в топик (%s) — " "tgbot bridge: не удалось отправить уведомление оператору в топик (%s) — "
@ -519,7 +477,6 @@ async def _notify_topic(
context, context,
exc_info=True, exc_info=True,
) )
return False
# ── Update routing ──────────────────────────────────────────────────────────── # ── Update routing ────────────────────────────────────────────────────────────
@ -550,11 +507,7 @@ async def _handle_private_message(
text_body = message.get("text") text_body = message.get("text")
if text_body == "/start": if text_body == "/start":
# E) команда — не содержательное обращение, топик не засоряем. # E) команда — не содержательное обращение, топик не засоряем.
await client.send_message( await client.send_message(chat_id=chat_id, text=GREETING_TEXT)
chat_id=chat_id,
text=GREETING_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
return return
if not settings.telegram_support_chat_id: if not settings.telegram_support_chat_id:
@ -564,11 +517,7 @@ async def _handle_private_message(
chat_id, chat_id,
) )
# Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет. # Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет.
await client.send_message( await client.send_message(chat_id=chat_id, text=SERVICE_UNAVAILABLE_TEXT)
chat_id=chat_id,
text=SERVICE_UNAVAILABLE_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
return return
message_id = message.get("message_id") message_id = message.get("message_id")
@ -596,11 +545,7 @@ async def _handle_private_message(
# за окно — `_flood_notify_limiter.check()` возвращает None (и сам # за окно — `_flood_notify_limiter.check()` возвращает None (и сам
# фиксирует попытку) ровно один раз за окно. # фиксирует попытку) ровно один раз за окно.
if _flood_notify_limiter.check(flood_key) is None: if _flood_notify_limiter.check(flood_key) is None:
await client.send_message( await client.send_message(chat_id=chat_id, text=FLOOD_LIMITED_TEXT)
chat_id=chat_id,
text=FLOOD_LIMITED_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
return return
_flood_limiter.record(flood_key) _flood_limiter.record(flood_key)
@ -611,7 +556,6 @@ async def _handle_private_message(
chat_id=settings.telegram_support_chat_id, chat_id=settings.telegram_support_chat_id,
text=header, text=header,
message_thread_id=settings.telegram_support_topic_id or None, 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( mirrored = await client.copy_message(
@ -619,7 +563,6 @@ async def _handle_private_message(
from_chat_id=chat_id, from_chat_id=chat_id,
message_id=message_id, message_id=message_id,
message_thread_id=settings.telegram_support_topic_id or None, 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 topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
@ -684,7 +627,6 @@ async def _handle_group_reply(
chat_id=target_chat_id, chat_id=target_chat_id,
from_chat_id=settings.telegram_support_chat_id, from_chat_id=settings.telegram_support_chat_id,
message_id=message_id, message_id=message_id,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
) )
except TelegramApiError as exc: except TelegramApiError as exc:
if exc.error_code == 403: if exc.error_code == 403:
@ -707,22 +649,10 @@ async def _handle_group_reply(
chat_id=target_chat_id, chat_id=target_chat_id,
direction="out", direction="out",
tg_message_id=tg_message_id, tg_message_id=tg_message_id,
# #3471 P0: id ЭТОГО сообщения оператора В ТОПИКЕ (было безусловно topic_message_id=None,
# None) — без него реплай оператора на СВОЙ предыдущий ответ не
# резолвился (искать было нечего), маршрут держался только на
# зеркале клиента. Совпадение с `in`-записью структурно исключено:
# `message_id` — id реплая оператора, а зеркало клиента уже занимает
# другой message_id в том же чате.
topic_message_id=message_id,
kind=_infer_kind(message), kind=_infer_kind(message),
text_body=message.get("text") or message.get("caption"), text_body=message.get("text") or message.get("caption"),
operator_tg_id=operator_id, 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 return
@ -756,64 +686,11 @@ async def _handle_group_reply(
operator = message.get("from") or {} operator = message.get("from") or {}
operator_id = operator.get("id") operator_id = operator.get("id")
try: storage.record_web_out_message(
storage.record_web_out_message( thread_id=web_thread_id,
thread_id=web_thread_id, text_body=text_body,
text_body=text_body, operator_tg_id=operator_id,
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,
)
return return
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем, # Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
@ -864,12 +741,7 @@ async def process_update(
`PendingRollbackError`, `process_update` вылетит без сохранения offset'а, `PendingRollbackError`, `process_update` вылетит без сохранения offset'а,
следующая итерация получит СТАРЫЙ offset от `get_offset()` и переиграет следующая итерация получит СТАРЫЙ offset от `get_offset()` и переиграет
тот же апдейт copyMessage задублирует зеркало клиента в топике на тот же апдейт copyMessage задублирует зеркало клиента в топике на
каждый повтор поллинга (#3 review, воспроизведено). Для веб-ветки каждый повтор поллинга (#3 review, воспроизведено).
(`_handle_group_reply` `record_web_out_message`) этот `SQLAlchemyError`
перехватывается ЛОКАЛЬНО, до этого места: там запись в БД И ЕСТЬ
доставка клиенту, поэтому rollback сопровождается уведомлением оператору
в топике, что ответ НЕ доставлен (#3471 P0) — сюда, на верхний уровень,
это исключение уже не долетает.
- любое прочее исключение (в т.ч. `TelegramApiError` площадка ОТВЕТИЛА - любое прочее исключение (в т.ч. `TelegramApiError` площадка ОТВЕТИЛА
отказом, повтор ничего не изменит) offset двигаем, «ядовитый» апдейт отказом, повтор ничего не изменит) offset двигаем, «ядовитый» апдейт
не блокирует поток. не блокирует поток.

View file

@ -43,7 +43,6 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time
from typing import Any from typing import Any
import httpx import httpx
@ -55,155 +54,6 @@ _DEFAULT_RETRY_AFTER_S = 5.0
_MAX_BACKOFF_S = 30.0 _MAX_BACKOFF_S = 30.0
_DEFAULT_MAX_RETRIES = 5 _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
# Текст зондирующего сообщения `verify_chat_and_topic` — см. докстринг там.
# Живёт в чате доли секунды (удаляется сразу после отправки), но должен быть
# узнаваем в логах ретранслятора/дебаге, если удаление вдруг не отработает.
_TOPIC_PROBE_TEXT = "\U0001f50d startup topic check"
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 разворачивает скаляр в # Раздельные таймауты вместо скаляра. httpx разворачивает скаляр в
# connect=read=write=pool, поэтому long-poll `getUpdates` (read = 30с, которые # connect=read=write=pool, поэтому long-poll `getUpdates` (read = 30с, которые
# Telegram держит запрос, + 10с запаса = 40с) ставил 40 секунд и на УСТАНОВКУ # Telegram держит запрос, + 10с запаса = 40с) ставил 40 секунд и на УСТАНОВКУ
@ -276,25 +126,6 @@ class TelegramNetworkError(TelegramError):
super().__init__(f"Telegram {method} unreachable after {attempts} attempts: {reason}") 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: def _request_timeout(read: float) -> httpx.Timeout:
"""Разворачивает «сколько ждать ответа» (скаляр вызывающего) в таймауты httpx. """Разворачивает «сколько ждать ответа» (скаляр вызывающего) в таймауты httpx.
@ -367,26 +198,10 @@ class TelegramClient:
token: str, token: str,
base_url: str = "https://api.telegram.org", base_url: str = "https://api.telegram.org",
timeout: float = _DEFAULT_TIMEOUT_S, 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: ) -> None:
# ── Ретранслятор через Beget (#3471) ──────────────────────────────── self._base = f"{base_url}/bot{token}"
# `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._timeout = timeout self._timeout = timeout
self._http: httpx.AsyncClient | None = None 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: def _http_client(self) -> httpx.AsyncClient:
"""Ленивое создание переиспользуемого AsyncClient (вне цикла ретраев).""" """Ленивое создание переиспользуемого AsyncClient (вне цикла ретраев)."""
@ -413,42 +228,6 @@ class TelegramClient:
async def __aexit__(self, *_exc: object) -> None: async def __aexit__(self, *_exc: object) -> None:
await self.aclose() 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( async def _request(
self, self,
method: str, method: str,
@ -457,24 +236,9 @@ class TelegramClient:
timeout: float | None = None, timeout: float | None = None,
max_retries: int = _DEFAULT_MAX_RETRIES, max_retries: int = _DEFAULT_MAX_RETRIES,
max_backoff: float | None = None, max_backoff: float | None = None,
rate_limit_max_wait: float | None = None,
) -> Any: ) -> Any:
"""POST `method` с JSON-телом `payload`. Ретраит 429/5xx/network, иначе raise сразу. """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` (#tgsupport-retry) — потолок паузы МЕЖДУ попытками. По
умолчанию `_MAX_BACKOFF_S` (30с) и полный `retry_after` из тела 429 это умолчанию `_MAX_BACKOFF_S` (30с) и полный `retry_after` из тела 429 это
воркерная политика, она НЕ меняется. Интерактивный вызывающий передаёт узкий воркерная политика, она НЕ меняется. Интерактивный вызывающий передаёт узкий
@ -488,23 +252,8 @@ class TelegramClient:
это штатные 30-60с) на число попыток и подвесил бы синхронный HTTP-запрос на это штатные 30-60с) на число попыток и подвесил бы синхронный HTTP-запрос на
минуты ровно то, от чего предостерегает докстринг `send_message`. минуты ровно то, от чего предостерегает докстринг `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}" url = f"{self._base}/{method}"
effective_timeout = timeout if timeout is not None else self._timeout
# Раздельные таймауты считаем ОДИН раз и передаём per-request: у общего # Раздельные таймауты считаем ОДИН раз и передаём per-request: у общего
# AsyncClient свой дефолт, а бюджет ответа у каждого вызова свой. # AsyncClient свой дефолт, а бюджет ответа у каждого вызова свой.
request_timeout = _request_timeout(effective_timeout) request_timeout = _request_timeout(effective_timeout)
@ -517,7 +266,7 @@ class TelegramClient:
while True: while True:
attempt += 1 attempt += 1
try: try:
response = await self._post(client, url, payload, request_timeout) response = await client.post(url, json=payload, timeout=request_timeout)
except httpx.TransportError as exc: except httpx.TransportError as exc:
# Ловим ВЕСЬ `TransportError`, а не узкий кортеж # Ловим ВЕСЬ `TransportError`, а не узкий кортеж
# `(TimeoutException, NetworkError)`: `RemoteProtocolError` # `(TimeoutException, NetworkError)`: `RemoteProtocolError`
@ -674,12 +423,8 @@ class TelegramClient:
message_id: int, message_id: int,
message_thread_id: int | None = None, message_thread_id: int | None = None,
reply_to_message_id: int | None = None, reply_to_message_id: int | None = None,
rate_limit_max_wait: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла. """copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла."""
`rate_limit_max_wait` см. `TelegramClient._request`; используется
`bridge.py` для точечного потолка ожидания на конкретных местах (review M1)."""
payload: dict[str, Any] = { payload: dict[str, Any] = {
"chat_id": chat_id, "chat_id": chat_id,
"from_chat_id": from_chat_id, "from_chat_id": from_chat_id,
@ -689,9 +434,7 @@ class TelegramClient:
payload["message_thread_id"] = message_thread_id payload["message_thread_id"] = message_thread_id
if reply_to_message_id: if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id payload["reply_to_message_id"] = reply_to_message_id
result = await self._request( result = await self._request("copyMessage", payload)
"copyMessage", payload, rate_limit_max_wait=rate_limit_max_wait
)
return result if isinstance(result, dict) else {} return result if isinstance(result, dict) else {}
async def send_message( async def send_message(
@ -704,7 +447,6 @@ class TelegramClient:
timeout: float | None = None, timeout: float | None = None,
max_retries: int | None = None, max_retries: int | None = None,
max_backoff: float | None = None, max_backoff: float | None = None,
rate_limit_max_wait: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке). """sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
@ -728,131 +470,5 @@ class TelegramClient:
kwargs["max_retries"] = max_retries kwargs["max_retries"] = max_retries
if max_backoff is not None: if max_backoff is not None:
kwargs["max_backoff"] = max_backoff 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) result = await self._request("sendMessage", payload, **kwargs)
return result if isinstance(result, dict) else {} 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`: живой прод-замер
(#3471) показал, что Telegram принимает и мгновенно подтверждает
`sendChatAction` с ЗАВЕДОМО несуществующим `message_thread_id`
(`{"ok":true,"result":true}`) метод молча принимает любую тему,
существующую или нет. Раньше здесь было обратное (неверное)
утверждение; см. `verify_chat_and_topic`, которая для проверки темы
использует `sendMessage`+`deleteMessage`."""
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 delete_message(self, *, chat_id: int, message_id: int) -> bool:
"""deleteMessage — удаляет сообщение бота.
Используется `verify_chat_and_topic` для зачистки зондирующего
`sendMessage`-пробника сразу после проверки темы: тема существует
тогда и только тогда, когда сообщение вообще удалось отправить
поэтому к моменту вызова `deleteMessage` id уже гарантированно
валиден."""
result = await self._request(
"deleteMessage",
{"chat_id": chat_id, "message_id": message_id},
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 с сетевыми
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
«через сутки тишины» на «в первую секунду после старта/рестарта».
Способ: `getChat(chat_id)` + `sendMessage`/`deleteMessage` в саму тему.
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не
выгнан/не заблокирован чистый read, нулевой видимый след.
- Раньше здесь стоял `send_chat_action` (typing-индикатор с
`message_thread_id`) в расчёте на то, что Telegram обязан знать про
`message_thread_id`, чтобы показать «печатает...» именно в нужном
треде. Живой прод-замер (#3471) опроверг это: `sendChatAction`
принимает и подтверждает ЗАВЕДОМО несуществующий
`message_thread_id` (`{"ok":true,"result":true}`) проверка была
ложно-зелёной ВСЕГДА, удалённая/переименованная тема проходила её
так же, как живая, а реальная отправка потом падала на каждом
сообщении. `sendMessage` с тем же мусорным id вместо этого мгновенно
отвечает `400 Bad Request: message thread not found` (эмпирически
подтверждено на том же проде), а с валидным id создаёт сообщение
которое здесь же стирается `deleteMessage`, так что в истории треда
не остаётся ничего, кроме микросекундного технического сообщения на
каждом старте/рестарте контейнера.
НЕ роняет процесс: любой `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:
probe = await client.send_message(
chat_id=chat_id,
text=_TOPIC_PROBE_TEXT,
message_thread_id=topic_id,
max_retries=1,
max_backoff=5.0,
)
probe_message_id = probe.get("message_id")
if probe_message_id:
await client.delete_message(chat_id=chat_id, message_id=probe_message_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

@ -32,15 +32,7 @@ def get_telegram_client() -> TelegramClient:
""" """
global _client global _client
if _client is None: if _client is None:
_client = TelegramClient( _client = TelegramClient(settings.telegram_bot_token)
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 return _client

View file

@ -61,36 +61,6 @@ def get_or_create_thread(db: Session, username: str) -> int:
return int(row[0]) 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( def record_inbound(
db: Session, db: Session,
*, *,
@ -98,7 +68,6 @@ def record_inbound(
text_body: str, text_body: str,
topic_message_id: int | None, topic_message_id: int | None,
support_chat_id: int | None, support_chat_id: int | None,
idempotency_key: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` — """Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора. id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
@ -106,103 +75,18 @@ def record_inbound(
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
support-группе если группу когда-нибудь сменят/пересоздадут, Telegram support-группе если группу когда-нибудь сменят/пересоздадут, Telegram
message_id стартует заново с 1 в новом чате и может совпасть с числом из message_id стартует заново с 1 в новом чате и может совпасть с числом из
старого без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188). старого без этого поля коллизия была бы ТИХОЙ (см. миграцию 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 всегда проходит."""
row = ( row = (
db.execute( db.execute(
text( text(
""" """
INSERT INTO web_support_messages INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id, (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 VALUES
(CAST(:thread_id AS bigint), 'in', :text_body, (CAST(:thread_id AS bigint), 'in', :text_body,
CAST(:topic_message_id AS bigint), CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint), NULL, :idempotency_key, NOW()) CAST(:support_chat_id AS bigint), NULL, 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())
RETURNING id, direction, text_body, operator_tg_id, created_at RETURNING id, direction, text_body, operator_tg_id, created_at
""" """
), ),
@ -222,17 +106,8 @@ def record_inbound(
def find_thread_by_topic_message( def find_thread_by_topic_message(
db: Session, topic_message_id: int, support_chat_id: int db: Session, topic_message_id: int, support_chat_id: int
) -> int | None: ) -> int | None:
"""Резолвит id зеркала/ответа (reply_to) в thread_id. """Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186).
БЕЗ фильтра по 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) это же и гарантирует на уровне БД.
Скоупим к ТЕКУЩЕМУ `support_chat_id` (#tgsupport-web review M1): строка со Скоупим к ТЕКУЩЕМУ `support_chat_id` (#tgsupport-web review M1): строка со
ЧУЖИМ (не NULL и не текущим) support_chat_id это исторический артефакт ЧУЖИМ (не NULL и не текущим) support_chat_id это исторический артефакт
@ -245,6 +120,7 @@ def find_thread_by_topic_message(
SELECT thread_id SELECT thread_id
FROM web_support_messages FROM web_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint) 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) AND (support_chat_id = CAST(:support_chat_id AS bigint) OR support_chat_id IS NULL)
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1
@ -256,40 +132,18 @@ def find_thread_by_topic_message(
def record_outbound( def record_outbound(
db: Session, db: Session, *, thread_id: int, text_body: str, operator_tg_id: int | None
*,
thread_id: int,
text_body: str,
operator_tg_id: int | None,
topic_message_id: int | None = None,
support_chat_id: int | None = None,
) -> int | None: ) -> int | None:
"""Записывает ответ оператора (реплай на веб-зеркало) как direction='out'. """Записывает ответ оператора (реплай на веб-зеркало) как direction='out'.
`topic_message_id` всегда NULL маршрутизирующий ключ живёт только на
`topic_message_id` (#3471 P0, раньше был безусловно NULL) — id ЭТОГО inbound-записи (см. tg_support_messages-конвенцию, 186)."""
сообщения оператора в топике. Раньше маршрутизирующий ключ жил только на
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 там же)."""
row = db.execute( row = db.execute(
text( text(
""" """
INSERT INTO web_support_messages INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id, (thread_id, direction, text_body, topic_message_id, operator_tg_id, created_at)
support_chat_id, operator_tg_id, created_at)
VALUES VALUES
(CAST(:thread_id AS bigint), 'out', :text_body, (CAST(:thread_id AS bigint), 'out', :text_body, NULL,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint),
CAST(:operator_tg_id AS bigint), NOW()) CAST(:operator_tg_id AS bigint), NOW())
RETURNING id RETURNING id
""" """
@ -297,8 +151,6 @@ def record_outbound(
{ {
"thread_id": thread_id, "thread_id": thread_id,
"text_body": text_body, "text_body": text_body,
"topic_message_id": topic_message_id,
"support_chat_id": support_chat_id,
"operator_tg_id": operator_tg_id, "operator_tg_id": operator_tg_id,
}, },
).fetchone() ).fetchone()

View file

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

View file

@ -14,7 +14,6 @@ Idempotent-логика (на каждый yandex-lot):
- история ПУСТА: - история ПУСТА:
* price_previous задан И != price_rub seed previous-точку (change_time = now-1d, * price_previous задан И != price_rub seed previous-точку (change_time = now-1d,
timestamp синтетический gate реального времени смены не несёт), затем current-точку; timestamp синтетический gate реального времени смены не несёт), затем current-точку;
НО previous, отличающаяся от текущей цены на порядок и больше, не сеется (#3385);
* иначе только current-точку (now). * иначе только current-точку (now).
- история ЕСТЬ и latest_price != price_rub одна current-точка (now) зафиксировали смену. - история ЕСТЬ и latest_price != price_rub одна current-точка (now) зафиксировали смену.
- история ЕСТЬ и latest_price == price_rub skip (цена не менялась). - история ЕСТЬ и latest_price == price_rub skip (цена не менялась).
@ -42,14 +41,6 @@ logger = logging.getLogger(__name__)
_SOURCE = "yandex" _SOURCE = "yandex"
# #3385: price.previous, отличающаяся от текущей цены в 9.5 раза и больше (в любую
# сторону), — не рынок, а потерянный разряд или цена в тысячах. Прод 17.09.2026, все
# 8313 посевов yandex: самый большой честный скачок ×2.13, дальше пусто до ×9.74; выше
# порога 11 посевов (×10 — 7, ×175…×1000 — 4), и у всех нижняя цена 0.137 тыс/м².
# Посев синтетический (now-1d, реального времени смены gate не несёт), без него
# теряется только фиктивная точка — текущая цена пишется всё равно.
_SEED_IMPLAUSIBLE_RATIO = 9.5
# Резолв listing_id по стабильному (source, source_id) — у Yandex source_id = offer_id. # Резолв listing_id по стабильному (source, source_id) — у Yandex source_id = offer_id.
_RESOLVE_SQL = text( _RESOLVE_SQL = text(
""" """
@ -105,12 +96,6 @@ def _insert_point(
) )
def _implausible_seed(previous: float, current: float) -> bool:
"""previous и current разнятся на порядок и больше (или одна из цен не > 0)."""
low, high = sorted((float(previous), float(current)))
return low <= 0 or high / low >= _SEED_IMPLAUSIBLE_RATIO
def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int: def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
"""Дописать offer_price_history по yandex-lots из gate price.previous/trend. """Дописать offer_price_history по yandex-lots из gate price.previous/trend.
@ -130,7 +115,6 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
inserted = 0 inserted = 0
skipped = 0 skipped = 0
seed_rejected = 0
errors = 0 errors = 0
for lot in lots: for lot in lots:
@ -156,29 +140,20 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
{"listing_id": listing_id}, {"listing_id": listing_id},
).fetchone() ).fetchone()
# Сдвиг разряда у yandex ловится ТОЛЬКО на посеве (#3385). Ветка «история # ПОТОЛОК: гейта сдвига разряда (#3376) здесь НЕТ, и он бы тут не
# есть» на проде не записала ни одной строки (из 28 558 строк этого # сработал. drop_decimal_slips требует двух свидетелей — скачка ×10
# писателя на 17.09): смену listings.price_rub раньше неё пишет триггер # к предыдущей точке и подтверждения у следующей. На этом пути серия
# record_listing_price_change (save_listings идёт первым), и сюда # максимум из двух точек (последняя лежащая в БД + текущая), у
# приходит latest == lot.price_rub. Все 7 скачков ×10 в yandex-истории # последней точки свидетель — текущая цена лота, а она и ЕСТЬ эта
# — пары «посев из price.previous → текущая цена», ошибочна нижняя. # точка: свидетель совпадает с подозреваемым, отношение всегда 1.0.
# То есть проводка была бы декорацией: ветка, которая по построению
# не может выбросить ни одной точки. Отлов ×10 у yandex требует
# другого механизма — сравнения со СЛЕДУЮЩИМ наблюдением, а значит
# DELETE уже вставленной строки. Отдельная задача: #3385.
if latest is None: if latest is None:
# История пуста — seed (+ опционально previous-точка). # История пуста — seed (+ опционально previous-точка).
prev = lot.price_previous_rub prev = lot.price_previous_rub
if ( if prev is not None and prev != lot.price_rub:
prev is not None
and prev != lot.price_rub
and _implausible_seed(prev, lot.price_rub)
):
seed_rejected += 1
logger.warning(
"yandex_price_history: price.previous=%s не сеется — на порядок "
"от текущей %s (source_id=%s)",
prev,
lot.price_rub,
lot.source_id,
)
elif prev is not None and prev != lot.price_rub:
_insert_point( _insert_point(
db, db,
listing_id=listing_id, listing_id=listing_id,
@ -216,11 +191,10 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
db.commit() db.commit()
logger.info( logger.info(
"yandex_price_history: lots=%d inserted=%d skipped=%d seed_rejected=%d errors=%d", "yandex_price_history: lots=%d inserted=%d skipped=%d errors=%d",
len(lots), len(lots),
inserted, inserted,
skipped, skipped,
seed_rejected,
errors, errors,
) )
return inserted return inserted

View file

@ -51,7 +51,6 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import LISTINGS_FRESH_DAYS, settings from app.core.config import LISTINGS_FRESH_DAYS, settings
from app.services import regions as regions_mod
from app.services import scrape_runs as runs_mod from app.services import scrape_runs as runs_mod
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется. # Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
@ -69,29 +68,13 @@ _PPM2_MIN: int = 30_000
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены # занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном # системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE # (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
# ниже) — симметрично deal-стороне. # ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный
# # столбец `district` (#647), эта константа станет per-city параметром для обеих сторон.
# #3512 (per-region ratio, migration 304): эта городская квота — костыль ИМЕННО региона
# 66 (asking-скрейп исторически покрывал только сам ЕКБ, см. #C2 выше), и трогать её
# нельзя — числа региона 66 обязаны остаться byte-for-byte прежними. Для ЛЮБОГО другого
# региона (deals.region_code / listings.region_code — обе таблицы несут колонку) городской
# квоты не было и не нужно: там достаточно `region_code = :region_code` симметрично на
# обеих сторонах (см. _REDERIVE_SQL_REGION ниже) — колонка `district` остаётся
# зарезервированной под #647 (гео-районы ВНУТРИ региона), per-region разрез теперь
# несёт `region_code`, а не `district`.
_ASKING_CITY_PATTERN: str = "%Екатеринбург%" _ASKING_CITY_PATTERN: str = "%Екатеринбург%"
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000). # Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals # QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок. # WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
# #3512: регионы, для которых считается ОТДЕЛЬНАЯ (не-ЕКБ) деривация ниже —
# весь реестр покрытия (app.services.regions — ЕДИНСТВЕННЫЙ источник правды про регионы)
# минус 66 (у него своя историческая деривация выше). Появление нового региона в реестре
# автоматически включает его в пересчёт ratio, без правки этого файла.
_OTHER_REGION_CODES: tuple[int, ...] = tuple(
sorted(code for code in regions_mod.REGIONS if code != 66)
)
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ: # #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms # tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы — # синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
@ -103,103 +86,6 @@ _AREA_ROOMS_BUCKET_SQL = (
) )
# ── #time-adjust: приведение SOLD-стороны к сегодняшнему дню (rollback за settings) ──
# ПРОБЛЕМА: sold-медиана (deal_side/deal_global/deal_geo) — сырые цены сделок за
# трейлинг-12 мес (на проде фактическое окно сентябрь 2025 - апрель 2026, медиана
# ~декабрь 2025), ask-медиана — объявления за LISTINGS_FRESH_DAYS (сегодня). На растущем
# рынке это занижает ratio (числитель отстаёт от знаменателя во времени). Фикс — тот же
# приём, что estimator._sber_time_factor применяет к ДКП-коридору (#794): каждая сделка
# домножается на factor = idx[последний доступный месяц серии] / idx[месяц сделки]
# (с clamp SBER_TIME_FACTOR_MIN/MAX). Серия и её имя — ТА ЖЕ карта sber_region_series_name
# (app.services.estimator), НЕ вторая карта — импортируется ЛЕНИВО внутри
# recompute_asking_to_sold_ratios(), т.к. estimator.py импортирует area_bucket ИЗ этого
# модуля на верхнем уровне (см. выше) — top-level импорт в обратную сторону дал бы цикл.
#
# Двойной учёт (проверено, см. PR-описание/vault): estimator._fetch_dkp_corridor тоже
# зовёт _sber_time_factor, но на ЖИВОЙ per-request ДКП-коридор (отдельный SQL, отдельная
# цель — condition-guard corridor-clamp поверх ASKING-медианы, estimator.py:3989-4002),
# а не на ratio из asking_to_sold_ratios. Эта таблица и коридор — независимые артефакты
# по одному сырому источнику (deals); один и тот же множитель здесь и там НЕ перемножается
# на одно и то же число дважды.
#
# Дашборд выбирается В SQL по приоритету SBER_COEFF_DASHBOARDS (bind-массив
# :sber_dashboards, array_position — первый непустой побеждает, тот же порядок, что
# estimator._load_sber_index_series перебирает по одному дашборду). Приведение
# управляется флагом settings.asking_ratio_time_adjust_enabled (:time_adjust_enabled) —
# False даёт factor=1.0 для КАЖДОЙ сделки (байт-в-байт прежнее поведение, откат без
# деплоя). Если серии для города вообще нет (sber_bounds пуст) — тоже factor=1.0; это
# НЕ ошибка (сделка не выбрасывается), но recompute_asking_to_sold_ratios логирует
# факт счётчиком, а не молча (см. _sber_series_missing ниже). Если для конкретного
# месяца сделки нет точки — берётся ближайший БОЛЕЕ РАННИЙ месяц серии (или самый
# ранний, если сделка старше начала серии) — то же правило, что estimator._sber_time_factor.
_SBER_FACTOR_CTES = """
sber_series_raw AS (
SELECT period_month, index_value_rub_m2, dashboard,
array_position(CAST(:sber_dashboards AS text[]), dashboard) AS dash_priority
FROM sber_price_index
WHERE city = CAST(:sber_city AS text)
AND (segment IS NULL OR segment ILIKE '%вторичн%')
AND dashboard = ANY(CAST(:sber_dashboards AS text[]))
),
sber_best_dashboard AS (
SELECT dashboard FROM sber_series_raw
WHERE dash_priority IS NOT NULL
ORDER BY dash_priority
LIMIT 1
),
sber_series AS (
SELECT r.period_month, r.index_value_rub_m2
FROM sber_series_raw r
JOIN sber_best_dashboard b ON r.dashboard = b.dashboard
),
sber_bounds AS (
SELECT MAX(period_month) AS latest_month, MIN(period_month) AS earliest_month
FROM sber_series
),
sber_latest_value AS (
SELECT s.index_value_rub_m2 AS latest_value
FROM sber_series s JOIN sber_bounds b ON s.period_month = b.latest_month
),
sber_earliest_value AS (
SELECT s.index_value_rub_m2 AS earliest_value
FROM sber_series s JOIN sber_bounds b ON s.period_month = b.earliest_month
)"""
# LEFT JOIN'ы к sber_* CTE выше — вставляются в `FROM deals` ДО `WHERE` (JOIN не может
# идти после WHERE). ON TRUE — CTE содержат максимум одну строку (не декартово произведение).
# snb — LATERAL «ближайший месяц серии <= месяца сделки», коррелирован по bare deal_date
# (единственная таблица в запросе с этой колонкой — алиас не нужен).
_SBER_FACTOR_JOINS = """
LEFT JOIN sber_bounds sb ON TRUE
LEFT JOIN sber_latest_value slv ON TRUE
LEFT JOIN sber_earliest_value sev ON TRUE
LEFT JOIN LATERAL (
SELECT s.index_value_rub_m2 AS base_value
FROM sber_series s
WHERE s.period_month <= date_trunc('month', deal_date)::date
ORDER BY s.period_month DESC
LIMIT 1
) snb ON TRUE
"""
# Сам фактор — 1.0 при выключенном флаге, при пустой серии, при сделке новее последнего
# месяца серии (без экстраполяции вперёд — симметрично estimator._sber_time_factor).
# GREATEST/LEAST — те же клампы SBER_TIME_FACTOR_MIN/MAX, что estimator применяет к
# ДКП-коридору (bind :factor_min/:factor_max — значения ОТТУДА, не второй набор констант).
_SBER_FACTOR_EXPR = """CASE
WHEN NOT CAST(:time_adjust_enabled AS boolean) THEN 1.0
WHEN sb.latest_month IS NULL THEN 1.0
WHEN date_trunc('month', deal_date)::date >= sb.latest_month THEN 1.0
ELSE GREATEST(
CAST(:factor_min AS double precision),
LEAST(
CAST(:factor_max AS double precision),
slv.latest_value / NULLIF(COALESCE(snb.base_value, sev.earliest_value), 0)
)
)
END"""
def area_bucket(area_m2: float) -> int: def area_bucket(area_m2: float) -> int:
"""Python-двойник _AREA_ROOMS_BUCKET_SQL (границы ИДЕНТИЧНЫ, #2620). """Python-двойник _AREA_ROOMS_BUCKET_SQL (границы ИДЕНТИЧНЫ, #2620).
@ -222,26 +108,14 @@ def area_bucket(area_m2: float) -> int:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── True-mirror cleanup: drop all #648 EKB rows before re-derivation ────────── # ── True-mirror cleanup: drop all #648 rows before re-derivation ──────────────
# district = '' — все строки #648 (district зарезервирован под #647, пока всегда ''). # district = '' — все строки #648 (district зарезервирован под #647, пока всегда '').
# #3512: явный region_code=66 — эта DELETE трогает ТОЛЬКО ЕКБ-строки; остальные регионы
# чистит своя _DELETE_SQL_REGION в цикле ниже (иначе один бланкет-DELETE стирал бы
# только что вставленные строки другого региона на повторном прогоне того же transaction).
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались # Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT. # stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
_DELETE_SQL = text( _DELETE_SQL = text(
""" """
DELETE FROM asking_to_sold_ratios DELETE FROM asking_to_sold_ratios
WHERE region_code = 66 AND district = '' WHERE district = ''
"""
)
# #3512: та же true-mirror очистка, но per-region (используется в цикле для
# _OTHER_REGION_CODES) — CAST(:region_code AS int), никогда :region_code::int (psycopg v3).
_DELETE_SQL_REGION = text(
"""
DELETE FROM asking_to_sold_ratios
WHERE region_code = CAST(:region_code AS int) AND district = ''
""" """
) )
@ -262,18 +136,14 @@ _DELETE_SQL_REGION = text(
# (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую). # (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую).
_REDERIVE_SQL = text( _REDERIVE_SQL = text(
f""" f"""
WITH{_SBER_FACTOR_CTES}, WITH
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра). #time-adjust: -- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
-- price_per_m2 домножен на sber-фактор приведения к последнему месяцу серии.
deal_side AS ( deal_side AS (
SELECT SELECT
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket, LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
percentile_cont(0.5) WITHIN GROUP ( percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
ORDER BY price_per_m2 * ({_SBER_FACTOR_EXPR})
) AS sold_median,
COUNT(*) AS n_deals COUNT(*) AS n_deals
FROM deals FROM deals
{_SBER_FACTOR_JOINS}
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND rooms IS NOT NULL AND rooms IS NOT NULL
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок) AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
@ -345,12 +215,9 @@ _REDERIVE_SQL = text(
-- SOLD медиана по ВСЕМ комнатам (без бакет-фильтра) за трейлинг-12мес для global row. -- SOLD медиана по ВСЕМ комнатам (без бакет-фильтра) за трейлинг-12мес для global row.
deal_global AS ( deal_global AS (
SELECT SELECT
percentile_cont(0.5) WITHIN GROUP ( percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
ORDER BY price_per_m2 * ({_SBER_FACTOR_EXPR})
) AS sold_median,
COUNT(*) AS n_deals COUNT(*) AS n_deals
FROM deals FROM deals
{_SBER_FACTOR_JOINS}
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND rooms IS NOT NULL AND rooms IS NOT NULL
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок) AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
@ -399,384 +266,19 @@ _REDERIVE_SQL = text(
) )
INSERT INTO asking_to_sold_ratios ( INSERT INTO asking_to_sold_ratios (
rooms_bucket, district, ratio, sold_median, ask_median, rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, region_code n_deals, n_listings, window_months, basis
) )
SELECT rooms_bucket, district, ratio, sold_median, ask_median, SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, 66 FROM global_row n_deals, n_listings, window_months, basis FROM global_row
UNION ALL UNION ALL
SELECT rooms_bucket, district, ratio, sold_median, ask_median, SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, 66 FROM per_bucket n_deals, n_listings, window_months, basis FROM per_bucket
""" """
) )
# ── Geography-matched per-region derivation (#3529) ───────────────────────────
# ПРОБЛЕМА (#3512-путь, прод-замер 2026-09 по региону 50): обе стороны фильтровались
# ТОЛЬКО по region_code и соединялись ТОЛЬКО по бакету комнат — т.е. sold-медиана и
# ask-медиана считались по РАЗНЫМ географическим популяциям одного региона.
# Разложение обл.50 по кольцам 10 км от центра Москвы (сделки 12 мес vs активные объявления):
# 20-30 км: 12 189 сделок / 23 292 объявления → 0.808
# 30-40 км: 4 132 / 10 206 → 0.847
# 50-60 км: 1 432 / 5 243 → 0.951
# 70-80 км: 150 / 2 294 → 0.688
# ВНУТРИ колец отношение 0.69-0.95, ближние кольца (76% сделок) — 0.81-0.85, а общий пул
# давал 0.891: объявления смещены к дальней дешёвой периферии СИЛЬНЕЕ, чем сделки. Это
# перекос СОСТАВА выборки, а не свойство рынка: выкупная цена по области системно завышена.
#
# РЕШЕНИЕ: считать коэффициент на СОГЛАСОВАННОЙ географии — обе стороны раскладываются
# по одним и тем же пространственным ячейкам, медианы берутся ВНУТРИ ячейки, и в итог
# идут только ячейки, где есть ОБЕ стороны, с весами по числу СДЕЛОК. Т.е. ask-сторона
# перевзвешивается на географию сделок (индекс Ласпейреса): ratio = Σ(w·sold) / Σ(w·ask),
# w = n_deals ячейки. Строка остаётся внутренне согласованной: ratio == sold_median/ask_median,
# где оба медианных столбца — взвешенные средние ячеечных медиан с ОДНИМИ весами.
#
# ПУТЬ ЕКБ (66) НЕ ТРОГАЕМ — там своя историческая калибровка городской квотой (#C2/#2583),
# числа региона 66 обязаны остаться byte-for-byte прежними (_REDERIVE_SQL выше).
# РАЗМЕР ЯЧЕЙКИ — регулярная сетка 0.1° широты × 0.2° долготы ≈ 11 км × 12-16 км на
# широтах 45-60°N (0.2° долготы × cos(lat): 15.7 км на 45°, 12.5 км на 55.7°, 11.1 км на 60°).
# Почему именно так:
# • Масштаб взят от замера выше: именно на ~10-км разрешении отношение перестаёт
# гулять от состава (внутри кольца 0.69-0.95 вместо 0.891 по пулу), при этом ячейка
# ещё достаточно крупная, чтобы набрать десятки сделок и объявлений.
# • Сетка, а НЕ кольца от центра: кольцам нужен центр, а у региона 50 своего
# города-центра нет (его фактический центр — Москва, т.е. ДРУГОЙ регион), и каждый
# следующий регион реестра потребовал бы своего анкора и своего шага. Сетке анкор не нужен.
# • Совмещение по НАЗВАНИЮ муниципалитета НЕВОЗМОЖНО: listings.city у региона 50
# пуста (3 строки из 70 996). geom есть с обеих сторон (объявления 70 996/70 996,
# сделки 87 562/113 351) — выравниваем ПРОСТРАНСТВЕННО.
# • FLOOR по градусам — чистая арифметика по ST_X/ST_Y, без репроекций и без стыковки
# с админграницами, которых в БД нет. Точность границ ячейки здесь не важна — важно,
# что ОБЕ стороны режутся ОДИНАКОВО.
_CELL_LAT_DEG: float = 0.1
_CELL_LON_DEG: float = 0.2
# Порог НА ЯЧЕЙКУ (все комнатности вместе) — сколько нужно, чтобы ячейка считалась
# покрытой ОБЕИМИ сторонами. Ниже глобального 30/30 НАМЕРЕННО: ячеечная медиана не
# публикуется сама по себе — она входит во взвешенную сумму, а публикуемый барьер
# остаётся прежним 30/30, но уже на СУММЕ по удержанным ячейкам (HAVING ниже).
_CELL_MIN_DEALS: int = 10
_CELL_MIN_LISTINGS: int = 10
# Порог на пару (ячейка, бакет комнат) — ещё мягче: внутри уже отобранной ячейки
# комнатность дробит выборку ещё на 5 частей. Меньше 5 наблюдений на сторону — медиана
# шум, и при большом весе этот шум попадёт в итоговую строку.
_CELL_BUCKET_MIN_DEALS: int = 5
_CELL_BUCKET_MIN_LISTINGS: int = 5
# ГАРДЫ ДЕГРАДАЦИИ (пункт 6 задачи): если согласованной географии по факту нет —
# лучше НЕ писать строку вообще (эстиматор деградирует явно, без коэффициента),
# чем посчитать неверно и выглядеть уверенно.
_MIN_MATCHED_CELLS: int = 3
# Доля СДЕЛОК (с geom), попавших в пересечение ячеек. Именно сделки — целевая популяция
# (на их географию перевзвешивается ask-сторона); объявления за пределами пересечения
# отбрасываются НАМЕРЕННО (это и есть фикс), поэтому гарда на них нет — только счётчик.
_MIN_DEAL_CELL_COVERAGE: float = 0.5
# Доля строк с geom на КАЖДОЙ стороне: если большая часть стороны без координат,
# выравнивать пространственно нечего — получился бы коэффициент по неслучайному остатку.
_MIN_GEOM_COVERAGE: float = 0.5
# Сигнальный (не блокирующий) порог: выше него пишется WARNING. 0.25 выбран чуть выше
# текущего прод-состояния региона 50 (25 789/113 351 = 22.8% сделок ждут геокодера),
# чтобы лог не шумел на норме, но ухудшение было видно сразу. Доля попадает в счётчики
# ВСЕГДА, независимо от порога — строки без geom не выпадают молча (пункт 3 задачи).
_GEOM_WARN_SHARE: float = 0.25
# ОБЩИЕ ФИЛЬТРЫ сторон — один источник правды для stats- и insert-запросов (иначе счётчики
# и деривация разъехались бы при первой же правке одного из них). Состав гардов тот же,
# что у ЕКБ-деривации (12-мес окно, ppm²-полоса, свежесть #2656, novostroyki #1186,
# area_m2 IS NOT NULL #2620) — меняется ТОЛЬКО гео-согласование.
_DEAL_FROM_REGION = """
FROM deals
"""
_DEAL_WHERE_REGION = """
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND region_code = CAST(:region_code AS int)
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
"""
# #time-adjust: FROM и WHERE разведены — deal_geo (ниже) вставляет sber-JOIN'ы МЕЖДУ
# ними (JOIN обязан стоять до WHERE), deal_all (stats, без time-adjust) склеивает как раньше.
_DEAL_FROM_WHERE_REGION = _DEAL_FROM_REGION + _DEAL_WHERE_REGION
_ASK_FROM_WHERE_REGION = """
FROM listings
WHERE is_active
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND rooms IS NOT NULL
AND area_m2 IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS int)
"""
# Ячеечные CTE — ОБЩИЕ для stats-запроса (счётчики + гард) и для самой деривации,
# чтобы решение «писать / не писать» принималось РОВНО по тем ячейкам, которые потом считаются.
_CELL_CTES_REGION = f"""{_SBER_FACTOR_CTES},
-- #time-adjust: price_per_m2 домножен на sber-фактор ДО попадания в deal_cell/
-- deal_cell_bucket приведение проезжает через весь взвешенный (Ласпейрес) расчёт
-- региона автоматически, отдельно трогать deal_cell/per_bucket не нужно.
deal_geo AS (
SELECT
FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat,
FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon,
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
price_per_m2 * ({_SBER_FACTOR_EXPR}) AS price_per_m2
{_DEAL_FROM_REGION}
{_SBER_FACTOR_JOINS}
{_DEAL_WHERE_REGION} AND geom IS NOT NULL
),
ask_geo AS (
SELECT
FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat,
FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon,
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
price_per_m2
{_ASK_FROM_WHERE_REGION} AND geom IS NOT NULL
),
deal_cell AS (
SELECT
cell_lat, cell_lon,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*) AS n_deals
FROM deal_geo
GROUP BY cell_lat, cell_lon
),
ask_cell AS (
SELECT
cell_lat, cell_lon,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*) AS n_listings
FROM ask_geo
GROUP BY cell_lat, cell_lon
),
-- СОГЛАСОВАННАЯ ГЕОГРАФИЯ: ячейки, где ОБЕ стороны имеют свою массу.
matched_cell AS (
SELECT
d.cell_lat, d.cell_lon,
d.sold_median, d.n_deals,
a.ask_median, a.n_listings
FROM deal_cell d
JOIN ask_cell a USING (cell_lat, cell_lon)
WHERE d.n_deals >= {_CELL_MIN_DEALS}
AND a.n_listings >= {_CELL_MIN_LISTINGS}
AND d.sold_median IS NOT NULL AND d.sold_median > 0
AND a.ask_median IS NOT NULL AND a.ask_median > 0
)"""
# Статистика СОСТАВА выборки — считается ДО деривации и решает, писать ли регион вообще.
# Строки БЕЗ geom тоже считаются (n_all vs n_geo) — они выпадают из деривации, и это
# должно быть видно в счётчиках, а не тихо (пункт 3 задачи).
_GEO_STATS_SQL_REGION = text(
f"""
WITH{_CELL_CTES_REGION},
deal_all AS (
SELECT
COUNT(*) AS n_all,
COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo
{_DEAL_FROM_WHERE_REGION} ),
ask_all AS (
SELECT
COUNT(*) AS n_all,
COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo
{_ASK_FROM_WHERE_REGION} )
SELECT
da.n_all AS deals_total,
da.n_geo AS deals_geo,
aa.n_all AS listings_total,
aa.n_geo AS listings_geo,
(SELECT COUNT(*) FROM deal_cell) AS cells_deal,
(SELECT COUNT(*) FROM ask_cell) AS cells_ask,
(SELECT COUNT(*) FROM deal_cell d
JOIN ask_cell a USING (cell_lat, cell_lon)) AS cells_both_sides,
(SELECT COUNT(*) FROM matched_cell) AS cells_matched,
(SELECT COALESCE(SUM(n_deals), 0) FROM matched_cell) AS deals_in_cells,
(SELECT COALESCE(SUM(n_listings), 0) FROM matched_cell) AS listings_in_cells
FROM deal_all da CROSS JOIN ask_all aa
"""
)
# Деривация на согласованной географии. Отличий от ЕКБ-пути (_REDERIVE_SQL) ровно два:
# 1. городская квота ЕКБ → симметричный region_code на обеих сторонах (#3512);
# 2. медианы считаются ВНУТРИ ячейки и агрегируются с весами по числу сделок (#3529).
# Окно 12 мес, ppm²-полоса, area-бакет ask-стороны, порог 30/30 на публикуемую строку — прежние.
_REDERIVE_SQL_REGION = text(
f"""
WITH{_CELL_CTES_REGION},
-- Внутри УЖЕ отобранных ячеек разрез по бакету комнат (обе стороны тот же набор
-- ячеек, т.е. гео-ключ есть И в фильтре, И в соединении в отличие от старого
-- `JOIN ... USING (rooms_bucket)`, где географии в соединении не было вообще).
deal_cell_bucket AS (
SELECT
g.cell_lat, g.cell_lon, g.rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS sold_median,
COUNT(*) AS n_deals
FROM deal_geo g
JOIN matched_cell m USING (cell_lat, cell_lon)
GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
),
ask_cell_bucket AS (
SELECT
g.cell_lat, g.cell_lon, g.rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS ask_median,
COUNT(*) AS n_listings
FROM ask_geo g
JOIN matched_cell m USING (cell_lat, cell_lon)
GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
),
bucket_cell AS (
SELECT
d.rooms_bucket,
d.sold_median, d.n_deals,
a.ask_median, a.n_listings
FROM deal_cell_bucket d
JOIN ask_cell_bucket a USING (cell_lat, cell_lon, rooms_bucket)
WHERE d.n_deals >= {_CELL_BUCKET_MIN_DEALS}
AND a.n_listings >= {_CELL_BUCKET_MIN_LISTINGS}
AND d.sold_median IS NOT NULL AND d.sold_median > 0
AND a.ask_median IS NOT NULL AND a.ask_median > 0
),
-- Взвешивание по числу СДЕЛОК: ask-сторона приводится к географии сделок.
-- ratio == sold_median/ask_median построчно (оба взвешенные средние с ОДНИМИ весами),
-- так что публикуемые столбцы остаются взаимно согласованными.
per_bucket AS (
SELECT
rooms_bucket,
''::text AS district,
CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
AS numeric) AS ratio,
round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
SUM(n_deals)::int AS n_deals,
SUM(n_listings)::int AS n_listings,
12 AS window_months,
'per_rooms'::text AS basis
FROM bucket_cell
GROUP BY rooms_bucket
-- ТОТ ЖЕ публикуемый барьер 30/30, что и раньше теперь на сумме по ячейкам.
HAVING SUM(n_deals) >= 30
AND SUM(n_listings) >= 30
AND SUM(ask_median * n_deals) > 0
),
-- Global -1 fallback те же ячейки, но без разреза по комнатности.
global_row AS (
SELECT
-1 AS rooms_bucket,
''::text AS district,
CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
AS numeric) AS ratio,
round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
SUM(n_deals)::int AS n_deals,
SUM(n_listings)::int AS n_listings,
12 AS window_months,
'global_fallback'::text AS basis
FROM matched_cell
HAVING SUM(n_deals) > 0
AND SUM(ask_median * n_deals) > 0
)
INSERT INTO asking_to_sold_ratios (
rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, region_code
)
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM global_row
UNION ALL
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM per_bucket
"""
)
# КОЛОНКА district (#647-слот) ОСТАЁТСЯ ПУСТОЙ и здесь. Идентификатор ячейки в неё не
# ложится: ячейка — ПРОМЕЖУТОЧНАЯ единица расчёта, а не единица публикации. На выходе
# по-прежнему одна строка на (регион, бакет) — агрегат по всем ячейкам; записать в
# district «какую-то одну» ячейку было бы враньём, а писать строку НА ЯЧЕЙКУ нельзя:
# потребитель (estimator._get_asking_sold_ratio) читает строго `district = ''` и
# ключевать оценку по гео-ячейке пока не умеет — это отдельная задача #647.
def _pct(part: float, whole: float) -> int:
"""Доля part/whole в ЦЕЛЫХ процентах (счётчики scrape_runs — dict[str, int])."""
if whole <= 0:
return 0
return round(100.0 * part / whole)
def geo_region_verdict(region_code: int, stats: dict[str, int] | None) -> tuple[bool, str]:
"""Писать ли строки региона по согласованной географии (пункт 6 — явная деградация).
Возвращает (ok, reason). ok=False регион НЕ получает НИ ОДНОЙ строки (старые всё
равно удалены), и эстиматор честно остаётся без коэффициента вместо неверного.
Чистая функция от строки stats тестируется без базы.
"""
if not stats:
return False, "geo-stats не вернулись"
deals_total = int(stats.get("deals_total") or 0)
deals_geo = int(stats.get("deals_geo") or 0)
listings_total = int(stats.get("listings_total") or 0)
listings_geo = int(stats.get("listings_geo") or 0)
cells_matched = int(stats.get("cells_matched") or 0)
deals_in_cells = int(stats.get("deals_in_cells") or 0)
if deals_total == 0 or listings_total == 0:
return False, f"нет данных: deals={deals_total} listings={listings_total}"
if deals_geo / deals_total < _MIN_GEOM_COVERAGE:
return False, (
f"сделки без geom: {_pct(deals_total - deals_geo, deals_total)}% "
f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})"
)
if listings_geo / listings_total < _MIN_GEOM_COVERAGE:
return False, (
f"объявления без geom: {_pct(listings_total - listings_geo, listings_total)}% "
f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})"
)
if cells_matched < _MIN_MATCHED_CELLS:
return False, (
f"ячеек с обеими сторонами {cells_matched} < {_MIN_MATCHED_CELLS} "
f"(географии сделок и объявлений практически не пересекаются)"
)
if deals_geo > 0 and deals_in_cells / deals_geo < _MIN_DEAL_CELL_COVERAGE:
return False, (
f"в пересечение ячеек попало {_pct(deals_in_cells, deals_geo)}% сделок "
f"(порог {_MIN_DEAL_CELL_COVERAGE:.0%})"
)
_ = region_code
return True, "ok"
def _geo_region_counters(region_code: int, stats: dict[str, int] | None) -> dict[str, int]:
"""Порегионные счётчики состава выборки (пункты 3 и 5 задачи), всё — int."""
s = stats or {}
deals_total = int(s.get("deals_total") or 0)
deals_geo = int(s.get("deals_geo") or 0)
listings_total = int(s.get("listings_total") or 0)
listings_geo = int(s.get("listings_geo") or 0)
deals_in_cells = int(s.get("deals_in_cells") or 0)
listings_in_cells = int(s.get("listings_in_cells") or 0)
cells_matched = int(s.get("cells_matched") or 0)
cells_both = int(s.get("cells_both_sides") or 0)
p = f"geo_r{region_code}_"
return {
p + "cells_deal": int(s.get("cells_deal") or 0),
p + "cells_ask": int(s.get("cells_ask") or 0),
p + "cells_matched": cells_matched,
# Ячейки, где есть обе стороны, но одна из них тоньше порога ячейки.
p + "cells_dropped": max(cells_both - cells_matched, 0),
# Сколько массы осталось ЗА пределами пересечения (от строк с geom).
p + "deals_outside_pct": _pct(deals_geo - deals_in_cells, deals_geo),
p + "listings_outside_pct": _pct(listings_geo - listings_in_cells, listings_geo),
# Строки без координат — не выпадают молча (пункт 3).
p + "deals_no_geom_pct": _pct(deals_total - deals_geo, deals_total),
p + "listings_no_geom_pct": _pct(listings_total - listings_geo, listings_total),
}
# ── Post-insert counters ────────────────────────────────────────────────────── # ── Post-insert counters ──────────────────────────────────────────────────────
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк # Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
# записано всего, сколько per_rooms, был ли использован global -1 fallback. # записано всего, сколько per_rooms, был ли использован global -1 fallback.
# #3512: считается ОДИН раз в самом конце, ПОСЛЕ всех регионов (ЕКБ + цикл по
# _OTHER_REGION_CODES) — district='' покрывает все регионы разом, счётчики суммарные.
_COUNTERS_SQL = text( _COUNTERS_SQL = text(
""" """
SELECT SELECT
@ -790,22 +292,13 @@ _COUNTERS_SQL = text(
def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]: def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
"""Пересчитать asking_to_sold_ratios per region (#3512, TRUE-MIRROR refresh #648 Stage 4). """Пересчитать asking_to_sold_ratios (TRUE-MIRROR refresh, #648 Stage 4).
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
В ОДНОЙ транзакции (атомарно таблица никогда не пуста mid-refresh для уже В ОДНОЙ транзакции (атомарно таблица никогда не пуста mid-refresh):
посчитанных регионов): 1. DELETE FROM asking_to_sold_ratios WHERE district = '' снести stale-строки.
1. Регион 66 (ЕКБ): DELETE (region_code=66, district='') 080-derivation 2. Заново прогнать 080-derivation INSERT...SELECT (per_rooms при 30/30 + global -1).
INSERT...SELECT (byte-for-byte прежняя логика городская квота ЕКБ, #C2/#2583). Затем counters из таблицы, commit, mark_done. Семантика == re-seed миграции 080.
2. Каждый прочий регион реестра app.services.regions (минус 66): тот же
DELETE/INSERT цикл, но derivation скоупится по `region_code` симметрично
на sold- и asking-стороне (без городской квоты она не применима вне
ЕКБ, см. комментарий у generic-derivation SQL ниже).
Регион без своих ДКП-сделок ИЛИ без активных listings просто не получает строк
(global_row/per_bucket WHERE-гарды отфильтровывают NULL-медианы) это НЕ
ошибка, а честный признак «данных пока недостаточно», ловится потребителем
(estimator._get_asking_sold_ratio) через отсутствие строки явная деградация.
Затем ОДИН общий counters-запрос по всей таблице, commit, mark_done.
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
@ -827,77 +320,10 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
"rows_written": 0, "rows_written": 0,
"per_rooms_rows": 0, "per_rooms_rows": 0,
"used_global_fallback": 0, "used_global_fallback": 0,
# #3529: сколько регионов посчитано по согласованной географии, а сколько
# деградировало явно (строк нет → эстиматор без коэффициента).
"geo_regions_written": 0,
"geo_regions_skipped": 0,
# #time-adjust: сколько регионов реально получили sber-приведение SOLD-стороны
# (серия найдена) vs посчитаны с factor=1.0 (флаг выключен ИЛИ серии нет вовсе).
"sber_time_adjust_regions_applied": 0,
"sber_time_adjust_regions_missing_series": 0,
} }
# #time-adjust: ленивый импорт — estimator.py импортирует area_bucket ИЗ этого модуля
# на верхнем уровне, top-level импорт в обратную сторону дал бы цикл (см. комментарий
# у _SBER_FACTOR_CTES выше).
from app.services.estimator import (
SBER_COEFF_DASHBOARDS,
SBER_TIME_FACTOR_MAX,
SBER_TIME_FACTOR_MIN,
sber_region_series_name,
)
sber_dashboards = list(SBER_COEFF_DASHBOARDS)
time_adjust_enabled = bool(settings.asking_ratio_time_adjust_enabled)
def _sber_params(sber_city: str) -> dict[str, object]:
return {
"sber_city": sber_city,
"sber_dashboards": sber_dashboards,
"time_adjust_enabled": time_adjust_enabled,
"factor_min": SBER_TIME_FACTOR_MIN,
"factor_max": SBER_TIME_FACTOR_MAX,
}
def _check_sber_series(region_code: int, sber_city: str) -> None:
"""Пункт 3 задачи: если серии для города вообще нет — не молча, счётчик+лог."""
if not time_adjust_enabled:
return
try:
row = (
db.execute(
text(
"""
SELECT COUNT(*) AS n FROM sber_price_index
WHERE city = CAST(:sber_city AS text)
AND dashboard = ANY(CAST(:sber_dashboards AS text[]))
"""
),
{"sber_city": sber_city, "sber_dashboards": sber_dashboards},
)
.mappings()
.first()
)
except Exception as exc: # pragma: no cover — defensive, graceful
logger.warning("sber series presence-check failed (graceful): %s", exc)
row = None
n = int(row["n"]) if row and row.get("n") is not None else 0
if n:
counters["sber_time_adjust_regions_applied"] += 1
else:
counters["sber_time_adjust_regions_missing_series"] += 1
logger.warning(
"asking_to_sold_ratio region_code=%d: sber_price_index серии нет "
"(city=%s) — SOLD-сторона считается БЕЗ time-adjust (factor=1.0)",
region_code,
sber_city,
)
try: try:
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними — # DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
# таблица не должна остаться пустой, если INSERT упадёт). Регион 66 — # таблица не должна остаться пустой, если INSERT упадёт).
# прежняя ЕКБ-деривация байт-в-байт; остальные регионы — цикл ниже (#3512).
ekb_sber_city = sber_region_series_name(66)
_check_sber_series(66, ekb_sber_city)
db.execute(_DELETE_SQL) db.execute(_DELETE_SQL)
db.execute( db.execute(
_REDERIVE_SQL, _REDERIVE_SQL,
@ -906,64 +332,8 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
"ppm2_max": settings.asking_ratio_ppm2_max, "ppm2_max": settings.asking_ratio_ppm2_max,
"asking_city": _ASKING_CITY_PATTERN, "asking_city": _ASKING_CITY_PATTERN,
"fresh_days": LISTINGS_FRESH_DAYS, "fresh_days": LISTINGS_FRESH_DAYS,
**_sber_params(ekb_sber_city),
}, },
) )
for region_code in _OTHER_REGION_CODES:
region_sber_city = sber_region_series_name(region_code)
_check_sber_series(region_code, region_sber_city)
params = {
"region_code": region_code,
"ppm2_min": _PPM2_MIN,
"ppm2_max": settings.asking_ratio_ppm2_max,
"fresh_days": LISTINGS_FRESH_DAYS,
**_sber_params(region_sber_city),
}
# Сначала состав выборки (#3529) — он же решает, писать ли регион вообще.
stats_row = db.execute(_GEO_STATS_SQL_REGION, params).mappings().first()
stats = dict(stats_row) if stats_row is not None else None
counters.update(_geo_region_counters(region_code, stats))
ok, reason = geo_region_verdict(region_code, stats)
# DELETE идёт В ЛЮБОМ случае: если согласованной географии больше нет, старый
# (считанный по пулу) коэффициент тем более не должен оставаться в таблице.
db.execute(_DELETE_SQL_REGION, {"region_code": region_code})
no_geom_deals = counters.get(f"geo_r{region_code}_deals_no_geom_pct", 0)
no_geom_listings = counters.get(f"geo_r{region_code}_listings_no_geom_pct", 0)
if max(no_geom_deals, no_geom_listings) >= int(_GEOM_WARN_SHARE * 100):
# Пункт 3: строки без координат не выпадают молча — это сигнал.
logger.warning(
"asking_to_sold_ratio region_code=%d: без geom сделок %d%%, "
"объявлений %d%%гео-согласование считается по остатку",
region_code,
no_geom_deals,
no_geom_listings,
)
if not ok:
counters["geo_regions_skipped"] += 1
counters[f"geo_r{region_code}_skipped"] = 1
logger.warning(
"asking_to_sold_ratio region_code=%d: СТРОКИ НЕ ПИШУТСЯ — %s. "
"Оценка останется без коэффициента (явная деградация)",
region_code,
reason,
)
continue
counters[f"geo_r{region_code}_skipped"] = 0
counters["geo_regions_written"] += 1
db.execute(_REDERIVE_SQL_REGION, params)
logger.info(
"asking_to_sold_ratio region_code=%d: ячеек с обеими сторонами %d "
"(отброшено по порогу %d), вне пересечения: сделок %d%%, объявлений %d%%",
region_code,
counters.get(f"geo_r{region_code}_cells_matched", 0),
counters.get(f"geo_r{region_code}_cells_dropped", 0),
counters.get(f"geo_r{region_code}_deals_outside_pct", 0),
counters.get(f"geo_r{region_code}_listings_outside_pct", 0),
)
row = db.execute(_COUNTERS_SQL).mappings().first() row = db.execute(_COUNTERS_SQL).mappings().first()
if row is not None: if row is not None:

View file

@ -42,12 +42,6 @@ counters["block_streak_histogram"], иначе эффект правки неч
своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог
печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как
"ABORT -- 1 consecutive blocks". "ABORT -- 1 consecutive blocks".
Третий критерий, "infra_streak" (#3439): max_consecutive_failures отказов НАШЕГО
тракта (сайдкар/пул, ban_kind='infra') подряд без единого ответа площадки. Такой
отказ в долю не входит (#3288), и до этого серия любой длины не рвала прогон ничем:
7193/7264/7302 по 1600 попыток, 1-2 карточки, status='done'. Прогон завершается
'failed' с причиной «тракт недоступен», а не 'banned': площадка молчала.
""" """
from __future__ import annotations from __future__ import annotations
@ -104,9 +98,9 @@ from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
# принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает # принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает
# его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси), # его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси),
# зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() — # зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() —
# без него warm-batch curl-путь (use_curl=True, avito_detail_backfill_use_curl) молча # без него warm-batch curl-путь (use_curl=True, ДЕФОЛТ прод-режима,
# терял бы backconnect (прямое datacenter-подключение вместо sticky-прокси), тот же # avito_detail_backfill_use_curl=True) молча терял бы backconnect (прямое
# класс бага что #2322/#2310. # datacenter-подключение вместо sticky-прокси), тот же класс бага что #2322/#2310.
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -238,10 +232,12 @@ async def _rotate_current_proxy(
settings.scraper_proxy_url БЕЗ lease там ротировать нечего, вызывающий цикл не settings.scraper_proxy_url БЕЗ lease там ротировать нечего, вызывающий цикл не
зовёт эту функцию в том режиме вовсе. зовёт эту функцию в том режиме вовсе.
Прод в browser-режиме, а не в curl: дефолт avito_detail_backfill_use_curl=False Прод при этом в browser-режиме, а не в curl: у контейнера tradein-scraper (там же
(#3045). Добор исполняют ДВА процесса — планировщик в tradein-scraper и ручка живёт планировщик) проверено AVITO_DETAIL_BACKFILL_USE_CURL=false при
админки scrape_avito_detail_backfill в tradein-backend, поэтому режим задаётся SCRAPER_FETCH_MODE=browser и USE_PROXY_POOL_BROWSER=true, так что ротация
дефолтом кода, а не environment'ом одного сервиса. активна. Значение true стоит только у tradein-backend, который добор не запускает.
Комментарий ниже по файлу (~строка 653) называет use_curl=True «прод-дефолтом»
это предсуществующее заблуждение, а не описание текущего прода.
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост) Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
НЕ должен ронять прогон логируем и продолжаем на текущем адресе. Эта попытка НЕ НЕ должен ронять прогон логируем и продолжаем на текущем адресе. Эта попытка НЕ
@ -333,8 +329,7 @@ async def run_avito_detail_backfill(
скользящем окне, см. settings.detail_backfill_block_ratio_window/ скользящем окне, см. settings.detail_backfill_block_ratio_window/
_threshold (module docstring). _threshold (module docstring).
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам, max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
default 25 (см. комментарий у чтения параметра ниже). Тот же порог рвёт default 25 (см. комментарий у чтения параметра ниже).
серию отказов НАШЕГО тракта без ответа площадки (infra_streak, #3439).
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674); mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
@ -554,10 +549,6 @@ async def run_avito_detail_backfill(
window_size=int(settings.detail_backfill_block_ratio_window), window_size=int(settings.detail_backfill_block_ratio_window),
ratio_threshold=float(settings.detail_backfill_block_ratio_threshold), ratio_threshold=float(settings.detail_backfill_block_ratio_threshold),
safety_min=max_consecutive_blocks, safety_min=max_consecutive_blocks,
# #3439: отказ сайдкара/пула — отказ нашей стороны, тот же разряд, что
# отказы-не-блоки, и тот же порог. Без него серия infra не рвала прогон
# ничем: 7302 — 1598 отказов сайдкара из 1600 попыток, 72 минуты, 'done'.
infra_streak_limit=max_consecutive_failures,
# snapshot_size гейтит safety-net (#3184 review MAJOR 2): пачка блоков в # snapshot_size гейтит safety-net (#3184 review MAJOR 2): пачка блоков в
# начале ДЛИННОГО прогона не должна абортить его так же, как раньше -- # начале ДЛИННОГО прогона не должна абортить его так же, как раньше --
# safety-net включён только когда снапшот короче окна и ratio-критерий # safety-net включён только когда снапшот короче окна и ratio-критерий
@ -714,7 +705,7 @@ async def run_avito_detail_backfill(
# config.scraper_proxy_url=None для backconnect-gate'а # config.scraper_proxy_url=None для backconnect-gate'а
# (elif not use_curl own-session path), теряя reconnect- # (elif not use_curl own-session path), теряя reconnect-
# on-403 поведение legacy (settings.scraper_proxy_url # on-403 поведение legacy (settings.scraper_proxy_url
# напрямую). Не влияет на use_curl=True # напрямую). Не влияет на use_curl=True (прод-дефолт)
# там reconnect_on_block=False уже гасит backconnect. # там reconnect_on_block=False уже гасит backconnect.
config=RealScraperConfig(), config=RealScraperConfig(),
origin=serp_origin, origin=serp_origin,
@ -815,7 +806,7 @@ async def run_avito_detail_backfill(
# и mark_failed с текстом про пул, как у домклика после #3283. # и mark_failed с текстом про пул, как у домклика после #3283.
if _caused_by_empty_pool(e): if _caused_by_empty_pool(e):
counters.failed += 1 counters.failed += 1
logger.warning( logger.error(
"avito_detail_backfill: run_id=%d СТОП — пул прокси пуст, " "avito_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d", "к площадке не ходили. enriched=%d attempted=%d",
run_id, run_id,
@ -922,7 +913,7 @@ async def run_avito_detail_backfill(
# ratio: прогон 5210 (14 блоков из 20, ровно порог) отпечатал # ratio: прогон 5210 (14 блоков из 20, ровно порог) отпечатал
# "ABORT -- 1 consecutive blocks" -- текущая серия в тот момент # "ABORT -- 1 consecutive blocks" -- текущая серия в тот момент
# действительно равнялась единице, но обрыв был не по ней. # действительно равнялась единице, но обрыв был не по ней.
logger.warning( logger.error(
"avito_detail_backfill: run_id=%d ABORT -- %s, " "avito_detail_backfill: run_id=%d ABORT -- %s, "
"частая причина: %s. enriched=%d attempted=%d", "частая причина: %s. enriched=%d attempted=%d",
run_id, run_id,
@ -1034,12 +1025,6 @@ async def run_avito_detail_backfill(
counters.duration_sec = time.monotonic() - start counters.duration_sec = time.monotonic() - start
current_counters = counters.to_dict() current_counters = counters.to_dict()
# #3288 п.5: перепись диагнозов блоков — в counters ЛЮБОГО финала, а не только
# 'banned' (mark_backfill_finished кладёт её лишь на ветке бана). Прогоны
# 7193/7264/7302 (1600 попыток, ~1599 блоков, status='done') ушли без диагноза
# в БД: чей был отказ — площадки или нашего тракта — SQL'ем не узнать.
if block_ban_kinds:
current_counters["ban_kinds"] = dict(block_ban_kinds) # type: ignore[assignment]
# #3184: гистограмма длин пачек блоков (streak -> сколько раз встретилась) -- # #3184: гистограмма длин пачек блоков (streak -> сколько раз встретилась) --
# иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize() # иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize()
# досчитывает хвостовую пачку, если прогон оборвался посреди серии. # досчитывает хвостовую пачку, если прогон оборвался посреди серии.
@ -1051,28 +1036,21 @@ async def run_avito_detail_backfill(
# и без этого ключа "banned" опять не отличить по причине (#3178). # и без этого ключа "banned" опять не отличить по причине (#3178).
if abort_reason is not None: if abort_reason is not None:
current_counters["abort_reason"] = abort_reason # type: ignore[assignment] current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
# Остановки по вине НАШЕЙ стороны — НЕ блок, поэтому и не aborted_by_blocks:
# иначе прогон уйдёт в 'banned' (или в 'done' по #3367) и запись будет
# утверждать про площадку то, чего не было.
stop_reason: str | None = None
if no_proxy_stop: if no_proxy_stop:
# #3288 (как #3283 у домклика): пустой пул — к площадке не ходили вовсе. # #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок,
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
current_counters["no_proxy_stop"] = 1 current_counters["no_proxy_stop"] = 1
stop_reason = "пул прокси пуст — к площадке не ходили (#3288)" runs_mod.mark_failed(
elif abort_reason == "infra_streak": db,
# #3439: сайдкар/пул отказывали подряд — площадка всё это время молчала. run_id,
stop_reason = ( "пул прокси пуст — к площадке не ходили (#3288)",
f"тракт недоступен: {breaker.abort_explanation()}, обогащено " current_counters,
f"{counters.enriched} из {counters.attempted}; причина: "
f"{_top_failure(failure_census)} (#3439)"
) )
if stop_reason is not None:
runs_mod.mark_failed(db, run_id, stop_reason, current_counters)
logger.info( logger.info(
"avito_detail_backfill: run_id=%d FINISHED (%s) -- attempted=%d " "avito_detail_backfill: run_id=%d FINISHED (пул пуст) -- attempted=%d "
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs", "enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
run_id, run_id,
stop_reason,
counters.attempted, counters.attempted,
counters.enriched, counters.enriched,
counters.blocked, counters.blocked,

View file

@ -256,7 +256,7 @@ async def backfill_cian_history(
if caused_by_no_proxy(exc): if caused_by_no_proxy(exc):
result.no_proxy_stop = True result.no_proxy_stop = True
result.listings_failed_fetch += 1 result.listings_failed_fetch += 1
logger.warning( logger.error(
"cian_history_backfill: СТОП — пул прокси пуст, к площадке " "cian_history_backfill: СТОП — пул прокси пуст, к площадке "
"не ходили. listing_id=%s processed=%d succeeded=%d", "не ходили. listing_id=%s processed=%d succeeded=%d",
listing_id, listing_id,

View file

@ -488,39 +488,12 @@ def _build_confirmations_sql(
) )
# ── Регионы, где снятие устаревших вообще осмысленно ──────────────────────────
# Снятие читает МОЛЧАНИЕ как «объявление ушло»: строку давно не видели свежей ->
# is_active=false. Такой вывод верен ТОЛЬКО там, где есть механизм пересбора: без
# регулярного обхода «давно не видели» неотличимо от «мы туда ни разу не ходили
# после разовой загрузки», и джоба снимет разом весь загруженный корпус региона.
#
# На 15.09.2026 регулярный сбор идёт только по Свердловской области (66): реестр
# якорей CITY_ANCHORS в ките покрывает её одну, у ДомКлика гео-скоуп зашит в ЕКБ.
# Объявления регионов 77 и 50 попали в listings разовой ручной загрузкой 10-12.09
# и с тех пор не обновлялись. Без фильтра ниже около 26.09 кандидатами разом стали
# бы 40 410 строк ДомКлика (ttl_days=14 по scraped_at), следом 60 174 cian и
# 24 152 yandex (ttl_days=30). Пострадала бы не только Москва с областью:
# аварийный потолок max_deactivated (DEFAULT_MAX_DEACTIVATED=15000, см. ниже)
# сработал бы раньше, а прогон при skipped_cap_exceeded НЕ ТРОГАЕТ НИ ОДНОЙ
# строки -- то есть чистка Екатеринбурга встала бы вместе с московской.
#
# Отсюда дефолт «снимаем только там, где сами собираем». Расширять список нужно
# ВМЕСТЕ с появлением расписания регулярного сбора по региону (якоря кита +
# гео-скоуп ДомКлика), а не раньше: пока пересбора нет, нечем отличить ушедшее
# объявление от неопрошенного. Переопределяется ключом "region_codes" в
# default_params строки расписания (jsonb-массив целых кодов регионов).
DEFAULT_DEACTIVATION_REGION_CODES: tuple[int, ...] = (66,)
def _build_all_segments_sql(staleness_column: str) -> Any: def _build_all_segments_sql(staleness_column: str) -> Any:
"""UPDATE без фильтра по сегменту: все сегменты для данного source. """UPDATE без фильтра по сегменту: все сегменты для данного source.
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings, staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings,
поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source, поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source,
:region_codes, :ttl_days, :run_id) остаются param-binding psycopg v3 safe :ttl_days, :run_id) остаются param-binding psycopg v3 safe (никаких :param::type).
(никаких :param::type). :region_codes -- см. DEFAULT_DEACTIVATION_REGION_CODES:
снятие идёт только по регионам с регулярным пересбором. NULL region_code не
матчится ANY(...) намеренно -- неизвестный регион нечем отнести к покрытому.
""" """
return text( return text(
f""" f"""
@ -528,7 +501,6 @@ def _build_all_segments_sql(staleness_column: str) -> Any:
UPDATE listings UPDATE listings
SET is_active = false SET is_active = false
WHERE source = :listing_source WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
RETURNING id, price_rub RETURNING id, price_rub
@ -542,8 +514,7 @@ def _build_segments_sql(staleness_column: str) -> Any:
"""UPDATE с фильтром по сегменту (segments задан): только указанные сегменты. """UPDATE с фильтром по сегменту (segments задан): только указанные сегменты.
staleness_column уже прошёл whitelist-проверку. = ANY(CAST(:segments AS text[])) staleness_column уже прошёл whitelist-проверку. = ANY(CAST(:segments AS text[]))
-- psycopg v3 адаптирует Python list -> text[]; то же для :region_codes -> int[] -- psycopg v3 адаптирует Python list -> text[].
(см. DEFAULT_DEACTIVATION_REGION_CODES).
""" """
return text( return text(
f""" f"""
@ -551,7 +522,6 @@ def _build_segments_sql(staleness_column: str) -> Any:
UPDATE listings UPDATE listings
SET is_active = false SET is_active = false
WHERE source = :listing_source WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment = ANY(CAST(:segments AS text[])) AND listing_segment = ANY(CAST(:segments AS text[]))
@ -571,8 +541,7 @@ def _build_null_segment_sql(staleness_column: str) -> Any:
отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' -- отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' --
их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах. их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах.
staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе, staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе.
но с :region_codes (см. DEFAULT_DEACTIVATION_REGION_CODES).
""" """
return text( return text(
f""" f"""
@ -580,7 +549,6 @@ def _build_null_segment_sql(staleness_column: str) -> Any:
UPDATE listings UPDATE listings
SET is_active = false SET is_active = false
WHERE source = :listing_source WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment IS NULL AND listing_segment IS NULL
@ -671,16 +639,13 @@ def _build_all_segments_candidates_count_sql(staleness_column: str) -> Any:
_build_all_segments_sql: рефакторинг уже протестированных UPDATE-builder'ов _build_all_segments_sql: рефакторинг уже протестированных UPDATE-builder'ов
вне скоупа PR-B. Синхронность с UPDATE закреплена тестом вне скоупа PR-B. Синхронность с UPDATE закреплена тестом
test_candidates_predicate_matches_update_predicate test_candidates_predicate_matches_update_predicate
(tests/test_deactivate_stale_deactivation_cap.py). Фильтр по :region_codes (tests/test_deactivate_stale_deactivation_cap.py).
обязан стоять и здесь: считать кандидатов по всем регионам, а снимать по
покрытым сбором -- значит кормить аварийный потолок чужими числами.
""" """
return text( return text(
f""" f"""
SELECT count(*) SELECT count(*)
FROM listings FROM listings
WHERE source = :listing_source WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
""" """
@ -696,7 +661,6 @@ def _build_segments_candidates_count_sql(staleness_column: str) -> Any:
SELECT count(*) SELECT count(*)
FROM listings FROM listings
WHERE source = :listing_source WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment = ANY(CAST(:segments AS text[])) AND listing_segment = ANY(CAST(:segments AS text[]))
@ -713,7 +677,6 @@ def _build_null_segment_candidates_count_sql(staleness_column: str) -> Any:
SELECT count(*) SELECT count(*)
FROM listings FROM listings
WHERE source = :listing_source WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment IS NULL AND listing_segment IS NULL
@ -728,7 +691,6 @@ def deactivate_stale_listings(
listing_source: str, listing_source: str,
ttl_days: int, ttl_days: int,
segments: list[str] | None = None, segments: list[str] | None = None,
region_codes: list[int] | None = None,
staleness_column: str = "last_seen_at", staleness_column: str = "last_seen_at",
min_confirmations: int = 0, min_confirmations: int = 0,
health_window_days: int = _HEALTH_WINDOW_DAYS, health_window_days: int = _HEALTH_WINDOW_DAYS,
@ -748,24 +710,6 @@ def deactivate_stale_listings(
segments: если задан -- деактивировать только объявления с указанными segments: если задан -- деактивировать только объявления с указанными
listing_segment значениями. None -> все сегменты (поведение avito по умолчанию). listing_segment значениями. None -> все сегменты (поведение avito по умолчанию).
Несовместимо с null_segment_only=True (см. ниже). Несовместимо с null_segment_only=True (см. ниже).
region_codes: регионы (listings.region_code), в которых снятие вообще
разрешено. None -> DEFAULT_DEACTIVATION_REGION_CODES = (66,), то есть
только Свердловская область. ЭТО НЕ КОСМЕТИЧЕСКИЙ ФИЛЬТР: снятие
трактует молчание как «объявление ушло», а это верно лишь там, где
есть механизм пересбора -- без регулярного обхода «давно не видели»
неотличимо от «мы туда не ходили с разовой загрузки» (см. подробный
комментарий у DEFAULT_DEACTIVATION_REGION_CODES: что именно случилось
бы с корпусом 77/50 и почему вместе с ним встала бы чистка ЕКБ).
Расширять список нужно ВМЕСТЕ с запуском регулярного сбора по региону,
через ключ "region_codes" в default_params строки расписания. None
означает дефолт, а НЕ «без фильтра»: отключить региональное
ограничение целиком нельзя намеренно -- только перечислить регионы
явно. Строки с region_code IS NULL не снимаются никогда (ANY(...) не
матчит NULL): неизвестный регион нечем отнести к покрытому сбором.
Гейты здоровья/пола (min_confirmations, revisit_floor_quantile) и
active_pool ОСТАЮТСЯ общими по источнику, без региона -- их пороги
откалиброваны на источнике целиком, региональная нарезка потребовала
бы пересчёта калибровки и здесь не делается.
staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist
{"last_seen_at", "scraped_at"} иначе ValueError ДО любого SQL. Дефолт {"last_seen_at", "scraped_at"} иначе ValueError ДО любого SQL. Дефолт
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
@ -871,11 +815,7 @@ def deactivate_stale_listings(
min_floor_pairs < 0 / floor_drop_ratio < 1 / max_deactivated <= 0, ИЛИ min_floor_pairs < 0 / floor_drop_ratio < 1 / max_deactivated <= 0, ИЛИ
любой из этих трёх -- bool (тот же класс jsonb-опечатки true/false любой из этих трёх -- bool (тот же класс jsonb-опечатки true/false
вместо числа, что и у ttl_days/cap_mult выше -- default_params вместо числа, что и у ttl_days/cap_mult выше -- default_params
расписания это единственный запланированный способ их переопределить), расписания это единственный запланированный способ их переопределить).
ЛИБО region_codes -- пустой список (= ANY(ARRAY[]) не матчит ничего,
прогон молча снял бы 0 и выглядел бы здоровым) или содержит не-int /
bool (та же jsonb-опечатка, что выше: "region_codes": [true] прошло бы
как [1] -- регион 1 существует).
""" """
counters: dict[str, int] = {"deactivated": 0} counters: dict[str, int] = {"deactivated": 0}
try: try:
@ -910,7 +850,9 @@ def deactivate_stale_listings(
raise ValueError(f"min_floor_pairs must be >= 0, got {min_floor_pairs!r}") raise ValueError(f"min_floor_pairs must be >= 0, got {min_floor_pairs!r}")
if isinstance(floor_drop_ratio, bool): if isinstance(floor_drop_ratio, bool):
raise ValueError(f"floor_drop_ratio must be a number, not bool: {floor_drop_ratio!r}") raise ValueError(
f"floor_drop_ratio must be a number, not bool: {floor_drop_ratio!r}"
)
if floor_drop_ratio < 1: if floor_drop_ratio < 1:
raise ValueError(f"floor_drop_ratio must be >= 1, got {floor_drop_ratio!r}") raise ValueError(f"floor_drop_ratio must be >= 1, got {floor_drop_ratio!r}")
@ -934,21 +876,6 @@ def deactivate_stale_listings(
if null_segment_only and segments is not None: if null_segment_only and segments is not None:
raise ValueError("null_segment_only=True несовместимо с заданным segments") raise ValueError("null_segment_only=True несовместимо с заданным segments")
# Регионы снятия (см. DEFAULT_DEACTIVATION_REGION_CODES): None -> дефолт,
# а НЕ «без фильтра» -- снимать можно только там, где идёт пересбор.
# Пустой список отвергаем явно: он матчил бы 0 строк, и прогон выглядел бы
# здоровым (deactivated=0) вместо того, чтобы упасть на очевидной ошибке
# конфигурации. bool отсекаем до isinstance(int), как у ttl_days/cap_mult
# выше: jsonb `true` прошёл бы как регион 1.
effective_region_codes: list[int] = (
list(DEFAULT_DEACTIVATION_REGION_CODES) if region_codes is None else list(region_codes)
)
if not effective_region_codes:
raise ValueError("region_codes must be a non-empty list, got empty")
for code in effective_region_codes:
if isinstance(code, bool) or not isinstance(code, int):
raise ValueError(f"region_codes must contain ints, got {code!r}")
# Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима # Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима
# на практике (вернуть «живость» может только повторный сбор), поэтому # на практике (вернуть «живость» может только повторный сбор), поэтому
# проверяем ПЕРЕД записью, а не откатываем после. # проверяем ПЕРЕД записью, а не откатываем после.
@ -1133,9 +1060,6 @@ def deactivate_stale_listings(
preflight_params: dict[str, Any] = { preflight_params: dict[str, Any] = {
"listing_source": listing_source, "listing_source": listing_source,
"ttl_days": effective_ttl_days, "ttl_days": effective_ttl_days,
# Тот же срез регионов, что и у UPDATE ниже -- иначе потолок
# считал бы кандидатов по регионам, которых снятие не коснётся.
"region_codes": effective_region_codes,
} }
if null_segment_only: if null_segment_only:
candidates_sql = _build_null_segment_candidates_count_sql(staleness_column) candidates_sql = _build_null_segment_candidates_count_sql(staleness_column)
@ -1184,7 +1108,6 @@ def deactivate_stale_listings(
params: dict[str, Any] = { params: dict[str, Any] = {
"listing_source": listing_source, "listing_source": listing_source,
"ttl_days": effective_ttl_days, "ttl_days": effective_ttl_days,
"region_codes": effective_region_codes,
"run_id": run_id, "run_id": run_id,
} }
result = db.execute(_build_null_segment_sql(staleness_column), params) result = db.execute(_build_null_segment_sql(staleness_column), params)
@ -1192,7 +1115,6 @@ def deactivate_stale_listings(
params = { params = {
"listing_source": listing_source, "listing_source": listing_source,
"ttl_days": effective_ttl_days, "ttl_days": effective_ttl_days,
"region_codes": effective_region_codes,
"segments": segments, "segments": segments,
"run_id": run_id, "run_id": run_id,
} }
@ -1201,7 +1123,6 @@ def deactivate_stale_listings(
params = { params = {
"listing_source": listing_source, "listing_source": listing_source,
"ttl_days": effective_ttl_days, "ttl_days": effective_ttl_days,
"region_codes": effective_region_codes,
"run_id": run_id, "run_id": run_id,
} }
result = db.execute(_build_all_segments_sql(staleness_column), params) result = db.execute(_build_all_segments_sql(staleness_column), params)
@ -1212,15 +1133,14 @@ def deactivate_stale_listings(
runs_mod.mark_done(db, run_id, counters) runs_mod.mark_done(db, run_id, counters)
logger.info( logger.info(
"deactivate_stale source=%s run_id=%d done: deactivated=%d " "deactivate_stale source=%s run_id=%d done: deactivated=%d "
"(ttl_days=%d эффективный, задан %d, segments=%r, region_codes=%r, " "(ttl_days=%d эффективный, задан %d, segments=%r, null_segment_only=%s, "
"null_segment_only=%s, staleness_column=%s)", "staleness_column=%s)",
listing_source, listing_source,
run_id, run_id,
counters["deactivated"], counters["deactivated"],
effective_ttl_days, effective_ttl_days,
ttl_days, ttl_days,
segments, segments,
effective_region_codes,
null_segment_only, null_segment_only,
staleness_column, staleness_column,
) )

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