Compare commits
154 commits
fix/3274-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6da7de06a4 | |||
| ce60fc214d | |||
| 39996ef3fd | |||
| f2c18f5216 | |||
| 19418066c2 | |||
| dbf46228fb | |||
| 824de24897 | |||
| 967f2b53ba | |||
| 2d2d9ebf17 | |||
| a83418799c | |||
| d7aaa00326 | |||
| 3d114672fe | |||
| 811b9ecc8f | |||
| d33c97521d | |||
|
|
89d095f486 | ||
| 0af80b4395 | |||
| 94d34e7dcb | |||
| 76a2963edc | |||
| 66a8c39ee4 | |||
| 76bf9f1cf7 | |||
| ed76ad8d43 | |||
| 3218190a89 | |||
| 8f06373e2f | |||
| 5ee208087d | |||
| 6ba04d845e | |||
| 8863781589 | |||
| e6e7a8db1c | |||
| 68495907d5 | |||
| d5f0557ca8 | |||
| 70bb5a3a8f | |||
| 59482cae85 | |||
| 5d46e59bdc | |||
| a8ba2d5d3c | |||
| a8503d3a58 | |||
| 9fedaa4582 | |||
| c265e1f769 | |||
| 162e101636 | |||
| 98a582e242 | |||
| cef872ace1 | |||
| 1f3585c136 | |||
| ba2eb3b149 | |||
| a6619e1d3f | |||
|
|
120526e313 | ||
|
|
8142834555 | ||
| 3ed759da7d | |||
|
|
acbfdf0a18 | ||
| 4c8c02cce5 | |||
|
|
8e7c65061b | ||
| dfe7910bb7 | |||
|
|
d5c876e3d0 | ||
| 628f59dcc0 | |||
|
|
1b595a0091 | ||
|
|
6433477f7c | ||
|
|
091befc9ff | ||
| 994eb79323 | |||
| 192e10d32d | |||
|
|
4b74356c60 | ||
| bbf70a3283 | |||
|
|
347342bb5c | ||
| 8f10ded2a0 | |||
|
|
6608fd5c70 | ||
| ede6fd5974 | |||
|
|
70b1419cde | ||
| d74121d74c | |||
| 694bf13d3d | |||
|
|
24c2052057 | ||
| 2edaae148f | |||
|
|
4d3e273405 | ||
| 382f266801 | |||
|
|
690f1ef5d2 | ||
| 05959464ae | |||
|
|
34c15ecf08 | ||
|
|
9a93e575cc | ||
| c826793a2a | |||
| 127c9a5c2a | |||
|
|
62a560387c | ||
|
|
e0564d12fe | ||
|
|
ac5b044f7e | ||
|
|
5e80b56bdc | ||
| c53eaf7079 | |||
|
|
9eb42607b9 | ||
|
|
e7f127bc6e | ||
| 01960b03be | |||
| 1254294ac3 | |||
| 2d87f70711 | |||
| 5d4d17a5e1 | |||
| 9298db0803 | |||
| c3840019c5 | |||
|
|
165c4a5edd | ||
|
|
423842ae36 | ||
|
|
93451fae08 | ||
|
|
99f123e646 | ||
|
|
25c938a833 | ||
|
|
655652ae1c | ||
| 2e928c715b | |||
| 9a8aaa2d2d | |||
|
|
6febb36afd | ||
| 1eee4b955d | |||
| 42daf8404f | |||
|
|
412d78f357 | ||
| 00e0bddfb4 | |||
| db47fa0ecd | |||
|
|
d78b1f8881 | ||
|
|
c6711d05c4 | ||
|
|
ef82a707fc | ||
| 2467943200 | |||
| cfcdb9393c | |||
| 03d9745b4f | |||
| dfc39355f9 | |||
| 668ac40631 | |||
| 33714e464e | |||
| 091137a0c5 | |||
| 5f2810b8c6 | |||
| be9aa2f907 | |||
| de2d67c7f9 | |||
| b71f3f9957 | |||
| 4053adad06 | |||
| c251c02f1e | |||
| 9fa01e7ebe | |||
|
|
10ffa93a1c | ||
| a3d4fcf0b3 | |||
|
|
1fa65eba6b | ||
| 8994e041cf | |||
|
|
087c48fef5 | ||
| ec245cf2b3 | |||
|
|
46326ba96e | ||
| b14f21aa78 | |||
|
|
1bcc922e80 | ||
| ba17c868d4 | |||
|
|
ce9c45c3c2 | ||
| deb6517bd5 | |||
|
|
37494dd74a | ||
| a99a9b870c | |||
|
|
09f4f17ef9 | ||
| c4315b3dfb | |||
|
|
86cba37218 | ||
| 6df6f92a2b | |||
| 4efcb712e4 | |||
|
|
7b84e4d2a5 | ||
| 16d99e0f1a | |||
| ae6d28d5e2 | |||
| cf71825c27 | |||
| d6b43c6100 | |||
|
|
bbdcfeb825 | ||
| 84920e6cbd | |||
| a780e3e66e | |||
| 9f696299de | |||
|
|
de5f4a32cd | ||
|
|
c5186883a9 | ||
|
|
996b814919 | ||
|
|
491f7d43ac | ||
|
|
50c1df5e0e | ||
|
|
b1727ca39c | ||
|
|
8fcef103c2 |
206 changed files with 24856 additions and 1994 deletions
|
|
@ -98,6 +98,24 @@ jobs:
|
||||||
python3 scripts/check-compose-ambiguous-hosts.py --selftest
|
python3 scripts/check-compose-ambiguous-hosts.py --selftest
|
||||||
python3 scripts/check-compose-ambiguous-hosts.py
|
python3 scripts/check-compose-ambiguous-hosts.py
|
||||||
|
|
||||||
|
- name: "Guard: подмена фронта МЕРЫ без окна недоступности (#3274)"
|
||||||
|
# Тем же шагом-соседом и по той же причине: секунды на PR, падение
|
||||||
|
# блокирует merge.
|
||||||
|
#
|
||||||
|
# ЗАЧЕМ. Публичный лендинг лежал 30–90 с на КАЖДОМ деплое МЕРЫ —
|
||||||
|
# не потому, что подмена контейнера медленная (0,5 с), а потому, что
|
||||||
|
# `up -d` со списком сервисов делает create всех (старые контейнеры
|
||||||
|
# УДАЛЯЮТСЯ) и только потом start, дождавшись зависимостей. Лечение —
|
||||||
|
# две половинки в разных файлах: `frontend` вынесен из общей пачки в
|
||||||
|
# deploy-tradein.yml + ретрай подключения в caddy/sites/apps.caddy.
|
||||||
|
# Обе обратимы молча и незаметно (дописать frontend обратно в SERVICES
|
||||||
|
# «за компанию»; скопировать новый публичный путь с блока без импорта),
|
||||||
|
# а отказ виден только непрерывной пробой во время деплоя — то есть
|
||||||
|
# никогда, если её никто не запустил.
|
||||||
|
run: |
|
||||||
|
python3 scripts/check-frontend-swap-window.py --selftest
|
||||||
|
python3 scripts/check-frontend-swap-window.py
|
||||||
|
|
||||||
- name: "Guard: Caddyfile синтаксически валиден"
|
- name: "Guard: Caddyfile синтаксически валиден"
|
||||||
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
||||||
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
|
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
|
||||||
|
|
@ -201,6 +219,30 @@ 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'
|
||||||
|
# #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'
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,13 @@ 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
|
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
|
||||||
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 }}
|
||||||
|
|
@ -188,6 +193,14 @@ 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
|
||||||
|
|
@ -250,6 +263,19 @@ 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
|
||||||
|
|
@ -268,19 +294,27 @@ 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"
|
||||||
if [ "$PROFILES" = "alerts" ]; then
|
# Сравнение через case, а не "=": PROFILES теперь может быть
|
||||||
|
# комбинацией через запятую ("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
|
||||||
|
|
@ -292,6 +326,43 @@ 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 неизменившимся: он сравнивает
|
||||||
# описание сервиса, а содержимое бинд-маунта в это сравнение не
|
# описание сервиса, а содержимое бинд-маунта в это сравнение не
|
||||||
|
|
@ -344,6 +415,51 @@ jobs:
|
||||||
done
|
done
|
||||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
||||||
|
|
||||||
|
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
|
||||||
|
# перечитывает ────────────────────────────────────────────────
|
||||||
|
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
||||||
|
# docker compose сравнивает описание сервиса, а НЕ содержимое
|
||||||
|
# бинд-маунта, поэтому уже работающий контейнер продолжает жить
|
||||||
|
# со старым конфигом сколько угодно — на проде дошло до 16 суток
|
||||||
|
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
||||||
|
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
||||||
|
#
|
||||||
|
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
|
||||||
|
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
|
||||||
|
# после `git reset --hard` подхватывается без пересоздания
|
||||||
|
# контейнера. --web.enable-lifecycle уже включён в compose ради
|
||||||
|
# этого шага (см. docker-compose.metrics.yml) — просто раньше
|
||||||
|
# никто не звал сам reload.
|
||||||
|
#
|
||||||
|
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
|
||||||
|
# положить работающий Prometheus молчаливым откатом на дефолты.
|
||||||
|
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
|
||||||
|
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml'; then
|
||||||
|
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
||||||
|
|
||||||
|
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
|
||||||
|
|
||||||
|
# lastConfigTime обновляется на КАЖДЫЙ успешный reload, даже
|
||||||
|
# если содержимое конфига не поменялось — значит сравнение
|
||||||
|
# "было/стало" надёжно ловит и несостоявшийся reload, и
|
||||||
|
# изменившиеся правила.
|
||||||
|
LAST_CONFIG_AFTER=""
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
LAST_CONFIG_AFTER="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
||||||
|
[ -n "$LAST_CONFIG_AFTER" ] && [ "$LAST_CONFIG_AFTER" != "$LAST_CONFIG_BEFORE" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$LAST_CONFIG_AFTER" ] || [ "$LAST_CONFIG_AFTER" = "$LAST_CONFIG_BEFORE" ]; then
|
||||||
|
echo "ОШИБКА: reload Prometheus не подтверждён — lastConfigTime не изменился ($LAST_CONFIG_BEFORE)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)."
|
||||||
|
else
|
||||||
|
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
||||||
agent-apps:
|
agent-apps:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -511,23 +511,36 @@ 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/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION —
|
# NEXT_PUBLIC_YM_ID — счётчик Яндекс.Метрики публичного контура.
|
||||||
# ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и
|
# Живёт на корпоративном аккаунте info@meraocenka.ru, там же, где
|
||||||
# мета-теги верификации поисковых консолей. Пустая строка = скрипт
|
# Вебмастер: счётчик и права на сайт на одном аккаунте связываются
|
||||||
# счётчика НЕ рендерится вообще (контракт фронта, см. тот же
|
# автоматически, на разных — только через письмо-подтверждение
|
||||||
# Dockerfile-комментарий). Когда номера появятся — вписать
|
# владельцу счётчика. Прежний номер 112563104 был заведён на личном
|
||||||
# литералом сюда И в retry-блок ниже (оба обязательны, иначе
|
# аккаунте подрядчика и на сайт не попал ни разу, данных не потерял.
|
||||||
# ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки
|
# NEXT_PUBLIC_YANDEX_VERIFICATION — токен подтверждения прав в
|
||||||
# образа (build-time bake, не runtime-правка на проде).
|
# Яндекс.Вебмастере, отдаётся мета-тегом (mera-public/layout.tsx).
|
||||||
|
# Токен привязан к аккаунту: у другого аккаунта он будет свой.
|
||||||
|
# Удалять его после подтверждения НЕЛЬЗЯ — Вебмастер перепроверяет
|
||||||
|
# права и снимает их, если тег исчез.
|
||||||
|
# Литералы, а не 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=
|
NEXT_PUBLIC_YM_ID=112672927
|
||||||
NEXT_PUBLIC_GA_ID=
|
NEXT_PUBLIC_GA_ID=
|
||||||
NEXT_PUBLIC_YANDEX_VERIFICATION=
|
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
|
||||||
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
|
||||||
|
|
@ -552,9 +565,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=
|
NEXT_PUBLIC_YM_ID=112672927
|
||||||
NEXT_PUBLIC_GA_ID=
|
NEXT_PUBLIC_GA_ID=
|
||||||
NEXT_PUBLIC_YANDEX_VERIFICATION=
|
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
|
||||||
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: |
|
||||||
|
|
|
||||||
|
|
@ -121,37 +121,98 @@ jobs:
|
||||||
infra: ${{ steps.filter.outputs.infra }}
|
infra: ${{ steps.filter.outputs.infra }}
|
||||||
# #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он
|
# #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он
|
||||||
# включает и compose, и сам workflow, где полный деплой обязателен.
|
# включает и compose, и сам workflow, где полный деплой обязателен.
|
||||||
# `github.event_name == 'push'` первым множителем НАМЕРЕННО: на
|
caddy_only: ${{ steps.filter.outputs.caddy_only }}
|
||||||
# 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
|
||||||
with:
|
env:
|
||||||
filters: |
|
BEFORE: ${{ github.event.before }}
|
||||||
backend:
|
EVENT: ${{ github.event_name }}
|
||||||
- 'backend/**'
|
run: |
|
||||||
- 'data/sql/**'
|
set -eu
|
||||||
frontend:
|
NULL_SHA=0000000000000000000000000000000000000000
|
||||||
- 'frontend/**'
|
BASE=""
|
||||||
infra:
|
if [ "$EVENT" = "push" ] && [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "$NULL_SHA" ]; then
|
||||||
- 'docker-compose.prod.yml'
|
git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \
|
||||||
- 'Caddyfile'
|
|| git fetch --depth=1 --no-tags origin "$BEFORE" >/dev/null 2>&1 \
|
||||||
- 'caddy/**'
|
|| true
|
||||||
- '.forgejo/workflows/deploy.yml'
|
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
|
||||||
# Пара фильтров для «правка ТОЛЬКО прокси» (#2916). Одного `caddy`
|
BASE="$BEFORE"
|
||||||
# мало: он true и когда вместе с конфигом приехал бэкенд — тогда
|
else
|
||||||
# нужен обычный полный деплой. `non_caddy` матчит ВСЁ остальное,
|
echo "::warning::коммит $BEFORE недоступен в клоне — деплой будет полным"
|
||||||
# и быстрый путь включается лишь когда он false.
|
fi
|
||||||
caddy:
|
fi
|
||||||
- 'Caddyfile'
|
|
||||||
- 'caddy/**'
|
if [ -n "$BASE" ]; then
|
||||||
non_caddy:
|
FILES=$(git -c core.quotePath=false diff --no-renames --name-only "$BASE" HEAD)
|
||||||
- '**'
|
N=$(printf '%s\n' "$FILES" | grep -c . || true)
|
||||||
- '!Caddyfile'
|
echo "База: $BASE → $(git rev-parse HEAD); изменённых файлов: $N"
|
||||||
- '!caddy/**'
|
printf '%s\n' "$FILES" | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
FILES=$(git -c core.quotePath=false ls-files)
|
||||||
|
N=$(printf '%s\n' "$FILES" | grep -c . || true)
|
||||||
|
echo "База не определена (event=$EVENT, before='${BEFORE:-}') — считаем изменённым весь репозиторий ($N файлов), деплой полный"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Те же наборы путей, что были в фильтрах до #3448.
|
||||||
|
CADDY_RE='^(Caddyfile$|caddy/)'
|
||||||
|
has() { printf '%s\n' "$FILES" | grep -qE "$1"; }
|
||||||
|
|
||||||
|
backend=false; frontend=false; infra=false; caddy_only=false
|
||||||
|
has '^(backend/|data/sql/)' && backend=true
|
||||||
|
has '^frontend/' && frontend=true
|
||||||
|
has '^(docker-compose\.prod\.yml$|Caddyfile$|caddy/|\.forgejo/workflows/deploy\.yml$)' && infra=true
|
||||||
|
# Быстрый путь: изменения ЕСТЬ и НИ ОДНО из них не лежит вне caddy.
|
||||||
|
# Проверка `N -gt 0` обязательна: пустой список иначе прошёл бы как
|
||||||
|
# «всё под caddy» и отключил бы сборку на ровном месте.
|
||||||
|
if [ "$N" -gt 0 ] && ! printf '%s\n' "$FILES" | grep -vE "$CADDY_RE" | grep -q .; then
|
||||||
|
caddy_only=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Флаги: backend=$backend frontend=$frontend infra=$infra caddy_only=$caddy_only"
|
||||||
|
{
|
||||||
|
echo "backend=$backend"
|
||||||
|
echo "frontend=$frontend"
|
||||||
|
echo "infra=$infra"
|
||||||
|
echo "caddy_only=$caddy_only"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
build-backend:
|
build-backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -997,12 +1058,17 @@ 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: force-recreate чтобы подхватить изменения в Caddyfile
|
# Caddy: пересоздание ТОЛЬКО когда без него правка не доедет (#3443).
|
||||||
# И в особенности новые volume mounts из docker-compose.prod.yml
|
# Здесь стоял безусловный `up -d --force-recreate --no-deps caddy` —
|
||||||
# (`reload` не пересоздаёт container, поэтому новые binds не появляются —
|
# то есть КАЖДЫЙ полный деплой сносил единственный процесс, слушающий
|
||||||
# был случай 2026-05-17 с PR #268 preview/ — потребовался manual SSH fix).
|
# 80/443, и все домены хоста отдавали `code=000` (замер 05.09: 67 с).
|
||||||
docker compose -p gendesign -f docker-compose.prod.yml up -d \
|
# Довод той правки (17.05, 11e78d73 — «иначе новые volume mounts не
|
||||||
--force-recreate --no-deps caddy
|
# появляются») не подтвердился: `up -d` БЕЗ флага пересоздаёт
|
||||||
|
# контейнер сам, как только меняется описание сервиса или образ.
|
||||||
|
# Разбор и проверки — в шапке 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
|
||||||
|
|
@ -1229,14 +1295,13 @@ jobs:
|
||||||
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
|
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
|
||||||
# страница = новая строка allowlist'а.
|
# страница = новая строка allowlist'а.
|
||||||
#
|
#
|
||||||
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой
|
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Опечатка в конфиге на
|
||||||
# осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы
|
# пересоздании уводит контейнер в crash-loop и роняет ВСЕ домены сразу, а
|
||||||
# принять битый конфиг и оставил бы работать старый — на общем деплое это
|
# `caddy reload` её просто не принимает: job краснеет, домены продолжают
|
||||||
# скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ
|
# обслуживаться прежним конфигом. С #3443 ровно тот же порядок действует и на
|
||||||
# применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой
|
# полном деплое — оба пути зовут ops/caddy-apply.sh, который сперва проверяет
|
||||||
# код → job краснеет, а домены продолжают обслуживаться старым конфигом.
|
# конфиг одноразовым контейнером и пересоздаёт Caddy, только если правка иначе
|
||||||
# Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop
|
# не доедет (пофайловый bind-маунт держит инод).
|
||||||
# и роняет ВСЕ домены сразу.
|
|
||||||
#
|
#
|
||||||
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
|
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
|
||||||
# вторая, уже против боевого файла после `git reset`.
|
# вторая, уже против боевого файла после `git reset`.
|
||||||
|
|
@ -1282,14 +1347,73 @@ 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
|
||||||
# Конфиг примонтирован read-only с хоста, пересборка не нужна —
|
# #3443: тот же скрипт, что и в полном деплое. Голый `exec caddy
|
||||||
# контейнер читает тот же файл, что только что обновил git.
|
# reload` здесь был ВЕРЕН только для каталогов (caddy/sites/**,
|
||||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
|
# caddy/local/**). Caddyfile и четыре сниппета смонтированы
|
||||||
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
# ПОФАЙЛОВО, а `git reset --hard` выше пишет новый инод — контейнер
|
||||||
echo "✓ конфиг прокси перезагружен без пересборки и без миграций"
|
# остаётся на прежнем, и reload перечитывает СТАРЫЙ текст. Отказ
|
||||||
|
# беззвучный: джоба зелёная, конфиг на диске новый, прокси работает
|
||||||
|
# по старому. Скрипт сверяет, что именно видит контейнер, и
|
||||||
|
# пересоздаёт его только в этом случае.
|
||||||
|
sh ops/caddy-apply.sh
|
||||||
|
echo "✓ быстрый путь завершён: без пересборки образов и без миграций"
|
||||||
|
|
||||||
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
|
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
|
||||||
#
|
#
|
||||||
|
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -109,3 +109,10 @@ ops/metrics/alertmanager/alertmanager.yml
|
||||||
# и Prometheus не видел ни одного приёмника (#3155). Производный файл убирает
|
# и Prometheus не видел ни одного приёмника (#3155). Производный файл убирает
|
||||||
# сам зазор — правится только там же, где принимается решение о профиле.
|
# сам зазор — правится только там же, где принимается решение о профиле.
|
||||||
ops/metrics/prometheus/alertmanager_targets.gen.yml
|
ops/metrics/prometheus/alertmanager_targets.gen.yml
|
||||||
|
|
||||||
|
# Временные рабочие копии-worktree вида _wt-<тема>/ живут рядом с репозиторием
|
||||||
|
# и в него попадать не должны. 09.09 копия _wt-mskcol попала в индекс одним
|
||||||
|
# файлом сборщика, и три следующих фикса ушли в дубликат мимо канонического
|
||||||
|
# tradein-mvp/scripts/local-avito-msk/collect.py — дефект нашёлся только при
|
||||||
|
# сверке page size.
|
||||||
|
_wt-*/
|
||||||
|
|
|
||||||
|
|
@ -1,785 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Локальный ручной сборщик SERP Авито по Москве и МО (эпик #2989, трек 1).
|
|
||||||
|
|
||||||
Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и
|
|
||||||
прокси-пул не задействованы вообще: браузер — уже открытый Chrome владельца
|
|
||||||
(подключение по CDP), парсер — импорт из scraper-kit, заливка — поток в psql
|
|
||||||
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
|
|
||||||
|
|
||||||
Дефолтный режим — --measure 100 (замер): полный проход только по явному --full.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any, Iterable
|
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
||||||
|
|
||||||
# --- импорт парсера из scraper-kit без установки backend -------------------
|
|
||||||
_KIT_SRC = Path(__file__).resolve().parents[2] / "packages" / "scraper-kit" / "src"
|
|
||||||
if str(_KIT_SRC) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_KIT_SRC))
|
|
||||||
|
|
||||||
from scraper_kit.providers.avito.serp import ( # noqa: E402
|
|
||||||
AvitoScraper,
|
|
||||||
_is_firewall_page,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Вкладка, открытая у владельца: вторичка, Москва + МО.
|
|
||||||
DEFAULT_BASE_URL = (
|
|
||||||
"https://www.avito.ru/moskva_i_mo/kvartiry/prodam/vtorichka-ASgBAgICAkSSA8YQ5geMUg"
|
|
||||||
"?f=ASgBAgICA0SSA8YQ5geMUuDC3c0CgOkP"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Замерено живым проходом (не из документации): выдача Москва+МО отдаёт 50 карточек
|
|
||||||
# на страницу. Пока здесь стояло 60, planned_pages считал count/60 и не запрашивал
|
|
||||||
# последние ~17% каждого коридора — 34 753 по счётчику против 28 352 собранных.
|
|
||||||
# Молчаливое усечение читается как «покрыто всё», поэтому число проверяется живьём.
|
|
||||||
PAGE_SIZE = 50 # карточек на странице выдачи
|
|
||||||
MAX_PAGES = 30 # потолок пагинации Авито → 30*50 = 1500 на один запрос
|
|
||||||
HARD_CAP = PAGE_SIZE * MAX_PAGES
|
|
||||||
|
|
||||||
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
|
|
||||||
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
|
|
||||||
PRICE_CEIL = 2_000_000_000
|
|
||||||
MIN_WIDTH_RATIO = 1.05 # уже этого коридор не делим (геометрическая ширина)
|
|
||||||
MAX_DEPTH = 12
|
|
||||||
|
|
||||||
_BATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
||||||
_POW_MARKERS = ("startpow", "доступ ограничен: проверка безопасности")
|
|
||||||
|
|
||||||
|
|
||||||
# Заполняется при входе в Loader.__aenter__ (playwright импортируется лениво,
|
|
||||||
# чтобы --help работал без установленного пакета).
|
|
||||||
PlaywrightTimeoutError: type[BaseException] = TimeoutError
|
|
||||||
|
|
||||||
|
|
||||||
# Через столько загрузок вкладка сборщика пересоздаётся (см. _recycle_if_needed).
|
|
||||||
PAGE_RECYCLE_EVERY = 75
|
|
||||||
|
|
||||||
|
|
||||||
class Blocked(Exception):
|
|
||||||
"""Первый признак блока. Ретраев нет — только немедленный стоп."""
|
|
||||||
|
|
||||||
def __init__(self, reason: str, detail: str = "") -> None:
|
|
||||||
super().__init__(f"{reason}: {detail}" if detail else reason)
|
|
||||||
self.reason = reason
|
|
||||||
self.detail = detail
|
|
||||||
|
|
||||||
|
|
||||||
class BudgetExhausted(Exception):
|
|
||||||
"""Потолок --measure выбран: штатный выход, не ошибка."""
|
|
||||||
|
|
||||||
|
|
||||||
# --- план коридоров --------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Corridor:
|
|
||||||
lo: int | None
|
|
||||||
hi: int | None
|
|
||||||
count: int | None = None
|
|
||||||
truncated: bool = False
|
|
||||||
pages_done: int = 0
|
|
||||||
status: str = "pending" # pending | done
|
|
||||||
missed: int = 0 # заведомо недобрано (count - HARD_CAP), если truncated
|
|
||||||
|
|
||||||
def label(self) -> str:
|
|
||||||
lo = "-" if self.lo is None else f"{self.lo:_}"
|
|
||||||
hi = "-" if self.hi is None else f"{self.hi:_}"
|
|
||||||
return f"[{lo} .. {hi}]"
|
|
||||||
|
|
||||||
def to_json(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"lo": self.lo, "hi": self.hi, "count": self.count,
|
|
||||||
"truncated": self.truncated, "pages_done": self.pages_done,
|
|
||||||
"status": self.status, "missed": self.missed,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_json(d: dict[str, Any]) -> "Corridor":
|
|
||||||
return Corridor(
|
|
||||||
lo=d.get("lo"), hi=d.get("hi"), count=d.get("count"),
|
|
||||||
truncated=bool(d.get("truncated")),
|
|
||||||
pages_done=int(d.get("pages_done") or 0),
|
|
||||||
status=d.get("status") or "pending", missed=int(d.get("missed") or 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
def planned_pages(self) -> int:
|
|
||||||
if not self.count:
|
|
||||||
return 1
|
|
||||||
return max(1, min(MAX_PAGES, math.ceil(self.count / PAGE_SIZE)))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Plan:
|
|
||||||
base_url: str
|
|
||||||
target: int
|
|
||||||
batch_id: str
|
|
||||||
corridors: list[Corridor] = field(default_factory=list)
|
|
||||||
created_at: str = ""
|
|
||||||
|
|
||||||
def save(self, path: Path) -> None:
|
|
||||||
path.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"version": 1, "base_url": self.base_url, "target": self.target,
|
|
||||||
"batch_id": self.batch_id, "created_at": self.created_at,
|
|
||||||
"corridors": [c.to_json() for c in self.corridors],
|
|
||||||
},
|
|
||||||
ensure_ascii=False, indent=1,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def load(path: Path) -> "Plan":
|
|
||||||
d = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
return Plan(
|
|
||||||
base_url=d["base_url"], target=int(d["target"]), batch_id=d["batch_id"],
|
|
||||||
created_at=d.get("created_at", ""),
|
|
||||||
corridors=[Corridor.from_json(c) for c in d.get("corridors", [])],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str:
|
|
||||||
"""URL коридора: pmin/pmax + пагинация. Гео-параметры не добавляем — #3043."""
|
|
||||||
parts = urlsplit(base_url)
|
|
||||||
q = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
|
|
||||||
if k not in {"p", "pmin", "pmax"}]
|
|
||||||
if lo is not None:
|
|
||||||
q.append(("pmin", str(int(lo))))
|
|
||||||
if hi is not None:
|
|
||||||
q.append(("pmax", str(int(hi))))
|
|
||||||
if page > 1:
|
|
||||||
q.append(("p", str(page)))
|
|
||||||
return urlunsplit(
|
|
||||||
(parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def geometric_mid(lo: int | None, hi: int) -> int:
|
|
||||||
"""Геометрическая середина коридора.
|
|
||||||
|
|
||||||
Цены логнормальны: арифметическая середина 1 млн..100 млн (≈50 млн)
|
|
||||||
отрезает вырожденно-пустую верхнюю половину. sqrt(lo*hi) делит выборку
|
|
||||||
заметно ровнее.
|
|
||||||
"""
|
|
||||||
low = max(int(lo or PRICE_FLOOR), 1)
|
|
||||||
mid = int(math.sqrt(low * float(hi)))
|
|
||||||
return max(low + 1, min(hi - 1, mid))
|
|
||||||
|
|
||||||
|
|
||||||
def width_ratio(lo: int | None, hi: int | None) -> float:
|
|
||||||
if hi is None:
|
|
||||||
return float("inf")
|
|
||||||
return float(hi) / max(float(lo or PRICE_FLOOR), 1.0)
|
|
||||||
|
|
||||||
|
|
||||||
# --- загрузка страницы -----------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _guard(html: str, status: int | None) -> None:
|
|
||||||
"""Порядок проверок фиксирован заданием; первое срабатывание = стоп."""
|
|
||||||
if status in (403, 439):
|
|
||||||
raise Blocked("platform", f"HTTP {status}")
|
|
||||||
if status == 429:
|
|
||||||
raise Blocked("ratelimit", "HTTP 429")
|
|
||||||
if _is_firewall_page(html):
|
|
||||||
raise Blocked("firewall", "firewall-страница на HTTP 200")
|
|
||||||
head = html[:4096].lower()
|
|
||||||
if any(m in head for m in _POW_MARKERS):
|
|
||||||
raise Blocked("challenge", "PoW / проверка безопасности")
|
|
||||||
|
|
||||||
|
|
||||||
class Loader:
|
|
||||||
"""Одна СВОЯ вкладка в уже открытом Chrome владельца (CDP).
|
|
||||||
|
|
||||||
Ни браузер, ни контекст, ни чужие вкладки не закрываются и не трогаются:
|
|
||||||
это рабочий Chrome с залогиненным техаккаунтом.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, delay: float, page_budget: int | None) -> None:
|
|
||||||
self._delay = delay
|
|
||||||
self._budget = page_budget
|
|
||||||
self.loads = 0
|
|
||||||
self._page: Any = None
|
|
||||||
self._pw: Any = None
|
|
||||||
self._browser: Any = None
|
|
||||||
self._ctx: Any = None
|
|
||||||
self._last_load = 0.0
|
|
||||||
self._loads_on_page = 0
|
|
||||||
|
|
||||||
async def __aenter__(self) -> "Loader":
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
from playwright.async_api import TimeoutError as _PwTimeout
|
|
||||||
|
|
||||||
global PlaywrightTimeoutError
|
|
||||||
PlaywrightTimeoutError = _PwTimeout
|
|
||||||
|
|
||||||
endpoint = os.environ.get("AVITO_CDP", "http://localhost:9222")
|
|
||||||
self._pw = await async_playwright().start()
|
|
||||||
try:
|
|
||||||
self._browser = await self._pw.chromium.connect_over_cdp(endpoint)
|
|
||||||
except Exception as exc: # noqa: BLE001 — подсказка важнее типа
|
|
||||||
await self._pw.stop()
|
|
||||||
raise SystemExit(
|
|
||||||
f"Не удалось подключиться по CDP к {endpoint}: {exc}\n"
|
|
||||||
"Запусти Chrome с залогиненным техаккаунтом Авито и ключом "
|
|
||||||
"--remote-debugging-port=9222, либо укажи адрес в AVITO_CDP."
|
|
||||||
) from exc
|
|
||||||
if not self._browser.contexts:
|
|
||||||
await self._pw.stop()
|
|
||||||
raise SystemExit(
|
|
||||||
"В подключённом Chrome нет ни одного контекста. Открой обычное окно "
|
|
||||||
"Chrome, запущенное с --remote-debugging-port=9222."
|
|
||||||
)
|
|
||||||
self._ctx = self._browser.contexts[0]
|
|
||||||
self._page = await self._ctx.new_page()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *exc: object) -> None:
|
|
||||||
if self._page is not None:
|
|
||||||
try:
|
|
||||||
await self._page.close() # ТОЛЬКО своя вкладка
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
if self._pw is not None:
|
|
||||||
try:
|
|
||||||
await self._pw.stop()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
def budget_left(self) -> bool:
|
|
||||||
return self._budget is None or self.loads < self._budget
|
|
||||||
|
|
||||||
async def _pause(self) -> None:
|
|
||||||
if self._last_load == 0.0:
|
|
||||||
return
|
|
||||||
jitter = self._delay * random.uniform(-0.2, 0.2)
|
|
||||||
wait = max(0.0, self._delay + jitter - (time.monotonic() - self._last_load))
|
|
||||||
if wait > 0:
|
|
||||||
print(f" пауза {wait:.1f} с", flush=True)
|
|
||||||
await asyncio.sleep(wait)
|
|
||||||
|
|
||||||
async def fetch(self, url: str) -> tuple[str, int | None]:
|
|
||||||
if not self.budget_left():
|
|
||||||
raise BudgetExhausted()
|
|
||||||
await self._recycle_if_needed()
|
|
||||||
await self._pause()
|
|
||||||
resp = await self._goto(url)
|
|
||||||
self._loads_on_page += 1
|
|
||||||
self.loads += 1
|
|
||||||
self._last_load = time.monotonic()
|
|
||||||
status = resp.status if resp is not None else None
|
|
||||||
try:
|
|
||||||
await self._page.wait_for_selector('[data-marker="item"]', timeout=7_000)
|
|
||||||
except Exception: # noqa: BLE001 — пустая/блочная страница разбирается ниже
|
|
||||||
pass
|
|
||||||
html = await self._read_content()
|
|
||||||
_guard(html, status)
|
|
||||||
return html, status
|
|
||||||
|
|
||||||
async def _recycle_if_needed(self) -> None:
|
|
||||||
"""Каждые PAGE_RECYCLE_EVERY загрузок пересоздаём свою вкладку.
|
|
||||||
|
|
||||||
Рендерер Chrome накапливает память по всем навигациям вкладки, а проход
|
|
||||||
по Москве — под тысячу страниц в одной. Наблюдалось живьём: вкладка
|
|
||||||
падала с «Опаньки… Код ошибки: Out of Memory» при 34 ГБ свободных в
|
|
||||||
системе, то есть упирался именно рендерер, а не машина. Свежая вкладка
|
|
||||||
стоит одну навигацию и обнуляет счёт.
|
|
||||||
|
|
||||||
Закрывается ТОЛЬКО своя вкладка; контекст и чужие вкладки владельца не
|
|
||||||
трогаются — это его рабочий Chrome.
|
|
||||||
"""
|
|
||||||
if self._loads_on_page < PAGE_RECYCLE_EVERY:
|
|
||||||
return
|
|
||||||
print(f" вкладка пересоздаётся после {self._loads_on_page} загрузок "
|
|
||||||
"(память рендерера)", flush=True)
|
|
||||||
old = self._page
|
|
||||||
self._page = await self._ctx.new_page()
|
|
||||||
self._loads_on_page = 0
|
|
||||||
try:
|
|
||||||
await old.close()
|
|
||||||
except Exception: # noqa: BLE001 — старая вкладка могла уже умереть
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _goto(self, url: str, attempts: int = 3):
|
|
||||||
"""goto с ограниченным ретраем на таймаут навигации.
|
|
||||||
|
|
||||||
Авито изредка держит соединение до упора и goto падает по timeout. Это
|
|
||||||
НЕ признак отказа: в наблюдавшемся случае вкладка показывала нормальную
|
|
||||||
выдачу, а маркеров фаервола/PoW не было. Но и молча ретраить бесконечно
|
|
||||||
нельзя — тихий отказ выглядит ровно так же. Поэтому: перед каждым
|
|
||||||
повтором пробуем прочитать то, что в документе, и прогнать через _guard,
|
|
||||||
чтобы настоящий блок остановил прогон с правильной причиной; исчерпали
|
|
||||||
попытки — жёсткий стоп с причиной nav_timeout.
|
|
||||||
"""
|
|
||||||
for i in range(attempts):
|
|
||||||
try:
|
|
||||||
return await self._page.goto(url, wait_until="domcontentloaded",
|
|
||||||
timeout=90_000)
|
|
||||||
except PlaywrightTimeoutError:
|
|
||||||
try:
|
|
||||||
partial = await self._page.content()
|
|
||||||
except Exception: # noqa: BLE001 — документа может не быть вовсе
|
|
||||||
partial = ""
|
|
||||||
if partial:
|
|
||||||
_guard(partial, None) # настоящий блок остановит прогон здесь
|
|
||||||
if i == attempts - 1:
|
|
||||||
raise Blocked("nav_timeout") from None
|
|
||||||
print(f" таймаут навигации, попытка {i + 2}/{attempts}", flush=True)
|
|
||||||
await asyncio.sleep(10.0)
|
|
||||||
|
|
||||||
async def _read_content(self, attempts: int = 4) -> str:
|
|
||||||
"""page.content() с узким ретраем на гонку клиентской перенавигации.
|
|
||||||
|
|
||||||
Авито дорисовывает выдачу после domcontentloaded, и content() иногда
|
|
||||||
попадает ровно в момент смены документа: "Unable to retrieve content
|
|
||||||
because the page is navigating and changing the content". Это НЕ отказ
|
|
||||||
площадки — гвардов не касается, поэтому ретраим только эту ошибку и
|
|
||||||
только её, а любую другую поднимаем как есть.
|
|
||||||
"""
|
|
||||||
last: Exception | None = None
|
|
||||||
for i in range(attempts):
|
|
||||||
try:
|
|
||||||
return await self._page.content()
|
|
||||||
except Exception as exc: # noqa: BLE001 — сузили проверкой текста ниже
|
|
||||||
if "page is navigating" not in str(exc):
|
|
||||||
raise
|
|
||||||
last = exc
|
|
||||||
print(f" content() поймал перенавигацию, попытка {i + 2}/{attempts}",
|
|
||||||
flush=True)
|
|
||||||
await asyncio.sleep(1.5)
|
|
||||||
raise RuntimeError(f"page.content() не отдал документ за {attempts} попыток") from last
|
|
||||||
|
|
||||||
|
|
||||||
# --- заливка в msk_raw -----------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _sql_str(value: str) -> str:
|
|
||||||
return "'" + value.replace("'", "''") + "'"
|
|
||||||
|
|
||||||
|
|
||||||
def _csv_rows(rows: Iterable[dict[str, Any]]) -> str:
|
|
||||||
buf = io.StringIO()
|
|
||||||
writer = csv.writer(buf, lineterminator="\n")
|
|
||||||
for r in rows:
|
|
||||||
writer.writerow([
|
|
||||||
r["source_id"], r["observed_at"], r["batch_id"], r["kind"],
|
|
||||||
r["url"], r["price"], r["payload"],
|
|
||||||
])
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def build_sql(batch_id: str, query: str, rows: list[dict[str, Any]],
|
|
||||||
started_at: str, kind: str = "serp") -> str:
|
|
||||||
"""Один поток на `psql -f -`: batch (FK!) → TEMP staging → \\copy → INSERT.
|
|
||||||
|
|
||||||
Одиночный `psql -c` через ssh ломается на квотинге скобок и кавычек, поэтому
|
|
||||||
только поток. rows_new = разница count(*) по batch_id до и после вставки.
|
|
||||||
"""
|
|
||||||
bid = _sql_str(batch_id)
|
|
||||||
return (
|
|
||||||
"BEGIN;\n"
|
|
||||||
"INSERT INTO msk_raw.batches (batch_id, kind, query, started_at)\n"
|
|
||||||
f"VALUES ({bid}, {_sql_str(kind)}, {_sql_str(query)}, "
|
|
||||||
f"CAST({_sql_str(started_at)} AS timestamptz))\n"
|
|
||||||
"ON CONFLICT (batch_id) DO NOTHING;\n"
|
|
||||||
"CREATE TEMP TABLE _stg (LIKE msk_raw.avito_cards INCLUDING DEFAULTS) "
|
|
||||||
"ON COMMIT DROP;\n"
|
|
||||||
"CREATE TEMP TABLE _before ON COMMIT DROP AS\n"
|
|
||||||
f" SELECT count(*) AS n FROM msk_raw.avito_cards WHERE batch_id = {bid};\n"
|
|
||||||
"\\copy _stg (source_id,observed_at,batch_id,kind,url,price,payload) "
|
|
||||||
"FROM STDIN WITH (FORMAT csv)\n"
|
|
||||||
+ _csv_rows(rows)
|
|
||||||
+ "\\.\n"
|
|
||||||
"INSERT INTO msk_raw.avito_cards "
|
|
||||||
"(source_id,observed_at,batch_id,kind,url,price,payload)\n"
|
|
||||||
"SELECT source_id,observed_at,batch_id,kind,url,price,payload FROM _stg\n"
|
|
||||||
"ON CONFLICT (source_id,batch_id,kind) DO NOTHING;\n"
|
|
||||||
"UPDATE msk_raw.batches b SET\n"
|
|
||||||
" rows_sent = coalesce(b.rows_sent,0) + (SELECT count(*) FROM _stg),\n"
|
|
||||||
" rows_new = coalesce(b.rows_new,0) +\n"
|
|
||||||
f" ((SELECT count(*) FROM msk_raw.avito_cards WHERE batch_id = {bid})\n"
|
|
||||||
" - (SELECT n FROM _before))\n"
|
|
||||||
f"WHERE b.batch_id = {bid};\n"
|
|
||||||
"COMMIT;\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_finalize_sql(batch_id: str, query: str, notes: str) -> str:
|
|
||||||
bid = _sql_str(batch_id)
|
|
||||||
return (
|
|
||||||
"INSERT INTO msk_raw.batches (batch_id, kind, query, started_at)\n"
|
|
||||||
f"VALUES ({bid}, 'serp', {_sql_str(query)}, now())\n"
|
|
||||||
"ON CONFLICT (batch_id) DO NOTHING;\n"
|
|
||||||
f"UPDATE msk_raw.batches SET finished_at = now(), notes = {_sql_str(notes)}\n"
|
|
||||||
f"WHERE batch_id = {bid};\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_psql(sql: str, ssh_host: str, container: str, db_user: str, db_name: str,
|
|
||||||
attempts: int = 4) -> None:
|
|
||||||
"""Заливка батча через ssh с ретраем на обрыв транспорта.
|
|
||||||
|
|
||||||
Прогон длится часами, и ssh рвётся: живьём поймано «Connection reset by peer»
|
|
||||||
(ssh возвращает 255) прямо посреди заливки — весь прогон умирал, а несброшенный
|
|
||||||
батч терялся. Ретраить безопасно: SQL идемпотентен (batch через ON CONFLICT DO
|
|
||||||
NOTHING, карточки через ON CONFLICT (source_id,batch_id,kind) DO NOTHING).
|
|
||||||
|
|
||||||
Ретраится ТОЛЬКО транспорт (ssh 255). Ошибка самого psql (ON_ERROR_STOP, любой
|
|
||||||
другой код) — это дефект данных или SQL, её повтор не лечит: поднимаем сразу.
|
|
||||||
"""
|
|
||||||
cmd = [
|
|
||||||
"ssh", ssh_host,
|
|
||||||
f"docker exec -i {container} psql -U {db_user} -d {db_name} "
|
|
||||||
"-v ON_ERROR_STOP=1 -f -",
|
|
||||||
]
|
|
||||||
for i in range(attempts):
|
|
||||||
proc = subprocess.run(cmd, input=sql.encode("utf-8"), capture_output=True)
|
|
||||||
out = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
|
|
||||||
if proc.returncode == 0:
|
|
||||||
if out:
|
|
||||||
print(f" psql: {out}", flush=True)
|
|
||||||
return
|
|
||||||
if proc.returncode != 255 or i == attempts - 1:
|
|
||||||
raise RuntimeError(f"psql через ssh вернул {proc.returncode}:\n{out}")
|
|
||||||
tail = out.splitlines()[-1] if out else "без вывода"
|
|
||||||
print(f" ssh оборвался ({tail}), повтор заливки {i + 2}/{attempts}", flush=True)
|
|
||||||
time.sleep(15.0 * (i + 1))
|
|
||||||
|
|
||||||
|
|
||||||
# --- накопитель карточек ---------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Sink:
|
|
||||||
"""Батчами на прод (ssh+psql) или в локальный CSV при --dry-run."""
|
|
||||||
|
|
||||||
batch_id: str
|
|
||||||
started_at: str
|
|
||||||
query: str
|
|
||||||
batch_size: int
|
|
||||||
dry_run: bool
|
|
||||||
csv_path: Path
|
|
||||||
ssh_host: str
|
|
||||||
container: str
|
|
||||||
db_user: str
|
|
||||||
db_name: str
|
|
||||||
buffer: list[dict[str, Any]] = field(default_factory=list)
|
|
||||||
sent: int = 0
|
|
||||||
skipped_non_numeric: int = 0
|
|
||||||
|
|
||||||
def add(self, lot: Any) -> None:
|
|
||||||
raw_id = str(getattr(lot, "source_id", "") or "")
|
|
||||||
try:
|
|
||||||
source_id = int(raw_id) # в БД bigint, у ScrapedLot — строка
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
self.skipped_non_numeric += 1
|
|
||||||
return
|
|
||||||
payload = lot.model_dump(mode="json")
|
|
||||||
self.buffer.append({
|
|
||||||
"source_id": source_id,
|
|
||||||
"observed_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"batch_id": self.batch_id,
|
|
||||||
"kind": "serp",
|
|
||||||
"url": payload.get("source_url"),
|
|
||||||
"price": payload.get("price_rub"),
|
|
||||||
"payload": json.dumps(payload, ensure_ascii=False),
|
|
||||||
})
|
|
||||||
|
|
||||||
def maybe_flush(self) -> None:
|
|
||||||
if len(self.buffer) >= self.batch_size:
|
|
||||||
self.flush()
|
|
||||||
|
|
||||||
def flush(self) -> None:
|
|
||||||
if not self.buffer:
|
|
||||||
return
|
|
||||||
rows, self.buffer = self.buffer, []
|
|
||||||
if self.dry_run:
|
|
||||||
fresh = not self.csv_path.exists()
|
|
||||||
with self.csv_path.open("a", encoding="utf-8", newline="") as fh:
|
|
||||||
if fresh:
|
|
||||||
fh.write("source_id,observed_at,batch_id,kind,url,price,payload\n")
|
|
||||||
fh.write(_csv_rows(rows))
|
|
||||||
print(f" [dry-run] {len(rows)} строк → {self.csv_path}", flush=True)
|
|
||||||
else:
|
|
||||||
run_psql(
|
|
||||||
build_sql(self.batch_id, self.query, rows, self.started_at),
|
|
||||||
self.ssh_host, self.container, self.db_user, self.db_name,
|
|
||||||
)
|
|
||||||
print(f" залито {len(rows)} строк в msk_raw.avito_cards", flush=True)
|
|
||||||
self.sent += len(rows)
|
|
||||||
|
|
||||||
def finalize(self, notes: str) -> None:
|
|
||||||
self.flush()
|
|
||||||
if self.dry_run:
|
|
||||||
print(f" [dry-run] finalize: {notes}", flush=True)
|
|
||||||
return
|
|
||||||
run_psql(
|
|
||||||
build_finalize_sql(self.batch_id, self.query, notes),
|
|
||||||
self.ssh_host, self.container, self.db_user, self.db_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- сбор ------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def parse_page(scraper: AvitoScraper, html: str, url: str) -> tuple[int | None, list[Any]]:
|
|
||||||
count = scraper._extract_total_count(html)
|
|
||||||
lots = scraper._parse_html(html, "https://www.avito.ru")
|
|
||||||
if not lots and count:
|
|
||||||
raise Blocked("empty_page", f"0 карточек при счётчике {count}: {url}")
|
|
||||||
return count, lots
|
|
||||||
|
|
||||||
|
|
||||||
async def probe(loader: Loader, scraper: AvitoScraper, base_url: str,
|
|
||||||
lo: int | None, hi: int | None) -> tuple[int | None, list[Any]]:
|
|
||||||
url = build_url(base_url, 1, lo, hi)
|
|
||||||
html, _ = await loader.fetch(url)
|
|
||||||
return parse_page(scraper, html, url)
|
|
||||||
|
|
||||||
|
|
||||||
async def build_plan(loader: Loader, scraper: AvitoScraper, base_url: str, target: int,
|
|
||||||
cache: dict[tuple[int | None, int | None], list[Any]]
|
|
||||||
) -> list[Corridor]:
|
|
||||||
"""Адаптивная бисекция по цене; страница 1 каждого коридора кэшируется."""
|
|
||||||
corridors: list[Corridor] = []
|
|
||||||
|
|
||||||
def emit(lo: int | None, hi: int | None, count: int | None,
|
|
||||||
truncated: bool, lots: list[Any]) -> None:
|
|
||||||
missed = max(0, (count or 0) - HARD_CAP) if truncated else 0
|
|
||||||
c = Corridor(lo=lo, hi=hi, count=count, truncated=truncated, missed=missed)
|
|
||||||
corridors.append(c)
|
|
||||||
cache[(lo, hi)] = lots
|
|
||||||
flag = " TRUNCATED" if truncated else ""
|
|
||||||
print(f" коридор {c.label()} count={count} "
|
|
||||||
f"страниц={c.planned_pages()}{flag}", flush=True)
|
|
||||||
if truncated:
|
|
||||||
print(f" ВНИМАНИЕ: коридор {c.label()} не влезает в потолок "
|
|
||||||
f"{HARD_CAP}; заведомо не добрано ~{missed} объявлений", flush=True)
|
|
||||||
|
|
||||||
async def find_upper(lo: int | None) -> int:
|
|
||||||
"""Верхнюю границу открытого коридора ищем удвоением от разумного старта."""
|
|
||||||
cand = max(int(lo or PRICE_FLOOR) * 2, PRICE_PROBE_START)
|
|
||||||
while cand < PRICE_CEIL:
|
|
||||||
cnt, _ = await probe(loader, scraper, base_url, cand, None)
|
|
||||||
print(f" проба хвоста pmin={cand:_} count={cnt}", flush=True)
|
|
||||||
if cnt is not None and cnt <= target:
|
|
||||||
return cand
|
|
||||||
cand *= 2
|
|
||||||
return cand
|
|
||||||
|
|
||||||
async def split(lo: int | None, hi: int | None, depth: int,
|
|
||||||
count: int | None, lots: list[Any]) -> None:
|
|
||||||
if count is None:
|
|
||||||
url = build_url(base_url, 1, lo, hi)
|
|
||||||
raise Blocked("empty_page", f"счётчик не прочитался: {url}")
|
|
||||||
if count <= target:
|
|
||||||
emit(lo, hi, count, False, lots)
|
|
||||||
return
|
|
||||||
if depth >= MAX_DEPTH or width_ratio(lo, hi) <= MIN_WIDTH_RATIO:
|
|
||||||
# Предохранитель: не молчим — помечаем truncated и считаем недобор.
|
|
||||||
emit(lo, hi, count, count > HARD_CAP, lots)
|
|
||||||
return
|
|
||||||
upper = hi if hi is not None else await find_upper(lo)
|
|
||||||
if hi is None:
|
|
||||||
tail_cnt, tail_lots = await probe(loader, scraper, base_url, upper, None)
|
|
||||||
emit(upper, None, tail_cnt,
|
|
||||||
bool(tail_cnt and tail_cnt > HARD_CAP), tail_lots)
|
|
||||||
mid = geometric_mid(lo, upper)
|
|
||||||
for sub_lo, sub_hi in ((lo, mid), (mid, upper)):
|
|
||||||
sub_cnt, sub_lots = await probe(loader, scraper, base_url, sub_lo, sub_hi)
|
|
||||||
print(f" проба {sub_lo or '-'}..{sub_hi} count={sub_cnt}", flush=True)
|
|
||||||
await split(sub_lo, sub_hi, depth + 1, sub_cnt, sub_lots)
|
|
||||||
|
|
||||||
root_cnt, root_lots = await probe(loader, scraper, base_url, None, None)
|
|
||||||
print(f"Всего по базовому запросу: {root_cnt}", flush=True)
|
|
||||||
await split(None, None, 0, root_cnt, root_lots)
|
|
||||||
return corridors
|
|
||||||
|
|
||||||
|
|
||||||
async def collect(args: argparse.Namespace) -> int:
|
|
||||||
# avito_serp_ekb_only=False обязателен: с True парсер выбрасывает всё, где в
|
|
||||||
# URL нет /ekaterinburg/ — то есть все подмосковные слаги (serp.py:2154).
|
|
||||||
scraper = AvitoScraper(
|
|
||||||
SimpleNamespace(avito_serp_ekb_only=False), # type: ignore[arg-type]
|
|
||||||
target_city_slug="moskva",
|
|
||||||
)
|
|
||||||
out_dir = Path(args.out_dir).resolve()
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
plan_path = out_dir / f"plan-{args.batch_id}.json"
|
|
||||||
csv_path = out_dir / f"cards-{args.batch_id}.csv"
|
|
||||||
started_at = datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
plan: Plan | None = None
|
|
||||||
if args.resume:
|
|
||||||
if not plan_path.exists():
|
|
||||||
print(f"--resume: плана нет — {plan_path}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
plan = Plan.load(plan_path)
|
|
||||||
done = sum(1 for c in plan.corridors if c.status == "done")
|
|
||||||
print(f"Resume по {plan_path}: коридоров {len(plan.corridors)}, "
|
|
||||||
f"готово {done}", flush=True)
|
|
||||||
|
|
||||||
# Resume: URL берём из сохранённого плана, а не из CLI — коридоры посчитаны
|
|
||||||
# именно под него. Расхождение = молчаливая заливка чужой выдачи под тем же
|
|
||||||
# batch_id, поэтому это ошибка, а не тихий приоритет одного из двух.
|
|
||||||
if plan is not None and plan.base_url != args.base_url:
|
|
||||||
raise SystemExit(
|
|
||||||
"--resume: план построен для другого URL."
|
|
||||||
f" В плане {plan.base_url}, в аргументах {args.base_url}."
|
|
||||||
" Убери --base-url (возьмётся из плана) либо начни новый batch_id."
|
|
||||||
)
|
|
||||||
base_url = plan.base_url if plan is not None else args.base_url
|
|
||||||
|
|
||||||
page_budget = None if args.full else args.measure
|
|
||||||
mode = "FULL" if args.full else f"MEASURE<={page_budget}"
|
|
||||||
print(f"Режим: {mode}; batch_id={args.batch_id}; delay={args.delay}s; "
|
|
||||||
f"target={args.target_count}; dry_run={args.dry_run}", flush=True)
|
|
||||||
|
|
||||||
sink = Sink(
|
|
||||||
batch_id=args.batch_id, started_at=started_at, query=base_url,
|
|
||||||
batch_size=args.batch_size, dry_run=args.dry_run, csv_path=csv_path,
|
|
||||||
ssh_host=args.ssh_host, container=args.container,
|
|
||||||
db_user=args.db_user, db_name=args.db_name,
|
|
||||||
)
|
|
||||||
cache: dict[tuple[int | None, int | None], list[Any]] = {}
|
|
||||||
total = 0
|
|
||||||
stop_reason = ""
|
|
||||||
rc = 0
|
|
||||||
loads = 0
|
|
||||||
|
|
||||||
async with Loader(args.delay, page_budget) as loader:
|
|
||||||
try:
|
|
||||||
if plan is None:
|
|
||||||
print("Строю план коридоров...", flush=True)
|
|
||||||
corridors = await build_plan(loader, scraper, base_url,
|
|
||||||
args.target_count, cache)
|
|
||||||
plan = Plan(base_url=base_url, target=args.target_count,
|
|
||||||
batch_id=args.batch_id, corridors=corridors,
|
|
||||||
created_at=started_at)
|
|
||||||
plan.save(plan_path)
|
|
||||||
print(f"План сохранён: {plan_path} ({len(corridors)} коридоров)",
|
|
||||||
flush=True)
|
|
||||||
|
|
||||||
for corridor in plan.corridors:
|
|
||||||
if corridor.status == "done":
|
|
||||||
continue
|
|
||||||
pages = corridor.planned_pages()
|
|
||||||
print(f"Коридор {corridor.label()} count={corridor.count} "
|
|
||||||
f"страниц={pages} (с {corridor.pages_done + 1})", flush=True)
|
|
||||||
for page in range(corridor.pages_done + 1, pages + 1):
|
|
||||||
key = (corridor.lo, corridor.hi)
|
|
||||||
if page == 1 and key in cache:
|
|
||||||
lots = cache.pop(key) # страница 1 уже скачана при планировании
|
|
||||||
else:
|
|
||||||
url = build_url(base_url, page, corridor.lo, corridor.hi)
|
|
||||||
html, _ = await loader.fetch(url)
|
|
||||||
_, lots = parse_page(scraper, html, url)
|
|
||||||
for lot in lots:
|
|
||||||
sink.add(lot)
|
|
||||||
total += len(lots)
|
|
||||||
corridor.pages_done = page
|
|
||||||
print(f" стр.{page}/{pages}: карточек {len(lots)}, "
|
|
||||||
f"итого {total}", flush=True)
|
|
||||||
sink.maybe_flush()
|
|
||||||
plan.save(plan_path)
|
|
||||||
if not lots:
|
|
||||||
print(" пустая страница — конец коридора", flush=True)
|
|
||||||
break
|
|
||||||
corridor.status = "done"
|
|
||||||
plan.save(plan_path)
|
|
||||||
except BudgetExhausted:
|
|
||||||
stop_reason = "потолок --measure исчерпан"
|
|
||||||
print(f"Стоп: {stop_reason}", flush=True)
|
|
||||||
except Blocked as exc:
|
|
||||||
stop_reason = f"BLOCKED/{exc.reason}: {exc.detail}"
|
|
||||||
print(f"СТОП: {stop_reason}", file=sys.stderr, flush=True)
|
|
||||||
rc = 2
|
|
||||||
finally:
|
|
||||||
loads = loader.loads
|
|
||||||
if plan is not None:
|
|
||||||
plan.save(plan_path)
|
|
||||||
|
|
||||||
truncated = [c for c in (plan.corridors if plan else []) if c.truncated]
|
|
||||||
missed = sum(c.missed for c in truncated)
|
|
||||||
notes = "; ".join(x for x in [
|
|
||||||
f"mode={mode}", f"loads={loads}", f"cards={total}",
|
|
||||||
f"skipped_non_numeric={sink.skipped_non_numeric}",
|
|
||||||
(f"truncated_corridors={len(truncated)} missed~{missed}" if truncated else ""),
|
|
||||||
stop_reason,
|
|
||||||
] if x)
|
|
||||||
try:
|
|
||||||
sink.finalize(notes)
|
|
||||||
except Exception as exc: # noqa: BLE001 — не прятать исходную причину стопа
|
|
||||||
print(f"finalize провалился: {exc}", file=sys.stderr)
|
|
||||||
rc = rc or 1
|
|
||||||
print(f"Готово. Загрузок: {loads}; карточек: {total}; отправлено: {sink.sent}; "
|
|
||||||
f"пропущено нечисловых source_id: {sink.skipped_non_numeric}; "
|
|
||||||
f"notes: {notes}", flush=True)
|
|
||||||
return rc
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
||||||
p = argparse.ArgumentParser(
|
|
||||||
prog="collect.py",
|
|
||||||
description="Ручной сбор SERP Авито (вторичка, Москва+МО) в прод-схему msk_raw.",
|
|
||||||
)
|
|
||||||
p.add_argument("--base-url", default=DEFAULT_BASE_URL,
|
|
||||||
help="базовый URL выдачи (дефолт — вкладка владельца)")
|
|
||||||
p.add_argument("--measure", type=int, default=100, metavar="N",
|
|
||||||
help="режим замера: не больше N загрузок страниц (дефолт 100)")
|
|
||||||
p.add_argument("--full", action="store_true",
|
|
||||||
help="полный проход без потолка страниц (включается только явно)")
|
|
||||||
p.add_argument("--dry-run", action="store_true",
|
|
||||||
help="ничего не слать на прод, писать CSV локально")
|
|
||||||
p.add_argument("--resume", action="store_true",
|
|
||||||
help="продолжить по сохранённому плану коридоров")
|
|
||||||
p.add_argument("--delay", type=float, default=8.0,
|
|
||||||
help="пауза между загрузками, с (±20%% джиттер, дефолт 8.0)")
|
|
||||||
p.add_argument("--batch-size", type=int, default=1000,
|
|
||||||
help="карточек в одной заливке (дефолт 1000)")
|
|
||||||
p.add_argument("--target-count", type=int, default=1500,
|
|
||||||
help="целевой размер коридора; больше — делим (дефолт 1500)")
|
|
||||||
p.add_argument("--batch-id", default=None,
|
|
||||||
help="batch_id в msk_raw.batches (дефолт msk-serp-<UTC>)")
|
|
||||||
p.add_argument("--out-dir", default=str(Path(__file__).resolve().parent / "runs"),
|
|
||||||
help="каталог плана/CSV")
|
|
||||||
p.add_argument("--ssh-host", default="selectel", help="ssh-хост прода")
|
|
||||||
p.add_argument("--container", default="tradein-postgres",
|
|
||||||
help="имя контейнера Postgres на проде")
|
|
||||||
p.add_argument("--db-user", default="tradein")
|
|
||||||
p.add_argument("--db-name", default="tradein")
|
|
||||||
args = p.parse_args(argv)
|
|
||||||
if args.batch_id is None:
|
|
||||||
args.batch_id = "msk-serp-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
||||||
if not _BATCH_ID_RE.match(args.batch_id):
|
|
||||||
p.error("--batch-id: допустимы только символы [A-Za-z0-9._-]")
|
|
||||||
if args.measure < 1:
|
|
||||||
p.error("--measure должен быть >= 1")
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
return asyncio.run(collect(parse_args(argv)))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -17,6 +17,7 @@ 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,
|
||||||
|
|
@ -1612,6 +1613,11 @@ 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(
|
||||||
|
|
@ -4911,6 +4917,7 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,19 @@ 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__``.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
"""Загрузчик OSM POI из Overpass API для site-finder.
|
"""Загрузчик OSM POI из Overpass API для site-finder.
|
||||||
|
|
||||||
Запускается раз в неделю через Celery beat. Поддерживает фильтр
|
Запускается раз в неделю через Celery beat (регион по умолчанию — ЕКБ, см. DEFAULT_REGION).
|
||||||
"не старше 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
|
||||||
|
|
@ -19,6 +24,31 @@ 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 ниже) — это "ИЛИ" на
|
||||||
|
|
@ -54,17 +84,119 @@ OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
def _bbox_tiles(
|
||||||
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
|
bbox: tuple[float, float, float, float], max_side_deg: float = MAX_TILE_SIDE_DEG
|
||||||
|
) -> 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 = EKB_BBOX
|
south, west, north, east = bbox
|
||||||
bbox = f"({south},{west},{north},{east})"
|
bbox_str = 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};way{filt}{bbox};);out center meta;"
|
return f"[out:json][timeout:30];(node{filt}{bbox_str};way{filt}{bbox_str};);out center meta;"
|
||||||
|
|
||||||
|
|
||||||
def _classify(tags: dict[str, str]) -> str | None:
|
def _classify(tags: dict[str, str]) -> str | None:
|
||||||
|
|
@ -80,55 +212,117 @@ def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_category(
|
async def _fetch_category(
|
||||||
client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
|
client: httpx.AsyncClient,
|
||||||
|
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-запрос с ОДНИМ повтором при транзиентной ошибке.
|
"""Один per-category Overpass-запрос (для ОДНОГО тайла bbox) с ОДНИМ повтором при
|
||||||
|
транзиентной ошибке; если тайл падает оба раза — адаптивно дробится на 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)
|
query = _build_overpass_query(tag_filters, bbox)
|
||||||
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) → %d [attempt %d]", tag_desc, category, len(elements), attempt
|
"Overpass: %s (%s) bbox=%s depth=%d → %d [attempt %d]",
|
||||||
|
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 after retry — category skipped this run: %s", tag_desc, e
|
"Overpass failed for %s bbox=%s at max split depth %d — tile skipped this run: %s",
|
||||||
|
tag_desc,
|
||||||
|
bbox,
|
||||||
|
depth,
|
||||||
|
e,
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def fetch_overpass() -> list[dict]:
|
async def fetch_overpass(region: str = DEFAULT_REGION) -> list[dict]:
|
||||||
"""Запросить Overpass API per category, вернуть combined список elements.
|
"""Запросить Overpass API per category × per tile, вернуть combined список elements.
|
||||||
|
|
||||||
Делаем отдельные запросы вместо одного гигантского — большой запрос
|
Делаем отдельные запросы вместо одного гигантского — большой запрос
|
||||||
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
||||||
policy: max 2 concurrent, лучше 1 req/s).
|
policy: max 2 concurrent, лучше 1 req/s). bbox региона режется на тайлы
|
||||||
|
≤ 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",
|
||||||
|
|
@ -136,24 +330,34 @@ async def fetch_overpass() -> 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():
|
||||||
elements = await _fetch_category(client, tag_filters, category)
|
for tile in tiles:
|
||||||
all_elements.extend(elements)
|
elements = await _fetch_category(client, tag_filters, category, tile, state)
|
||||||
await asyncio.sleep(1.0)
|
all_elements.extend(elements)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Overpass: total %d elements across %d category-queries",
|
"Overpass region=%s: total %d elements across %d category-queries × %d tiles",
|
||||||
|
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() -> dict[str, int]:
|
def sync_poi_to_db(region: str = DEFAULT_REGION) -> 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())
|
elements = asyncio.run(fetch_overpass(region))
|
||||||
# 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)
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,22 @@ 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 patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -37,3 +47,34 @@ 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)
|
||||||
|
|
|
||||||
463
backend/tests/ops/test_3443_caddy_reload_not_recreate.py
Normal file
463
backend/tests/ops/test_3443_caddy_reload_not_recreate.py
Normal file
|
|
@ -0,0 +1,463 @@
|
||||||
|
"""Полный деплой не пересоздаёт 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), (
|
||||||
|
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
|
||||||
|
)
|
||||||
460
backend/tests/ops/test_3448_caddy_only_detection.py
Normal file
460
backend/tests/ops/test_3448_caddy_only_detection.py
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
"""Гейт: быстрый путь «правка только прокси» действительно включается (#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)."
|
||||||
|
)
|
||||||
112
backend/tests/ops/test_3467_prometheus_reload.py
Normal file
112
backend/tests/ops/test_3467_prometheus_reload.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""Правки Prometheus-конфига/правил обязаны доезжать до работающего процесса.
|
||||||
|
|
||||||
|
ЧТО СЛУЧИЛОСЬ НА ПРОДЕ. `GET /api/v1/status/runtimeinfo` внутри
|
||||||
|
`gendesign-prometheus` 12.09 отдавал `lastConfigTime`, совпадающий со
|
||||||
|
`startTime` контейнера, — конфиг и правила не перечитывались 16 суток.
|
||||||
|
Деплой при этом был зелёный: `docker compose up -d` не пересоздаёт
|
||||||
|
контейнер из-за изменения содержимого бинд-маунта (он сравнивает только
|
||||||
|
описание сервиса), а `--web.enable-lifecycle` был включён в
|
||||||
|
docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не вызывал.
|
||||||
|
|
||||||
|
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
||||||
|
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
||||||
|
в этом же workflow — только для Prometheus починка не пересоздание
|
||||||
|
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
|
||||||
|
пути заново, так что новый инод после `git reset --hard` подхватывается
|
||||||
|
без даунтайма.
|
||||||
|
|
||||||
|
Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается
|
||||||
|
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
|
||||||
|
подтверждён сменой lastConfigTime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def _text() -> str:
|
||||||
|
return WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_promtool_checks_config_and_rules() -> None:
|
||||||
|
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
||||||
|
text = _text()
|
||||||
|
assert "promtool check config /etc/prometheus/prometheus.yml" in text, (
|
||||||
|
"нет проверки конфига promtool'ом — битый prometheus.yml долетит до reload"
|
||||||
|
)
|
||||||
|
assert "promtool check rules" in text, (
|
||||||
|
"нет проверки правил promtool'ом — битое правило долетит до reload"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_endpoint_is_called() -> None:
|
||||||
|
"""Сам reload обязан вызываться — иначе валидация ничего не решает."""
|
||||||
|
text = _text()
|
||||||
|
assert "localhost:9090/-/reload" in text, (
|
||||||
|
"нет вызова POST /-/reload — конфиг/правила проверяются, но в силу не вступают "
|
||||||
|
"(#3467: lastConfigTime не менялся 16 суток при зелёном деплое)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_happens_after_validation_not_before() -> None:
|
||||||
|
"""Reload обязан идти ПОСЛЕ promtool, а не до/вместо него."""
|
||||||
|
text = _text()
|
||||||
|
check_pos = text.index("promtool check config /etc/prometheus/prometheus.yml")
|
||||||
|
reload_pos = text.index("localhost:9090/-/reload")
|
||||||
|
assert check_pos < reload_pos, (
|
||||||
|
"reload стоит раньше проверки конфига — битый конфиг мог бы применяться вслепую"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_is_guarded_by_the_promtool_check() -> None:
|
||||||
|
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
||||||
|
text = _text()
|
||||||
|
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
|
||||||
|
else_pos = text.index("else", guard_start)
|
||||||
|
reload_pos = text.index("localhost:9090/-/reload")
|
||||||
|
assert guard_start < reload_pos < else_pos, (
|
||||||
|
"вызов reload лежит вне ветки успешной проверки promtool — "
|
||||||
|
"битый конфиг всё равно приведёт к reload, либо reload вообще не защищён проверкой"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
|
||||||
|
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
||||||
|
text = _text()
|
||||||
|
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
|
||||||
|
else_pos = text.index("else", guard_start)
|
||||||
|
fi_pos = text.index("fi", else_pos)
|
||||||
|
else_branch = text[else_pos:fi_pos]
|
||||||
|
assert "localhost:9090/-/reload" not in else_branch, (
|
||||||
|
"reload вызывается даже в ветке провалившейся проверки"
|
||||||
|
)
|
||||||
|
assert "exit 1" in else_branch, (
|
||||||
|
"провал promtool не роняет шаг — деплой останется зелёным при битом конфиге"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acceptance_checks_last_config_time_actually_changed() -> None:
|
||||||
|
"""Приёмка обязана сверять `lastConfigTime` до/после, а не доверять коду ответа reload.
|
||||||
|
|
||||||
|
`wget` на POST /-/reload может отрапортовать успех, даже если Prometheus
|
||||||
|
молча остался на старом конфиге (например, если бинарь внутри образа не
|
||||||
|
поддерживает --post-data так, как ожидалось) — единственное надёжное
|
||||||
|
подтверждение реального перечитывания конфига это смена таймстемпа.
|
||||||
|
"""
|
||||||
|
text = _text()
|
||||||
|
assert text.count("lastConfigTime") >= 2, (
|
||||||
|
"нет сравнения lastConfigTime до/после — reload не проверяется по факту"
|
||||||
|
)
|
||||||
|
assert "LAST_CONFIG_BEFORE" in text and "LAST_CONFIG_AFTER" in text, (
|
||||||
|
"нет явного до/после сравнения таймстемпа последней перезагрузки конфига"
|
||||||
|
)
|
||||||
|
verify_start = text.index("LAST_CONFIG_AFTER")
|
||||||
|
verify_block_end = text.index("Prometheus: конфиг и правила проверены", verify_start)
|
||||||
|
verify_block = text[verify_start:verify_block_end]
|
||||||
|
assert "exit 1" in verify_block, (
|
||||||
|
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
|
||||||
|
)
|
||||||
108
backend/tests/ops/test_alert_value_is_the_described_quantity.py
Normal file
108
backend/tests/ops/test_alert_value_is_the_described_quantity.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""Гейт: `$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
|
||||||
|
)
|
||||||
|
|
@ -176,3 +176,29 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,20 @@ 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"
|
||||||
|
|
||||||
|
|
|
||||||
36
backend/tests/test_3471_product_metrics.py
Normal file
36
backend/tests/test_3471_product_metrics.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Продуктовый счётчик экспорта отчётов (#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
|
||||||
257
backend/tests/test_poi_loader.py
Normal file
257
backend/tests/test_poi_loader.py
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
"""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
|
||||||
|
|
@ -133,6 +133,35 @@ 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).
|
||||||
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
|
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
|
||||||
|
|
@ -155,10 +184,13 @@ 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 browse
|
file_server
|
||||||
}
|
}
|
||||||
|
|
||||||
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
|
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
|
||||||
|
|
@ -371,10 +403,58 @@ 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 }`, так что путь и без этой строки не
|
||||||
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
|
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
|
||||||
|
|
@ -438,7 +518,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 /docs /business /sitemap.xml
|
@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
|
||||||
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 {
|
||||||
|
|
@ -469,7 +549,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|docs|business)/$
|
@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)/$
|
||||||
handle @meraShortSlash {
|
handle @meraShortSlash {
|
||||||
route {
|
route {
|
||||||
uri strip_suffix /
|
uri strip_suffix /
|
||||||
|
|
@ -557,7 +637,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/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/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/
|
||||||
handle @meraLongPages {
|
handle @meraLongPages {
|
||||||
route {
|
route {
|
||||||
uri strip_prefix /trade-in/mera-public
|
uri strip_prefix /trade-in/mera-public
|
||||||
|
|
|
||||||
|
|
@ -105,11 +105,44 @@ metrics.gendsgn.ru {
|
||||||
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
|
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
|
||||||
# поставил не человек. Прав в системе токен не даёт никаких.
|
# поставил не человек. Прав в системе токен не даёт никаких.
|
||||||
#
|
#
|
||||||
# Сервис отвечает только на /ack/* и /healthz; всё прочее — 404.
|
# Сервис отвечает только на /ack/*, /glitchtip и /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`.
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,71 @@ 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 + что-то ещё для остальных двух чисел.
|
||||||
|
#
|
||||||
|
# ⚠️ ИМЕНА МЕТРИК НИЖЕ (celery_queue_length, celery_worker_up,
|
||||||
|
# celery_task_failed_total) — по документации проекта на момент правки, БЕЗ
|
||||||
|
# прогона на реальном брокере (агент писал этот файл без доступа к проду).
|
||||||
|
# Сверить с `curl http://gendesign-celery-exporter:9808/metrics` на хосте
|
||||||
|
# после первого деплоя и поправить `ops/metrics/prometheus/rules/infra.yml`
|
||||||
|
# при расхождении — иначе алерты будут молча ничего не ловить.
|
||||||
|
celery-exporter:
|
||||||
|
image: danihodovic/celery-exporter:0.13.0
|
||||||
|
container_name: gendesign-celery-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["apps"]
|
||||||
|
command:
|
||||||
|
- "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}"
|
||||||
|
- "--queue=celery"
|
||||||
|
expose:
|
||||||
|
- "9808"
|
||||||
|
networks:
|
||||||
|
- shared
|
||||||
|
mem_limit: 128m
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
# ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
|
# ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
|
||||||
# forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
|
# forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
|
||||||
# ничем не ограничен — политики ретенции у GlitchTip нет вообще.
|
# ничем не ограничен — политики ретенции у GlitchTip нет вообще.
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,12 @@ 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:
|
||||||
|
|
@ -210,7 +216,74 @@ 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
|
||||||
|
|
@ -230,6 +303,12 @@ 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"
|
||||||
|
|
|
||||||
138
ops/caddy-apply.sh
Executable file
138
ops/caddy-apply.sh
Executable file
|
|
@ -0,0 +1,138 @@
|
||||||
|
#!/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 "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."
|
||||||
|
|
@ -27,6 +27,28 @@
|
||||||
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
|
||||||
|
|
@ -51,6 +73,7 @@ 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}
|
||||||
|
|
@ -153,6 +176,79 @@ 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>"
|
||||||
|
|
@ -243,11 +339,24 @@ 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
|
||||||
if self.path != "/alertmanager":
|
# Тело читается ДО любой развилки и ветки отказа. protocol_version =
|
||||||
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
# HTTP/1.1, то есть соединение переиспользуется, а Caddy перед нами
|
||||||
return
|
# держит пул к апстриму. Ответить 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)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
raw = self.rfile.read(length) if length else b"{}"
|
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")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
payload = json.loads(raw.decode() or "{}")
|
payload = json.loads(raw.decode() or "{}")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
|
|
@ -261,6 +370,36 @@ 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]
|
||||||
|
|
@ -268,6 +407,11 @@ 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()
|
||||||
|
|
|
||||||
256
ops/metrics/alert-ack/test_app.py
Normal file
256
ops/metrics/alert-ack/test_app.py
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
"""Тесты для резервного канала 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"}]
|
||||||
|
|
@ -71,9 +71,16 @@ 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|critical"]
|
target_matchers: [severity = "warning"]
|
||||||
equal: ["host"]
|
equal: ["host"]
|
||||||
|
|
||||||
receivers:
|
receivers:
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,27 @@ 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` — и это
|
||||||
// правильно: цель видна как недоступная, а не отсутствует молча.
|
// правильно: цель видна как недоступная, а не отсутствует молча.
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,13 @@ 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 = "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 = "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)"
|
||||||
action = "keep"
|
action = "keep"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,9 +74,15 @@ 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"]
|
source_labels = ["__name__", "name"]
|
||||||
regex = ".+"
|
regex = "up;.*|scrape_samples_scraped;.*|container_[^;]*;.+"
|
||||||
action = "keep"
|
action = "keep"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": 25, "stacking": { "mode": "normal" }, "showPoints": "never", "lineWidth": 1 } },
|
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } },
|
||||||
"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" } } ] },
|
||||||
|
|
|
||||||
161
ops/metrics/grafana/dashboards/product.json
Normal file
161
ops/metrics/grafana/dashboards/product.json
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
{
|
||||||
|
"uid": "gendesign-product",
|
||||||
|
"title": "Продуктовые метрики",
|
||||||
|
"description": "Числа бизнеса, а не процесса: сколько людей реально что-то сделали в «Мере» и «Птице» (#3471). Источник — те же счётчики Prometheus, что и на дашборде «Приложения», только считают не HTTP-статусы, а продуктовые события (оценка, лид, отчёт, вход, обращение в поддержку). Часовое окно нарочно грубое: при текущем трафике (сотни событий в сутки) минутные всплески — шум, а не сигнал.",
|
||||||
|
"tags": ["gendesign", "product"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"editable": false,
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"refresh": "5m",
|
||||||
|
"time": { "from": "now-24h", "to": "now" },
|
||||||
|
"panels": [
|
||||||
|
{ "type": "row", "title": "Мера — воронка продукта", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Оценок в час, по исходу",
|
||||||
|
"description": "insufficient_data — не ошибка: аналогов рядом с адресом не нашлось, расчёт прошёл штатно. Тревожиться стоит, если эта линия начинает расти быстрее ok — значит покрытие рынка проседает, а не то, что сломался расчёт.",
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (outcome) (increase(mera_estimates_total[1h]))",
|
||||||
|
"legendFormat": "{{outcome}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0,
|
||||||
|
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{ "matcher": { "id": "byName", "options": "insufficient_data" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
|
||||||
|
{ "matcher": { "id": "byName", "options": "ok" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Подсказки адреса в час, нашёлся ли результат",
|
||||||
|
"description": "found=no — человек напечатал адрес, а автокомплит ничего не предложил: либо адреса нет в базе, либо он вне зоны покрытия. Устойчивый рост этой линии — повод расширять покрытие, а не баг одного запроса.",
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (found) (increase(mera_address_suggestions_total[1h]))",
|
||||||
|
"legendFormat": "найден: {{found}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0,
|
||||||
|
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{ "matcher": { "id": "byRegexp", "options": ".*no.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Лиды и скачанные отчёты в час",
|
||||||
|
"description": "Лид — заявка с телефоном после оценки, отчёт — скачанный PDF по оценке. Оба редкие: сверяйте с недельным окном (кнопка времени вверху), часовой провал сам по себе ни о чём не говорит при таком трафике.",
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 },
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "increase(mera_leads_total[1h])", "legendFormat": "лиды" },
|
||||||
|
{ "refId": "B", "expr": "increase(mera_reports_exported_total[1h])", "legendFormat": "отчёты" }
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0,
|
||||||
|
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Входы в час, по исходу",
|
||||||
|
"description": "Устойчивый рост failed при ровном success — либо перебор паролей, либо сломался клиент (истёкшая сессия, старый билд фронта). При подозрении на перебор смотрите заодно панель «Отказы авторизации и лимитера» на дашборде «Приложения» — там 401/429 по HTTP.",
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (result) (increase(mera_logins_total[1h]))",
|
||||||
|
"legendFormat": "{{result}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0,
|
||||||
|
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] },
|
||||||
|
{ "matcher": { "id": "byName", "options": "success" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Обращения в поддержку в час, по каналу",
|
||||||
|
"description": "anon — обращения с экрана входа, без логина (типично «не могу войти»), web — уже залогиненные пользователи. Всплеск anon без роста web — обычно означает баг именно в форме/процессе входа, а не общий рост нагрузки на поддержку.",
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (channel) (increase(mera_support_messages_total[1h]))",
|
||||||
|
"legendFormat": "{{channel}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0,
|
||||||
|
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{ "matcher": { "id": "byName", "options": "anon" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Экспортов отчётов по участку в час, по формату",
|
||||||
|
"description": "Формат — то, что реально скачали: md/json/docx/pptx/pdf/tg-сводка §22-форсайта плюс best_layouts_pdf (ТЗ на проектирование). Провал всех форматов разом при живом трафике на дашборде «Приложения» значит, что сломан сам экспорт, а не рендер одного конкретного формата.",
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 26 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (format) (increase(sitefinder_reports_exported_total[1h]))",
|
||||||
|
"legendFormat": "{{format}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0,
|
||||||
|
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
ops/metrics/grafana/provisioning/alerting/README.md
Normal file
16
ops/metrics/grafana/provisioning/alerting/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Эта папка сознательно пустая
|
||||||
|
|
||||||
|
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`, а не просто добавить файл в эту папку.
|
||||||
|
|
@ -44,6 +44,21 @@ 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
|
||||||
|
|
@ -125,11 +140,19 @@ 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)`
|
||||||
|
# выбрасывает серии без лимита ДО деления.
|
||||||
- alert: ContainerNearMemoryLimit
|
- alert: ContainerNearMemoryLimit
|
||||||
expr: |
|
expr: |
|
||||||
container_spec_memory_limit_bytes{name!=""} > 0
|
container_memory_working_set_bytes{name!=""}
|
||||||
and container_memory_working_set_bytes{name!=""}
|
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
|
||||||
/ container_spec_memory_limit_bytes{name!=""} > 0.90
|
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
|
|
@ -137,6 +160,135 @@ groups:
|
||||||
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:
|
||||||
|
# Гейт по RPS внутри знаменателя — тот же приём, что у
|
||||||
|
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
|
||||||
|
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
|
||||||
|
# дежурного зря.
|
||||||
|
- alert: AppHighErrorRate
|
||||||
|
expr: |
|
||||||
|
sum by (app) (rate(http_requests_total{job="app", status=~"5.."}[5m]))
|
||||||
|
/ (sum by (app) (rate(http_requests_total{job="app"}[5m])) > 0.1) > 0.05
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
host: apps
|
||||||
|
annotations:
|
||||||
|
summary: "Доля 5xx выше 5%"
|
||||||
|
description: "{{ $labels.app }}: {{ $value | humanizePercentage }} ответов 5xx за последние 5 минут при RPS выше 0.1."
|
||||||
|
|
||||||
|
# Порог 5s — заведомо выше рабочего профиля обоих продуктов (у «Меры»
|
||||||
|
# типичный расчёт 90мс, у «Птицы» верхняя граница гистограммы — 60с под
|
||||||
|
# тяжёлую геометрию, но это единичные хвостовые запросы, не p95).
|
||||||
|
# Калибровка по реальному трафику — отдельная задача, не эта.
|
||||||
|
- alert: AppHighLatencyP95
|
||||||
|
expr: |
|
||||||
|
histogram_quantile(0.95, sum by (le, app) (rate(http_request_duration_seconds_bucket{job="app"}[10m]))) > 5
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
host: apps
|
||||||
|
annotations:
|
||||||
|
summary: "p95 задержки ответа выше 5 секунд"
|
||||||
|
description: "{{ $labels.app }}: p95 за 10 минут — {{ $value | humanizeDuration }}."
|
||||||
|
|
||||||
|
# ── Redis и очередь Celery (#3471) ───────────────────────────────────────────
|
||||||
|
# Слепая зона: до этих правил ни redis_*, ни celery_* не собирались вовсе.
|
||||||
|
# Redis — общий инстанс на три потребителя (celery-брокер Site Finder, кэш
|
||||||
|
# trade-in, glitchtip — см. docker-compose.prod.yml), поэтому его смерть
|
||||||
|
# клиентская, отсюда severity: critical без явного host: apps — серия
|
||||||
|
# приходит только с продуктового alloy (alloy-apps.alloy), host в неё
|
||||||
|
# проставляется через external_labels уже на месте.
|
||||||
|
#
|
||||||
|
# ⚠️ Имена метрик celery_queue_length / celery_worker_up /
|
||||||
|
# celery_task_failed_total — по документации celery-exporter на момент
|
||||||
|
# написания правил, без проверки на реальном брокере (см. комментарий у
|
||||||
|
# сервиса celery-exporter в docker-compose.metrics-agent.yml). Сверить после
|
||||||
|
# первого деплоя.
|
||||||
|
- name: redis-celery
|
||||||
|
interval: 60s
|
||||||
|
rules:
|
||||||
|
- alert: RedisDown
|
||||||
|
expr: up{job="redis"} == 0 or redis_up == 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Redis недоступен"
|
||||||
|
description: "redis_exporter не может достучаться до Redis (или сам процесс лёг). Разом теряют связь celery-брокер Site Finder, SearchCache trade-in и glitchtip."
|
||||||
|
|
||||||
|
# `absent()` — как у CadvisorDown: если сам celery-exporter не поднялся,
|
||||||
|
# серии celery_worker_up не будет вообще, а не будет со значением 0.
|
||||||
|
- alert: NoActiveCeleryWorkers
|
||||||
|
expr: count(celery_worker_up == 1) == 0 or absent(celery_worker_up)
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Ни одного живого воркера Celery"
|
||||||
|
description: "celery-exporter не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
||||||
|
|
||||||
|
# Порог 150 ПРЕДВАРИТЕЛЬНЫЙ: реальных данных по глубине очереди нет (до
|
||||||
|
# этой правки метрика не собиралась). beat_schedule.py на момент правки
|
||||||
|
# содержит 44 periodic-задачи с разным временем срабатывания — даже
|
||||||
|
# маловероятный залп всех разом даёт кратно меньше 150. Порог взят с
|
||||||
|
# запасом сознательно и требует пересмотра через неделю наблюдений по
|
||||||
|
# факту `celery_queue_length`.
|
||||||
|
#
|
||||||
|
# `delta(...) >= 0` — очередь не УМЕНЬШАЕТСЯ за 15 минут (тот же приём,
|
||||||
|
# что и "растёт и не разгребается" в тексте задачи): просто высокое
|
||||||
|
# значение без этого условия поймало бы и здоровый кратковременный всплеск.
|
||||||
|
- alert: CeleryQueueGrowing
|
||||||
|
expr: |
|
||||||
|
celery_queue_length{queue_name="celery"} > 150
|
||||||
|
and delta(celery_queue_length{queue_name="celery"}[15m]) >= 0
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Очередь Celery растёт и не разгребается"
|
||||||
|
description: "В очереди {{ $value }} задач, за 15 минут меньше не стало. Похоже на залипший воркер или устойчивый рост нагрузки."
|
||||||
|
|
||||||
# ── Postgres ────────────────────────────────────────────────────────────────
|
# ── Postgres ────────────────────────────────────────────────────────────────
|
||||||
- name: postgres
|
- name: postgres
|
||||||
interval: 60s
|
interval: 60s
|
||||||
|
|
@ -172,12 +324,20 @@ 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.2
|
/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5) < 0.2
|
||||||
for: 6h
|
for: 6h
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
|
|
@ -185,6 +345,11 @@ 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
|
||||||
|
|
|
||||||
162
ops/metrics/tg-relay/app.py
Normal file
162
ops/metrics/tg-relay/app.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
#!/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()
|
||||||
144
ops/metrics/tg-relay/test_app.py
Normal file
144
ops/metrics/tg-relay/test_app.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""Тесты для 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()
|
||||||
187
scripts/check-frontend-swap-window.py
Normal file
187
scripts/check-frontend-swap-window.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Гейт: подмена tradein-frontend не возвращает окно недоступности (#3274).
|
||||||
|
|
||||||
|
ПОЧЕМУ. Публичный лендинг meraocenka.ru лежал 30–90 с на КАЖДОМ деплое МЕРЫ.
|
||||||
|
Причина — НЕ медленная подмена контейнера (она стоит полсекунды), а то, что
|
||||||
|
`docker compose up -d` со СПИСКОМ сервисов работает в две фазы: сначала create
|
||||||
|
(старый контейнер каждого сервиса останавливается и удаляется — иначе занято
|
||||||
|
`container_name`), потом start, в порядке зависимостей и с ожиданием их
|
||||||
|
условий. Всё, что между фазами, фронт лежит.
|
||||||
|
|
||||||
|
Замер на проде 10.09 (docker inspect .Created/.StartedAt, два деплоя подряд):
|
||||||
|
|
||||||
|
пачка сервисов: tradein-backend создан 15:01:40 → запущен 15:02:10 (30 с)
|
||||||
|
один сервис: tradein-frontend создан 16:42:17.5 → запущен 16:42:18.0 (0,5 с)
|
||||||
|
|
||||||
|
В логе Caddy у первого деплоя три 503 на лендинге (15:01:46, 15:01:52,
|
||||||
|
15:02:06), у второго — ни одного.
|
||||||
|
|
||||||
|
ЧТО ДЕРЖИТ РЕШЕНИЕ, И ПОЧЕМУ ЭТО ГЕЙТ, А НЕ КОММЕНТАРИЙ. Половинки лежат в
|
||||||
|
разных файлах и обе невидимо обратимы:
|
||||||
|
|
||||||
|
1) deploy-tradein.yml — `frontend` ВЫНЕСЕН из общего `up -d $SERVICES` в
|
||||||
|
свою команду. Достаточно дописать его обратно в SERVICES «за компанию»,
|
||||||
|
и окно вернётся целиком, молча: деплой останется зелёным.
|
||||||
|
2) caddy/sites/apps.caddy — каждый `reverse_proxy tradein-frontend:3000`
|
||||||
|
импортирует (tradein_frontend_retry) (lb_try_duration), который добирает
|
||||||
|
оставшиеся ~0,5 с. Новый публичный путь копируют с соседнего блока — и
|
||||||
|
если копируют блок БЕЗ импорта, именно этот путь снова отдаёт 502.
|
||||||
|
|
||||||
|
Оба отказа не видны ни по статусу джобы, ни по глазам: их видно только
|
||||||
|
непрерывной пробой во время деплоя (scripts/probe-deploy-window.sh).
|
||||||
|
|
||||||
|
ЧЕГО НЕ ЛОВИТ. Это разбор текста, а не исполнение: гейт не проверяет, что
|
||||||
|
команда реально отработала и что Caddy реально ретраит (это проверяется
|
||||||
|
пробой на живом деплое). Не смотрит на сервисы, кроме frontend, — бэкенду
|
||||||
|
ретрай намеренно не дан (его старт длиннее, чем разумное ожидание клиента).
|
||||||
|
|
||||||
|
Запуск: python3 scripts/check-frontend-swap-window.py [--selftest]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
WORKFLOW = REPO / ".forgejo" / "workflows" / "deploy-tradein.yml"
|
||||||
|
APPS_CADDY = REPO / "caddy" / "sites" / "apps.caddy"
|
||||||
|
|
||||||
|
SNIPPET_NAME = "tradein_frontend_retry"
|
||||||
|
FRONTEND_UPSTREAM = "reverse_proxy tradein-frontend:3000 {"
|
||||||
|
|
||||||
|
# `SERVICES="browser backend tgbot"` / `SERVICES="$SERVICES scraper"`.
|
||||||
|
_SERVICES_RE = re.compile(r'^\s*SERVICES=(["\']?)(.*?)\1\s*$', re.M)
|
||||||
|
# Одиночная команда подмены фронта.
|
||||||
|
_FRONTEND_UP_RE = re.compile(r"up -d --no-deps frontend\s*$", re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_comments(text: str) -> str:
|
||||||
|
"""Убирает строки-комментарии (shell/YAML/Caddy — везде `#`).
|
||||||
|
|
||||||
|
Обязательно: разбор дефекта живёт в комментарии рядом с правкой и содержит
|
||||||
|
его же формулировку. Без этого гейт спорил бы с собственным объяснением.
|
||||||
|
"""
|
||||||
|
return "\n".join(ln for ln in text.splitlines() if not ln.lstrip().startswith("#"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_workflow(text: str) -> list[str]:
|
||||||
|
body = strip_comments(text)
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for m in _SERVICES_RE.finditer(body):
|
||||||
|
value = m.group(2)
|
||||||
|
if re.search(r"(^|\s)frontend(\s|$)", value):
|
||||||
|
errors.append(
|
||||||
|
f'SERVICES={value!r} снова содержит `frontend`: он вернётся в общий '
|
||||||
|
f"`up -d` со списком сервисов, а это и есть окно 30–90 с (#3274). "
|
||||||
|
f"Фронт подменяется отдельной командой `up -d --no-deps frontend`."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _FRONTEND_UP_RE.search(body):
|
||||||
|
errors.append(
|
||||||
|
"в deploy-tradein.yml нет отдельной команды `up -d --no-deps frontend` — "
|
||||||
|
"подмена фронта либо пропала, либо снова уехала в общую пачку (#3274)."
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def check_caddy(text: str) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
lines = text.splitlines()
|
||||||
|
body = strip_comments(text)
|
||||||
|
|
||||||
|
if f"({SNIPPET_NAME}) {{" not in body:
|
||||||
|
errors.append(
|
||||||
|
f"снипет ({SNIPPET_NAME}) не объявлен в apps.caddy — импортировать нечего."
|
||||||
|
)
|
||||||
|
elif "lb_try_duration" not in body:
|
||||||
|
errors.append(
|
||||||
|
f"снипет ({SNIPPET_NAME}) есть, но без `lb_try_duration` — он больше "
|
||||||
|
"ничего не добирает, оставшиеся ~0,5 с подмены снова видны как 502."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Блок апстрима фронта: от строки `reverse_proxy tradein-frontend:3000 {`
|
||||||
|
# до закрывающей скобки на её же отступе. Импорт должен быть внутри.
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.strip().startswith("#") or FRONTEND_UPSTREAM not in line:
|
||||||
|
continue
|
||||||
|
indent = len(line) - len(line.lstrip())
|
||||||
|
block: list[str] = []
|
||||||
|
for nxt in lines[i + 1 :]:
|
||||||
|
if nxt.strip() == "}" and (len(nxt) - len(nxt.lstrip())) == indent:
|
||||||
|
break
|
||||||
|
block.append(nxt)
|
||||||
|
if not any(f"import {SNIPPET_NAME}" in b for b in block if not b.strip().startswith("#")):
|
||||||
|
errors.append(
|
||||||
|
f"apps.caddy:{i + 1} — `reverse_proxy tradein-frontend:3000` без "
|
||||||
|
f"`import {SNIPPET_NAME}`: на этом пути подмена контейнера снова "
|
||||||
|
f"видна посетителю как 502 (#3274)."
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> None:
|
||||||
|
good_wf = 'SERVICES="browser backend tgbot"\n docker compose up -d --no-deps frontend\n'
|
||||||
|
assert check_workflow(good_wf) == [], check_workflow(good_wf)
|
||||||
|
|
||||||
|
bad_wf = 'SERVICES="browser backend frontend tgbot"\n docker compose up -d --no-deps frontend\n'
|
||||||
|
assert any("SERVICES" in e for e in check_workflow(bad_wf)), "не поймал frontend в SERVICES"
|
||||||
|
|
||||||
|
missing_wf = 'SERVICES="browser backend tgbot"\n docker compose up -d --no-deps $SERVICES\n'
|
||||||
|
assert any("отдельной команды" in e for e in check_workflow(missing_wf)), (
|
||||||
|
"не поймал пропажу отдельной команды"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Комментарий с той же формулировкой не должен ронять гейт.
|
||||||
|
commented = '# SERVICES="browser backend frontend tgbot" # так было до #3274\n' + good_wf
|
||||||
|
assert check_workflow(commented) == [], "гейт спорит с собственным комментарием"
|
||||||
|
|
||||||
|
good_caddy = (
|
||||||
|
"(tradein_frontend_retry) {\n lb_try_duration 2s\n}\n"
|
||||||
|
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
|
||||||
|
" import tradein_frontend_retry\n header_up -X-Y\n }\n}\n"
|
||||||
|
)
|
||||||
|
assert check_caddy(good_caddy) == [], check_caddy(good_caddy)
|
||||||
|
|
||||||
|
bad_caddy = (
|
||||||
|
"(tradein_frontend_retry) {\n lb_try_duration 2s\n}\n"
|
||||||
|
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
|
||||||
|
" header_up -X-Y\n }\n}\n"
|
||||||
|
)
|
||||||
|
assert any("без `import" in e for e in check_caddy(bad_caddy)), "не поймал блок без импорта"
|
||||||
|
|
||||||
|
no_snippet = (
|
||||||
|
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
|
||||||
|
" import tradein_frontend_retry\n }\n}\n"
|
||||||
|
)
|
||||||
|
assert any("не объявлен" in e for e in check_caddy(no_snippet)), "не поймал пропажу снипета"
|
||||||
|
|
||||||
|
print("SELFTEST OK")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if "--selftest" in sys.argv:
|
||||||
|
selftest()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
errors = check_workflow(WORKFLOW.read_text(encoding="utf-8"))
|
||||||
|
errors += check_caddy(APPS_CADDY.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("Гейт #3274 (окно подмены фронта) НЕ ПРОЙДЕН:\n")
|
||||||
|
for e in errors:
|
||||||
|
print(f" ✗ {e}")
|
||||||
|
print(
|
||||||
|
"\nЧем проверять эффект на живом деплое: scripts/probe-deploy-window.sh "
|
||||||
|
"(запускать НА хосте прода, см. шапку скрипта)."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("Гейт #3274: фронт подменяется отдельной командой, все 9 апстримов с ретраем — OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -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, suggest_addresses
|
from app.api.v1.geocode import SuggestResponse, effective_region_code, 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,6 +222,9 @@ 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:
|
||||||
|
|
@ -326,6 +329,7 @@ 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()
|
||||||
|
|
@ -479,9 +483,15 @@ class ShowcaseStats(BaseModel):
|
||||||
|
|
||||||
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
|
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
|
||||||
посетитель не может отличить выборку из работы оценщика от её лучшего
|
посетитель не может отличить выборку из работы оценщика от её лучшего
|
||||||
хвоста. `eligible` минус `written` — сколько годных строк не поместилось
|
хвоста. `eligible` — сколько строк прогон СОБРАЛ (данных хватило),
|
||||||
в витрину; `rejection_rule` — по какому правилу отсеяно остальное,
|
`written` — сколько из них показано; `rejection_rule` — по какому правилу
|
||||||
записанное ТЕМ прогоном, который эти строки посчитал.
|
отобраны показанные, записанное ТЕМ прогоном, который их посчитал.
|
||||||
|
|
||||||
|
`eligible` минус `written` — НЕ «столько не поместилось»: с 2026-09-12
|
||||||
|
витрина показывает полосу расхождения −5 %..+20 %, и в разницу входят
|
||||||
|
строки, отсеянные полосой. Что это именно отбор, а не вся сверка, говорит
|
||||||
|
`rejection_rule` — поэтому счётчики и правило показываются вместе, одной
|
||||||
|
подписью, а не порознь.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
considered: int
|
considered: int
|
||||||
|
|
@ -550,7 +560,7 @@ def public_showcase(
|
||||||
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
|
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
|
||||||
|
|
||||||
Вместе со строками едет `stats` — сколько сделок рассмотрено, сколько
|
Вместе со строками едет `stats` — сколько сделок рассмотрено, сколько
|
||||||
годных строк не поместилось и по какому правилу отсеяно остальное. Числа
|
строк прогон собрал и по какому правилу из них отобраны показанные. Числа
|
||||||
считает пересчёт; без них витрина не имеет права подписаться честно.
|
считает пересчёт; без них витрина не имеет права подписаться честно.
|
||||||
"""
|
"""
|
||||||
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
|
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ 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
|
||||||
|
|
@ -324,6 +325,7 @@ 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,
|
||||||
|
|
@ -452,6 +454,7 @@ 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"],
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
|
@ -11,15 +10,34 @@ 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
|
from app.core.db import get_db, run_db_thread
|
||||||
|
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)],
|
||||||
|
|
@ -81,9 +99,21 @@ async def suggest_addresses(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
|
region_code: Annotated[
|
||||||
|
int | None,
|
||||||
|
Query(
|
||||||
|
description=(
|
||||||
|
"Регион покрытия (#3051). None (дефолт) — выводится из `city_hint` "
|
||||||
|
"через реестр регионов, иначе 66 (Свердловская область, прежнее "
|
||||||
|
"поведение). 77 — Москва: без него DaData и Nominatim получают "
|
||||||
|
"свердловский hard-констрейнт и молча возвращают ПУСТО на "
|
||||||
|
"московском адресе."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
) -> SuggestResponse:
|
) -> SuggestResponse:
|
||||||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
"""Автокомплит адресов в регионе `region_code` (дефолт 66 — Свердловская область;
|
||||||
остаётся быстрым fast-path).
|
ЕКБ — основной трафик, остаётся быстрым fast-path).
|
||||||
|
|
||||||
Используется в EstimateForm для подсказок пока пользователь печатает.
|
Используется в EstimateForm для подсказок пока пользователь печатает.
|
||||||
Bounded viewbox — генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
|
Bounded viewbox — генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
|
||||||
|
|
@ -93,8 +123,17 @@ async def suggest_addresses(
|
||||||
/api/v1/geocode/suggest?q=Малышева
|
/api/v1/geocode/suggest?q=Малышева
|
||||||
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
|
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
|
||||||
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
|
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
|
||||||
|
/api/v1/geocode/suggest?q=Тверская+6®ion_code=77 # Москва
|
||||||
"""
|
"""
|
||||||
items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
|
resolved_region_code = effective_region_code(region_code, city_hint)
|
||||||
|
try:
|
||||||
|
items = await suggest(
|
||||||
|
q, db=db, limit=limit, city_hint=city_hint, region_code=resolved_region_code
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
# Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента.
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
ADDRESS_SUGGESTIONS.labels(found="yes" if items else "no").inc()
|
||||||
return SuggestResponse(
|
return SuggestResponse(
|
||||||
items=[
|
items=[
|
||||||
SuggestItem(
|
SuggestItem(
|
||||||
|
|
@ -238,9 +277,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 asyncio.to_thread(_resolve_house_id_by_fias, db, fias_id)
|
target_house_id = await run_db_thread(_resolve_house_id_by_fias, db, fias_id)
|
||||||
|
|
||||||
facts = await asyncio.to_thread(
|
facts = await run_db_thread(
|
||||||
_lookup_house_facts,
|
_lookup_house_facts,
|
||||||
db,
|
db,
|
||||||
target_house_id=target_house_id,
|
target_house_id=target_house_id,
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,14 @@ 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 TelegramApiError, TelegramClient
|
from app.services.tgbot.client import TelegramError
|
||||||
|
from app.services.tgbot.shared import get_telegram_client
|
||||||
|
from app.tasks.glitchtip_alert_retry import retry_forward_alert
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -183,16 +187,28 @@ 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")
|
@router.post("/ops/glitchtip-webhook", response_model=None)
|
||||||
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]:
|
) -> dict[str, str] | JSONResponse:
|
||||||
"""Приёмник 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``) — этот
|
||||||
хендлер сам делает единственную проверку секрета.
|
хендлер сам делает единственную проверку секрета.
|
||||||
|
|
||||||
|
|
@ -213,7 +229,10 @@ async def glitchtip_webhook(
|
||||||
received_at = datetime.now(UTC)
|
received_at = datetime.now(UTC)
|
||||||
text = _build_message(raw_body, received_at)
|
text = _build_message(raw_body, received_at)
|
||||||
|
|
||||||
client = TelegramClient(settings.telegram_bot_token)
|
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
|
||||||
|
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
||||||
|
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
||||||
|
client = get_telegram_client()
|
||||||
try:
|
try:
|
||||||
await client.send_message(
|
await client.send_message(
|
||||||
chat_id=settings.telegram_alerts_chat_id,
|
chat_id=settings.telegram_alerts_chat_id,
|
||||||
|
|
@ -224,8 +243,30 @@ async def glitchtip_webhook(
|
||||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
)
|
)
|
||||||
except TelegramApiError:
|
except TelegramError:
|
||||||
|
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
|
||||||
|
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
|
||||||
|
# 500 из необработанного исключения (#3456). 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")
|
||||||
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
|
return JSONResponse(
|
||||||
|
status_code=502,
|
||||||
|
content={"detail": "failed to forward alert to telegram"},
|
||||||
|
background=BackgroundTask(
|
||||||
|
retry_forward_alert,
|
||||||
|
client,
|
||||||
|
chat_id=settings.telegram_alerts_chat_id,
|
||||||
|
text=text,
|
||||||
|
message_thread_id=settings.telegram_alerts_topic_id or None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from sqlalchemy.orm import Session
|
||||||
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 get_db
|
from app.core.db import get_db
|
||||||
|
from app.observability.metrics import LEADS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -173,6 +174,7 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ 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
|
||||||
|
|
@ -23,7 +22,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
|
from app.core.db import get_db, run_db_thread
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -67,7 +66,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 asyncio.to_thread(
|
counters = await run_db_thread(
|
||||||
erase_person_data,
|
erase_person_data,
|
||||||
db,
|
db,
|
||||||
username=payload.username,
|
username=payload.username,
|
||||||
|
|
|
||||||
|
|
@ -62,18 +62,23 @@ import hashlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
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 TelegramApiError, TelegramClient
|
from app.services.tgbot.client import TelegramError
|
||||||
|
from app.services.tgbot.shared import get_telegram_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -122,10 +127,58 @@ _INTERACTIVE_SEND_TIMEOUT_S = 5.0
|
||||||
_INTERACTIVE_SEND_MAX_RETRIES = 3
|
_INTERACTIVE_SEND_MAX_RETRIES = 3
|
||||||
_INTERACTIVE_SEND_MAX_BACKOFF_S = 1.0
|
_INTERACTIVE_SEND_MAX_BACKOFF_S = 1.0
|
||||||
|
|
||||||
|
# Счётчик ОТКАЗОВ отправки — отдельный от бюджетов выше (#tgsupport-fail-cooldown).
|
||||||
|
#
|
||||||
|
# Зачем вообще второй счётчик. `_send_limiter`/`_anon_ip_limiter` расходуются
|
||||||
|
# ТОЛЬКО на успехе (review L3, non-destructive peek выше) — это верно для
|
||||||
|
# «не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram
|
||||||
|
# недоступен, лимита нет ВООБЩЕ. Каждый повтор пользователя при этом стоит до
|
||||||
|
# `1 + _INTERACTIVE_SEND_MAX_RETRIES` = 4 попыток к api.telegram.org и не
|
||||||
|
# расходует ни один бюджет. Двух-трёх вкладок с авто-ретраем хватает, чтобы
|
||||||
|
# выесть лимиты группы ровно в тот момент, когда канал и так еле жив.
|
||||||
|
#
|
||||||
|
# Отсюда — дешёвый gate ПЕРЕД походом в Telegram, на своих ключах (тех же, что
|
||||||
|
# у основных лимитеров: username / anon-thread-key и client IP).
|
||||||
|
#
|
||||||
|
# N=5. Отказ одной отправки — не событие: по замеру #tgsupport-retry доля отказов
|
||||||
|
# на попытку 15-38%, но после 4 попыток до пользователя доходит ~0.8-2% отказов.
|
||||||
|
# Пять подряд на живом канале — вероятность порядка 1e-10, то есть cooldown
|
||||||
|
# физически не может сработать на «просто не повезло»; он срабатывает только на
|
||||||
|
# настоящей недоступности. Плюс `reset()` на успехе: считаем именно ПОДРЯД, одна
|
||||||
|
# успешная отправка стирает историю.
|
||||||
|
#
|
||||||
|
# 30с окна (оно же длительность cooldown: блок держится, пока самый старый из
|
||||||
|
# N отказов не выпадет из окна). Верхняя граница осмысленности — реальная
|
||||||
|
# недоступность Telegram по тому же замеру длится минутами, так что 30с заведомо
|
||||||
|
# короче и пользователя после восстановления канала не наказывают. Нижняя —
|
||||||
|
# один отказавший интерактивный запрос сам по себе занимает до 23с
|
||||||
|
# (4 попытки × 5с + 3 паузы × 1с), cooldown короче этого просто не имел бы смысла.
|
||||||
|
_SEND_FAILURE_LIMIT = 5
|
||||||
|
_SEND_FAILURE_WINDOW_S = 30.0
|
||||||
|
_send_failure_limiter = SlidingWindowLimiter(
|
||||||
|
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
|
||||||
|
)
|
||||||
|
# Отдельный экземпляр для IP-ключей — ровно как `_anon_ip_limiter` отдельный от
|
||||||
|
# `_send_limiter`: ключи разных пространств (IP vs thread-key) в одном словаре
|
||||||
|
# смешивать нельзя.
|
||||||
|
_anon_ip_failure_limiter = SlidingWindowLimiter(
|
||||||
|
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
|
||||||
|
)
|
||||||
|
|
||||||
|
_SEND_UNAVAILABLE_DETAIL = "Telegram сейчас недоступен. Попробуйте через полминуты."
|
||||||
|
|
||||||
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
|
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
|
||||||
# треде отдавало бы ВЕСЬ лог переписки. См. `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) уже гарантирует его
|
||||||
|
|
@ -165,6 +218,18 @@ class SupportMessageOut(BaseModel):
|
||||||
text_body: str
|
text_body: str
|
||||||
operator_tg_id: int | None = None
|
operator_tg_id: int | None = None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
# Дошло ли сообщение до БД. `True` по умолчанию — все существующие пути
|
||||||
|
# (`GET /support/messages`, успешный POST) строят модель из storage-строки и
|
||||||
|
# ничего про флаг не знают, контракт для них не меняется.
|
||||||
|
#
|
||||||
|
# `False` ставит ТОЛЬКО `_unpersisted_message_out`: доставлено оператору, но
|
||||||
|
# не записано. Флаг нужен потому, что без него деградация неотличима от
|
||||||
|
# тишины: фронт выбрасывает тело POST и рендерит транскрипт исключительно из
|
||||||
|
# `GET /support/messages` (`useSupportChat.ts`), где этого сообщения нет —
|
||||||
|
# поле ввода очищается, сообщение не появляется, ошибки нет. Пользователь
|
||||||
|
# решает, что не отправилось, и шлёт снова — ровно тот дубль в топике,
|
||||||
|
# против которого вся эта ветка и сделана.
|
||||||
|
persisted: bool = True
|
||||||
|
|
||||||
@field_validator("created_at", mode="before")
|
@field_validator("created_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -190,8 +255,150 @@ def _format_mirror_text(username: str, message_text: str) -> str:
|
||||||
return f"[С САЙТА] {username}:\n{message_text}"
|
return f"[С САЙТА] {username}:\n{message_text}"
|
||||||
|
|
||||||
|
|
||||||
|
def _too_many_failures_error(retry_after: float) -> HTTPException:
|
||||||
|
"""429 вместо похода в Telegram — канал только что отказал N раз подряд."""
|
||||||
|
return HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=_SEND_UNAVAILABLE_DETAIL,
|
||||||
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unpersisted_message_out(text_body: str) -> SupportMessageOut:
|
||||||
|
"""Синтетический ответ для случая «в топик доставлено, а в БД не записано».
|
||||||
|
|
||||||
|
Почему ответ вообще УСПЕШНЫЙ. Сообщение оператору реально доставлено —
|
||||||
|
отдать на это ошибку значит соврать: пользователь повторит, и в топике
|
||||||
|
окажется дубль (плюс второе предупреждение оператору). Успех здесь честнее
|
||||||
|
отказа, но он неполный, и об этом клиенту надо сказать явно.
|
||||||
|
|
||||||
|
Что сообщает `persisted=False`: «доставлено, но ответить тебе через чат не
|
||||||
|
смогут; повторять не надо». Именно флаг — контракт для клиента, а НЕ `id=0`:
|
||||||
|
по идентификатору клиент отличить деградацию не обязан и не будет.
|
||||||
|
|
||||||
|
`id` при этом всё равно нужен модели, и 0 — сознательный сентинел, а не
|
||||||
|
выдумка: реальные id в `web_support_messages` начинаются с 1 (serial),
|
||||||
|
поэтому 0 ни с чем не столкнётся, а `GET /support/messages` принимает
|
||||||
|
`since>=0`. Даже если фронт когда-нибудь начнёт курсорить по возвращённому
|
||||||
|
id (сейчас он перечитывает тред целиком с `since=0`, `useSupportChat.ts`),
|
||||||
|
нулевой курсор не перепрыгнет ни одного реального сообщения: занижение
|
||||||
|
курсора безопасно, завышение — нет.
|
||||||
|
"""
|
||||||
|
return SupportMessageOut(
|
||||||
|
id=0,
|
||||||
|
direction="in",
|
||||||
|
text_body=text_body,
|
||||||
|
operator_tg_id=None,
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
persisted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _warn_operator_message_not_recorded(*, label: str, topic_message_id: int | None) -> None:
|
||||||
|
"""Реплаем к только что доставленному зеркалу предупреждает оператора, что
|
||||||
|
ответ на ЭТО сообщение не смаршрутизируется: треда в БД нет, на реплай к
|
||||||
|
осиротевшему зеркалу `bridge._handle_group_reply` напишет только WARNING, а
|
||||||
|
клиент не увидит ничего. Без предупреждения оператор отвечает в пустоту и
|
||||||
|
считает, что помог.
|
||||||
|
|
||||||
|
Текст обращения сюда НЕ дублируется (ПДн) — оператор видит его в сообщении,
|
||||||
|
к которому это реплай.
|
||||||
|
|
||||||
|
Формулировка учитывает, что клиенту ответили успехом (`persisted=False`,
|
||||||
|
«принято, повторять не надо»): рассчитывать на «клиент напишет снова»
|
||||||
|
оператору нельзя, поднимать тред придётся иначе.
|
||||||
|
|
||||||
|
Любой отказ гасится логом: основное сообщение УЖЕ доставлено, превращать
|
||||||
|
неудачу служебного уведомления в 500 поверх успеха нельзя.
|
||||||
|
"""
|
||||||
|
text = (
|
||||||
|
f"ВНИМАНИЕ · {label}: сообщение доставлено в топик, но НЕ записано в базу "
|
||||||
|
"(сбой БД). Треда у этого обращения нет — ответить клиенту через бота "
|
||||||
|
"НЕЛЬЗЯ: реплай на это сообщение никуда не уйдёт. Повтора тоже не ждите, "
|
||||||
|
"клиенту показано, что сообщение принято и отправлять его снова не нужно. "
|
||||||
|
"Если в обращении есть контакт — свяжитесь напрямую; иначе передайте "
|
||||||
|
"дежурному и сообщите об отказе БД."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
client = get_telegram_client()
|
||||||
|
await client.send_message(
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
text=text,
|
||||||
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
# reply_to может отсутствовать (sendMessage не вернул message_id) —
|
||||||
|
# тогда уведомление уходит отдельным сообщением в топик: хуже, чем
|
||||||
|
# реплай, но несравнимо лучше тишины.
|
||||||
|
reply_to_message_id=topic_message_id,
|
||||||
|
# Тот же узкий интерактивный бюджет (review H1): клиент ждёт ответа
|
||||||
|
# ручки, а не доставки служебного уведомления.
|
||||||
|
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||||
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
|
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Шире `TelegramError` намеренно: это best-effort хвост уже успешного
|
||||||
|
# запроса, любой отказ здесь обязан остаться в логе, а не у клиента.
|
||||||
|
logger.exception(
|
||||||
|
"web support: не удалось предупредить оператора о несохранённом сообщении (%s)",
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rollback_quietly(db: Session) -> None:
|
||||||
|
"""Откат после `SQLAlchemyError`. Сам rollback на мёртвом соединении тоже
|
||||||
|
может бросить, а мы уже решили отдать клиенту успех — `get_db` в любом
|
||||||
|
случае закроет сессию в `finally`."""
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
logger.warning("web support: rollback после сбоя БД тоже не удался", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_idempotency_key(request: Request, *, identity_key: str, text: str) -> str:
|
||||||
|
"""Ключ идемпотентности inbound-отправки (#3471, миграция 301).
|
||||||
|
|
||||||
|
Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key` —
|
||||||
|
ЕДИНСТВЕННЫЙ надёжный путь: клиент генерирует ключ ОДИН раз на "намерение
|
||||||
|
отправить" и переиспользует его на любом ретрае (fetch retry / переотправка
|
||||||
|
после таймаута) независимо от того, что именно менялось в UI между
|
||||||
|
попытками (см. `useSendSupportMessage` во фронтенде — реальный трафик
|
||||||
|
обязан идти этим путём). Fallback без заголовка — детерминированный
|
||||||
|
отпечаток sha256(identity, текст, минутное окно) — существует ТОЛЬКО для
|
||||||
|
клиентов, которые заголовок не прислали (п.5 требования — старое поведение
|
||||||
|
не должно сломаться), и у него два честных изъяна, оба снимаются самим
|
||||||
|
фактом использования заголовка:
|
||||||
|
1. два РАЗНЫХ по смыслу сообщения с одинаковым текстом от одного и того
|
||||||
|
же человека в течение одной минуты ("да" и ещё раз "да", "+", "ок")
|
||||||
|
СХЛОПНУТСЯ в одно — второе будет молча проглочено, клиент получит id
|
||||||
|
первого, оператор не увидит второе сообщение вообще;
|
||||||
|
2. окно — не скользящие 60 секунд, а округление `time.time() // 60` вниз:
|
||||||
|
фактический срок дедупликации случаен от 0 до 60с в зависимости от
|
||||||
|
момента внутри минуты, и часть настоящих повторов (ретрай ровно на
|
||||||
|
границе окна) эту защиту не получит.
|
||||||
|
Оба пункта перестают быть важны, когда клиент реально шлёт заголовок
|
||||||
|
(см. `useSendSupportMessage`).
|
||||||
|
|
||||||
|
Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ —
|
||||||
|
он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда
|
||||||
|
попадает ТОЛЬКО как вход в sha256, в открытом виде не сохраняется и не
|
||||||
|
возвращается — жёсткое правило проекта "текст не в логах" (ПДн) при этом
|
||||||
|
не нарушается, даже если бы этот ключ где-то залогировали.
|
||||||
|
|
||||||
|
Префиксы `client:`/`auto:` разводят два namespace'а ключей (защита от
|
||||||
|
случайного совпадения клиентского токена с fallback-хэшем) — дёшево и не
|
||||||
|
требует отдельной колонки.
|
||||||
|
"""
|
||||||
|
header = (request.headers.get(_IDEMPOTENCY_HEADER) or "").strip()
|
||||||
|
if header and _CLIENT_IDEMPOTENCY_KEY_RE.match(header):
|
||||||
|
return f"client:{header}"
|
||||||
|
window = int(time.time() // 60)
|
||||||
|
fingerprint = f"{identity_key}|{text}|{window}"
|
||||||
|
return "auto:" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/support/messages", response_model=SupportMessageOut)
|
@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)],
|
||||||
|
|
@ -219,7 +426,47 @@ async def send_support_message(
|
||||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
)
|
)
|
||||||
|
|
||||||
client = TelegramClient(settings.telegram_bot_token)
|
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`): канал только что отказал
|
||||||
|
# N раз подряд — не тратим на этот запрос ещё четыре попытки к Telegram.
|
||||||
|
cooldown = _send_failure_limiter.retry_after(username)
|
||||||
|
if cooldown is not None:
|
||||||
|
raise _too_many_failures_error(cooldown)
|
||||||
|
|
||||||
|
# Идемпотентность (#3471). Что именно закрывает этот pre-check — важно не
|
||||||
|
# переоценить: строка в БД (и её idempotency_key) появляется ТОЛЬКО ПОСЛЕ
|
||||||
|
# успешной доставки в Telegram (H1 ниже), поэтому потерю самого запроса на
|
||||||
|
# плече Selectel -> api.telegram.org этот механизм НЕ дедуплицирует — если
|
||||||
|
# `send_message` не удался, ключ нигде не записан, и повтор клиента после
|
||||||
|
# неудачи это законная первая попытка. Реально закрывается другой, тоже
|
||||||
|
# частый случай: доставка УЖЕ состоялась (Telegram принял, строка
|
||||||
|
# закоммичена), но ответ до клиента не дошёл (обрыв на обратном пути,
|
||||||
|
# клиентский таймаут) или пользователь кликнул "отправить" второй раз по
|
||||||
|
# той же ещё не отрисовавшейся отправке — тогда pre-check находит уже
|
||||||
|
# записанное сообщение по ключу и отдаёт его же, не уходя в Telegram снова.
|
||||||
|
# Тред может ещё не существовать (это первая попытка этого ключа) — тогда
|
||||||
|
# сравнивать не с чем, идём в Telegram как обычно; сам факт "нет треда"
|
||||||
|
# здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке (см. H1) —
|
||||||
|
# если бы сообщение под этим ключом уже было записано, тред уже был бы.
|
||||||
|
idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text)
|
||||||
|
existing_thread_id = storage.find_thread_id(db, username)
|
||||||
|
if existing_thread_id is not None:
|
||||||
|
existing = storage.find_inbound_by_idempotency_key(
|
||||||
|
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
logger.info(
|
||||||
|
"web support: repeat send (idempotency key already recorded) "
|
||||||
|
"username=%s thread_id=%d message_id=%s",
|
||||||
|
username,
|
||||||
|
existing_thread_id,
|
||||||
|
existing["id"],
|
||||||
|
)
|
||||||
|
return SupportMessageOut(**existing)
|
||||||
|
|
||||||
|
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
|
||||||
|
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
||||||
|
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
||||||
|
client = get_telegram_client()
|
||||||
try:
|
try:
|
||||||
mirrored = await client.send_message(
|
mirrored = await client.send_message(
|
||||||
chat_id=settings.telegram_support_chat_id,
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
|
@ -230,16 +477,22 @@ async def send_support_message(
|
||||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
||||||
)
|
)
|
||||||
except TelegramApiError:
|
except TelegramError:
|
||||||
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
|
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
|
||||||
# TelegramApiError и не бывает — см. client.py docstring про redaction).
|
# TelegramApiError и не бывает — см. client.py docstring про redaction).
|
||||||
|
# Предок, а не `TelegramApiError`: при таймауте до Telegram пользователь
|
||||||
|
# должен увидеть тот же «сервис недоступен», а не 500 (#3456).
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"web support: не удалось отправить зеркало в топик (username=%s)", username
|
"web support: не удалось отправить зеркало в топик (username=%s)", username
|
||||||
)
|
)
|
||||||
|
_send_failure_limiter.record(username)
|
||||||
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||||
|
|
||||||
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
|
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
|
||||||
_send_limiter.record(username)
|
_send_limiter.record(username)
|
||||||
|
SUPPORT_MESSAGES.labels(channel="web").inc()
|
||||||
|
# Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы.
|
||||||
|
_send_failure_limiter.reset(username)
|
||||||
|
|
||||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||||
if topic_message_id is None:
|
if topic_message_id is None:
|
||||||
|
|
@ -253,15 +506,35 @@ async def send_support_message(
|
||||||
)
|
)
|
||||||
|
|
||||||
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
|
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
|
||||||
thread_id = storage.get_or_create_thread(db, username)
|
#
|
||||||
row = storage.record_inbound(
|
# Оборотная сторона этого порядка: отказ БД здесь означает, что сообщение
|
||||||
db,
|
# оператору УЖЕ доставлено. Отдавать на это 500 (как было) — худший из
|
||||||
thread_id=thread_id,
|
# вариантов: клиент видит ошибку, шлёт повторно, в топике дубль, а на
|
||||||
text_body=payload.text,
|
# осиротевшее зеркало оператор отвечает в пустоту. Поэтому отвечаем успехом
|
||||||
topic_message_id=topic_message_id,
|
# (доставка правда состоялась) и отдельным сообщением предупреждаем
|
||||||
support_chat_id=settings.telegram_support_chat_id,
|
# оператора, что отвечать на это зеркало бесполезно.
|
||||||
)
|
try:
|
||||||
db.commit()
|
thread_id = storage.get_or_create_thread(db, username)
|
||||||
|
row = storage.record_inbound(
|
||||||
|
db,
|
||||||
|
thread_id=thread_id,
|
||||||
|
text_body=payload.text,
|
||||||
|
topic_message_id=topic_message_id,
|
||||||
|
support_chat_id=settings.telegram_support_chat_id,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
# Ни текста обращения (ПДн), ни токена в логе — только кто и что сломалось.
|
||||||
|
logger.exception(
|
||||||
|
"web support: сообщение доставлено в топик, но не записано в БД (username=%s)",
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
_rollback_quietly(db)
|
||||||
|
await _warn_operator_message_not_recorded(
|
||||||
|
label=f"[С САЙТА] {username}", topic_message_id=topic_message_id
|
||||||
|
)
|
||||||
|
return _unpersisted_message_out(payload.text)
|
||||||
|
|
||||||
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
|
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
|
||||||
return SupportMessageOut(**row)
|
return SupportMessageOut(**row)
|
||||||
|
|
@ -408,8 +681,46 @@ async def send_anon_support_message(
|
||||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`) — оба ключа, как и у
|
||||||
|
# бюджетов выше: per-token ловит одну вкладку с авто-ретраем, per-IP — ту же
|
||||||
|
# петлю после сброса куки.
|
||||||
|
for cooldown in (
|
||||||
|
_send_failure_limiter.retry_after(thread_key),
|
||||||
|
_anon_ip_failure_limiter.retry_after(ip),
|
||||||
|
):
|
||||||
|
if cooldown is not None:
|
||||||
|
raise _too_many_failures_error(cooldown)
|
||||||
|
|
||||||
display_id = _anon_display_id(token)
|
display_id = _anon_display_id(token)
|
||||||
client = TelegramClient(settings.telegram_bot_token)
|
|
||||||
|
# Идемпотентность (#3471) — см. развёрнутый комментарий в авторизованной
|
||||||
|
# ветке. Ограничение специфично для анонимной ветки: если предыдущая
|
||||||
|
# попытка сама провалилась ДО выдачи куки (`is_new_token=True` тогда и
|
||||||
|
# сейчас), identity_key каждый раз новый (случайный токен) и pre-check
|
||||||
|
# структурно не может найти прошлую попытку — это тот же класс проблемы,
|
||||||
|
# что и потеря куки в сети, вне scope этой правки.
|
||||||
|
idempotency_key = _resolve_idempotency_key(request, identity_key=thread_key, text=payload.text)
|
||||||
|
existing_thread_id = storage.find_thread_id(db, thread_key)
|
||||||
|
if existing_thread_id is not None:
|
||||||
|
existing = storage.find_inbound_by_idempotency_key(
|
||||||
|
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
logger.info(
|
||||||
|
"web support (anon): repeat send (idempotency key already recorded) "
|
||||||
|
"%s thread_id=%d message_id=%s",
|
||||||
|
display_id,
|
||||||
|
existing_thread_id,
|
||||||
|
existing["id"],
|
||||||
|
)
|
||||||
|
if is_new_token:
|
||||||
|
_set_anon_cookie(response, token)
|
||||||
|
return SupportMessageOut(**existing)
|
||||||
|
|
||||||
|
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
|
||||||
|
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
||||||
|
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
||||||
|
client = get_telegram_client()
|
||||||
try:
|
try:
|
||||||
mirrored = await client.send_message(
|
mirrored = await client.send_message(
|
||||||
chat_id=settings.telegram_support_chat_id,
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
|
@ -419,15 +730,22 @@ async def send_anon_support_message(
|
||||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
||||||
)
|
)
|
||||||
except TelegramApiError:
|
except TelegramError:
|
||||||
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
|
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
|
||||||
|
# Про предок вместо `TelegramApiError` — см. комментарий в парной ручке.
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
|
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
|
||||||
)
|
)
|
||||||
|
_send_failure_limiter.record(thread_key)
|
||||||
|
_anon_ip_failure_limiter.record(ip)
|
||||||
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||||
|
|
||||||
_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)
|
||||||
|
_anon_ip_failure_limiter.reset(ip)
|
||||||
|
|
||||||
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
|
||||||
if topic_message_id is None:
|
if topic_message_id is None:
|
||||||
|
|
@ -437,15 +755,37 @@ async def send_anon_support_message(
|
||||||
display_id,
|
display_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
thread_id = storage.get_or_create_thread(db, thread_key)
|
# Отказ БД после успешной отправки — см. развёрнутый комментарий в парной
|
||||||
row = storage.record_inbound(
|
# (авторизованной) ручке: сообщение оператору уже доставлено, 500 тут создаёт
|
||||||
db,
|
# дубли в топике и «осиротевшее» зеркало, на которое оператор отвечает зря.
|
||||||
thread_id=thread_id,
|
try:
|
||||||
text_body=payload.text,
|
thread_id = storage.get_or_create_thread(db, thread_key)
|
||||||
topic_message_id=topic_message_id,
|
row = storage.record_inbound(
|
||||||
support_chat_id=settings.telegram_support_chat_id,
|
db,
|
||||||
)
|
thread_id=thread_id,
|
||||||
db.commit()
|
text_body=payload.text,
|
||||||
|
topic_message_id=topic_message_id,
|
||||||
|
support_chat_id=settings.telegram_support_chat_id,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
# Ни текста обращения (ПДн), ни токена (bearer треда) в логе — только
|
||||||
|
# несекретный ярлык треда.
|
||||||
|
logger.exception(
|
||||||
|
"web support (anon): сообщение доставлено в топик, но не записано в БД (%s)",
|
||||||
|
display_id,
|
||||||
|
)
|
||||||
|
_rollback_quietly(db)
|
||||||
|
await _warn_operator_message_not_recorded(
|
||||||
|
label=f"[С САЙТА · БЕЗ ВХОДА] {display_id}", topic_message_id=topic_message_id
|
||||||
|
)
|
||||||
|
# Куку ставим ВСЁ РАВНО: тред в БД не создан, но идентичность посетителя
|
||||||
|
# обязана пережить этот сбой — иначе следующее сообщение (когда база
|
||||||
|
# поднимется) заведёт ВТОРОЙ тред, и переписка разъедется на два.
|
||||||
|
if is_new_token:
|
||||||
|
_set_anon_cookie(response, token)
|
||||||
|
return _unpersisted_message_out(payload.text)
|
||||||
|
|
||||||
if is_new_token:
|
if is_new_token:
|
||||||
_set_anon_cookie(response, token)
|
_set_anon_cookie(response, token)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ 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,
|
||||||
|
|
@ -79,8 +80,10 @@ _estimate_limiter = SlidingWindowLimiter(
|
||||||
# внешние тиры; пила одновременных оценок выедает пул и тормозит весь /api/v1/*.
|
# внешние тиры; пила одновременных оценок выедает пул и тормозит весь /api/v1/*.
|
||||||
# Образец — public/mera.py::_suggest_slots (4 слота на секундное автодополнение).
|
# Образец — public/mera.py::_suggest_slots (4 слота на секундное автодополнение).
|
||||||
#
|
#
|
||||||
# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений
|
# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений.
|
||||||
# из 15 возможных, остаток пула остаётся прочим ручкам. Ожидание слота 5с ≈ две
|
# Пул под это заведомо шире: 5+15=20 на процесс, и потолок пула держится не
|
||||||
|
# меньше СУММЫ объявленных потолков одновременности, включая 8 фоновых догрузок
|
||||||
|
# (core/db.py + tests/test_3408_pool_ceiling.py, #3408). Ожидание слота 5с ≈ две
|
||||||
# длительности оценки: если за это время слот не освободился, очередь глубока и
|
# длительности оценки: если за это время слот не освободился, очередь глубока и
|
||||||
# честный ответ — быстрый 429 с Retry-After, а не растущая очередь (очередь под
|
# честный ответ — быстрый 429 с Retry-After, а не растущая очередь (очередь под
|
||||||
# нагрузкой — те же занятые соединения плюс таймаут у клиента; mera.py:117-127).
|
# нагрузкой — те же занятые соединения плюс таймаут у клиента; mera.py:117-127).
|
||||||
|
|
@ -558,6 +561,13 @@ 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.
|
||||||
|
|
@ -1011,6 +1021,7 @@ 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,
|
||||||
|
|
@ -2185,8 +2196,11 @@ def get_street_deals(
|
||||||
"""ДКП-сделки Росреестра по улице целевого адреса.
|
"""ДКП-сделки Росреестра по улице целевого адреса.
|
||||||
|
|
||||||
Open dataset Росреестра агрегирует адреса до улицы (без номера дома).
|
Open dataset Росреестра агрегирует адреса до улицы (без номера дома).
|
||||||
Поэтому это per-street view, не per-house. Фильтр по rooms + area
|
Поэтому это per-street view, не per-house. До квартир-аналогов выборку
|
||||||
сужает выборку до квартир-аналогов.
|
сужает полоса площади ±area_tolerance; комнатность клиента в фильтр НЕ
|
||||||
|
входит — `deals.rooms` не комнатность, а синтетика из той же площади
|
||||||
|
(#3256, см. estimator._fetch_dkp_corridor). `rooms` остаётся параметром
|
||||||
|
ручки: он описывает запрос и попадает в лог, но не в WHERE.
|
||||||
|
|
||||||
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
|
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
|
||||||
в import-rosreestr.sh).
|
в import-rosreestr.sh).
|
||||||
|
|
@ -2196,6 +2210,7 @@ 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)
|
||||||
|
|
@ -2239,6 +2254,17 @@ 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(
|
||||||
|
|
@ -2250,7 +2276,11 @@ 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 rooms = CAST(:rooms AS integer)
|
AND region_code = CAST(:region_code AS integer)
|
||||||
|
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
|
||||||
|
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
|
||||||
|
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто —
|
||||||
|
-- в комментарии estimator._fetch_dkp_corridor.
|
||||||
AND area_m2 BETWEEN :area_min AND :area_max
|
AND area_m2 BETWEEN :area_min AND :area_max
|
||||||
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
|
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
|
||||||
AND price_rub > 0
|
AND price_rub > 0
|
||||||
|
|
@ -2261,7 +2291,7 @@ 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,
|
||||||
"rooms": rooms,
|
"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,
|
||||||
|
|
@ -2272,12 +2302,16 @@ def get_street_deals(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
|
# #3256: лог называет ТОТ ключ, которым искали. Комнатность клиента в
|
||||||
|
# выборку не входит (deals.rooms — синтетика из площади), поэтому она
|
||||||
|
# печатается как контекст запроса, а не как параметр фильтра.
|
||||||
logger.info(
|
logger.info(
|
||||||
"street-deals: no rows found street=%r rooms=%d area=%.1f±%.0f%%",
|
"street-deals: no rows found street=%r area=%.1f±%.0f%% "
|
||||||
|
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
|
||||||
street_name,
|
street_name,
|
||||||
rooms,
|
|
||||||
area_m2,
|
area_m2,
|
||||||
area_tolerance * 100,
|
area_tolerance * 100,
|
||||||
|
rooms,
|
||||||
)
|
)
|
||||||
return StreetDealsResponse(
|
return StreetDealsResponse(
|
||||||
street=street_name,
|
street=street_name,
|
||||||
|
|
@ -2305,12 +2339,14 @@ def get_street_deals(
|
||||||
top10 = [_deal_to_analog(dict(r)) for r in rows[:10]]
|
top10 = [_deal_to_analog(dict(r)) for r in rows[:10]]
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"street-deals: street=%r rooms=%d area=%.1f count=%d median_ppm2=%.0f",
|
"street-deals: street=%r area=%.1f±%.0f%% count=%d median_ppm2=%.0f "
|
||||||
|
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
|
||||||
street_name,
|
street_name,
|
||||||
rooms,
|
|
||||||
area_m2,
|
area_m2,
|
||||||
|
area_tolerance * 100,
|
||||||
count,
|
count,
|
||||||
median_ppm2,
|
median_ppm2,
|
||||||
|
rooms,
|
||||||
)
|
)
|
||||||
|
|
||||||
return StreetDealsResponse(
|
return StreetDealsResponse(
|
||||||
|
|
@ -2475,8 +2511,12 @@ def get_sales_vs_listings(
|
||||||
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
|
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
|
||||||
|
|
||||||
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
|
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
|
||||||
matching listing на той же улице с такими же rooms / близкой area_m2 /
|
matching listing на той же улице с близкой area_m2 / listing_date в окне
|
||||||
listing_date в окне [deal_date - window_days, deal_date + 30d grace].
|
[deal_date - window_days, deal_date + 30d grace]. Комнатность в ключе стоит
|
||||||
|
ТОЛЬКО на стороне объявлений (`l.rooms = p_rooms`, комнатность клиента): у
|
||||||
|
сделок Росреестра `rooms` — синтетика из площади, предикат по ней снят
|
||||||
|
миграцией 300 (#3451/#3256). Поэтому `deal_rooms` в паре может не совпадать
|
||||||
|
с запрошенным `rooms`.
|
||||||
|
|
||||||
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
|
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
|
||||||
чтобы вычислить linkage_rate.
|
чтобы вычислить linkage_rate.
|
||||||
|
|
@ -2486,7 +2526,12 @@ def get_sales_vs_listings(
|
||||||
|
|
||||||
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
|
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
|
||||||
"""
|
"""
|
||||||
from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
|
from app.services.estimator import (
|
||||||
|
_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(
|
||||||
|
|
@ -2516,16 +2561,36 @@ 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
|
||||||
deal_id, deal_date, deal_price_rub, deal_price_per_m2,
|
sv.deal_id, sv.deal_date, sv.deal_price_rub, sv.deal_price_per_m2,
|
||||||
deal_area_m2, deal_rooms, deal_floor, deal_address,
|
sv.deal_area_m2, sv.deal_rooms, sv.deal_floor, sv.deal_address,
|
||||||
listing_id, listing_source, listing_source_url,
|
sv.listing_id, sv.listing_source, sv.listing_source_url,
|
||||||
listing_date, listing_price_rub, listing_price_per_m2,
|
sv.listing_date, sv.listing_price_rub, sv.listing_price_per_m2,
|
||||||
listing_area_m2, days_listing_to_deal, discount_pct
|
sv.listing_area_m2, sv.days_listing_to_deal, sv.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),
|
||||||
|
|
@ -2534,7 +2599,9 @@ 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)
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|
@ -2545,6 +2612,7 @@ 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()
|
||||||
|
|
@ -2776,15 +2844,206 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
|
||||||
COVERAGE_GREEN_MIN_N = 8
|
COVERAGE_GREEN_MIN_N = 8
|
||||||
COVERAGE_YELLOW_MIN_N = 12
|
COVERAGE_YELLOW_MIN_N = 12
|
||||||
|
|
||||||
|
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
|
||||||
|
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
|
||||||
|
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
|
||||||
|
# тестом test_offered_cities_match_coverage_cities), а Москвы в том дропдауне
|
||||||
|
# нет и быть не должно — там выбирают город Свердловской области. Порог при
|
||||||
|
# этом нужен, и живёт он здесь, в том же _COVERAGE_CITY_THRESHOLDS.
|
||||||
|
#
|
||||||
|
# Тир — ЖЁЛТЫЙ (12), не зелёный. Замер на проде 10.09.2026: 200 случайных
|
||||||
|
# московских объявлений, когорта считалась тем же предикатом, что сама проба
|
||||||
|
# (см. SQL в coverage_probe ниже) — медиана когорты 14, p25 = 8, доля точек с
|
||||||
|
# когортой >= 12 равна 0.57, с когортой >= 8 равна 0.77. Контроли той же
|
||||||
|
# метрикой: Екатеринбург (зелёный, порог 8) — медиана 37, доля >= 12 равна
|
||||||
|
# 0.865; Нижний Тагил (жёлтый, порог 12) — медиана 11, доля >= 12 равна 0.473.
|
||||||
|
# То есть по плотности когорты Москва втрое реже зелёного эталона и стоит
|
||||||
|
# вплотную к жёлтому: с порогом 8 «ok» получали бы 77% адресов, но за этим «ok»
|
||||||
|
# стояла бы заметно менее надёжная оценка. Плюс модельная сторона для Москвы
|
||||||
|
# ещё не откалибрована (время правится свердловским рядом СберИндекса, полоса
|
||||||
|
# цен одна на весь город), так что 43% адресов честнее показать как thin.
|
||||||
|
COVERAGE_MOSCOW_MIN_N = COVERAGE_YELLOW_MIN_N
|
||||||
|
|
||||||
|
# Ключ центроида и display-имя города — РАЗНЫЕ вещи, и здесь это видно яснее
|
||||||
|
# всего: у Москвы 67 центроидов (сетка, см. `_MOSCOW_GRID_DEG` ниже), а наружу,
|
||||||
|
# в поле `city` ответа, обязано уходить одно имя «Москва» и один порог. Иначе
|
||||||
|
# повторится ровно тот баг, что был с Берёзовским: человек получает в ответе
|
||||||
|
# название района вместо своего города.
|
||||||
|
COVERAGE_MOSCOW_DISPLAY = "Москва"
|
||||||
|
|
||||||
|
# СЕТКА МОСКОВСКИХ ЦЕНТРОИДОВ (11.09.2026).
|
||||||
|
#
|
||||||
|
# Откуда координаты. Это не справочник районов и не ручной подбор: точки
|
||||||
|
# получены кластеризацией НАШЕГО ЖЕ корпуса объявлений средствами PostGIS —
|
||||||
|
# `ST_ClusterKMeans(ST_Transform(geom, 32637), K) OVER ()` по 35 552 активным
|
||||||
|
# вторичным объявлениям region_code=77 (geom заполнена у 100%), центроид
|
||||||
|
# кластера = `ST_Centroid(ST_Collect(...))` в UTM 37N, округление до 4 знаков.
|
||||||
|
# Поэтому точки стоят там, где реально живут объявления, а не там, где на карте
|
||||||
|
# нарисован центр района. Подписи в ключах — человеческие ярлыки для читаемости
|
||||||
|
# (ближайший район/поселение), на резолв они не влияют никак.
|
||||||
|
#
|
||||||
|
# Почему точек 67. Кластеризация дала K=56: порог качества был «меньше 2%
|
||||||
|
# московских объявлений уходит к подмосковному центроиду», свип по K на тех же
|
||||||
|
# данных — 24 → 6.06%, 30 → 4.02%, 36 → 3.67%, 42 → 3.46%, 48 → 2.23%,
|
||||||
|
# 54 → 2.12%, 56 → 1.79%, 60 → 1.64%, 75 → 0.84%, 90 → 0.45%, и K=56 — первое
|
||||||
|
# значение, берущее 2%. Из этих 56 один кластер выброшен как артефакт геокода,
|
||||||
|
# и 12 точек добавлены вручную по замеру 11.09.2026 (жилые районы, целиком
|
||||||
|
# проигрывавшие конкурс подмосковной точке): 56 − 1 + 12 = 67.
|
||||||
|
#
|
||||||
|
# Выброшенный призрак. «Москва (Восточный, эксклав)» 56.0087/37.7960: n=1,
|
||||||
|
# отрыв от остальной сетки 17.9 км. Адрес объявления — «ВАО, р-н Восточный, 4»,
|
||||||
|
# а настоящий район Восточный лежит на 55.81/37.85: геокодер промахнулся
|
||||||
|
# на 22 км. Точка стояла в 3.06 км от Пушкино и раздавала ему имя «Москва».
|
||||||
|
# Взамен добавлена «Москва (Восточный)» по фактическому центру девяти
|
||||||
|
# объявлений района. Проверены ВСЕ кластеры с n<60 либо изоляцией >12 км
|
||||||
|
# (9 штук, адреса подняты с прода) — артефакт ровно один. Второй кандидат,
|
||||||
|
# «Москва (юг ТАО)» 55.2643/37.1033 (n=1, адрес без улицы и дома =
|
||||||
|
# settlement-fallback геокодера), ОСТАВЛЕН намеренно: точка стоит внутри ТАО,
|
||||||
|
# при радиусе 8 км не достаёт ни до одного города области, а снос стоил бы
|
||||||
|
# покрытия анклава.
|
||||||
|
#
|
||||||
|
# Где проходит граница с областью. Двумя механизмами сразу, и нужны оба.
|
||||||
|
# (1) Конкурс ближайшего центроида: 32 подмосковные точки
|
||||||
|
# (`_COVERAGE_NEGATIVE_CENTROIDS_DEG` ниже) участвуют в поиске ближайшего, но
|
||||||
|
# порога не имеют, поэтому адрес, для которого выиграл подмосковный центр,
|
||||||
|
# получает «город не определён». (2) Радиус, который теперь СВОЙСТВО ЦЕНТРОИДА
|
||||||
|
# (`_CENTROID_RADIUS_KM` ниже), а не одна константа на всю страну: у московских
|
||||||
|
# точек он 8 км, у свердловских остались прежние 25.
|
||||||
|
#
|
||||||
|
# Почему радиус пришлось сделать свойством точки. 25 км были рассчитаны на ОДИН
|
||||||
|
# центроид в центре города — круг вокруг Кремля. У плотной сетки круги
|
||||||
|
# СКЛАДЫВАЮТСЯ, и объединение 67 кругов по 25 км — это уже не «Москва с
|
||||||
|
# запасом», а пятно, накрывающее половину области. Наро-Фоминск, Кубинка и Чехов
|
||||||
|
# резолвились как «Москва» именно так: до ближайшей точки сетки им 9.83, 17.18
|
||||||
|
# и 22.25 км (расстояния посчитаны _haversine_km этого же модуля, сфера
|
||||||
|
# R=6371 — замер на эллипсоиде PostGIS даёт на 0.1-0.5 км больше), то есть
|
||||||
|
# внутрь общего круга они попадали, а собственной
|
||||||
|
# отрицательной точки у них нет. Прежнее утверждение в этом месте — будто
|
||||||
|
# радиус 25 км «больше не ограничение, запас четырёхкратный» — было ложным:
|
||||||
|
# ограничение не исчезло, оно поменяло знак. Раньше радиус резал покрытие
|
||||||
|
# изнутри (адрес дальше 25 км от Кремля терял город), теперь протекал наружу.
|
||||||
|
#
|
||||||
|
# Откуда 8 км. Свип по радиусу на итоговой сетке, тот же корпус (35 552
|
||||||
|
# активных вторичных объявления, region_code=77): R = 5, 6, 7 и 8 дают
|
||||||
|
# ОДИНАКОВЫЙ результат — 0.00% объявлений вне радиуса, 29/29 московских
|
||||||
|
# контрольных адресов резолвятся в «Москву», 32/32 областных дают «не
|
||||||
|
# определён». Верхний край плато жёсткий: при R=10 внутрь входит Наро-Фоминск
|
||||||
|
# (9.83 км — город БЕЗ отрицательной точки), при R=12 — ещё Голицыно и
|
||||||
|
# Лыткарино. Берём верхний край: 8 км — это 3.5 км запаса на адреса вне
|
||||||
|
# корпуса (худший московский контроль — Рублёво, 4.44 км до сетки) и всё ещё
|
||||||
|
# на 1.83 км ниже потолка 9.83. Брать меньше нечем оправдать: по корпусу
|
||||||
|
# выигрыша нет, а запас на дырки в сетке тает.
|
||||||
|
#
|
||||||
|
# Остаточная цена. 48 московских объявлений из 35 552 = 0.14% (на прежней
|
||||||
|
# конфигурации — 636 = 1.79%) всё ещё выигрываются подмосковным центром:
|
||||||
|
# Молжаниновский 15, Реутов 8, Подольск 7, Долгопрудный 7, Мытищи 7,
|
||||||
|
# Красногорск 3, Королёв 1. Крупнейший кусок снимается 13-й точкой
|
||||||
|
# «Молжаниновский» 55.9480/37.3478 (0.14% → 0.09%) — она в замере посчитана,
|
||||||
|
# но в список не внесена, чтобы конфигурация здесь совпадала с той, на которой
|
||||||
|
# прогнаны оба контрольных набора. Одно объявление не берётся ничем:
|
||||||
|
# 56.0087/37.7960, тот самый кривой геокод, — лечится перегеокодированием
|
||||||
|
# строки адреса, а не сеткой.
|
||||||
|
#
|
||||||
|
# Не перегенерируйте кластеризацию перед правкой: ST_ClusterKMeans без сида
|
||||||
|
# недетерминирован, разброс между прогонами ±0.2 п.п. Список ниже перепроверен
|
||||||
|
# отдельным прогоном именно в том виде, в каком он здесь записан.
|
||||||
|
_MOSCOW_GRID_DEG: dict[str, tuple[float, float]] = {
|
||||||
|
"Москва (Рогово, ТАО)": (55.2146, 37.0695),
|
||||||
|
"Москва (юг ТАО)": (55.2643, 37.1033),
|
||||||
|
"Москва (Вороновское, ТАО)": (55.3159, 37.1759),
|
||||||
|
"Москва (Кленовское, ТАО)": (55.3343, 37.3299),
|
||||||
|
"Москва (ТАО у Подольска)": (55.3569, 37.4010),
|
||||||
|
"Москва (Шишкин Лес, ТАО)": (55.4202, 37.1687),
|
||||||
|
"Москва (Щапово, ТАО)": (55.4212, 37.3979),
|
||||||
|
"Москва (Новофёдоровское, ТАО)": (55.4307, 36.8675),
|
||||||
|
"Москва (Троицк, юг)": (55.4568, 37.2867),
|
||||||
|
"Москва (Остафьево, НАО)": (55.4760, 37.5249),
|
||||||
|
"Москва (Киевский, ТАО)": (55.4884, 36.9199),
|
||||||
|
"Москва (Троицк)": (55.4953, 37.3150),
|
||||||
|
"Москва (Щербинка, НАО)": (55.5046, 37.5578),
|
||||||
|
"Москва (Ватутинки, НАО)": (55.5187, 37.3605),
|
||||||
|
"Москва (Первомайское, ТАО)": (55.5405, 37.1599),
|
||||||
|
"Москва (Коммунарка, НАО)": (55.5474, 37.4927),
|
||||||
|
"Москва (Филимонковское, НАО)": (55.5568, 37.3188),
|
||||||
|
"Москва (Северное Бутово)": (55.5615, 37.5697),
|
||||||
|
"Москва (Саларьево, НАО)": (55.5862, 37.4568),
|
||||||
|
"Москва (Марушкино, НАО)": (55.5933, 37.1658),
|
||||||
|
"Москва (Бирюлёво Западное)": (55.5945, 37.6447),
|
||||||
|
"Москва (Внуковское, НАО)": (55.6030, 37.3796),
|
||||||
|
"Москва (Ясенево)": (55.6138, 37.5535),
|
||||||
|
"Москва (Зябликово)": (55.6225, 37.7303),
|
||||||
|
"Москва (Ново-Переделкино)": (55.6317, 37.3215),
|
||||||
|
"Москва (Нагорный)": (55.6425, 37.6245),
|
||||||
|
"Москва (Солнцево)": (55.6455, 37.3966),
|
||||||
|
"Москва (Тропарёво-Никулино)": (55.6576, 37.4684),
|
||||||
|
"Москва (Люблино)": (55.6696, 37.7461),
|
||||||
|
"Москва (Обручевский)": (55.6711, 37.5432),
|
||||||
|
"Москва (Даниловский)": (55.6921, 37.6397),
|
||||||
|
"Москва (Некрасовка)": (55.7033, 37.9093),
|
||||||
|
"Москва (Раменки)": (55.7048, 37.4870),
|
||||||
|
"Москва (Кузьминки)": (55.7143, 37.7723),
|
||||||
|
"Москва (Кунцево)": (55.7281, 37.4239),
|
||||||
|
"Москва (Якиманка)": (55.7290, 37.6055),
|
||||||
|
"Москва (Таганский)": (55.7370, 37.6845),
|
||||||
|
"Москва (Пресненский)": (55.7486, 37.5282),
|
||||||
|
"Москва (Новогиреево)": (55.7545, 37.8107),
|
||||||
|
"Москва (Тверской)": (55.7655, 37.6038),
|
||||||
|
"Москва (Хорошёво-Мнёвники)": (55.7657, 37.4668),
|
||||||
|
"Москва (Преображенское)": (55.7886, 37.7045),
|
||||||
|
"Москва (Хорошёвский)": (55.7951, 37.5373),
|
||||||
|
"Москва (Строгино)": (55.7982, 37.3859),
|
||||||
|
"Москва (Северное Измайлово)": (55.8071, 37.7881),
|
||||||
|
"Москва (Останкинский)": (55.8086, 37.6174),
|
||||||
|
"Москва (Покровское-Стрешнево)": (55.8203, 37.4470),
|
||||||
|
"Москва (Митино)": (55.8467, 37.3877),
|
||||||
|
"Москва (Отрадное)": (55.8558, 37.5796),
|
||||||
|
"Москва (Ховрино)": (55.8581, 37.4924),
|
||||||
|
"Москва (Бабушкинский)": (55.8633, 37.6740),
|
||||||
|
"Москва (Лианозово)": (55.8944, 37.5622),
|
||||||
|
"Москва (Куркино — Молжаниново)": (55.9169, 37.3837),
|
||||||
|
"Москва (Зеленоград, юг)": (55.9793, 37.1620),
|
||||||
|
"Москва (Зеленоград, север)": (55.9958, 37.2147),
|
||||||
|
# Кластер «Москва (Восточный, эксклав)» (56.0087, 37.7960) ВЫБРОШЕН
|
||||||
|
# 11.09.2026 как артефакт геокода — см. «Выброшенный призрак» выше.
|
||||||
|
#
|
||||||
|
# Ниже — 12 точек, добавленных тем же замером вручную: жилые районы
|
||||||
|
# Москвы, которые целиком проигрывали конкурс ближайшего подмосковной
|
||||||
|
# точке. В комментарии — сколько объявлений возвращает точка и кому они
|
||||||
|
# уходили (прод-корпус, is_active AND region_code=77).
|
||||||
|
"Москва (Восточный)": (55.8194, 37.8740), # +9, уходили к Балашихе
|
||||||
|
"Москва (Левобережный)": (55.8713, 37.4592), # +91, к Химкам
|
||||||
|
"Москва (Орехово-Борисово Южное)": (55.5986, 37.7233), # +80, к Развилке
|
||||||
|
"Москва (Северный)": (55.9278, 37.5411), # +79, к Долгопрудному
|
||||||
|
"Москва (Митино-запад)": (55.8410, 37.3507), # +78, к Красногорску
|
||||||
|
"Москва (Ивановское)": (55.7717, 37.8326), # +65, к Реутову
|
||||||
|
"Москва (Жулебино)": (55.6885, 37.8521), # +62, к Люберцам
|
||||||
|
"Москва (Можайский-запад)": (55.7008, 37.3936), # +50, к Немчиновке
|
||||||
|
"Москва (Новокосино)": (55.7385, 37.8576), # +44, к Реутову
|
||||||
|
"Москва (Капотня)": (55.6341, 37.7981), # +29, к Дзержинскому
|
||||||
|
"Москва (Куркино)": (55.8895, 37.3980), # +20, к Химкам
|
||||||
|
"Москва (Некрасовка-восток)": (55.6838, 37.9216), # +13, к Люберцам
|
||||||
|
}
|
||||||
|
|
||||||
|
# Все ключи сетки — один display «Москва» и один порог. Кортеж выводится из
|
||||||
|
# самого словаря, чтобы список ключей физически не мог разойтись с сеткой.
|
||||||
|
COVERAGE_MOSCOW_CENTROID_KEYS = tuple(_MOSCOW_GRID_DEG)
|
||||||
|
|
||||||
|
|
||||||
def _fold_city(name: str) -> str:
|
def _fold_city(name: str) -> str:
|
||||||
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
|
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
|
||||||
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
|
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
|
||||||
|
|
||||||
|
|
||||||
|
# Ключ — folded ИМЯ ЦЕНТРОИДА, значение — (display-имя города, порог). Для
|
||||||
|
# свердловских городов ключ и display совпадают, для московских центроидов —
|
||||||
|
# нет (все 67 ключей сетки дают «Москва»).
|
||||||
_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(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
|
||||||
|
for k in COVERAGE_MOSCOW_CENTROID_KEYS
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
|
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
|
||||||
|
|
@ -2807,7 +3066,15 @@ _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
||||||
# статичного справочника из 8 географических центров населённых пунктов —
|
# статичного справочника из 8 географических центров населённых пунктов —
|
||||||
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
|
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
|
||||||
# рядом с порогами, которые они резолвят.
|
# рядом с порогами, которые они резолвят.
|
||||||
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
|
# Радиусы матчинга. Радиус — СВОЙСТВО ЦЕНТРОИДА (таблица `_CENTROID_RADIUS_KM`
|
||||||
|
# ниже, применяется в `_resolve_coverage_city`), потому что 25 км осмысленны
|
||||||
|
# ровно для конфигурации «один центроид на город», а у Москвы центроидов 67 и
|
||||||
|
# их круги складываются — разбор в комментарии над `_MOSCOW_GRID_DEG`.
|
||||||
|
# Свердловские точки радиуса не меняли: у каждой из них один центр на город,
|
||||||
|
# складываться нечему, а замер, которым выбраны эти 25 км, к сетке отношения
|
||||||
|
# не имеет.
|
||||||
|
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дефолт: один центроид на город, дальше — not_covered
|
||||||
|
COVERAGE_MOSCOW_MATCH_RADIUS_KM = 8.0 # сетка из 67 точек; верхний край плато 5..8, потолок 9.83
|
||||||
|
|
||||||
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||||
"Екатеринбург": (56.8389, 60.6057),
|
"Екатеринбург": (56.8389, 60.6057),
|
||||||
|
|
@ -2819,8 +3086,99 @@ _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),
|
||||||
|
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
|
||||||
|
# Как сетка получена, почему точек именно столько, где проходит граница
|
||||||
|
# с областью и какова остаточная цена — см. большой комментарий над
|
||||||
|
# `_MOSCOW_GRID_DEG`. Здесь только подстановка: координаты и ключи живут
|
||||||
|
# в одном месте, чтобы их нельзя было рассинхронизировать.
|
||||||
|
**_MOSCOW_GRID_DEG,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ОТРИЦАТЕЛЬНЫЕ центроиды: подмосковные города и посёлки, попадающие внутрь
|
||||||
|
# радиуса московских точек. Список 11.09.2026 перепроверен на итоговой
|
||||||
|
# конфигурации (67 точек сетки, радиус 8 км): все 32 точки живы — ни одну
|
||||||
|
# не перехватывает московский центроид, в самой точке выигрывает она сама,
|
||||||
|
# и ответ остаётся «город не определён». Внутрь 8 км от какой-нибудь точки
|
||||||
|
# сетки попадают 13 из них (Химки, Реутов, Люберцы, Котельники, Дзержинский,
|
||||||
|
# Красногорск, Долгопрудный, Мытищи, Видное, Одинцово, Подольск, Апрелевка,
|
||||||
|
# Селятино) — каждая выигрывает собственным центром. Ближайшая пара после
|
||||||
|
# добавления новых точек: «Митино-запад» в 1.6 км от Красногорска. Участвуют
|
||||||
|
# в конкурсе ближайшего центроида наравне с городами из
|
||||||
|
# `_CITY_CENTROIDS_DEG`, но НАМЕРЕННО отсутствуют в `_COVERAGE_CITY_THRESHOLDS`:
|
||||||
|
# центроид без порога = «город не определён», тот же ответ, что для точки
|
||||||
|
# в чистом поле. Обоснование и цена — в комментарии «ГРАНИЦА С ОБЛАСТЬЮ» выше.
|
||||||
|
#
|
||||||
|
# Координаты — WGS84, общедоступные центры НП; список закрытый и расширяется
|
||||||
|
# только по замеру (точка вне радиуса всех московских бесполезна, точка внутри
|
||||||
|
# Москвы сожгла бы ещё часть покрытия). Ключи здесь и в `_CITY_CENTROIDS_DEG`
|
||||||
|
# не должны пересекаться — это проверяется тестом.
|
||||||
|
#
|
||||||
|
# Города области, которые НЕ попадают ни в один московский радиус, здесь и не
|
||||||
|
# нужны: Наро-Фоминск (9.83 км до сетки), Кубинка (17.18), Чехов (22.25) отдают
|
||||||
|
# «город не определён» просто потому, что вне 8 км. При прежних 25 км все трое
|
||||||
|
# резолвились как «Москва» — это и был дефект, ради которого радиус переехал
|
||||||
|
# в свойство центроида.
|
||||||
|
_COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||||
|
"Андреевка": (55.9772, 37.1100),
|
||||||
|
"Менделеево": (56.0333, 37.2333),
|
||||||
|
"Сходня": (55.9500, 37.3000),
|
||||||
|
"Чёрная Грязь": (55.9667, 37.3167),
|
||||||
|
"Лунёво": (56.0000, 37.3500),
|
||||||
|
"Поварово": (56.0667, 37.0667),
|
||||||
|
"Дедовск": (55.8672, 37.1200),
|
||||||
|
"Нахабино": (55.8500, 37.1833),
|
||||||
|
"Реутов": (55.7614, 37.8564),
|
||||||
|
"Подольск": (55.4312, 37.5450),
|
||||||
|
"Апрелевка": (55.5500, 37.0700),
|
||||||
|
"Немчиновка": (55.7050, 37.3450),
|
||||||
|
"Химки": (55.8894, 37.4450),
|
||||||
|
"Одинцово": (55.6789, 37.2639),
|
||||||
|
"Лобня": (56.0100, 37.4750),
|
||||||
|
"Мытищи": (55.9116, 37.7308),
|
||||||
|
"Котельники": (55.6553, 37.8619),
|
||||||
|
"Красногорск": (55.8317, 37.3300),
|
||||||
|
"Люберцы": (55.6767, 37.8931),
|
||||||
|
"Дзержинский": (55.6294, 37.8500),
|
||||||
|
"Развилка": (55.5842, 37.7392),
|
||||||
|
"Климовск": (55.3667, 37.5333),
|
||||||
|
"Балашиха": (55.7969, 37.9386),
|
||||||
|
"Истра": (55.9167, 36.8667),
|
||||||
|
"Долгопрудный": (55.9386, 37.5100),
|
||||||
|
"Королёв": (55.9142, 37.8256),
|
||||||
|
"Видное": (55.5519, 37.7133),
|
||||||
|
"Селятино": (55.5081, 36.9825),
|
||||||
|
"Томилино": (55.6528, 37.9472),
|
||||||
|
"Некрасовский": (56.0500, 37.5500),
|
||||||
|
"Барвиха": (55.7333, 37.2333),
|
||||||
|
"Железнодорожный": (55.7444, 38.0128),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Радиус матчинга по центроиду: ключ — folded имя центроида, значение — км.
|
||||||
|
# Чего здесь нет — берёт `COVERAGE_CITY_MATCH_RADIUS_KM` (25 км), то есть все
|
||||||
|
# свердловские точки. Таблица выводится из самих словарей координат, чтобы
|
||||||
|
# радиус физически не мог разойтись со списком точек.
|
||||||
|
#
|
||||||
|
# Почему у отрицательных точек радиус бесконечный. Отрицательный центроид
|
||||||
|
# ничего не «накрывает»: он влияет на ответ только там, где он ближе всех
|
||||||
|
# (в своей ячейке Вороного), а выигравший центроид без порога даёт «город не
|
||||||
|
# определён» на ЛЮБОМ расстоянии — обе ветки `_resolve_coverage_city` (вышли
|
||||||
|
# за радиус / нет порога) возвращают один и тот же ответ. Поэтому конечное
|
||||||
|
# число здесь было бы декорацией: поведение оно не меняет, но читалось бы как
|
||||||
|
# «дальше отрицательная точка перестаёт действовать», чего не происходит.
|
||||||
|
# math.inf говорит правду — границу отрицательной точки задаёт геометрия
|
||||||
|
# соседей, а не радиус, и попытка «подрезать» её числом ничего не вернёт
|
||||||
|
# Москве, зато спрячет этот факт от следующего читателя.
|
||||||
|
_CENTROID_RADIUS_KM: dict[str, float] = {
|
||||||
|
**{_fold_city(k): COVERAGE_MOSCOW_MATCH_RADIUS_KM for k in COVERAGE_MOSCOW_CENTROID_KEYS},
|
||||||
|
**{_fold_city(k): math.inf for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _centroid_radius_km(centroid_key: str) -> float:
|
||||||
|
"""Радиус матчинга КОНКРЕТНОГО центроида в км (см. `_CENTROID_RADIUS_KM`)."""
|
||||||
|
return _CENTROID_RADIUS_KM.get(_fold_city(centroid_key), COVERAGE_CITY_MATCH_RADIUS_KM)
|
||||||
|
|
||||||
|
|
||||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
|
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
|
||||||
|
|
@ -2836,23 +3194,49 @@ def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
|
||||||
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
|
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
|
||||||
|
|
||||||
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
|
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
|
||||||
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
|
в пределах СОБСТВЕННОГО радиуса этого центроида (`_centroid_radius_km`:
|
||||||
|
25 км у свердловских городов, 8 км у точек московской сетки — почему так,
|
||||||
|
см. комментарий над `_MOSCOW_GRID_DEG`); иначе город не определён.
|
||||||
|
Детерминированно
|
||||||
и без участия клиента — см. комментарий над `_CITY_CENTROIDS_DEG` про то,
|
и без участия клиента — см. комментарий над `_CITY_CENTROIDS_DEG` про то,
|
||||||
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
|
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
|
||||||
больше НЕ допускаются в качестве источника истины.
|
больше НЕ допускаются в качестве источника истины.
|
||||||
|
|
||||||
|
В конкурсе ближайшего участвуют И отрицательные центроиды
|
||||||
|
(`_COVERAGE_NEGATIVE_CENTROIDS_DEG`) — подмосковные точки без порога.
|
||||||
|
Выигравший центроид без порога означает «город не определён»: так граница
|
||||||
|
Москвы и области проходит по конкурсу центров, а не по кругу радиуса
|
||||||
|
(см. «ГРАНИЦА С ОБЛАСТЬЮ» над `_CITY_CENTROIDS_DEG`).
|
||||||
|
|
||||||
|
Возвращается display-имя из `_COVERAGE_CITY_THRESHOLDS`, а НЕ ключ
|
||||||
|
центроида: у Москвы 67 центроидов на один город, и наружу все они обязаны
|
||||||
|
отдавать «Москва» с одним порогом (см. `COVERAGE_MOSCOW_CENTROID_KEYS`).
|
||||||
"""
|
"""
|
||||||
nearest_city: str | None = None
|
nearest_city: str | None = None
|
||||||
nearest_km = math.inf
|
nearest_km = math.inf
|
||||||
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
|
for city, (clat, clon) in (
|
||||||
|
*_CITY_CENTROIDS_DEG.items(),
|
||||||
|
*_COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
|
||||||
|
):
|
||||||
distance_km = _haversine_km(lat, lon, clat, clon)
|
distance_km = _haversine_km(lat, lon, clat, clon)
|
||||||
if distance_km < nearest_km:
|
if distance_km < nearest_km:
|
||||||
nearest_km = distance_km
|
nearest_km = distance_km
|
||||||
nearest_city = city
|
nearest_city = city
|
||||||
|
|
||||||
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
|
# Радиус берётся у ПОБЕДИТЕЛЯ конкурса, а не общий: круги плотной сетки
|
||||||
|
# складываются, и один глобальный радиус либо режет Свердловскую область,
|
||||||
|
# либо протекает из Москвы в область (см. `_CENTROID_RADIUS_KM`).
|
||||||
|
if nearest_city is None or nearest_km > _centroid_radius_km(nearest_city):
|
||||||
return "", 0, False
|
return "", 0, False
|
||||||
|
|
||||||
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
|
# .get(), а не индексирование: ближайшим мог оказаться отрицательный
|
||||||
|
# центроид (или новый центроид, для которого забыли завести порог) — это
|
||||||
|
# «город не определён», а не KeyError и 500 на живом адресе.
|
||||||
|
entry = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(nearest_city))
|
||||||
|
if entry is None:
|
||||||
|
return "", 0, False
|
||||||
|
|
||||||
|
display, threshold = entry
|
||||||
return display, threshold, True
|
return display, threshold, True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ 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):
|
||||||
|
|
@ -101,6 +102,18 @@ 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 доходит до
|
||||||
|
|
|
||||||
|
|
@ -1287,6 +1287,69 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,68 @@
|
||||||
from collections.abc import Generator
|
import asyncio
|
||||||
|
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)). Флаг на УРОВНЕ ДВИЖКА кроет все
|
||||||
|
|
@ -16,6 +70,41 @@ engine = create_engine(
|
||||||
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
||||||
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
||||||
hide_parameters=True,
|
hide_parameters=True,
|
||||||
|
# #3408 п.1. Потолок пула обязан быть НЕ МЕНЬШЕ суммы потолков одновременности,
|
||||||
|
# которые сам же процесс и объявляет: 4 оценки (`api/v1/trade_in`
|
||||||
|
# `_ESTIMATE_CONCURRENCY`) + 4 подсказки (`api/public/mera` `_SUGGEST_CONCURRENCY`)
|
||||||
|
# + 8 фоновых догрузок (`services/estimator` `_MAX_DEFERRED_REFRESH_TASKS`) = 16.
|
||||||
|
# Дефолт SQLAlchemy 5+10=15 меньше этой суммы, то есть исчерпать пул можно
|
||||||
|
# штатной работой, не абузом. Гейт — tests/test_3408_pool_ceiling.py.
|
||||||
|
#
|
||||||
|
# Инвариант «пул >= суммы объявленных потолков» — это ПОЛ, а не гарантия:
|
||||||
|
# сумма считает по одному коннекту на объявленную единицу работы, а коннект
|
||||||
|
# держит и всё остальное — любая ручка с `Depends(get_db)` тоже. Глобального
|
||||||
|
# обработчика `sqlalchemy.exc.TimeoutError` в app/main.py нет, поэтому на
|
||||||
|
# исчерпанном пуле соседние ручки отдают 500 (после ожидания `pool_timeout`),
|
||||||
|
# а не медленный 200.
|
||||||
|
#
|
||||||
|
# pool_size оставлен дефолтным (5): это ПОСТОЯННО открытые коннекты, а в покое
|
||||||
|
# прод держит 5-6 (замер 11.09). Растёт только overflow — коннекты пика,
|
||||||
|
# которые пул закрывает сам. Потолок процесса: 5 + 15 = 20; воркер один
|
||||||
|
# (docker-compose.prod.yml, uvicorn без --workers), Postgres max_connections=100.
|
||||||
|
max_overflow=15,
|
||||||
|
# #3408 п.2. Дефолтные 30 с ожидания коннекта длиннее ЛЮБОГО бюджета внешнего
|
||||||
|
# источника в эстиматоре: 8 с (Yandex / Cian / house_meta), 12 с (geocode),
|
||||||
|
# 20 с (Avito IMV — `estimate_avito_imv_timeout_s`, config.py:852). Сам чекаут
|
||||||
|
# прервать `asyncio.wait_for` не может: он занимает поток `asyncio.to_thread`
|
||||||
|
# целиком, а пул потоков конечен (min(32, cpu+4)) — исчерпанный пул коннектов
|
||||||
|
# так превращается в исчерпанный пул потоков. 5 с короче самого КОРОТКОГО
|
||||||
|
# бюджета: занятый пул деградирует ОДИН источник, а не весь запрос.
|
||||||
|
#
|
||||||
|
# Отдельный коммит в конце ветки намеренно (ревью PR #3444): это единственная
|
||||||
|
# правка, которая меняет режим отказа с «медленно» на «быстро с ошибкой», и
|
||||||
|
# едет она во ВСЕ сервисы образа — backend, scraper, tgbot
|
||||||
|
# (tradein-mvp/docker-compose.prod.yml). За 29 ч логов исчерпания пула не было
|
||||||
|
# ни разу, то есть новое значение на проде пока не на чем проверить.
|
||||||
|
# ТРИГГЕР ОТКАТА на 30 с: любое `QueuePool limit ... timed out` в логах
|
||||||
|
# бэкенда ЛИБО рост failed+zombie в `scrape_runs` после деплоя.
|
||||||
|
pool_timeout=5,
|
||||||
)
|
)
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
@ -30,3 +119,39 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -41,17 +41,84 @@ 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:
|
||||||
message = record.getMessage()
|
args = record.args
|
||||||
scrubbed = scrub_query_secrets(message)
|
if isinstance(args, tuple) and args:
|
||||||
if scrubbed != message:
|
# Есть позиционные args — скрабим КАЖДЫЙ элемент отдельно, arity не трогаем.
|
||||||
record.msg = scrubbed
|
# `record.msg` (шаблон вида `"%s ..."`) не трогаем вовсе: в реальных вызовах
|
||||||
record.args = ()
|
# этого кодбейза секрет+query-контекст лежат САМОДОСТАТОЧНО внутри одного
|
||||||
|
# аргумента (например 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 И на обработчики корневого логгера.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,18 @@ class SlidingWindowLimiter:
|
||||||
del self._hits[k]
|
del self._hits[k]
|
||||||
return len(bucket)
|
return len(bucket)
|
||||||
|
|
||||||
|
def reset(self, key: str) -> None:
|
||||||
|
"""Обнуляет окно под *key*.
|
||||||
|
|
||||||
|
Нужен вызывающим, которые считают не «сколько сделано», а «сколько ПОДРЯД»
|
||||||
|
— например счётчик отказов отправки (`support.py:_send_failure_limiter`):
|
||||||
|
успешная отправка означает, что канал жив, и предыдущие отказы больше не
|
||||||
|
должны приближать cooldown. Для лимитеров-бюджетов (`_send_limiter`,
|
||||||
|
`_anon_ip_limiter`, `_notify_limiter`) не используется — там обнуление
|
||||||
|
по запросу было бы дырой в самом лимите.
|
||||||
|
"""
|
||||||
|
self._hits.pop(key, None)
|
||||||
|
|
||||||
def check(self, key: str) -> float | None:
|
def check(self, key: str) -> float | None:
|
||||||
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
||||||
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
|
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ 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 (
|
||||||
|
|
@ -50,6 +51,7 @@ from app.core.rbac import rbac_guard
|
||||||
from app.core.request_audit import RequestAuditMiddleware
|
from app.core.request_audit import RequestAuditMiddleware
|
||||||
from app.observability import metrics as app_metrics
|
from app.observability import metrics as app_metrics
|
||||||
from app.observability.sentry_scrub import scrub_pii_event
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
|
from app.services.tgbot.shared import close_telegram_client, init_telegram_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -78,31 +80,43 @@ 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: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
def _before_send(event: Event, hint: Hint) -> Event | None:
|
||||||
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
|
"""Композиция payments-disabled drop + платёжный body-wipe + PII-scrub +
|
||||||
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
|
Telegram bot-токен redaction + RetryError fingerprint-стабилизация
|
||||||
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
|
(#tgsupport-web, PR-D2, glitchtip-noise, #3471) — см.
|
||||||
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
|
app/tgbot_main.py._before_send (идентичная композиция без последнего
|
||||||
TelegramClient в стек-фреймах при ошибке sendMessage, а
|
шага, тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже
|
||||||
|
держит TelegramClient в стек-фреймах при ошибке sendMessage, а
|
||||||
include_local_variables=False ниже — первый рубеж защиты.
|
include_local_variables=False ниже — первый рубеж защиты.
|
||||||
|
|
||||||
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные —
|
#3471: payments-disabled drop идёт ПЕРВЫМ шагом — это единственный
|
||||||
режет `request.data` целиком только для `/payments/*`, остальные пути
|
процесс из трёх entrypoint'ов, который реально держит ASGI-роут
|
||||||
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
|
`/api/v1/payments/*`, поэтому именно здесь события возникают; ранний
|
||||||
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
|
return None экономит остальную композицию на заведомо отбрасываемом
|
||||||
(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."""
|
||||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
|
||||||
|
if dropped is None:
|
||||||
|
return None
|
||||||
|
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
|
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
|
||||||
|
|
@ -216,7 +230,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
|
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
|
||||||
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
|
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
|
||||||
# this API process never actually launched scheduler_loop() in production.
|
# this API process never actually launched scheduler_loop() in production.
|
||||||
yield
|
|
||||||
|
# Общий на приложение Telegram-клиент (#tg-connection-resilience): ручки
|
||||||
|
# support/glitchtip раньше создавали его на КАЖДЫЙ запрос, то есть каждое
|
||||||
|
# зеркалирование сообщения начиналось с полного TCP+TLS-хендшейка до
|
||||||
|
# api.telegram.org. Один пул keep-alive на процесс, закрываем на shutdown.
|
||||||
|
# Без токена не создаём: ручки в этом случае и так отвечают 503.
|
||||||
|
if settings.telegram_bot_token:
|
||||||
|
init_telegram_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await close_telegram_client()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,47 @@ 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__``.
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ 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]"
|
||||||
|
|
@ -256,6 +257,41 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,16 @@ 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: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
|
def _before_send(event: Event, hint: Hint) -> Event | None:
|
||||||
"""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-
|
||||||
|
|
@ -58,6 +60,13 @@ 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, сотни адресов за прогон) —
|
||||||
|
|
@ -66,13 +75,16 @@ if settings.glitchtip_dsn:
|
||||||
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
|
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
|
||||||
См. sentry_scrub docstring.
|
См. sentry_scrub docstring.
|
||||||
"""
|
"""
|
||||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
|
||||||
|
if dropped is None:
|
||||||
|
return None
|
||||||
|
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
return stabilize_retry_error_fingerprint(scrubbed, hint)
|
return stabilize_retry_error_fingerprint(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=settings.glitchtip_dsn,
|
dsn=settings.glitchtip_dsn,
|
||||||
|
|
|
||||||
|
|
@ -158,10 +158,14 @@ class DkpCorridor(BaseModel):
|
||||||
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
|
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
|
||||||
|
|
||||||
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
|
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
|
||||||
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
|
rooms + площади ±15% за период. Показывается как тонкая референсная линия
|
||||||
линия «коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит
|
«коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит за
|
||||||
за [low,high]×slack — добавляется текстовая пометка. НЕ хард-клампит оценку.
|
[low,high]×slack — добавляется текстовая пометка.
|
||||||
None / count=0 если по улице нет сопоставимых сделок.
|
None / count=0 если по улице нет сопоставимых сделок.
|
||||||
|
|
||||||
|
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
||||||
|
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
||||||
|
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
count: int # число ДКП-сделок в выборке
|
count: int # число ДКП-сделок в выборке
|
||||||
|
|
@ -183,6 +187,30 @@ 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 = сделок меньше порога, ценовые страховки коридора выключены.
|
||||||
|
|
||||||
|
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
|
||||||
|
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
||||||
|
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
||||||
|
цену — зона n=3..9 на экране была неотличима от работающей.
|
||||||
|
|
||||||
|
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
|
||||||
|
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
|
||||||
|
deals-headline-fallback берёт медиану коридора начиная с трёх сделок.
|
||||||
|
Потребителю (витрине) поэтому корректно говорить про РАЗМЕР ВЫБОРКИ, а
|
||||||
|
не про то, что цену коридор не трогал.
|
||||||
|
|
||||||
|
Производное от count, поэтому верно во ВСЕХ конструкторах DkpCorridor
|
||||||
|
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
|
||||||
|
вторым числом.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings # локально: schemas остаётся import-light
|
||||||
|
|
||||||
|
return self.count < settings.estimate_corridor_clamp_min_n
|
||||||
|
|
||||||
|
|
||||||
class PriceTrendPoint(BaseModel):
|
class PriceTrendPoint(BaseModel):
|
||||||
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).
|
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ async def backfill_cian_price_history(
|
||||||
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
|
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
|
||||||
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
|
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
|
||||||
# логом, который читается как «Циан нас блокирует».
|
# логом, который читается как «Циан нас блокирует».
|
||||||
logger.error(
|
logger.warning(
|
||||||
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
|
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
|
||||||
"на listing_id=%s (обработано %d из %d)",
|
"на listing_id=%s (обработано %d из %d)",
|
||||||
exc,
|
exc,
|
||||||
|
|
|
||||||
|
|
@ -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.error(
|
logger.warning(
|
||||||
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
|
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
|
||||||
"cookies НЕ помечены протухшими, retry на следующем такте",
|
"cookies НЕ помечены протухшими, retry на следующем такте",
|
||||||
exc,
|
exc,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ 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__)
|
||||||
|
|
||||||
|
|
@ -173,7 +174,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 = (response.text or "")[:200]
|
body_preview = scrub_body_secrets(response.text)[:200]
|
||||||
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
||||||
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
||||||
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
||||||
|
|
@ -213,7 +214,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 = (response.text or "")[:200]
|
body_preview = scrub_body_secrets(response.text)[: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
|
||||||
|
|
||||||
|
|
@ -349,6 +350,7 @@ async def suggest_addresses(
|
||||||
limit: int = 8,
|
limit: int = 8,
|
||||||
city: str | None = "Екатеринбург",
|
city: str | None = "Екатеринбург",
|
||||||
region: str | None = None,
|
region: str | None = None,
|
||||||
|
regions: list[str] | None = None,
|
||||||
) -> list[DadataSuggestion]:
|
) -> list[DadataSuggestion]:
|
||||||
"""Автокомплит адресов через DaData /suggest/address.
|
"""Автокомплит адресов через DaData /suggest/address.
|
||||||
|
|
||||||
|
|
@ -367,6 +369,13 @@ async def suggest_addresses(
|
||||||
БЕЗ типа («Свердловская», а тип — отдельно в `region_type`="обл").
|
БЕЗ типа («Свердловская», а тип — отдельно в `region_type`="обл").
|
||||||
Передашь «Свердловская область» — совпадений не будет, и запрос
|
Передашь «Свердловская область» — совпадений не будет, и запрос
|
||||||
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
|
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
|
||||||
|
regions: НЕСКОЛЬКО регионов разом — `locations` у DaData это список, и
|
||||||
|
элементы в нём складываются по ИЛИ. Нужно там, где один регион даёт
|
||||||
|
заведомо неверный ответ: адрес Авито по Москве и области («Юбилейная
|
||||||
|
ул.,20Б») лежит либо в 77, либо в 50, и констрейнт из одного региона
|
||||||
|
молча притягивает подмосковный дом к московской улице. Имеет
|
||||||
|
приоритет над `region`/`city`. Имена — так же БЕЗ типа («Москва»,
|
||||||
|
«Московская»).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[DadataSuggestion] — пустой список если:
|
list[DadataSuggestion] — пустой список если:
|
||||||
|
|
@ -394,7 +403,11 @@ async def suggest_addresses(
|
||||||
"query": query.strip(),
|
"query": query.strip(),
|
||||||
"count": max(1, min(int(limit), 20)),
|
"count": max(1, min(int(limit), 20)),
|
||||||
}
|
}
|
||||||
if region:
|
if regions:
|
||||||
|
# Список → ИЛИ по регионам (см. docstring). Пустые имена выбрасываем:
|
||||||
|
# `{"region": ""}` — не «любой регион», а гарантированный ноль хитов.
|
||||||
|
body["locations"] = [{"region": r} for r in regions if r and r.strip()]
|
||||||
|
elif region:
|
||||||
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
|
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
|
||||||
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
|
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
|
||||||
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,
|
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,
|
||||||
|
|
@ -424,7 +437,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 = (response.text or "")[:200]
|
body_preview = scrub_body_secrets(response.text)[: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 []
|
||||||
|
|
||||||
|
|
|
||||||
157
tradein-mvp/backend/app/services/deal_city_key.py
Normal file
157
tradein-mvp/backend/app/services/deal_city_key.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""Ключ города ДКП-сделки для ценовых полос (#3051 «Москва», округа).
|
||||||
|
|
||||||
|
ПРОБЛЕМА. deal_city_price_bands ключуется (region_code, city), а deals.city у
|
||||||
|
ВСЕХ 212 937 московских сделок буквально 'Москва' — на весь город получалась
|
||||||
|
ОДНА полоса 34221..718870 ₽/м². Москва неоднородна на порядок (Хамовники против
|
||||||
|
Некрасовки), поэтому единая полоса одновременно и не режет опечатки в дорогом
|
||||||
|
центре, и режет легитимный рынок на окраинах.
|
||||||
|
|
||||||
|
РЕШЕНИЕ. Ключом города становится
|
||||||
|
COALESCE(NULLIF(raw_payload->>'src_city', ''), city)
|
||||||
|
— у московских сделок Росреестра raw_payload.src_city несёт муниципальный округ
|
||||||
|
('муниципальный округ Хамовники' и т.п.): заполнен у 198 600 из 212 937 сделок
|
||||||
|
(93.27%), 197 различных значений. Оставшиеся 6.73% (14 376 сделок) отдают
|
||||||
|
'Москва' и образуют СВОЮ строку-фолбэк (n=14376 → tier 'full', полоса
|
||||||
|
22475..772165), а не проваливаются в глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2,
|
||||||
|
откалиброванные под Екатеринбург. Ключи дизъюнктны: либо 'муниципальный округ X',
|
||||||
|
либо ровно 'Москва' — b.city = ключ_сделки всегда матчит одну строку.
|
||||||
|
|
||||||
|
ИНВАРИАНТ РЕГИОНА 66. У сделок Свердловской области src_city пуст у ВСЕХ
|
||||||
|
108 623 строк, поэтому COALESCE отдаёт city и ключ не меняется. Проверено на
|
||||||
|
проде 2026-09-10: сделок региона 66, где ключ отличается от city, — 0 штук;
|
||||||
|
пересчёт по новому выражению даёт те же 383 строки полос, все совпадают с
|
||||||
|
текущими по (ppm2_min, ppm2_max, n_deals, tier).
|
||||||
|
|
||||||
|
NULL-семантика. Если raw_payload отсутствует целиком (NULL), то
|
||||||
|
NULL->>'src_city' = NULL → NULLIF(NULL,'') = NULL → COALESCE отдаёт city.
|
||||||
|
Если src_city есть, но пустая строка — NULLIF гасит её в NULL, тот же исход.
|
||||||
|
Питоновский helper ниже повторяет эту семантику один-в-один (пустая строка
|
||||||
|
считается отсутствующей, пробелы НЕ подрезаются — SQL их тоже не подрезает).
|
||||||
|
|
||||||
|
Модуль намеренно крошечный и без зависимостей: выражение обязано быть ОДНИМ на
|
||||||
|
derivation (app/tasks/deal_city_price_bands_refresh.py) и на все читающие места
|
||||||
|
(app/services/estimator.py). Три копии выражения = три места, где полосы
|
||||||
|
разъезжаются молча.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Имя вычисляемой колонки-ключа в SELECT'ах, которые читают сделки для питонового
|
||||||
|
# пути фильтрации (_fetch_deals → _is_plausible_deal).
|
||||||
|
DEAL_CITY_KEY_COLUMN = "city_key"
|
||||||
|
|
||||||
|
|
||||||
|
def deal_city_key_sql(alias: str = "d") -> str:
|
||||||
|
"""SQL-выражение ключа города сделки.
|
||||||
|
|
||||||
|
alias — префикс таблицы deals в запросе ('d' для `FROM deals d`, '' для
|
||||||
|
`FROM deals` без алиаса, как в derivation задачи полос).
|
||||||
|
"""
|
||||||
|
prefix = f"{alias}." if alias else ""
|
||||||
|
return f"COALESCE(NULLIF({prefix}raw_payload->>'src_city', ''), {prefix}city)"
|
||||||
|
|
||||||
|
|
||||||
|
def deal_city_key(row: Mapping[str, Any]) -> str | None:
|
||||||
|
"""Питоновский эквивалент deal_city_key_sql для уже прочитанной строки сделки.
|
||||||
|
|
||||||
|
Порядок: готовая колонка DEAL_CITY_KEY_COLUMN (её считает SQL) → src_city из
|
||||||
|
raw_payload, если строку прочитали вместе с payload → city. Пустая строка
|
||||||
|
трактуется как отсутствие значения — как NULLIF(x, '') в SQL.
|
||||||
|
"""
|
||||||
|
key = row.get(DEAL_CITY_KEY_COLUMN)
|
||||||
|
if key:
|
||||||
|
return str(key)
|
||||||
|
raw = row.get("raw_payload")
|
||||||
|
if isinstance(raw, Mapping):
|
||||||
|
src = raw.get("src_city")
|
||||||
|
if src:
|
||||||
|
return str(src)
|
||||||
|
city = row.get("city")
|
||||||
|
return str(city) if city else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Двухступенчатый поиск полосы (#3051, разрыв на деплое) ───────────────────
|
||||||
|
#
|
||||||
|
# Таблицу deal_city_price_bands наполняет НОЧНАЯ задача, а читающая сторона
|
||||||
|
# уезжает на ключ-округ сразу с деплоем. В окне между деплоем и первым рефрешем
|
||||||
|
# по региону 77 в таблице лежит ровно ОДНА строка city='Москва': 93.27%
|
||||||
|
# московских сделок искали бы ключ 'муниципальный округ X', не находили и падали
|
||||||
|
# на глобальные DEAL_MIN_PPM2=50000 / DEAL_MAX_PPM2=800000 (калибровка ЕКБ) —
|
||||||
|
# нижняя граница прыгала бы с 34221 до 50000 и молча выбрасывала легитимно
|
||||||
|
# дешёвые сделки. Это ХУЖЕ, чем было до правки.
|
||||||
|
#
|
||||||
|
# Поэтому поиск полосы ДВУХСТУПЕНЧАТЫЙ и одинаковый во всех трёх читающих местах
|
||||||
|
# (два SQL-джойна ДКП-коридора + питоновский путь _fetch_deals):
|
||||||
|
# ступень 1 — строка по ключу-округу (deal_city_key / deal_city_key_sql);
|
||||||
|
# ступень 2 — строка по deals.city;
|
||||||
|
# ступень 3 — глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2.
|
||||||
|
# Порядок деплоя перестаёт иметь значение, а округ, для которого строки ещё нет
|
||||||
|
# (свежий округ, n<10, мусорное значение src_city), деградирует в ГОРОДСКУЮ
|
||||||
|
# полосу, а не в екатеринбургскую калибровку.
|
||||||
|
#
|
||||||
|
# Ступень не расщепляется по границам: ppm2_min и ppm2_max в таблице NOT NULL
|
||||||
|
# (data/sql/178_deal_city_price_bands.sql), значит найденная строка отдаёт ОБЕ
|
||||||
|
# границы — COALESCE не может взять min из округа, а max из города.
|
||||||
|
#
|
||||||
|
# ИНВАРИАНТ РЕГИОНА 66. src_city пуст у всех его 108 623 сделок → ключ ступени 1
|
||||||
|
# равен ключу ступени 2, обе ступени находят одну и ту же строку, результат
|
||||||
|
# байт-в-байт прежний. Вторая ступень для него — тавтология, не изменение.
|
||||||
|
|
||||||
|
DEAL_CITY_BAND_ALIAS = "b" # ступень 1: строка по ключу-округу
|
||||||
|
DEAL_CITY_BAND_FALLBACK_ALIAS = "bc" # ступень 2: строка по deals.city
|
||||||
|
|
||||||
|
|
||||||
|
def deal_city_band_join_sql(alias: str = "d", indent: str = "") -> str:
|
||||||
|
"""Два LEFT JOIN'а к deal_city_price_bands: ступень 1 (округ) + ступень 2 (город).
|
||||||
|
|
||||||
|
alias — префикс таблицы deals, indent — отступ строк со 2-й (косметика SQL).
|
||||||
|
"""
|
||||||
|
prefix = f"{alias}." if alias else ""
|
||||||
|
b, bc = DEAL_CITY_BAND_ALIAS, DEAL_CITY_BAND_FALLBACK_ALIAS
|
||||||
|
lines = [
|
||||||
|
f"LEFT JOIN deal_city_price_bands {b}",
|
||||||
|
f" ON {b}.region_code = {prefix}region_code",
|
||||||
|
f" AND {b}.city = {deal_city_key_sql(alias)}",
|
||||||
|
f"LEFT JOIN deal_city_price_bands {bc}",
|
||||||
|
f" ON {bc}.region_code = {prefix}region_code",
|
||||||
|
f" AND {bc}.city = {prefix}city",
|
||||||
|
]
|
||||||
|
return ("\n" + indent).join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def deal_city_band_bounds_sql(
|
||||||
|
min_param: str = ":ppm_min", max_param: str = ":ppm_max", indent: str = ""
|
||||||
|
) -> str:
|
||||||
|
"""Границы полосы одним выражением: округ → город → глобальные константы."""
|
||||||
|
b, bc = DEAL_CITY_BAND_ALIAS, DEAL_CITY_BAND_FALLBACK_ALIAS
|
||||||
|
lines = [
|
||||||
|
f"BETWEEN COALESCE({b}.ppm2_min, {bc}.ppm2_min, CAST({min_param} AS int))",
|
||||||
|
f" AND COALESCE({b}.ppm2_max, {bc}.ppm2_max, CAST({max_param} AS int))",
|
||||||
|
]
|
||||||
|
return ("\n" + indent).join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_city_band(
|
||||||
|
bands: Mapping[tuple[int, str], tuple[int, int]] | None,
|
||||||
|
region_code: int,
|
||||||
|
city_key: str | None,
|
||||||
|
city: str | None,
|
||||||
|
default: tuple[int, int],
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Питоновский эквивалент двух LEFT JOIN'ов выше: округ → город → default.
|
||||||
|
|
||||||
|
Ступени и их порядок обязаны совпадать с SQL-версией: разъехавшийся порядок
|
||||||
|
означал бы, что питоновский фильтр сделок судит по другой полосе, чем
|
||||||
|
ДКП-коридор на тех же данных.
|
||||||
|
"""
|
||||||
|
table = bands or {}
|
||||||
|
for key in (city_key, city):
|
||||||
|
if key is None:
|
||||||
|
continue
|
||||||
|
band = table.get((region_code, key))
|
||||||
|
if band is not None:
|
||||||
|
return band
|
||||||
|
return default
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -46,6 +46,8 @@ 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).
|
||||||
|
|
@ -520,6 +522,62 @@ 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
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -529,16 +587,26 @@ def upsert_gar_houses(
|
||||||
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
|
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
|
||||||
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
|
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
|
||||||
#
|
#
|
||||||
# На каждый canon берём ОДНУ ГАР-строку (DISTINCT ON … ORDER BY flat_count DESC, house_guid) —
|
# На каждый canon берём ОДНУ ГАР-строку (ROW_NUMBER … 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 ILIKE по full_address) обязательно: без него
|
# канон, что и в gar_pick. С city-фильтром (:city IS NOT NULL, region 66) поведение
|
||||||
# «Машиностроителей 6» в 4 городах region 66 даёт ложные коллизии. Предикат
|
# byte-for-byte прежнее — ILIKE по full_address обязателен: без него «Машиностроителей 6» в
|
||||||
# `gar_flat_count IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие строки.
|
# 4 городах region 66 даёт ложные коллизии.
|
||||||
|
#
|
||||||
|
# БЕЗ 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_pick AS (
|
WITH gar_scope AS (
|
||||||
SELECT DISTINCT ON (canon)
|
SELECT
|
||||||
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
|
||||||
|
|
@ -551,7 +619,21 @@ _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,
|
||||||
|
|
@ -571,15 +653,19 @@ def match_houses_to_gar(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
region_code: str | None = None,
|
region_code: str | None = None,
|
||||||
city_filter: str | None = "Екатеринбург",
|
city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).
|
"""Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).
|
||||||
|
|
||||||
city_filter (умолч. «Екатеринбург») ограничивает ГАР-сторону по full_address ILIKE —
|
city_filter: CITY_FILTER_AUTO (умолч.) → резолвится по region_code через
|
||||||
защита от cross-town коллизий внутри region 66; None отключает фильтр (city-aware матч
|
default_city_filter_for_region (region 66 → «Екатеринбург», иначе None). Явный
|
||||||
за пределами ЕКБ — future work). Возвращает число обновлённых домов. Идемпотентно
|
None отключает фильтр НЕЗАВИСИМО от региона; явная строка — ILIKE-override
|
||||||
(plain UPDATE, IS DISTINCT FROM gate).
|
(любой регион). Без фильтра ambiguity одноимённых улиц разных городов закрыта
|
||||||
|
отдельно — см. _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(
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ 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
|
||||||
|
|
@ -36,7 +37,11 @@ _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, прежнее поведение).
|
||||||
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
|
# 50 → "московская", а НЕ "москва": Nominatim отдаёт `state="Московская
|
||||||
|
# область"` для области и `state="Москва"` для города. Маркер "московская" не
|
||||||
|
# матчит "Москва" (подстроки нет) — результат внутри города Москвы, который
|
||||||
|
# щедрый `bbox_region` области накрывает целиком, будет честно отвергнут.
|
||||||
|
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва", 50: "московская"}
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -757,6 +762,30 @@ def _region_viewbox(region: Region) -> str:
|
||||||
return f"{lon_min},{lat_max},{lon_max},{lat_min}"
|
return f"{lon_min},{lat_max},{lon_max},{lat_min}"
|
||||||
|
|
||||||
|
|
||||||
|
def _viewbox_for_region(region_code: int) -> str:
|
||||||
|
"""Nominatim `viewbox` по коду региона — ЕДИНАЯ точка для всех тиров.
|
||||||
|
|
||||||
|
`region_code=66` → литеральная `OBLAST66_VIEWBOX["viewbox"]`: значение
|
||||||
|
историческое, из bbox не выводится, поэтому byte-identical прежнему
|
||||||
|
поведению. Прочие регионы — рамка из реестра (`_region_viewbox`).
|
||||||
|
"""
|
||||||
|
if region_code == 66:
|
||||||
|
return OBLAST66_VIEWBOX["viewbox"]
|
||||||
|
return _region_viewbox(_ALL_REGIONS[region_code])
|
||||||
|
|
||||||
|
|
||||||
|
def _region_default_city(region_code: int) -> str:
|
||||||
|
"""Главный город региона — текстовый суффикс запроса, когда город не назван.
|
||||||
|
|
||||||
|
`region_code=66` → литеральный "Екатеринбург" (byte-identical dual-query
|
||||||
|
#2580/C2). Прочие — `canonical_city` реестра, иначе `city_token` с заглавной.
|
||||||
|
"""
|
||||||
|
if region_code == 66:
|
||||||
|
return "Екатеринбург"
|
||||||
|
region = _ALL_REGIONS[region_code]
|
||||||
|
return region.canonical_city or region.city_token.capitalize()
|
||||||
|
|
||||||
|
|
||||||
async def _nominatim_query(
|
async def _nominatim_query(
|
||||||
client: httpx.AsyncClient, address: str, region_code: int = 66
|
client: httpx.AsyncClient, address: str, region_code: int = 66
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
|
|
@ -775,7 +804,7 @@ async def _nominatim_query(
|
||||||
поведению (те же bbox-значения и та же viewbox-строка).
|
поведению (те же bbox-значения и та же viewbox-строка).
|
||||||
"""
|
"""
|
||||||
region = _ALL_REGIONS[region_code]
|
region = _ALL_REGIONS[region_code]
|
||||||
viewbox = OBLAST66_VIEWBOX["viewbox"] if region_code == 66 else _region_viewbox(region)
|
viewbox = _viewbox_for_region(region_code)
|
||||||
await _nominatim_throttle()
|
await _nominatim_throttle()
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"https://nominatim.openstreetmap.org/search",
|
"https://nominatim.openstreetmap.org/search",
|
||||||
|
|
@ -933,8 +962,29 @@ class GeocodeSuggestion:
|
||||||
# 'locality' вместо 'city' — consistent с Nominatim-веткой).
|
# 'locality' вместо 'city' — consistent с Nominatim-веткой).
|
||||||
_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
|
_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
|
||||||
|
|
||||||
|
# region_code → значение поля DaData `region` (БЕЗ типа: «Свердловская», а не
|
||||||
|
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
|
||||||
|
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
|
||||||
|
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
|
||||||
|
_DADATA_REGION_NAMES: dict[int, str] = {
|
||||||
|
66: SVERDLOVSK_OBLAST_REGION,
|
||||||
|
77: "Москва",
|
||||||
|
# Без типа — DaData хранит `region="Московская"`, `region_type="обл"`.
|
||||||
|
50: "Московская",
|
||||||
|
}
|
||||||
|
|
||||||
async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
|
||||||
|
def _dadata_region_name(region_code: int) -> str:
|
||||||
|
"""Имя региона для hard-констрейнта DaData. Неизвестный код → ValueError."""
|
||||||
|
try:
|
||||||
|
return _DADATA_REGION_NAMES[region_code]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"dadata suggest: unknown region_code={region_code!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _dadata_suggest(
|
||||||
|
query: str, limit: int = 8, region_code: int = 66
|
||||||
|
) -> list[GeocodeSuggestion]:
|
||||||
"""Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion.
|
"""Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion.
|
||||||
|
|
||||||
Дроп candidate'ов без координат (DaData возвращает их для широких categories
|
Дроп candidate'ов без координат (DaData возвращает их для широких categories
|
||||||
|
|
@ -945,9 +995,8 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
|
||||||
внутри `suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/
|
внутри `suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/
|
||||||
Серов/etc никогда не появились бы в подсказках.
|
Серов/etc никогда не появились бы в подсказках.
|
||||||
"""
|
"""
|
||||||
raw = await dadata.suggest_addresses(
|
region_name = _dadata_region_name(region_code)
|
||||||
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
|
raw = await dadata.suggest_addresses(query, limit=limit, city=None, region=region_name)
|
||||||
)
|
|
||||||
if not raw:
|
if not raw:
|
||||||
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
|
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
|
||||||
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
|
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
|
||||||
|
|
@ -957,7 +1006,7 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
|
||||||
"dadata suggest: 0 кандидатов для %r при region=%r — "
|
"dadata suggest: 0 кандидатов для %r при region=%r — "
|
||||||
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
|
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
|
||||||
query[:60],
|
query[:60],
|
||||||
SVERDLOVSK_OBLAST_REGION,
|
region_name,
|
||||||
)
|
)
|
||||||
out: list[GeocodeSuggestion] = []
|
out: list[GeocodeSuggestion] = []
|
||||||
for s in raw:
|
for s in raw:
|
||||||
|
|
@ -980,8 +1029,14 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
|
async def _nominatim_query_multi(
|
||||||
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
|
client: httpx.AsyncClient, query: str, limit: int, region_code: int = 66
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Один Nominatim search с рамкой региона `region_code`. Возвращает up to N items.
|
||||||
|
|
||||||
|
`region_code=66` (дефолт) — byte-identical прежнему поведению: та же
|
||||||
|
viewbox-строка `OBLAST66_VIEWBOX` (см. `_viewbox_for_region`).
|
||||||
|
"""
|
||||||
await _nominatim_throttle()
|
await _nominatim_throttle()
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"https://nominatim.openstreetmap.org/search",
|
"https://nominatim.openstreetmap.org/search",
|
||||||
|
|
@ -990,7 +1045,7 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"limit": str(limit),
|
"limit": str(limit),
|
||||||
"countrycodes": "ru",
|
"countrycodes": "ru",
|
||||||
"viewbox": OBLAST66_VIEWBOX["viewbox"],
|
"viewbox": _viewbox_for_region(region_code),
|
||||||
"bounded": "1",
|
"bounded": "1",
|
||||||
"addressdetails": "1",
|
"addressdetails": "1",
|
||||||
},
|
},
|
||||||
|
|
@ -1027,7 +1082,12 @@ def _dedupe_nominatim_items(*item_lists: list[dict]) -> list[dict]:
|
||||||
|
|
||||||
|
|
||||||
async def _nominatim_query_city_aware(
|
async def _nominatim_query_city_aware(
|
||||||
client: httpx.AsyncClient, query: str, city: str | None, city_specified: bool, limit: int
|
client: httpx.AsyncClient,
|
||||||
|
query: str,
|
||||||
|
city: str | None,
|
||||||
|
city_specified: bool,
|
||||||
|
limit: int,
|
||||||
|
region_code: int = 66,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Строит и выполняет Nominatim-запрос(ы) с учётом того, известен ли город.
|
"""Строит и выполняет Nominatim-запрос(ы) с учётом того, известен ли город.
|
||||||
|
|
||||||
|
|
@ -1050,18 +1110,32 @@ async def _nominatim_query_city_aware(
|
||||||
ЕКБ-кандидаты идут первыми (majority-случай, привычный порядок).
|
ЕКБ-кандидаты идут первыми (majority-случай, привычный порядок).
|
||||||
"""
|
"""
|
||||||
if city:
|
if city:
|
||||||
return await _nominatim_query_multi(client, f"{query}, {city}", limit)
|
return await _nominatim_query_multi(
|
||||||
|
client, f"{query}, {city}", limit, region_code=region_code
|
||||||
|
)
|
||||||
if city_specified:
|
if city_specified:
|
||||||
return await _nominatim_query_multi(client, query, limit)
|
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
||||||
ekb_data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
|
# Город неизвестен. У региона БЕЗ города-ядра (`has_city_core=False`,
|
||||||
bare_data = await _nominatim_query_multi(client, query, limit)
|
# реестр регионов; на сегодня это 50 — Московская область) суффикс главного
|
||||||
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
|
# города подставлять НЕЛЬЗЯ: «Луговая» есть и в Красногорске, и в Сабурово,
|
||||||
|
# и суффикс уверенно притянет чужой город — ровно ловушка #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`).
|
||||||
|
default_city = _region_default_city(region_code)
|
||||||
|
city_data = await _nominatim_query_multi(
|
||||||
|
client, f"{query}, {default_city}", limit, region_code=region_code
|
||||||
|
)
|
||||||
|
bare_data = await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
||||||
|
return _dedupe_nominatim_items(city_data, bare_data)[:limit]
|
||||||
|
|
||||||
|
|
||||||
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
|
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
|
||||||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
|
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
|
||||||
async def _nominatim_suggest(
|
async def _nominatim_suggest(
|
||||||
query: str, limit: int = 8, city_hint: str | None = None
|
query: str, limit: int = 8, city_hint: str | None = None, region_code: int = 66
|
||||||
) -> list[GeocodeSuggestion]:
|
) -> list[GeocodeSuggestion]:
|
||||||
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный
|
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный
|
||||||
запрос ничего не находит).
|
запрос ничего не находит).
|
||||||
|
|
@ -1079,17 +1153,26 @@ async def _nominatim_suggest(
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Accept-Language": "ru,en;q=0.8",
|
"Accept-Language": "ru,en;q=0.8",
|
||||||
}
|
}
|
||||||
city, city_specified = _resolve_city_for_geocode(query, city_hint)
|
city, city_specified = _resolve_city_for_geocode(query, city_hint, region_code)
|
||||||
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
|
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
|
||||||
# Tier 1: оригинальный query
|
# Tier 1: оригинальный query
|
||||||
data = await _nominatim_query_city_aware(client, query, city, city_specified, limit)
|
data = await _nominatim_query_city_aware(
|
||||||
|
client, query, city, city_specified, limit, region_code=region_code
|
||||||
|
)
|
||||||
|
|
||||||
# Tier 2: typo-варианты если оригинал пустой
|
# Tier 2: typo-варианты если оригинал пустой
|
||||||
if not data:
|
if not data:
|
||||||
for variant in _typo_variants(query, limit=3):
|
for variant in _typo_variants(query, limit=3):
|
||||||
variant_city, variant_specified = _resolve_city_for_geocode(variant, city_hint)
|
variant_city, variant_specified = _resolve_city_for_geocode(
|
||||||
|
variant, city_hint, region_code
|
||||||
|
)
|
||||||
data = await _nominatim_query_city_aware(
|
data = await _nominatim_query_city_aware(
|
||||||
client, variant, variant_city, variant_specified, limit
|
client,
|
||||||
|
variant,
|
||||||
|
variant_city,
|
||||||
|
variant_specified,
|
||||||
|
limit,
|
||||||
|
region_code=region_code,
|
||||||
)
|
)
|
||||||
if data:
|
if data:
|
||||||
logger.info("nominatim suggest typo-fixed: %s → %s", query, variant)
|
logger.info("nominatim suggest typo-fixed: %s → %s", query, variant)
|
||||||
|
|
@ -1765,7 +1848,11 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int =
|
||||||
|
|
||||||
|
|
||||||
async def suggest(
|
async def suggest(
|
||||||
query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None
|
query: str,
|
||||||
|
db: Session | None = None,
|
||||||
|
limit: int = 8,
|
||||||
|
city_hint: str | None = None,
|
||||||
|
region_code: int = 66,
|
||||||
) -> list[GeocodeSuggestion]:
|
) -> list[GeocodeSuggestion]:
|
||||||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||||
остаётся быстрым fast-path). Cadastral FDW → DaData → Nominatim → [].
|
остаётся быстрым fast-path). Cadastral FDW → DaData → Nominatim → [].
|
||||||
|
|
@ -1779,10 +1866,22 @@ async def suggest(
|
||||||
(#2593: Yandex Geocoder, который был primary external provider до DaData,
|
(#2593: Yandex Geocoder, который был primary external provider до DaData,
|
||||||
удалён). DaData region-constraint уже охватывает всю область (не только
|
удалён). DaData region-constraint уже охватывает всю область (не только
|
||||||
ЕКБ) — city_hint ей не нужен.
|
ЕКБ) — city_hint ей не нужен.
|
||||||
|
region_code: регион покрытия (дефолт 66, #3051) — какой регион уходит в
|
||||||
|
hard-констрейнты провайдеров: DaData `region` (`_dadata_region_name`) и
|
||||||
|
Nominatim `viewbox`+bounded (`_viewbox_for_region`). БЕЗ него московский
|
||||||
|
адрес молча схлопывался в пустой список: оба констрейнта — ФИЛЬТРЫ, а не
|
||||||
|
boost, и «не тот регион» неотличимо от «адрес не найден». Локальные
|
||||||
|
ЕКБ-тиры (кадастр) для region_code != 66 пропускаются целиком — данных
|
||||||
|
по другим регионам в FDW физически нет. Дефолт — byte-identical
|
||||||
|
прежнему поведению по Свердловской области.
|
||||||
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
|
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
|
||||||
"""
|
"""
|
||||||
if not query or len(query.strip()) < 2:
|
if not query or len(query.strip()) < 2:
|
||||||
return []
|
return []
|
||||||
|
try:
|
||||||
|
_ALL_REGIONS[region_code]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"suggest: unknown region_code={region_code!r}") from exc
|
||||||
|
|
||||||
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
|
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
|
||||||
# EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query явно
|
# EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query явно
|
||||||
|
|
@ -1792,17 +1891,21 @@ async def suggest(
|
||||||
# иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed`
|
# иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed`
|
||||||
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим —
|
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим —
|
||||||
# они уже oblast-aware.
|
# они уже oblast-aware.
|
||||||
if db is not None and _ekb_local_tiers_allowed(query, city_hint):
|
# #3051: `region_code != 66` закрывает кадастровый тир ДО `_ekb_local_tiers_allowed`
|
||||||
|
# — gendesign_cad_buildings содержит только ЕКБ, звать его для Москвы значит
|
||||||
|
# платить FDW-round-trip ради гарантированного нуля (тот же гейт в
|
||||||
|
# `_geocode_resolve`; сигнатуру `_ekb_local_tiers_allowed` умышленно не трогаем).
|
||||||
|
if db is not None and region_code == 66 and _ekb_local_tiers_allowed(query, city_hint):
|
||||||
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
||||||
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
|
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
|
||||||
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 asyncio.to_thread(_cadastral_house_match, db, street, house)
|
hit = await run_db_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 asyncio.to_thread(_cadastral_forward_sync, db, query.strip(), limit)
|
cad_results = await run_db_thread(_cadastral_forward_sync, db, query.strip(), limit)
|
||||||
if cad_results:
|
if cad_results:
|
||||||
return cad_results
|
return cad_results
|
||||||
|
|
||||||
|
|
@ -1810,7 +1913,7 @@ async def suggest(
|
||||||
# лучший fit для РФ адресов.
|
# лучший fit для РФ адресов.
|
||||||
if settings.dadata_api_token:
|
if settings.dadata_api_token:
|
||||||
try:
|
try:
|
||||||
dadata_results = await _dadata_suggest(query, limit)
|
dadata_results = await _dadata_suggest(query, limit, region_code)
|
||||||
if dadata_results:
|
if dadata_results:
|
||||||
return dadata_results
|
return dadata_results
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -1818,7 +1921,7 @@ async def suggest(
|
||||||
|
|
||||||
# Tier 3: Nominatim (последний fallback — OSM, без ключа)
|
# Tier 3: Nominatim (последний fallback — OSM, без ключа)
|
||||||
try:
|
try:
|
||||||
return await _nominatim_suggest(query, limit, city_hint=city_hint)
|
return await _nominatim_suggest(query, limit, city_hint=city_hint, region_code=region_code)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("nominatim suggest failed")
|
logger.exception("nominatim suggest failed")
|
||||||
return []
|
return []
|
||||||
|
|
@ -1909,7 +2012,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 asyncio.to_thread(_cache_get, db, addr_norm)
|
cached = await run_db_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)
|
||||||
|
|
@ -1942,7 +2045,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 asyncio.to_thread(_geoportal_house_match, db, street, house)
|
hit = await run_db_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
|
||||||
|
|
@ -1955,7 +2058,7 @@ async def _geocode_resolve(
|
||||||
confidence="exact",
|
confidence="exact",
|
||||||
city_ambiguous=city_ambiguous,
|
city_ambiguous=city_ambiguous,
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
await run_db_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,
|
||||||
|
|
@ -1970,7 +2073,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 asyncio.to_thread(_cadastral_house_match, db, street, house)
|
hit = await run_db_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,
|
||||||
|
|
@ -1980,7 +2083,7 @@ async def _geocode_resolve(
|
||||||
confidence="exact",
|
confidence="exact",
|
||||||
city_ambiguous=city_ambiguous,
|
city_ambiguous=city_ambiguous,
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
await run_db_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,
|
||||||
|
|
@ -1991,9 +2094,7 @@ 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 asyncio.to_thread(
|
cad_suggestions = await run_db_thread(_cadastral_forward_sync, db, address.strip(), limit=1)
|
||||||
_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(
|
||||||
|
|
@ -2004,7 +2105,7 @@ async def _geocode_resolve(
|
||||||
confidence="exact",
|
confidence="exact",
|
||||||
city_ambiguous=city_ambiguous,
|
city_ambiguous=city_ambiguous,
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
await run_db_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
|
||||||
)
|
)
|
||||||
|
|
@ -2015,7 +2116,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 asyncio.to_thread(_cache_put, db, addr_norm, result)
|
await run_db_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:
|
||||||
|
|
@ -2034,7 +2135,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 asyncio.to_thread(_local_houses_match, db, local_street, local_house)
|
hit = await run_db_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,
|
||||||
|
|
@ -2243,7 +2344,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 asyncio.to_thread(_cadastral_reverse_sync_full, db, lat, lon)
|
cad = await run_db_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(
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,8 @@ 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:
|
||||||
|
|
@ -207,9 +208,27 @@ 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): географию location_index
|
# даже если сам продукт уже скрейпит соседние города/области (city-sweep): географию
|
||||||
# явно ограничил владелец продукта.
|
# 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
|
||||||
|
|
@ -223,6 +242,7 @@ _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)
|
||||||
|
|
@ -248,6 +268,7 @@ _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)
|
||||||
|
|
@ -255,6 +276,17 @@ _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
|
||||||
|
|
@ -293,6 +325,7 @@ 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,
|
||||||
|
|
@ -317,6 +350,7 @@ 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,
|
||||||
|
|
@ -333,18 +367,38 @@ 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
|
db: Any, lat: float, lon: float, radius_m: int, top_n: int, region: regions_mod.Region
|
||||||
) -> tuple[list[NearbyPoi], str]:
|
) -> tuple[list[NearbyPoi], str]:
|
||||||
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
|
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
|
||||||
|
|
||||||
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
|
Graceful fallback → ([], "unavailable") вместо 500 или сфабрикованного списка. Дефект
|
||||||
→ ([], "unavailable") вместо 500 или сфабрикованного списка.
|
(прод 13.09.2026): гейт раньше смотрел на count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850
|
||||||
|
строк, все екатеринбургские) — для Москвы это count > 0 (таблица не пуста), и статус
|
||||||
|
молча становился "ok" с пустым списком, хотя внутри московского продукт-ядра лежит 0
|
||||||
|
POI. Гейт обязан смотреть на наличие POI В РЕЛЕВАНТНОЙ области (bbox_product_core
|
||||||
|
региона точки), а не на размер таблицы целиком. Загрузка московских POI — отдельная
|
||||||
|
задача; здесь только честная деградация тем же статусом "unavailable" (терминология
|
||||||
|
не расширяется — trade_in.py и так трактует его как «нет данных», см. #2131).
|
||||||
"""
|
"""
|
||||||
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
|
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
|
||||||
if total == 0:
|
in_region = (
|
||||||
|
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 is empty (refresh job not yet run on this "
|
"location_index: osm_poi_ekb_local has no rows in region=%s product core "
|
||||||
"environment) — nearby_poi unavailable, no fabricated factors"
|
"(empty or refresh job not yet run for this region) — nearby_poi unavailable, "
|
||||||
|
"no fabricated factors",
|
||||||
|
region.code,
|
||||||
)
|
)
|
||||||
return [], "unavailable"
|
return [], "unavailable"
|
||||||
|
|
||||||
|
|
@ -423,7 +477,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)
|
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n, region)
|
||||||
|
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ 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
|
||||||
|
|
@ -236,6 +239,12 @@ 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) включён по умолчанию: незасеянное расписание
|
||||||
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
||||||
|
|
@ -275,6 +284,7 @@ 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,
|
||||||
|
|
@ -325,6 +335,34 @@ 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)
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
@ -487,6 +525,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
@ -911,6 +950,7 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -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.error(
|
logger.warning(
|
||||||
"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): отказ, БЕЗ обхода через "
|
||||||
|
|
|
||||||
|
|
@ -1309,6 +1309,8 @@ 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" — прочее
|
||||||
|
|
||||||
|
|
@ -1328,6 +1330,14 @@ 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
|
|
@ -10,13 +10,23 @@
|
||||||
Регион 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, циклы недопустимы).
|
geocoder / location_index / matching.normalize, циклы недопустимы). json/
|
||||||
|
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 ниже).
|
||||||
|
|
@ -28,9 +38,18 @@ 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
|
||||||
|
|
@ -38,15 +57,29 @@ 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-поля
|
||||||
(Росреестр по Москве отдаёт муниципальный округ/поселение
|
(Росреестр по Москве отдаёт муниципальный округ/поселение
|
||||||
|
|
@ -68,6 +101,7 @@ 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:
|
||||||
|
|
@ -76,6 +110,213 @@ 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"
|
||||||
|
|
@ -159,27 +400,183 @@ 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 region_for_point(lat: float, lon: float) -> Region | None:
|
def _bbox_area(bbox: BBox) -> float:
|
||||||
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
|
"""Грубая «площадь» 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:
|
||||||
|
"""Регион покрытия, которому принадлежит точка, или 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-поведению до того, как для него добавят полигон.
|
||||||
"""
|
"""
|
||||||
for code in sorted(REGIONS):
|
near_miss: list[int] = []
|
||||||
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
|
for code in _POINT_LOOKUP_ORDER:
|
||||||
return REGIONS[code]
|
region = REGIONS[code]
|
||||||
return None
|
if not is_within_bbox(lat, lon, region.bbox_region):
|
||||||
|
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())
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,18 @@ 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
|
||||||
|
|
@ -71,6 +83,7 @@ SBER_REF_AREAS: dict[str, str] = {
|
||||||
"643": "Россия",
|
"643": "Россия",
|
||||||
"66": "Свердловская область",
|
"66": "Свердловская область",
|
||||||
"77": "Москва",
|
"77": "Москва",
|
||||||
|
"50": "Московская область",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,13 @@
|
||||||
Telegram 403 (клиент заблокировал бота) → is_blocked=true + уведомление в
|
Telegram 403 (клиент заблокировал бота) → is_blocked=true + уведомление в
|
||||||
топике (только для Telegram-ветки — у веб-клиента нет "заблокировал бота").
|
топике (только для Telegram-ветки — у веб-клиента нет "заблокировал бота").
|
||||||
C) Дедуп: update_id <= сохранённого offset — skip. Offset сохраняется И
|
C) Дедуп: update_id <= сохранённого offset — skip. Offset сохраняется И
|
||||||
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
|
коммитится в той же транзакции, что и запись сообщения (см. `process_update`),
|
||||||
`finally`), после КАЖДОГО апдейта — рестарт воркера не переигрывает уже
|
после КАЖДОГО апдейта — рестарт воркера не переигрывает уже обработанные
|
||||||
обработанные апдейты и не подвисает вечно на «ядовитом» апдейте.
|
апдейты и не подвисает вечно на «ядовитом» апдейте. Исключение —
|
||||||
|
ТРАНЗИЕНТНЫЙ сетевой отказ (`TelegramNetworkError`, т.е. исчерпанный бюджет
|
||||||
|
ретраев клиента): такой апдейт СОЗНАТЕЛЬНО остаётся неподтверждённым, чтобы
|
||||||
|
Telegram отдал его снова, иначе ответ оператора пропадал бы навсегда
|
||||||
|
(#tg-connection-resilience). Потолок переигрываний — `_MAX_NETWORK_REPLAYS`.
|
||||||
D) TELEGRAM_BOT_TOKEN пуст → бот выключен — проверяется в `app.tgbot_main`
|
D) TELEGRAM_BOT_TOKEN пуст → бот выключен — проверяется в `app.tgbot_main`
|
||||||
(entrypoint), не здесь.
|
(entrypoint), не здесь.
|
||||||
E) /start клиенту → короткое приветствие МЕРЫ, без зеркалирования в топик
|
E) /start клиенту → короткое приветствие МЕРЫ, без зеркалирования в топик
|
||||||
|
|
@ -76,7 +80,7 @@ from app.core.config import settings
|
||||||
from app.core.ratelimit import SlidingWindowLimiter
|
from app.core.ratelimit import SlidingWindowLimiter
|
||||||
from app.core.shutdown import shutdown_requested
|
from app.core.shutdown import shutdown_requested
|
||||||
from app.services.tgbot import web_support_storage
|
from app.services.tgbot import web_support_storage
|
||||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
from app.services.tgbot.client import TelegramApiError, TelegramClient, TelegramNetworkError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -131,6 +135,59 @@ FLOOD_LIMITED_TEXT = (
|
||||||
# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил).
|
# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил).
|
||||||
_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено."
|
_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено."
|
||||||
|
|
||||||
|
# Уведомления оператору в топике отправляются ИЗНУТРИ poll loop, который
|
||||||
|
# однопоточный: пока висит одна отправка, не обрабатывается НИ ОДИН следующий
|
||||||
|
# апдейт. Поэтому им нужен интерактивный бюджет, а не воркерный дефолт клиента
|
||||||
|
# (5 ретраев, backoff до 30с, полный retry_after на 429 — минуты стопа на
|
||||||
|
# ВТОРИЧНОМ действии). Числа — те же, что у интерактивных отправок веб-чата
|
||||||
|
# поддержки (`_INTERACTIVE_SEND_*` в app/api/v1/support.py, подобраны замером
|
||||||
|
# прода #tgsupport-retry); сознательно ДУБЛИРУЕМ, а не импортируем из слоя API —
|
||||||
|
# воркер не должен зависеть от роутера.
|
||||||
|
_NOTIFY_SEND_TIMEOUT_S = 5.0
|
||||||
|
_NOTIFY_SEND_MAX_RETRIES = 3
|
||||||
|
_NOTIFY_SEND_MAX_BACKOFF_S = 1.0
|
||||||
|
|
||||||
|
# Потолок ожидания в TelegramGroupRateLimiter для отправок ИЗ ЭТОГО модуля,
|
||||||
|
# которые не проходят через _notify_topic (review M1, #3471). Без него
|
||||||
|
# `client.send_message`/`copy_message` без явного `timeout` наследуют
|
||||||
|
# лимитер-политику "без потолка" (см. TelegramClient._request) — приемлемую
|
||||||
|
# ДЛЯ ФОНОВОЙ отправки как таковой, но НЕ здесь: эти вызовы идут внутри
|
||||||
|
# `run_poll_loop`, который на каждый апдейт держит ОТКРЫТУЮ сессию БД
|
||||||
|
# (SessionLocal, см. вызывающих) и однопоточно блокирует опрос СЛЕДУЮЩИХ
|
||||||
|
# апдейтов — минутный сон здесь стопорит и БД-соединение, и весь мост, а не
|
||||||
|
# только одно сообщение. Второй повод — SIGTERM drain: `tgbot_main._DRAIN_TIMEOUT_S`
|
||||||
|
# даёт 100с на завершение текущей итерации; ожидание слота дольше этого
|
||||||
|
# бюджета уже не успевает подчиниться cooperative drain. 20с — заметно больше,
|
||||||
|
# чем разумная очередь при исчерпанном лимите (окно 60с, обычно секунды), но
|
||||||
|
# заметно МЕНЬШЕ минуты и вписывается в drain-бюджет с запасом.
|
||||||
|
_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S = 20.0
|
||||||
|
|
||||||
|
# Потолок переигрываний ОДНОГО update_id на транзиентных сетевых отказах
|
||||||
|
# (#tg-connection-resilience).
|
||||||
|
#
|
||||||
|
# Зачем потолок: без него «вечно недоставляемый» апдейт заклинил бы очередь
|
||||||
|
# НАВСЕГДА — ровно то, от чего защищал прежний безусловный `finally: save_offset`.
|
||||||
|
# Потерять одно сообщение плохо, потерять весь поток — хуже, поэтому на потолке
|
||||||
|
# offset всё-таки сдвигается, но ГРОМКО (`logger.error`), а не молча.
|
||||||
|
#
|
||||||
|
# Про дубли: `TelegramNetworkError` означает исчерпанный бюджет ретраев клиента,
|
||||||
|
# при этом запрос МОГ дойти до Telegram (потерялся ответ) — переигрывание тогда
|
||||||
|
# доставит клиенту то же сообщение второй раз. Это осознанный at-least-once
|
||||||
|
# компромисс: дубль и клиент, и оператор ВИДЯТ и могут поправить, а тихая потеря
|
||||||
|
# ответа не оставляет следа нигде, кроме строчки в логе. Полноценная
|
||||||
|
# идемпотентность по (update_id, target_chat_id) потребовала бы нового
|
||||||
|
# персистентного состояния (колонка/таблица + миграция) ради редкого случая;
|
||||||
|
# вместо этого число возможных дублей жёстко ограничено сверху — не больше
|
||||||
|
# (_MAX_NETWORK_REPLAYS - 1) повторов на апдейт.
|
||||||
|
_MAX_NETWORK_REPLAYS = 3
|
||||||
|
|
||||||
|
# update_id -> сколько раз мы уже отказались подтверждать этот апдейт.
|
||||||
|
# In-memory осознанно: воркер long-polling однопоточный, запись живёт ровно до
|
||||||
|
# подтверждения апдейта (`pop` в `process_update`), так что словарь не растёт.
|
||||||
|
# Рестарт воркера обнуляет счётчик — это допустимо (новый процесс = новая сеть),
|
||||||
|
# потолок всё равно действует в пределах каждой жизни процесса.
|
||||||
|
_network_replay_attempts: dict[int, int] = {}
|
||||||
|
|
||||||
|
|
||||||
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
|
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
|
||||||
class BridgeStorage(Protocol):
|
class BridgeStorage(Protocol):
|
||||||
|
|
@ -181,7 +238,13 @@ class BridgeStorage(Protocol):
|
||||||
) -> int | None: ...
|
) -> int | None: ...
|
||||||
|
|
||||||
def record_web_out_message(
|
def record_web_out_message(
|
||||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
self,
|
||||||
|
*,
|
||||||
|
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: ...
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -334,14 +397,20 @@ 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
|
||||||
|
|
@ -371,13 +440,21 @@ class SqlBridgeStorage:
|
||||||
)
|
)
|
||||||
|
|
||||||
def record_web_out_message(
|
def record_web_out_message(
|
||||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
self,
|
||||||
|
*,
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -400,6 +477,51 @@ def _format_topic_header(
|
||||||
return f"Новое обращение от {display_name}{username_part} (chat_id={chat_id})"
|
return f"Новое обращение от {display_name}{username_part} (chat_id={chat_id})"
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_topic(
|
||||||
|
client: TelegramClient,
|
||||||
|
*,
|
||||||
|
text: str,
|
||||||
|
reply_to_message_id: int | None,
|
||||||
|
context: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Служебное уведомление оператору в support-топик (вторичное действие).
|
||||||
|
|
||||||
|
Два свойства, которых не было у прямых `client.send_message` вызовов:
|
||||||
|
1) узкий интерактивный бюджет (`_NOTIFY_SEND_*`) — иначе одна такая отправка
|
||||||
|
стопорит весь однопоточный poll loop на минуты;
|
||||||
|
2) собственный `except` — провал УВЕДОМЛЕНИЯ не отменяет основную ветку
|
||||||
|
обработки (клиент уже помечен заблокированным / медиа-реплай уже отклонён)
|
||||||
|
и не решает судьбу апдейта.
|
||||||
|
`context` — только технические идентификаторы (chat_id/thread_id), НЕ текст
|
||||||
|
переписки: логи моста принципиально не содержат ПДн.
|
||||||
|
|
||||||
|
Возвращает True, если уведомление реально ушло, False — если само
|
||||||
|
уведомление тоже упало (напр. Telegram недоступен). Вызывающий, для
|
||||||
|
которого проваленное уведомление означает ПОЛНУЮ тишину (ни клиенту, ни
|
||||||
|
оператору), обязан на False залогировать `logger.error` с идентификаторами
|
||||||
|
(#3471 P0) — иначе единственный след остаётся только в этом WARNING.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await client.send_message(
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
text=text,
|
||||||
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
reply_to_message_id=reply_to_message_id,
|
||||||
|
timeout=_NOTIFY_SEND_TIMEOUT_S,
|
||||||
|
max_retries=_NOTIFY_SEND_MAX_RETRIES,
|
||||||
|
max_backoff=_NOTIFY_SEND_MAX_BACKOFF_S,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"tgbot bridge: не удалось отправить уведомление оператору в топик (%s) — "
|
||||||
|
"основная ветка обработки не отменяется",
|
||||||
|
context,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── Update routing ────────────────────────────────────────────────────────────
|
# ── Update routing ────────────────────────────────────────────────────────────
|
||||||
async def _handle_private_message(
|
async def _handle_private_message(
|
||||||
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||||
|
|
@ -428,7 +550,11 @@ 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(chat_id=chat_id, text=GREETING_TEXT)
|
await client.send_message(
|
||||||
|
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:
|
||||||
|
|
@ -438,7 +564,11 @@ async def _handle_private_message(
|
||||||
chat_id,
|
chat_id,
|
||||||
)
|
)
|
||||||
# Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет.
|
# Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет.
|
||||||
await client.send_message(chat_id=chat_id, text=SERVICE_UNAVAILABLE_TEXT)
|
await client.send_message(
|
||||||
|
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")
|
||||||
|
|
@ -466,7 +596,11 @@ 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(chat_id=chat_id, text=FLOOD_LIMITED_TEXT)
|
await client.send_message(
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
@ -477,6 +611,7 @@ 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(
|
||||||
|
|
@ -484,6 +619,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -548,19 +684,20 @@ 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:
|
||||||
# Клиент заблокировал бота — фиксируем и уведомляем оператора в топике.
|
# Клиент заблокировал бота — фиксируем и уведомляем оператора в топике.
|
||||||
storage.mark_blocked(target_chat_id)
|
storage.mark_blocked(target_chat_id)
|
||||||
await client.send_message(
|
await _notify_topic(
|
||||||
chat_id=settings.telegram_support_chat_id,
|
client,
|
||||||
text=(
|
text=(
|
||||||
f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — "
|
f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — "
|
||||||
"бот заблокирован."
|
"бот заблокирован."
|
||||||
),
|
),
|
||||||
message_thread_id=settings.telegram_support_topic_id or None,
|
|
||||||
reply_to_message_id=message_id,
|
reply_to_message_id=message_id,
|
||||||
|
context=f"403 на доставке клиенту chat_id={target_chat_id}",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
raise
|
raise
|
||||||
|
|
@ -570,10 +707,22 @@ 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,
|
||||||
topic_message_id=None,
|
# #3471 P0: id ЭТОГО сообщения оператора В ТОПИКЕ (было безусловно
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
@ -592,11 +741,11 @@ async def _handle_group_reply(
|
||||||
web_thread_id,
|
web_thread_id,
|
||||||
kind,
|
kind,
|
||||||
)
|
)
|
||||||
await client.send_message(
|
await _notify_topic(
|
||||||
chat_id=settings.telegram_support_chat_id,
|
client,
|
||||||
text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT,
|
text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT,
|
||||||
message_thread_id=settings.telegram_support_topic_id or None,
|
|
||||||
reply_to_message_id=message_id if isinstance(message_id, int) else None,
|
reply_to_message_id=message_id if isinstance(message_id, int) else None,
|
||||||
|
context=f"медиа-реплай на веб-зеркало thread_id={web_thread_id}",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -607,11 +756,64 @@ 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")
|
||||||
storage.record_web_out_message(
|
try:
|
||||||
thread_id=web_thread_id,
|
storage.record_web_out_message(
|
||||||
text_body=text_body,
|
thread_id=web_thread_id,
|
||||||
operator_tg_id=operator_id,
|
text_body=text_body,
|
||||||
)
|
operator_tg_id=operator_id,
|
||||||
|
# #3471 P0: id ЭТОГО сообщения оператора в топике — без него
|
||||||
|
# реплай оператора на СВОЙ предыдущий веб-ответ не резолвится
|
||||||
|
# (см. `find_thread_by_topic_message`, direction-фильтр снят).
|
||||||
|
topic_message_id=message_id if isinstance(message_id, int) else None,
|
||||||
|
# Deep review PR #3479: БЕЗ этого out-строка писалась бы с
|
||||||
|
# support_chat_id=NULL — `find_thread_by_topic_message` матчит
|
||||||
|
# NULL под ЛЮБЫМ текущим чатом (лениентный wildcard для легаси
|
||||||
|
# строк до 187/188), т.е. каждая out-строка стала бы вечным
|
||||||
|
# wildcard. При ротации support-группы новый message_id мог бы
|
||||||
|
# совпасть со старой out-строкой и увести ответ в ЧУЖОЙ тред —
|
||||||
|
# ровно то, от чего защищала скоупинг-миграция 187/188.
|
||||||
|
support_chat_id=settings.telegram_support_chat_id,
|
||||||
|
)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
# #3471 P0: для веб-треда ЭТА запись — и есть доставка клиенту (веб-
|
||||||
|
# фронт вычитывает ответ обычным polling'ом web_support_messages).
|
||||||
|
# Откат без уведомления означал бы: оператор уверен, что ответил,
|
||||||
|
# клиент ждёт молча. Offset ниже всё равно сдвигается — НЕ потому,
|
||||||
|
# что апдейт "частично применён в Telegram" (в этой ветке до сбоя в
|
||||||
|
# Telegram ничего не уходило вообще: сам реплай оператора Telegram
|
||||||
|
# уже полностью доставил ДО того, как мы начали его разбирать,
|
||||||
|
# ретраить на стороне площадки нечего), а потому что действует общая
|
||||||
|
# политика `process_update` для `SQLAlchemyError` — сбой БД не
|
||||||
|
# переигрывается (в отличие от `TelegramNetworkError`), а
|
||||||
|
# сигнализируется громко; здесь это explicit-просьба оператору
|
||||||
|
# прислать ответ заново — human-in-the-loop retry вместо
|
||||||
|
# технического. rollback() ОБЯЗАН отработать ДО уведомления —
|
||||||
|
# сессия в failed-transaction state, а `_notify_topic` шлёт через
|
||||||
|
# `client`, не через `storage`, поэтому сам rollback тут не нужен для
|
||||||
|
# отправки, но нужен, чтобы process_update дальше не упал на
|
||||||
|
# save_offset/commit тем же PendingRollbackError (см. #3 review).
|
||||||
|
storage.rollback()
|
||||||
|
notified = await _notify_topic(
|
||||||
|
client,
|
||||||
|
text=(
|
||||||
|
"Не удалось сохранить ваш ответ из-за сбоя базы данных — клиенту "
|
||||||
|
"он НЕ доставлен. Пожалуйста, отправьте ответ ещё раз."
|
||||||
|
),
|
||||||
|
reply_to_message_id=message_id if isinstance(message_id, int) else None,
|
||||||
|
context=f"сбой БД на доставке веб-ответа thread_id={web_thread_id}",
|
||||||
|
)
|
||||||
|
if not notified:
|
||||||
|
# Оба канала молчат (БД и уведомление) — единственный след,
|
||||||
|
# который останется, это эта строка. Идентификаторы, НЕ текст
|
||||||
|
# (ПДн в лог не идёт) — по ним человек найдёт ответ оператора в
|
||||||
|
# топике и перешлёт его руками (#3471 P0).
|
||||||
|
logger.error(
|
||||||
|
"tgbot bridge: сбой БД на веб-ответе И не удалось уведомить "
|
||||||
|
"оператора (thread_id=%d, message_id=%s) — ответ клиенту "
|
||||||
|
"потерян молча, требуется ручной разбор support-топика",
|
||||||
|
web_thread_id,
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
|
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
|
||||||
|
|
@ -636,25 +838,46 @@ async def _handle_group_reply(
|
||||||
|
|
||||||
async def process_update(
|
async def process_update(
|
||||||
update: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
update: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""Маршрутизирует один Telegram update. Дедуп (C) + атомарный offset-commit.
|
"""Маршрутизирует один Telegram update. Дедуп (C) + атомарный offset-commit.
|
||||||
|
|
||||||
Дедуп: update_id <= сохранённого offset — skip без side-effects. Offset
|
Возвращает True, если offset сдвинут (апдейт подтверждён, Telegram его больше
|
||||||
сохраняется и коммитится ПОСЛЕ обработки (в т.ч. если обработка упала —
|
не отдаст), и False, если апдейт СОЗНАТЕЛЬНО оставлен неподтверждённым ради
|
||||||
иначе «ядовитый» апдейт блокировал бы весь поток навсегда).
|
переигрывания. На False вызывающий (`run_poll_loop`) ОБЯЗАН прервать разбор
|
||||||
|
пачки: offset у Telegram — единая «высшая отметка», подтверждение любого
|
||||||
|
СЛЕДУЮЩЕГО апдейта неявно подтвердило бы и этот, и переигрывания не было бы.
|
||||||
|
|
||||||
Различаем сбой БД (`SQLAlchemyError`) от прочих (Telegram API и т.п.):
|
Дедуп: update_id <= сохранённого offset — skip без side-effects.
|
||||||
сбой БД оставляет сессию в failed-transaction state — `rollback()` ОБЯЗАН
|
|
||||||
отработать ПЕРЕД `save_offset`, иначе тот сам кинет `PendingRollbackError`,
|
Судьба offset'а по классам отказа:
|
||||||
`process_update` вылетит без сохранения offset'а, следующая итерация
|
- `TelegramNetworkError` (транзиентный: Telegram не ответил, бюджет ретраев
|
||||||
`run_poll_loop` получит СТАРЫЙ offset от `get_offset()` и переиграет тот же
|
клиента исчерпан) — offset НЕ двигаем, `rollback()` частичных записей,
|
||||||
апдейт заново — copyMessage задублирует зеркало клиента в топике на
|
апдейт переигрывается на следующей итерации. Иначе ответ оператора
|
||||||
каждый повтор поллинга (#3 review, воспроизведено).
|
терялся НАВСЕГДА: copyMessage не дошёл, `record_message` не выполнился,
|
||||||
|
Telegram апдейт больше не отдаст, а оператор уверен, что ответил
|
||||||
|
(#tg-connection-resilience). Ограничено `_MAX_NETWORK_REPLAYS` — на
|
||||||
|
потолке offset всё-таки сдвигается с `logger.error`, иначе «вечно
|
||||||
|
недоставляемый» апдейт заклинил бы поток навсегда.
|
||||||
|
- `SQLAlchemyError` (сбой БД) — offset двигаем, но `rollback()` ОБЯЗАН
|
||||||
|
отработать ПЕРЕД `save_offset`: сбой БД оставляет сессию в
|
||||||
|
failed-transaction state, иначе `save_offset` сам кинет
|
||||||
|
`PendingRollbackError`, `process_update` вылетит без сохранения offset'а,
|
||||||
|
следующая итерация получит СТАРЫЙ offset от `get_offset()` и переиграет
|
||||||
|
тот же апдейт — copyMessage задублирует зеркало клиента в топике на
|
||||||
|
каждый повтор поллинга (#3 review, воспроизведено). Для веб-ветки
|
||||||
|
(`_handle_group_reply` → `record_web_out_message`) этот `SQLAlchemyError`
|
||||||
|
перехватывается ЛОКАЛЬНО, до этого места: там запись в БД И ЕСТЬ
|
||||||
|
доставка клиенту, поэтому rollback сопровождается уведомлением оператору
|
||||||
|
в топике, что ответ НЕ доставлен (#3471 P0) — сюда, на верхний уровень,
|
||||||
|
это исключение уже не долетает.
|
||||||
|
- любое прочее исключение (в т.ч. `TelegramApiError` — площадка ОТВЕТИЛА
|
||||||
|
отказом, повтор ничего не изменит) — offset двигаем, «ядовитый» апдейт
|
||||||
|
не блокирует поток.
|
||||||
"""
|
"""
|
||||||
update_id = update.get("update_id")
|
update_id = update.get("update_id")
|
||||||
if not isinstance(update_id, int):
|
if not isinstance(update_id, int):
|
||||||
logger.warning("tgbot bridge: update без валидного update_id — игнор")
|
logger.warning("tgbot bridge: update без валидного update_id — игнор")
|
||||||
return
|
return True
|
||||||
|
|
||||||
current_offset = storage.get_offset()
|
current_offset = storage.get_offset()
|
||||||
if update_id <= current_offset:
|
if update_id <= current_offset:
|
||||||
|
|
@ -663,7 +886,7 @@ async def process_update(
|
||||||
update_id,
|
update_id,
|
||||||
current_offset,
|
current_offset,
|
||||||
)
|
)
|
||||||
return
|
return True
|
||||||
|
|
||||||
message = update.get("message")
|
message = update.get("message")
|
||||||
try:
|
try:
|
||||||
|
|
@ -677,6 +900,35 @@ async def process_update(
|
||||||
await _handle_group_reply(message, client, storage)
|
await _handle_group_reply(message, client, storage)
|
||||||
# иначе — необрабатываемый тип чата/апдейта (edited_message, канал и
|
# иначе — необрабатываемый тип чата/апдейта (edited_message, канал и
|
||||||
# т.п.) — тихий игнор, но offset всё равно сдвигаем ниже.
|
# т.п.) — тихий игнор, но offset всё равно сдвигаем ниже.
|
||||||
|
except TelegramNetworkError:
|
||||||
|
attempts = _network_replay_attempts.get(update_id, 0) + 1
|
||||||
|
# Частичные записи этого апдейта не должны уехать в БД чужим commit'ом
|
||||||
|
# (сессия одна на всю пачку) — переигрывание начинается с чистого листа.
|
||||||
|
storage.rollback()
|
||||||
|
if attempts < _MAX_NETWORK_REPLAYS:
|
||||||
|
_network_replay_attempts[update_id] = attempts
|
||||||
|
logger.warning(
|
||||||
|
"tgbot bridge: Telegram недоступен на update_id=%d (отказ %d из %d) — "
|
||||||
|
"offset НЕ сдвигаем, апдейт переиграется на следующей итерации",
|
||||||
|
update_id,
|
||||||
|
attempts,
|
||||||
|
_MAX_NETWORK_REPLAYS,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
logger.error(
|
||||||
|
"tgbot bridge: update_id=%d исчерпал потолок переигрываний (%d сетевых "
|
||||||
|
"отказов подряд) — сдвигаем offset, содержимое апдейта ПОТЕРЯНО; поток не "
|
||||||
|
"блокируем, требуется ручной разбор support-топика "
|
||||||
|
"(chat_id=%s, message_id=%s)",
|
||||||
|
update_id,
|
||||||
|
_MAX_NETWORK_REPLAYS,
|
||||||
|
# Идентификаторы, а НЕ текст: это единственная строка, по которой
|
||||||
|
# человек найдёт потерянный ответ оператора в топике и перешлёт его
|
||||||
|
# руками. Без них в логе остаётся только update_id, которого в
|
||||||
|
# интерфейсе Telegram не видно. Текст сообщения — ПДн, в лог не идёт.
|
||||||
|
(message or {}).get("chat", {}).get("id") if isinstance(message, dict) else None,
|
||||||
|
(message or {}).get("message_id") if isinstance(message, dict) else None,
|
||||||
|
)
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"tgbot bridge: DB-ошибка на update_id=%d — rollback перед сохранением "
|
"tgbot bridge: DB-ошибка на update_id=%d — rollback перед сохранением "
|
||||||
|
|
@ -690,9 +942,12 @@ async def process_update(
|
||||||
"(не блокируем поток на 'ядовитом' апдейте)",
|
"(не блокируем поток на 'ядовитом' апдейте)",
|
||||||
update_id,
|
update_id,
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
storage.save_offset(update_id)
|
# Апдейт подтверждён — счётчик переигрываний больше не нужен (словарь не растёт).
|
||||||
storage.commit()
|
_network_replay_attempts.pop(update_id, None)
|
||||||
|
storage.save_offset(update_id)
|
||||||
|
storage.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ── Long-polling loop ─────────────────────────────────────────────────────────
|
# ── Long-polling loop ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -717,8 +972,20 @@ async def run_poll_loop(
|
||||||
updates = await client.get_updates(
|
updates = await client.get_updates(
|
||||||
offset=offset + 1, timeout=poll_timeout_s, allowed_updates=["message"]
|
offset=offset + 1, timeout=poll_timeout_s, allowed_updates=["message"]
|
||||||
)
|
)
|
||||||
for update in updates:
|
for idx, update in enumerate(updates):
|
||||||
await process_update(update, client, storage)
|
if not await process_update(update, client, storage):
|
||||||
|
# Апдейт намеренно не подтверждён (транзиентный сетевой
|
||||||
|
# отказ). Обрабатывать остаток пачки НЕЛЬЗЯ: offset —
|
||||||
|
# единая «высшая отметка», подтверждение следующего
|
||||||
|
# апдейта неявно подтвердило бы и этот. Остаток Telegram
|
||||||
|
# отдаст заново на следующей итерации.
|
||||||
|
logger.warning(
|
||||||
|
"tgbot bridge: update_id=%s не подтверждён — остаток пачки "
|
||||||
|
"(%d апдейтов) разберём на следующей итерации",
|
||||||
|
update.get("update_id"),
|
||||||
|
len(updates) - idx - 1,
|
||||||
|
)
|
||||||
|
break
|
||||||
consecutive_errors = 0
|
consecutive_errors = 0
|
||||||
except Exception:
|
except Exception:
|
||||||
consecutive_errors += 1
|
consecutive_errors += 1
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,22 @@ Docs: https://core.telegram.org/bots/api
|
||||||
Ретраи:
|
Ретраи:
|
||||||
- HTTP 429 (Too Many Requests) — уважаем `parameters.retry_after` из тела ответа
|
- HTTP 429 (Too Many Requests) — уважаем `parameters.retry_after` из тела ответа
|
||||||
(Telegram сам говорит сколько ждать), fallback на `_DEFAULT_RETRY_AFTER_S`.
|
(Telegram сам говорит сколько ждать), fallback на `_DEFAULT_RETRY_AFTER_S`.
|
||||||
- HTTP 5xx / сетевые ошибки (timeout/connect) — экспоненциальный backoff,
|
- HTTP 5xx / транспортные ошибки (`httpx.TransportError`: timeout, connect,
|
||||||
`capped` на `_MAX_BACKOFF_S`.
|
обрыв протокола, прокси) — экспоненциальный backoff, `capped` на
|
||||||
|
`_MAX_BACKOFF_S`.
|
||||||
|
- Прочие отказы запроса (`httpx.RequestError`: битый ответ) — НЕ ретряются,
|
||||||
|
сразу `TelegramNetworkError`: повтор не чинит ни испорченный ответ, ни
|
||||||
|
кривую конфигурацию.
|
||||||
- Любая другая 4xx (400/401/403/404) — НЕ ретраится, сразу `TelegramApiError`
|
- Любая другая 4xx (400/401/403/404) — НЕ ретраится, сразу `TelegramApiError`
|
||||||
(запрос некорректен или прав нет — повтор не поможет).
|
(запрос некорректен или прав нет — повтор не поможет).
|
||||||
|
|
||||||
|
Наружу летит только свой тип: `TelegramApiError` (площадка ответила отказом) или
|
||||||
|
`TelegramNetworkError` (не ответила), общий предок — `TelegramError`. Сырые
|
||||||
|
httpx-исключения из клиента не выходят: инвариант держат ДВА `except` в
|
||||||
|
`_request` — `httpx.TransportError` (ретраится) и страховочный
|
||||||
|
`httpx.RequestError` (не ретраится), вместе покрывающие всё дерево отказов
|
||||||
|
запроса, включая те, что появятся в httpx позже.
|
||||||
|
|
||||||
БЕЗОПАСНОСТЬ: наши `logger.*`-вызовы здесь содержат только имя метода API,
|
БЕЗОПАСНОСТЬ: наши `logger.*`-вызовы здесь содержат только имя метода API,
|
||||||
HTTP-статус и `description` из ответа Telegram — токен туда не пишем.
|
HTTP-статус и `description` из ответа Telegram — токен туда не пишем.
|
||||||
Это НЕ гарантирует, что токен не утечёт по другим стокам: он живёт в
|
Это НЕ гарантирует, что токен не утечёт по другим стокам: он живёт в
|
||||||
|
|
@ -32,6 +43,7 @@ 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
|
||||||
|
|
@ -43,8 +55,199 @@ _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
|
||||||
|
|
||||||
class TelegramApiError(Exception):
|
# Текст зондирующего сообщения `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 разворачивает скаляр в
|
||||||
|
# connect=read=write=pool, поэтому long-poll `getUpdates` (read = 30с, которые
|
||||||
|
# Telegram держит запрос, + 10с запаса = 40с) ставил 40 секунд и на УСТАНОВКУ
|
||||||
|
# соединения. Живой connect до api.telegram.org из прод-контейнера занимает
|
||||||
|
# 0.036с — 40-секундное ожидание коннекта было чистой слепотой: худший цикл
|
||||||
|
# 4 попытки × 40с + backoff ≈ 174с, и всё это время бот не видит ответов
|
||||||
|
# оператора (замер 12.09.2026: разрывы в логе 06:40:10 → 06:42:22 → 06:43:35,
|
||||||
|
# 576 строк `network error` и 7 полных исчерпаний бюджета ретраев за сутки).
|
||||||
|
# connect/write/pool к ожиданию ОТВЕТА Telegram отношения не имеют и коротки.
|
||||||
|
_CONNECT_TIMEOUT_S = 5.0
|
||||||
|
_WRITE_TIMEOUT_S = 10.0
|
||||||
|
_POOL_TIMEOUT_S = 5.0
|
||||||
|
|
||||||
|
# Пул keep-alive соединений на ОДИН экземпляр клиента. Параллелизма тут почти
|
||||||
|
# нет (long-polling — один запрос за раз, интерактивные ручки — единицы в
|
||||||
|
# минуту), так что смысл пула не в ширине, а в том, чтобы TCP+TLS-хендшейк не
|
||||||
|
# повторялся на каждый запрос и каждый ретрай.
|
||||||
|
_MAX_KEEPALIVE_CONNECTIONS = 5
|
||||||
|
_MAX_CONNECTIONS = 10
|
||||||
|
|
||||||
|
# Сколько держать простаивающее соединение. Задаём ЯВНО, потому что дефолт
|
||||||
|
# httpx — 5 секунд, и с ним пул не давал бы ничего там, где он нужнее всего:
|
||||||
|
# poll loop переиспользует соединение (следующий getUpdates уходит сразу), а
|
||||||
|
# вот веб-поддержка шлёт сообщения раз в минуты — за 5с соединение протухает и
|
||||||
|
# каждое зеркало снова платит полный TCP+TLS.
|
||||||
|
#
|
||||||
|
# Плата за длинный keep-alive — возросший шанс взять из пула соединение, которое
|
||||||
|
# уже закрыла та сторона; httpx отдаёт это как `RemoteProtocolError` («Server
|
||||||
|
# disconnected without sending a response»). Он ретраится с #3457, так что
|
||||||
|
# сценарий закрыт: попытка на протухшем соединении стоит один повтор, а не отказ.
|
||||||
|
_KEEPALIVE_EXPIRY_S = 90.0
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramError(Exception):
|
||||||
|
"""Общий предок отказов клиента: и «ответил ok: false», и «не ответил вовсе».
|
||||||
|
|
||||||
|
Нужен ровно затем, чтобы вызывающий мог одной строкой сказать «Telegram не
|
||||||
|
сработал» и отдать свой 502. До #3456 сетевой отказ прилетал наружу сырым
|
||||||
|
`httpx.ConnectTimeout`, мимо `except TelegramApiError`, и FastAPI отдавал
|
||||||
|
500 — см. `TelegramNetworkError`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramApiError(TelegramError):
|
||||||
"""Telegram Bot API ответил `ok: false` (после исчерпания ретраев, если применимо)."""
|
"""Telegram Bot API ответил `ok: false` (после исчерпания ретраев, если применимо)."""
|
||||||
|
|
||||||
def __init__(self, method: str, error_code: int, description: str) -> None:
|
def __init__(self, method: str, error_code: int, description: str) -> None:
|
||||||
|
|
@ -54,6 +257,59 @@ class TelegramApiError(Exception):
|
||||||
super().__init__(f"Telegram API {method} failed: {error_code} {description}")
|
super().__init__(f"Telegram API {method} failed: {error_code} {description}")
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNetworkError(TelegramError):
|
||||||
|
"""Ответа от Telegram не было: таймаут/обрыв, ретраи исчерпаны.
|
||||||
|
|
||||||
|
Отдельный тип, а не `TelegramApiError`, потому что `error_code`/`description`
|
||||||
|
брать неоткуда — Telegram ничего не сказал. Вызывающие, которым важна ТОЛЬКО
|
||||||
|
реакция площадки (`bridge`, разбирающий 403 «бот заблокирован»), продолжают
|
||||||
|
ловить `TelegramApiError` и этот отказ не перехватывают.
|
||||||
|
|
||||||
|
Причина сохраняется в `__cause__`: в GlitchTip виден исходный httpx-класс,
|
||||||
|
по которому и отличают таймаут соединения от сброса TLS (#3156).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, method: str, reason: str, attempts: int) -> None:
|
||||||
|
self.method = method
|
||||||
|
self.reason = reason
|
||||||
|
self.attempts = attempts
|
||||||
|
super().__init__(f"Telegram {method} unreachable after {attempts} attempts: {reason}")
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramRateLimitedError(TelegramError):
|
||||||
|
"""Слот в `TelegramGroupRateLimiter` не появился за `max_wait` секунд (review H1).
|
||||||
|
|
||||||
|
Бросается ТОЛЬКО когда вызывающий явно (или неявно, через `timeout`)
|
||||||
|
попросил ограниченное ожидание — фоновые вызовы без такого ограничения
|
||||||
|
ждут очередь сколько нужно и этого исключения никогда не увидят. Общий
|
||||||
|
предок `TelegramError` — существующие `except TelegramError` в
|
||||||
|
`app.api.v1.support`/`glitchtip` подхватывают этот отказ автоматически, без
|
||||||
|
правки самих ручек, и отвечают своим честным 502 вместо зависшего запроса.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, chat_id: int, max_wait: float) -> None:
|
||||||
|
self.chat_id = chat_id
|
||||||
|
self.max_wait = max_wait
|
||||||
|
super().__init__(
|
||||||
|
f"Telegram group rate limit: no slot for chat_id={chat_id} within {max_wait:.1f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_timeout(read: float) -> httpx.Timeout:
|
||||||
|
"""Разворачивает «сколько ждать ответа» (скаляр вызывающего) в таймауты httpx.
|
||||||
|
|
||||||
|
`read` — запрошенный бюджет ОТВЕТА (для long-poll это `poll_timeout + 10s`);
|
||||||
|
connect/write/pool фиксированы модульными константами и коротки: ждать
|
||||||
|
ответа Telegram — не то же самое, что ждать установки соединения.
|
||||||
|
"""
|
||||||
|
return httpx.Timeout(
|
||||||
|
connect=_CONNECT_TIMEOUT_S,
|
||||||
|
read=read,
|
||||||
|
write=_WRITE_TIMEOUT_S,
|
||||||
|
pool=_POOL_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_retry_after(
|
def _extract_retry_after(
|
||||||
response: httpx.Response, default: float = _DEFAULT_RETRY_AFTER_S
|
response: httpx.Response, default: float = _DEFAULT_RETRY_AFTER_S
|
||||||
) -> float:
|
) -> float:
|
||||||
|
|
@ -87,16 +343,111 @@ def _error_from_body(response: httpx.Response) -> tuple[int, str]:
|
||||||
|
|
||||||
|
|
||||||
class TelegramClient:
|
class TelegramClient:
|
||||||
"""Bot API клиент на httpx.AsyncClient. Каждый вызов — отдельное короткоживущее соединение."""
|
"""Bot API клиент поверх ОДНОГО долгоживущего `httpx.AsyncClient`.
|
||||||
|
|
||||||
|
Соединение переиспользуется всё время жизни экземпляра: `AsyncClient`
|
||||||
|
создаётся лениво при первом запросе и хранится в `self._http`. Раньше он
|
||||||
|
создавался ВНУТРИ цикла ретраев — то есть keep-alive не было вовсе: полный
|
||||||
|
TCP+TLS-хендшейк на каждый запрос и на каждую повторную попытку, и заново
|
||||||
|
кидался кубик «встанет ли коннект». Для long-polling'а, ходящего каждые
|
||||||
|
~30с в бесконечном цикле, это была основная статья сетевых отказов.
|
||||||
|
|
||||||
|
Отсюда — требование к вызывающим: экземпляр НАДО переиспользовать (один на
|
||||||
|
процесс воркера, один на FastAPI-приложение — см.
|
||||||
|
`app.services.tgbot.shared`), а не создавать на каждый запрос, и закрывать
|
||||||
|
через `aclose()` или `async with`.
|
||||||
|
|
||||||
|
Таймаут теперь per-request: у `AsyncClient` он стоит дефолтом, а каждый
|
||||||
|
вызов `_request` передаёт свой `httpx.Timeout` (long-poll — свои 40с на
|
||||||
|
read, интерактивные ручки — свой узкий бюджет).
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
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:
|
||||||
self._base = f"{base_url}/bot{token}"
|
# ── Ретранслятор через Beget (#3471) ────────────────────────────────
|
||||||
|
# `relay_base_url` пуст по умолчанию → `_relay_base is _direct_base`,
|
||||||
|
# и `_post` ниже не делает второй попытки (фолбэчить с прямого пути
|
||||||
|
# НА прямой же путь бессмысленно). Заданный адрес переключает основной
|
||||||
|
# путь на ретранслятор, прямой остаётся ЗАПАСНЫМ на случай его отказа.
|
||||||
|
self._direct_base = f"{base_url}/bot{token}"
|
||||||
|
self._relay_base = f"{relay_base_url}/bot{token}" if relay_base_url else self._direct_base
|
||||||
|
self._relay_secret = relay_secret
|
||||||
|
self._base = self._relay_base
|
||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
|
self._http: httpx.AsyncClient | None = None
|
||||||
|
# Один лимитер на экземпляр клиента — см. `TelegramGroupRateLimiter`.
|
||||||
|
# Ретранслятор здесь ни при чём: лимитер стоит ДО `_post`, то есть
|
||||||
|
# считает отправку независимо от того, уйдёт она через relay или
|
||||||
|
# напрямую (#3471) — оба пути ниже по стеку от этой точки.
|
||||||
|
self._rate_limiter = TelegramGroupRateLimiter(group_rate_limit_per_minute)
|
||||||
|
|
||||||
|
def _http_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Ленивое создание переиспользуемого AsyncClient (вне цикла ретраев)."""
|
||||||
|
if self._http is None:
|
||||||
|
self._http = httpx.AsyncClient(
|
||||||
|
timeout=_request_timeout(self._timeout),
|
||||||
|
limits=httpx.Limits(
|
||||||
|
max_keepalive_connections=_MAX_KEEPALIVE_CONNECTIONS,
|
||||||
|
max_connections=_MAX_CONNECTIONS,
|
||||||
|
keepalive_expiry=_KEEPALIVE_EXPIRY_S,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self._http
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Закрывает пул соединений. Идемпотентно; после — клиент снова ленив."""
|
||||||
|
http, self._http = self._http, None
|
||||||
|
if http is not None:
|
||||||
|
await http.aclose()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> TelegramClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_exc: object) -> None:
|
||||||
|
await self.aclose()
|
||||||
|
|
||||||
|
async def _post(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
url: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
timeout: httpx.Timeout,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""POST с фолбэком на прямой путь при отказе РЕТРАНСЛЯТОРА (#3471).
|
||||||
|
|
||||||
|
Когда ретранслятор не настроен, `_relay_base is _direct_base`, `via_relay`
|
||||||
|
ниже всегда `False`, и метод ведёт себя как простой `client.post` — этот
|
||||||
|
путь ничем не отличается от поведения до #3471 (механизм отката).
|
||||||
|
|
||||||
|
Когда настроен: `url` бьёт в `self._relay_base`. Транспортный отказ (не
|
||||||
|
ответ Telegram ЧЕРЕЗ ретранслятор, а отказ ДО него — TCP/TLS до самого
|
||||||
|
relay-хоста) даёт РОВНО ОДНУ попытку напрямую к api.telegram.org — не
|
||||||
|
рекурсивно: если недоступен и прямой путь, исключение поднимается как
|
||||||
|
обычно и подхватывается retry-циклом `_request` на общих основаниях (со
|
||||||
|
следующей попытки цикл снова пробует ретранслятор first — временный
|
||||||
|
сбой relay не должен постоянно понижать клиента до прямого пути).
|
||||||
|
"""
|
||||||
|
via_relay = self._relay_base != self._direct_base and url.startswith(self._relay_base)
|
||||||
|
headers = (
|
||||||
|
{"X-Relay-Secret": self._relay_secret} if via_relay and self._relay_secret else None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await client.post(url, json=payload, timeout=timeout, headers=headers)
|
||||||
|
except httpx.TransportError:
|
||||||
|
if not via_relay:
|
||||||
|
raise
|
||||||
|
direct_url = self._direct_base + url[len(self._relay_base) :]
|
||||||
|
logger.warning(
|
||||||
|
"tg client: ретранслятор недоступен, одна попытка напрямую к Telegram"
|
||||||
|
)
|
||||||
|
return await client.post(direct_url, json=payload, timeout=timeout)
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
|
|
@ -106,9 +457,24 @@ 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 — это
|
||||||
воркерная политика, она НЕ меняется. Интерактивный вызывающий передаёт узкий
|
воркерная политика, она НЕ меняется. Интерактивный вызывающий передаёт узкий
|
||||||
|
|
@ -122,17 +488,51 @@ class TelegramClient:
|
||||||
это штатные 30-60с) на число попыток и подвесил бы синхронный HTTP-запрос на
|
это штатные 30-60с) на число попыток и подвесил бы синхронный HTTP-запрос на
|
||||||
минуты — ровно то, от чего предостерегает докстринг `send_message`.
|
минуты — ровно то, от чего предостерегает докстринг `send_message`.
|
||||||
"""
|
"""
|
||||||
url = f"{self._base}/{method}"
|
|
||||||
effective_timeout = timeout if timeout is not None else self._timeout
|
effective_timeout = timeout if timeout is not None else self._timeout
|
||||||
|
|
||||||
|
# Лимитер применяется ТОЛЬКО к методам с `chat_id` в payload (отправка
|
||||||
|
# в конкретный чат) — `getUpdates` его не несёт и лимиту не подлежит.
|
||||||
|
# Списывается ОДИН слот на логический вызов `_request` (то есть на
|
||||||
|
# одну попытку отправки конкретного сообщения), а не на каждую HTTP
|
||||||
|
# попытку внутри ретрай-цикла ниже: ретраи по 429/5xx лечат один и тот
|
||||||
|
# же send, а не порождают новые отправки. Приоритет `wait_cap` — см.
|
||||||
|
# докстринг параметра `rate_limit_max_wait` выше.
|
||||||
|
chat_id = payload.get("chat_id")
|
||||||
|
if isinstance(chat_id, int):
|
||||||
|
wait_cap = rate_limit_max_wait
|
||||||
|
if wait_cap is None and timeout is not None:
|
||||||
|
wait_cap = effective_timeout
|
||||||
|
await self._rate_limiter.acquire(chat_id, max_wait=wait_cap)
|
||||||
|
|
||||||
|
url = f"{self._base}/{method}"
|
||||||
|
# Раздельные таймауты считаем ОДИН раз и передаём per-request: у общего
|
||||||
|
# AsyncClient свой дефолт, а бюджет ответа у каждого вызова свой.
|
||||||
|
request_timeout = _request_timeout(effective_timeout)
|
||||||
backoff_cap = _MAX_BACKOFF_S if max_backoff is None else max_backoff
|
backoff_cap = _MAX_BACKOFF_S if max_backoff is None else max_backoff
|
||||||
attempt = 0
|
attempt = 0
|
||||||
|
# Клиент берём ДО цикла: пересоздавать его на каждую попытку значило бы
|
||||||
|
# заново платить за TCP+TLS ровно там, где сеть уже показала себя плохо.
|
||||||
|
client = self._http_client()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=effective_timeout) as client:
|
response = await self._post(client, url, payload, request_timeout)
|
||||||
response = await client.post(url, json=payload)
|
except httpx.TransportError as exc:
|
||||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
# Ловим ВЕСЬ `TransportError`, а не узкий кортеж
|
||||||
|
# `(TimeoutException, NetworkError)`: `RemoteProtocolError`
|
||||||
|
# («Server disconnected without sending a response» — бытовой
|
||||||
|
# ответ api.telegram.org из РФ), `ProxyError`,
|
||||||
|
# `LocalProtocolError` и `UnsupportedProtocol` — СЁСТРЫ
|
||||||
|
# `NetworkError` по `TransportError`, а не наследники. Кортеж
|
||||||
|
# оставлял дыру ровно того класса, который чинил #3456: отказ
|
||||||
|
# вылетал сырым httpx мимо `except TelegramError` в ручках и
|
||||||
|
# снова давал 500 вместо 502 — и вдобавок не ретраился ни разу.
|
||||||
|
# Расширение ретраев на `RemoteProtocolError` наследует уже
|
||||||
|
# принятый здесь риск at-least-once (запрос мог дойти до
|
||||||
|
# Telegram, потерялся ответ) — он тот же, что у давно
|
||||||
|
# ретраящегося `ReadTimeout`; политика не меняется.
|
||||||
|
#
|
||||||
# Тип исключения обязан попасть в строку (#3156). У
|
# Тип исключения обязан попасть в строку (#3156). У
|
||||||
# httpx.ReadError и httpx.ConnectError `str(exc)` пуст, и лог
|
# httpx.ReadError и httpx.ConnectError `str(exc)` пуст, и лог
|
||||||
# выглядел так: «network error (попытка 1/3): — retry через 2s»
|
# выглядел так: «network error (попытка 1/3): — retry через 2s»
|
||||||
|
|
@ -148,7 +548,7 @@ class TelegramClient:
|
||||||
attempt,
|
attempt,
|
||||||
reason,
|
reason,
|
||||||
)
|
)
|
||||||
raise
|
raise TelegramNetworkError(method, reason, attempt) from exc
|
||||||
backoff = min(2.0**attempt, backoff_cap)
|
backoff = min(2.0**attempt, backoff_cap)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"tg client: %s — network error (попытка %d/%d): %s — retry через %.1fs",
|
"tg client: %s — network error (попытка %d/%d): %s — retry через %.1fs",
|
||||||
|
|
@ -160,6 +560,26 @@ class TelegramClient:
|
||||||
)
|
)
|
||||||
await asyncio.sleep(backoff)
|
await asyncio.sleep(backoff)
|
||||||
continue
|
continue
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
# Страховка на остаток дерева отказов запроса: сегодня это
|
||||||
|
# `DecodingError` (битая компрессия в ответе), завтра — всё, что
|
||||||
|
# httpx заведёт под `RequestError`. `TooManyRedirects` сюда НЕ
|
||||||
|
# относится: клиент создаётся с дефолтным `follow_redirects=False`
|
||||||
|
# и редиректы не ходит. Порядок `except`-ов
|
||||||
|
# значим: `TransportError` — наследник `RequestError`, и стоять
|
||||||
|
# обязан ВЫШЕ, иначе сетевые отказы перестали бы ретраиться.
|
||||||
|
#
|
||||||
|
# Без ретраев намеренно: это не «площадка недоступна», а
|
||||||
|
# испорченный ответ или кривая конфигурация — повтор не лечит
|
||||||
|
# ни то, ни другое, а пять попыток с backoff подвесили бы
|
||||||
|
# интерактивную ручку почти на минуту впустую. Свой тип тут
|
||||||
|
# нужен ровно за тем же, за чем и выше: чтобы ручка увидела
|
||||||
|
# `TelegramError` и отдала 502, а не 500.
|
||||||
|
reason = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
||||||
|
logger.error(
|
||||||
|
"tg client: %s — запрос не состоялся (без ретраев): %s", method, reason
|
||||||
|
)
|
||||||
|
raise TelegramNetworkError(method, reason, attempt) from exc
|
||||||
|
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
retry_after = _extract_retry_after(response)
|
retry_after = _extract_retry_after(response)
|
||||||
|
|
@ -232,8 +652,11 @@ class TelegramClient:
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Long-polling getUpdates. `timeout` — сколько Telegram держит запрос открытым (сек).
|
"""Long-polling getUpdates. `timeout` — сколько Telegram держит запрос открытым (сек).
|
||||||
|
|
||||||
HTTP-таймаут запроса берётся с запасом (`timeout + 10s`), чтобы не обрывать
|
Запас `+10s` относится к READ-таймауту (сколько ждём ответа), чтобы не
|
||||||
соединение раньше, чем ответит сам Telegram long-poll.
|
обрывать соединение раньше, чем ответит сам Telegram long-poll. На
|
||||||
|
connect/write/pool он НЕ распространяется — они короткие и фиксированы
|
||||||
|
(`_CONNECT_TIMEOUT_S` и соседи): установка соединения либо занимает
|
||||||
|
десятки миллисекунд, либо не состоится вовсе.
|
||||||
"""
|
"""
|
||||||
payload: dict[str, Any] = {"offset": offset, "timeout": timeout}
|
payload: dict[str, Any] = {"offset": offset, "timeout": timeout}
|
||||||
if allowed_updates is not None:
|
if allowed_updates is not None:
|
||||||
|
|
@ -251,8 +674,12 @@ 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,
|
||||||
|
|
@ -262,7 +689,9 @@ 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("copyMessage", payload)
|
result = await self._request(
|
||||||
|
"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(
|
||||||
|
|
@ -275,6 +704,7 @@ 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 — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
|
||||||
|
|
||||||
|
|
@ -298,5 +728,131 @@ 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
|
||||||
|
|
|
||||||
58
tradein-mvp/backend/app/services/tgbot/shared.py
Normal file
58
tradein-mvp/backend/app/services/tgbot/shared.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Общий на приложение `TelegramClient` (#tg-connection-resilience).
|
||||||
|
|
||||||
|
Зачем: до этого три HTTP-ручки (`api.v1.support` ×2, `api.v1.glitchtip`) делали
|
||||||
|
`TelegramClient(token)` на КАЖДЫЙ входящий запрос, а клиент внутри пересоздавал
|
||||||
|
`httpx.AsyncClient` на каждую попытку — то есть keep-alive не было ни на каком
|
||||||
|
уровне и каждый запрос начинался с полного TCP+TLS-хендшейка до
|
||||||
|
api.telegram.org. Здесь живёт один экземпляр на процесс: создаётся в lifespan
|
||||||
|
(`app.main`), закрывается на shutdown там же.
|
||||||
|
|
||||||
|
Воркер бота (`app.tgbot_main`) сюда НЕ ходит — у него свой процесс без ASGI и
|
||||||
|
свой экземпляр на всё время жизни поллинга.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.tgbot.client import TelegramClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_client: TelegramClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_telegram_client() -> TelegramClient:
|
||||||
|
"""Общий клиент приложения. Ленив: создаётся при первом обращении.
|
||||||
|
|
||||||
|
Ленивость (а не «только из lifespan») нужна из-за kill-switch: при пустом
|
||||||
|
`TELEGRAM_BOT_TOKEN` в lifespan создавать нечего, а тесты ручек поднимают
|
||||||
|
приложение без прохода через startup.
|
||||||
|
"""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = TelegramClient(
|
||||||
|
settings.telegram_bot_token,
|
||||||
|
relay_base_url=settings.telegram_relay_base_url,
|
||||||
|
relay_secret=settings.telegram_relay_secret,
|
||||||
|
# API-роль (review H2, #3471) — см. докстринг настройки в
|
||||||
|
# app.core.config: бюджет группы разделён статически между этим
|
||||||
|
# процессом и app.tgbot_main, суммарно ниже площадочного лимита.
|
||||||
|
group_rate_limit_per_minute=settings.telegram_group_rate_limit_api_per_minute,
|
||||||
|
)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def init_telegram_client() -> TelegramClient:
|
||||||
|
"""Явное создание на старте приложения (lifespan)."""
|
||||||
|
return get_telegram_client()
|
||||||
|
|
||||||
|
|
||||||
|
async def close_telegram_client() -> None:
|
||||||
|
"""Закрывает общий клиент на shutdown. Идемпотентно."""
|
||||||
|
global _client
|
||||||
|
client, _client = _client, None
|
||||||
|
if client is not None:
|
||||||
|
await client.aclose()
|
||||||
|
logger.info("tg shared client: пул соединений закрыт")
|
||||||
|
|
@ -61,6 +61,36 @@ 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,
|
||||||
*,
|
*,
|
||||||
|
|
@ -68,6 +98,7 @@ 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-топике, ключ маршрутизации ответа оператора.
|
||||||
|
|
@ -75,18 +106,103 @@ 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, created_at)
|
support_chat_id, operator_tg_id, idempotency_key, 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, NOW())
|
CAST(:support_chat_id AS bigint), NULL, :idempotency_key, NOW())
|
||||||
|
ON CONFLICT (thread_id, idempotency_key)
|
||||||
|
WHERE idempotency_key IS NOT NULL AND direction = 'in'
|
||||||
|
DO NOTHING
|
||||||
|
RETURNING id, direction, text_body, operator_tg_id, created_at
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"text_body": text_body,
|
||||||
|
"topic_message_id": topic_message_id,
|
||||||
|
"support_chat_id": support_chat_id,
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if row is not None:
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# Конфликт пойман индексом — idempotency_key почти наверняка не NULL здесь
|
||||||
|
# (при NULL partial-индекс в конфликт не участвует), но НЕ `assert`: это
|
||||||
|
# прод-путь ПОСЛЕ уже доставленного в Telegram сообщения (см. H1 в
|
||||||
|
# app.api.v1.support), а `except SQLAlchemyError` в вызывающей стороне
|
||||||
|
# `AssertionError` не ловит — под `python -O` assert вдобавок исчезает
|
||||||
|
# молча и `existing` осталось бы `None`, что развалилось бы чуть ниже при
|
||||||
|
# сборке ответа. Вместо падения — явная ветка с логом и WORKING откатом.
|
||||||
|
existing = (
|
||||||
|
find_inbound_by_idempotency_key(db, thread_id=thread_id, idempotency_key=idempotency_key)
|
||||||
|
if idempotency_key is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
# Ожидаемый случай гонки (#3471): проигравший запрос уже отправил своё
|
||||||
|
# СОБСТВЕННОЕ зеркало в Telegram (свой topic_message_id) до того, как
|
||||||
|
# обнаружил конфликт здесь — эта копия зеркала теперь осиротела
|
||||||
|
# (реплай оператора на неё никуда не смаршрутизируется, т.к. строки
|
||||||
|
# для неё в БД нет). Осознанно не чиним это в этом PR (см. коммент
|
||||||
|
# выше по коду), но фиксируем в логе, а не молчим — ровно то же самое,
|
||||||
|
# для чего уже есть `_warn_operator_message_not_recorded` в другом месте.
|
||||||
|
logger.warning(
|
||||||
|
"web support: гонка по idempotency_key — зеркало topic_message_id=%s "
|
||||||
|
"(thread_id=%d) отправлено, но НЕ записано, победила строка id=%d",
|
||||||
|
topic_message_id,
|
||||||
|
thread_id,
|
||||||
|
existing["id"],
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Крайний случай (в норме недостижим при READ COMMITTED, которую использует
|
||||||
|
# этот сервис): индекс сообщил о конфликте, но повторное чтение строку не
|
||||||
|
# нашло. Логируем и пишем БЕЗ идемпотентности — NULL-ключ никогда не
|
||||||
|
# конфликтует сам с собой (partial-индекс его не видит), INSERT гарантированно
|
||||||
|
# пройдёт; для этого одного сообщения дедупликация отключается, но клиент
|
||||||
|
# получает корректный ответ вместо 500 после уже состоявшейся доставки.
|
||||||
|
logger.warning(
|
||||||
|
"web support: ON CONFLICT сообщил о конфликте (thread_id=%d, "
|
||||||
|
"idempotency_key=%s), но повторное чтение строки её не нашло — "
|
||||||
|
"записываем без идемпотентности",
|
||||||
|
thread_id,
|
||||||
|
"<set>" if idempotency_key is not None else None,
|
||||||
|
)
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO web_support_messages
|
||||||
|
(thread_id, direction, text_body, topic_message_id,
|
||||||
|
support_chat_id, operator_tg_id, idempotency_key, created_at)
|
||||||
|
VALUES
|
||||||
|
(CAST(:thread_id AS bigint), 'in', :text_body,
|
||||||
|
CAST(:topic_message_id AS bigint),
|
||||||
|
CAST(:support_chat_id AS bigint), NULL, NULL, NOW())
|
||||||
RETURNING id, direction, text_body, operator_tg_id, created_at
|
RETURNING id, direction, text_body, operator_tg_id, created_at
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -106,8 +222,17 @@ 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 — это исторический артефакт
|
||||||
|
|
@ -120,7 +245,6 @@ 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
|
||||||
|
|
@ -132,18 +256,40 @@ def find_thread_by_topic_message(
|
||||||
|
|
||||||
|
|
||||||
def record_outbound(
|
def record_outbound(
|
||||||
db: Session, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
db: Session,
|
||||||
|
*,
|
||||||
|
thread_id: int,
|
||||||
|
text_body: str,
|
||||||
|
operator_tg_id: int | None,
|
||||||
|
topic_message_id: int | None = None,
|
||||||
|
support_chat_id: int | None = None,
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
"""Записывает ответ оператора (реплай на веб-зеркало) как direction='out'.
|
"""Записывает ответ оператора (реплай на веб-зеркало) как direction='out'.
|
||||||
`topic_message_id` всегда NULL — маршрутизирующий ключ живёт только на
|
|
||||||
inbound-записи (см. tg_support_messages-конвенцию, 186)."""
|
`topic_message_id` (#3471 P0, раньше был безусловно NULL) — id ЭТОГО
|
||||||
|
сообщения оператора в топике. Раньше маршрутизирующий ключ жил только на
|
||||||
|
inbound-записи (конвенция 186/187), из-за чего реплай оператора на СВОЙ
|
||||||
|
предыдущий ответ был нерезолвим — искать было нечего, а `reply_to_message_id`
|
||||||
|
указывал на строку без ключа. `find_thread_by_topic_message` теперь матчит
|
||||||
|
обе стороны (direction-фильтр там снят).
|
||||||
|
|
||||||
|
`support_chat_id` (deep review PR #3479) — ОБЯЗАТЕЛЕН при заполненном
|
||||||
|
`topic_message_id`: `find_thread_by_topic_message` матчит `support_chat_id
|
||||||
|
IS NULL` как лениентный wildcard "под любым текущим чатом" (легаси-строки
|
||||||
|
до 187/188). Без этого поля КАЖДАЯ out-строка была бы таким wildcard — при
|
||||||
|
ротации support-группы новый message_id мог бы совпасть со старой
|
||||||
|
out-строкой и увести ответ в ЧУЖОЙ тред (ровно то, от чего защищала
|
||||||
|
скоупинг-миграция 187/188, см. review M1 там же)."""
|
||||||
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, operator_tg_id, created_at)
|
(thread_id, direction, text_body, topic_message_id,
|
||||||
|
support_chat_id, operator_tg_id, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(CAST(:thread_id AS bigint), 'out', :text_body, NULL,
|
(CAST(:thread_id AS bigint), 'out', :text_body,
|
||||||
|
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
|
||||||
"""
|
"""
|
||||||
|
|
@ -151,6 +297,8 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -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.error(
|
logger.warning(
|
||||||
"yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run "
|
"yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run "
|
||||||
"пропущен, ни один листинг не обработан",
|
"пропущен, ни один листинг не обработан",
|
||||||
exc,
|
exc,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ 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² — отсекает нежилые/технические сделки; не меняется.
|
||||||
|
|
@ -68,13 +69,29 @@ _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-стороне. Когда появится per-city ratio через зарезервированный
|
# ниже) — симметрично deal-стороне.
|
||||||
# столбец `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). Три представления ОДНОЙ формулы —
|
||||||
|
|
@ -108,14 +125,26 @@ def area_bucket(area_m2: float) -> int:
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── True-mirror cleanup: drop all #648 rows before re-derivation ──────────────
|
# ── True-mirror cleanup: drop all #648 EKB 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 district = ''
|
WHERE region_code = 66 AND 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 = ''
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -266,19 +295,372 @@ _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
|
n_deals, n_listings, window_months, basis, region_code
|
||||||
)
|
)
|
||||||
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 FROM global_row
|
n_deals, n_listings, window_months, basis, 66 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 FROM per_bucket
|
n_deals, n_listings, window_months, basis, 66 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_WHERE_REGION = """
|
||||||
|
FROM deals
|
||||||
|
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'
|
||||||
|
"""
|
||||||
|
|
||||||
|
_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"""
|
||||||
|
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
|
||||||
|
{_DEAL_FROM_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
|
||||||
|
|
@ -292,13 +674,22 @@ _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 (TRUE-MIRROR refresh, #648 Stage 4).
|
"""Пересчитать asking_to_sold_ratios per region (#3512, 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-строки.
|
посчитанных регионов):
|
||||||
2. Заново прогнать 080-derivation INSERT...SELECT (per_rooms при 30/30 + global -1).
|
1. Регион 66 (ЕКБ): DELETE (region_code=66, district='') → 080-derivation
|
||||||
Затем counters из таблицы, commit, mark_done. Семантика == re-seed миграции 080.
|
INSERT...SELECT (byte-for-byte прежняя логика — городская квота ЕКБ, #C2/#2583).
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
@ -320,10 +711,15 @@ 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,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
|
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
|
||||||
# таблица не должна остаться пустой, если INSERT упадёт).
|
# таблица не должна остаться пустой, если INSERT упадёт). Регион 66 —
|
||||||
|
# прежняя ЕКБ-деривация байт-в-байт; остальные регионы — цикл ниже (#3512).
|
||||||
db.execute(_DELETE_SQL)
|
db.execute(_DELETE_SQL)
|
||||||
db.execute(
|
db.execute(
|
||||||
_REDERIVE_SQL,
|
_REDERIVE_SQL,
|
||||||
|
|
@ -334,6 +730,58 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
for region_code in _OTHER_REGION_CODES:
|
||||||
|
params = {
|
||||||
|
"region_code": region_code,
|
||||||
|
"ppm2_min": _PPM2_MIN,
|
||||||
|
"ppm2_max": settings.asking_ratio_ppm2_max,
|
||||||
|
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||||
|
}
|
||||||
|
# Сначала состав выборки (#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:
|
||||||
|
|
|
||||||
|
|
@ -806,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.error(
|
logger.warning(
|
||||||
"avito_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
"avito_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
||||||
"к площадке не ходили. enriched=%d attempted=%d",
|
"к площадке не ходили. enriched=%d attempted=%d",
|
||||||
run_id,
|
run_id,
|
||||||
|
|
@ -913,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.error(
|
logger.warning(
|
||||||
"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,
|
||||||
|
|
|
||||||
|
|
@ -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.error(
|
logger.warning(
|
||||||
"cian_history_backfill: СТОП — пул прокси пуст, к площадке "
|
"cian_history_backfill: СТОП — пул прокси пуст, к площадке "
|
||||||
"не ходили. listing_id=%s processed=%d succeeded=%d",
|
"не ходили. listing_id=%s processed=%d succeeded=%d",
|
||||||
listing_id,
|
listing_id,
|
||||||
|
|
|
||||||
|
|
@ -488,12 +488,39 @@ 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,
|
||||||
:ttl_days, :run_id) остаются param-binding — psycopg v3 safe (никаких :param::type).
|
:region_codes, :ttl_days, :run_id) остаются param-binding — psycopg v3 safe
|
||||||
|
(никаких :param::type). :region_codes -- см. DEFAULT_DEACTIVATION_REGION_CODES:
|
||||||
|
снятие идёт только по регионам с регулярным пересбором. NULL region_code не
|
||||||
|
матчится ANY(...) намеренно -- неизвестный регион нечем отнести к покрытому.
|
||||||
"""
|
"""
|
||||||
return text(
|
return text(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -501,6 +528,7 @@ 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
|
||||||
|
|
@ -514,7 +542,8 @@ 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[].
|
-- psycopg v3 адаптирует Python list -> text[]; то же для :region_codes -> int[]
|
||||||
|
(см. DEFAULT_DEACTIVATION_REGION_CODES).
|
||||||
"""
|
"""
|
||||||
return text(
|
return text(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -522,6 +551,7 @@ 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[]))
|
||||||
|
|
@ -541,7 +571,8 @@ 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"""
|
||||||
|
|
@ -549,6 +580,7 @@ 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
|
||||||
|
|
@ -639,13 +671,16 @@ 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).
|
(tests/test_deactivate_stale_deactivation_cap.py). Фильтр по :region_codes
|
||||||
|
обязан стоять и здесь: считать кандидатов по всем регионам, а снимать по
|
||||||
|
покрытым сбором -- значит кормить аварийный потолок чужими числами.
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
"""
|
"""
|
||||||
|
|
@ -661,6 +696,7 @@ 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[]))
|
||||||
|
|
@ -677,6 +713,7 @@ 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
|
||||||
|
|
@ -691,6 +728,7 @@ 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,
|
||||||
|
|
@ -710,6 +748,24 @@ 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
|
||||||
|
|
@ -815,7 +871,11 @@ 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:
|
||||||
|
|
@ -850,9 +910,7 @@ 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(
|
raise ValueError(f"floor_drop_ratio must be a number, not bool: {floor_drop_ratio!r}")
|
||||||
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}")
|
||||||
|
|
||||||
|
|
@ -876,6 +934,21 @@ 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. Деактивация необратима
|
||||||
# на практике (вернуть «живость» может только повторный сбор), поэтому
|
# на практике (вернуть «живость» может только повторный сбор), поэтому
|
||||||
# проверяем ПЕРЕД записью, а не откатываем после.
|
# проверяем ПЕРЕД записью, а не откатываем после.
|
||||||
|
|
@ -1060,6 +1133,9 @@ 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)
|
||||||
|
|
@ -1108,6 +1184,7 @@ 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)
|
||||||
|
|
@ -1115,6 +1192,7 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
@ -1123,6 +1201,7 @@ 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)
|
||||||
|
|
@ -1133,14 +1212,15 @@ 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, null_segment_only=%s, "
|
"(ttl_days=%d эффективный, задан %d, segments=%r, region_codes=%r, "
|
||||||
"staleness_column=%s)",
|
"null_segment_only=%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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,16 @@ kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refres
|
||||||
asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды считались по тому же
|
asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды считались по тому же
|
||||||
свежему срезу deals, что и ratio-таблица того же дня.
|
свежему срезу deals, что и ratio-таблица того же дня.
|
||||||
|
|
||||||
SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в
|
SQL derivation ниже держит ту же трёхуровневую схему, что seed в
|
||||||
data/sql/298_deal_city_price_bands_region.sql (region_stats / city_stats / tiered:
|
data/sql/298_deal_city_price_bands_region.sql (region_stats / city_stats / tiered:
|
||||||
трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см.
|
full N>=30 / rough N 10-29 / region_fallback N 1-9, см. комментарий в 194/298 для
|
||||||
комментарий в 194/298 для полного обоснования тиров и hard floor/ceiling клампов).
|
полного обоснования тиров и hard floor'а 8000), но РАСХОДИТСЯ с ним в двух местах
|
||||||
|
(#3051, округа Москвы): ключ города — COALESCE(NULLIF(raw_payload->>'src_city',''),
|
||||||
|
city) вместо голого city, и потолок ppm² — региональный region_ppm2_max вместо
|
||||||
|
литерала 800000. Полное обоснование обоих — в комментарии над _REDERIVE_SQL.
|
||||||
|
Для региона 66 обе правки тождественны прежнему поведению (src_city пуст у всех
|
||||||
|
его сделок, региональный потолок вырождается ровно в 800000) — проверено на проде
|
||||||
|
2026-09-10 пересчётом: 383 строки, все совпадают с текущими.
|
||||||
|
|
||||||
#3051 «Москва» (298): ключ (region_code, city) вместо (city) — region_stats и
|
#3051 «Москва» (298): ключ (region_code, city) вместо (city) — region_stats и
|
||||||
city_stats теперь группируются ПО РЕГИОНУ (region_code), а не по всей таблице
|
city_stats теперь группируются ПО РЕГИОНУ (region_code), а не по всей таблице
|
||||||
|
|
@ -25,8 +31,10 @@ deals целиком. Без этого пул для tier='region_fallback' о
|
||||||
подмешивал бы сделки другого (Москва в deals region_code=77 иначе тянула бы
|
подмешивал бы сделки другого (Москва в deals region_code=77 иначе тянула бы
|
||||||
p1-floor малых городов Свердловской обл. region_code=66 вверх). Для
|
p1-floor малых городов Свердловской обл. region_code=66 вверх). Для
|
||||||
region_code=66 derivation байт-в-байт прежняя (194): фильтр
|
region_code=66 derivation байт-в-байт прежняя (194): фильтр
|
||||||
NOT (region_code = 66 AND city = 'Екатеринбург') — тот же инвариант, что
|
NOT (region_code = 66 AND <ключ> = 'Екатеринбург'), где <ключ> — то же
|
||||||
раньше `city <> 'Екатеринбург'`; region_stats/city_stats для региона 66 видят
|
выражение, по которому идёт GROUP BY (прод 2026-09-11: у региона 66 ключ == city
|
||||||
|
у всех 108 623 сделок, обе формы исключают одни и те же 55 749 строк);
|
||||||
|
region_stats/city_stats для региона 66 видят
|
||||||
ТУ ЖЕ популяцию строк, что видели до появления региона 77 в deals.
|
ТУ ЖЕ популяцию строк, что видели до появления региона 77 в deals.
|
||||||
|
|
||||||
Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror
|
Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror
|
||||||
|
|
@ -35,7 +43,7 @@ NOT (region_code = 66 AND city = 'Екатеринбург') — тот же и
|
||||||
сделки), поэтому merge-по-ключу (ON CONFLICT DO UPDATE) достаточен: город,
|
сделки), поэтому merge-по-ключу (ON CONFLICT DO UPDATE) достаточен: город,
|
||||||
перешедший в другой tier, просто перезаписывается на следующем refresh.
|
перешедший в другой tier, просто перезаписывается на следующем refresh.
|
||||||
Екатеринбург НЕ включён для региона 66 (WHERE NOT (region_code = 66 AND
|
Екатеринбург НЕ включён для региона 66 (WHERE NOT (region_code = 66 AND
|
||||||
city = 'Екатеринбург')) — estimator.py fallback на глобальные
|
<ключ> = 'Екатеринбург')) — estimator.py fallback на глобальные
|
||||||
DEAL_MIN_PPM2/DEAL_MAX_PPM2 для ЕКБ остаётся byte-identical (invariant из
|
DEAL_MIN_PPM2/DEAL_MAX_PPM2 для ЕКБ остаётся byte-identical (invariant из
|
||||||
178/194/298 сохранён).
|
178/194/298 сохранён).
|
||||||
"""
|
"""
|
||||||
|
|
@ -48,63 +56,179 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
|
from app.services.deal_city_key import deal_city_key_sql
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 298, region-aware) ──────────────────
|
# Ключ города сделки — ОДНО выражение на derivation и на читающую сторону
|
||||||
_REDERIVE_SQL = text(
|
# (estimator.py), см. app/services/deal_city_key.py. Здесь alias пустой:
|
||||||
|
# в запросе ниже `FROM deals` без алиаса.
|
||||||
|
_CITY_KEY_SQL = deal_city_key_sql(alias="")
|
||||||
|
|
||||||
|
# ── Числа формулы регионального потолка ppm² (#3051) ─────────────────────────
|
||||||
|
# ОДИН источник и для SQL (_REGION_CEILING_SQL ниже), и для питоновского
|
||||||
|
# эквивалента region_ppm2_max(). Раньше формула жила двумя копиями (SQL +
|
||||||
|
# локальная копия в тесте): подмена множителя 6 на 3 оставляла ВСЕ тесты
|
||||||
|
# зелёными, тихо роняя потолок Москвы с 1766742 до 883371 и снова срезая дорогие
|
||||||
|
# округа. Теперь правка любого из этих чисел автоматически едет в обе стороны.
|
||||||
|
REGION_CEILING_FLOOR = 800_000 # исторический якорь: ниже потолок не падает нигде
|
||||||
|
REGION_CEILING_MEDIAN_MULT = 6 # шесть медианных ₽/м² региона — заведомо не рынок
|
||||||
|
REGION_CEILING_MEDIAN_Q = 0.5 # медиана региона
|
||||||
|
REGION_CEILING_CAP_Q = 0.9999 # шапка: одиночный мусорный выброс не раздувает потолок
|
||||||
|
|
||||||
|
|
||||||
|
def region_ppm2_max(p_cap: int, p_median: int) -> int:
|
||||||
|
"""Региональный потолок ppm²: GREATEST(floor, LEAST(p99.99, mult * медиана)).
|
||||||
|
|
||||||
|
Питоновский эквивалент _REGION_CEILING_SQL — собран из ТЕХ ЖЕ констант, а не
|
||||||
|
из своих чисел. Замеры прода 2026-09-10: регион 66 → (615312, 52706) = 800000
|
||||||
|
(тот же прежний литерал), регион 77 → (1944535, 294457) = 1766742.
|
||||||
"""
|
"""
|
||||||
|
return max(REGION_CEILING_FLOOR, min(p_cap, REGION_CEILING_MEDIAN_MULT * p_median))
|
||||||
|
|
||||||
|
|
||||||
|
_REGION_CEILING_CAP_SQL = (
|
||||||
|
f"round(percentile_cont({REGION_CEILING_CAP_Q}) WITHIN GROUP (ORDER BY price_per_m2))::int"
|
||||||
|
)
|
||||||
|
_REGION_CEILING_MEDIAN_SQL = (
|
||||||
|
f"{REGION_CEILING_MEDIAN_MULT} * "
|
||||||
|
f"round(percentile_cont({REGION_CEILING_MEDIAN_Q}) WITHIN GROUP (ORDER BY price_per_m2))::int"
|
||||||
|
)
|
||||||
|
_REGION_CEILING_SQL = (
|
||||||
|
f"GREATEST({REGION_CEILING_FLOOR}, "
|
||||||
|
f"LEAST({_REGION_CEILING_CAP_SQL}, {_REGION_CEILING_MEDIAN_SQL}))"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Derivation + re-seed (region-aware; #3051 округа Москвы + региональный потолок) ──
|
||||||
|
#
|
||||||
|
# #3051 (округа). Ключ города — COALESCE(NULLIF(raw_payload->>'src_city',''), city)
|
||||||
|
# вместо голого city: у московских сделок src_city несёт муниципальный округ
|
||||||
|
# (заполнен у 93.27% из 212 937), и вместо ОДНОЙ полосы 'Москва' 34221..718870
|
||||||
|
# получается 197 ключей — 152 в тире full, 8 rough, 37 region_fallback. Остаточные
|
||||||
|
# 6.73% сделок без src_city дают собственную строку 'Москва' (n=14376, tier full,
|
||||||
|
# 22475..772165) — они не проваливаются в глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2,
|
||||||
|
# откалиброванные под ЕКБ. Регион 66 не меняется: src_city пуст у всех его сделок
|
||||||
|
# (прод 2026-09-10: 0 строк, где ключ != city).
|
||||||
|
#
|
||||||
|
# #3051 (потолок). Литерал 800000 был калибровкой Свердловской области, а в Москве
|
||||||
|
# p99 округов доходит до 1 405 882 ₽/м² — 15 округов из 197 упирались в потолок,
|
||||||
|
# т.е. он резал не опечатки, а легитимный рынок. Потолок стал РЕГИОНАЛЬНЫМ
|
||||||
|
# (region_ppm2_max в region_stats):
|
||||||
|
# GREATEST(800000, LEAST(p9999_региона, 6 * медиана_региона))
|
||||||
|
# Три множителя, каждый со своим смыслом: 800000 — исторический якорь, ниже
|
||||||
|
# которого потолок не опускается нигде (страхует и от обвала цен); 6 * медиана —
|
||||||
|
# привязка к масштабу региона (шесть медианных ₽/м² — заведомо не рынок, а
|
||||||
|
# опечатка или доля); p99.99 — жёсткая шапка, чтобы одиночный мусорный выброс не
|
||||||
|
# раздул потолок. Замеры: регион 66 → GREATEST(800000, LEAST(615312, 316236)) =
|
||||||
|
# 800000, тот же литерал; регион 77 → GREATEST(800000, LEAST(1944535, 1766742)) =
|
||||||
|
# 1766742.
|
||||||
|
#
|
||||||
|
# Инвариант региона 66 проверен на проде 2026-09-10 пересчётом по этому же
|
||||||
|
# выражению: 383 строки против 383 текущих, все совпадают по
|
||||||
|
# (ppm2_min, ppm2_max, n_deals, tier). Запас прочности: чтобы потолок 66 сдвинулся,
|
||||||
|
# нужно ОДНОВРЕМЕННО медиане перевалить 133 333 (сейчас 52 706, x2.53) и p99.99
|
||||||
|
# перевалить 800 000 (сейчас 615 312, x1.3).
|
||||||
|
#
|
||||||
|
# Тиры (full N>=30 / rough N 10-29 / region_fallback N<10) и обоснование floor'а
|
||||||
|
# 8000 — без изменений, см. миграции 194/298.
|
||||||
|
_REDERIVE_SQL = text(
|
||||||
|
f"""
|
||||||
WITH region_stats AS (
|
WITH region_stats AS (
|
||||||
SELECT
|
SELECT
|
||||||
region_code,
|
region_code,
|
||||||
GREATEST(
|
GREATEST(
|
||||||
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
|
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
|
||||||
8000
|
8000
|
||||||
) AS region_ppm2_min
|
) AS region_ppm2_min,
|
||||||
|
{_REGION_CEILING_SQL} AS region_ppm2_max
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND doc_type = 'ДКП'
|
AND doc_type = 'ДКП'
|
||||||
AND price_per_m2 IS NOT NULL
|
AND price_per_m2 IS NOT NULL
|
||||||
AND city IS NOT NULL
|
-- #3051: непустоту ключа судим ТЕМ ЖЕ выражением, по которому идут GROUP BY
|
||||||
|
-- и предикат ЕКБ ниже (было: сырая колонка city). Сделка с непустым src_city
|
||||||
|
-- и NULL в city даёт ВАЛИДНЫЙ ключ, но сырой фильтр выбрасывал её целиком —
|
||||||
|
-- и из её собственной городской строки, и из региональной статистики, молча
|
||||||
|
-- занижая n_deals и перцентили региона, в том числе потолок. Прод 2026-09-11:
|
||||||
|
-- в популяции 321 560 сделок, city IS NULL — 0 строк, поэтому обе формы
|
||||||
|
-- сегодня тождественны: регион 66 — 52 874 строки, p1=15345, p50=52706,
|
||||||
|
-- p99.99=615312; регион 77 — 212 937 строк, 34221 / 294457 / 1944535;
|
||||||
|
-- расхождение 0 по всем регионам. Совпадение больше не держится на данных.
|
||||||
|
AND {_CITY_KEY_SQL} IS NOT NULL
|
||||||
AND region_code IS NOT NULL
|
AND region_code IS NOT NULL
|
||||||
AND NOT (region_code = 66 AND city = 'Екатеринбург')
|
-- #3051: исключение ЕКБ судится ТЕМ ЖЕ выражением ключа, по которому идёт
|
||||||
|
-- GROUP BY ниже (было: голая колонка city). Разъехавшиеся предикат и ключ
|
||||||
|
-- держались на данных: сегодня у региона 66 src_city пуст у всех 108 623
|
||||||
|
-- сделок, ключ == city, и обе формы дают одни и те же 55 749 исключённых
|
||||||
|
-- строк (прод 2026-09-11: by_city=55749, by_key=55749, расхождение 0).
|
||||||
|
-- Появись источник с src_city='Екатеринбург' у сделки с другим city —
|
||||||
|
-- старая форма пропустила бы её в derivation и завела строку полосы
|
||||||
|
-- 'Екатеринбург', которую ступень 1 нашла бы для настоящих ЕКБ-сделок,
|
||||||
|
-- сломав намеренное исключение. Теперь исключение и ключ — одно выражение.
|
||||||
|
AND NOT (region_code = 66 AND {_CITY_KEY_SQL} = 'Екатеринбург')
|
||||||
GROUP BY region_code
|
GROUP BY region_code
|
||||||
),
|
),
|
||||||
city_stats AS (
|
city_stats AS (
|
||||||
SELECT
|
SELECT
|
||||||
region_code,
|
region_code,
|
||||||
city,
|
{_CITY_KEY_SQL} AS city,
|
||||||
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
|
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
|
||||||
AS ppm2_p1,
|
AS ppm2_p1,
|
||||||
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
|
-- p99 сырой: клампится региональным потолком в ветке full ниже,
|
||||||
|
-- а не литералом 800000 (см. шапку).
|
||||||
|
round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int
|
||||||
AS ppm2_p99,
|
AS ppm2_p99,
|
||||||
count(*) AS n_deals
|
count(*) AS n_deals
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND doc_type = 'ДКП'
|
AND doc_type = 'ДКП'
|
||||||
AND price_per_m2 IS NOT NULL
|
AND price_per_m2 IS NOT NULL
|
||||||
AND city IS NOT NULL
|
-- #3051: непустоту ключа судим ТЕМ ЖЕ выражением, по которому идут GROUP BY
|
||||||
|
-- и предикат ЕКБ ниже (было: сырая колонка city). Сделка с непустым src_city
|
||||||
|
-- и NULL в city даёт ВАЛИДНЫЙ ключ, но сырой фильтр выбрасывал её целиком —
|
||||||
|
-- и из её собственной городской строки, и из региональной статистики, молча
|
||||||
|
-- занижая n_deals и перцентили региона, в том числе потолок. Прод 2026-09-11:
|
||||||
|
-- в популяции 321 560 сделок, city IS NULL — 0 строк, поэтому обе формы
|
||||||
|
-- сегодня тождественны: регион 66 — 52 874 строки, p1=15345, p50=52706,
|
||||||
|
-- p99.99=615312; регион 77 — 212 937 строк, 34221 / 294457 / 1944535;
|
||||||
|
-- расхождение 0 по всем регионам. Совпадение больше не держится на данных.
|
||||||
|
AND {_CITY_KEY_SQL} IS NOT NULL
|
||||||
AND region_code IS NOT NULL
|
AND region_code IS NOT NULL
|
||||||
AND NOT (region_code = 66 AND city = 'Екатеринбург')
|
-- #3051: исключение ЕКБ судится ТЕМ ЖЕ выражением ключа, по которому идёт
|
||||||
GROUP BY region_code, city
|
-- GROUP BY ниже (было: голая колонка city). Разъехавшиеся предикат и ключ
|
||||||
|
-- держались на данных: сегодня у региона 66 src_city пуст у всех 108 623
|
||||||
|
-- сделок, ключ == city, и обе формы дают одни и те же 55 749 исключённых
|
||||||
|
-- строк (прод 2026-09-11: by_city=55749, by_key=55749, расхождение 0).
|
||||||
|
-- Появись источник с src_city='Екатеринбург' у сделки с другим city —
|
||||||
|
-- старая форма пропустила бы её в derivation и завела строку полосы
|
||||||
|
-- 'Екатеринбург', которую ступень 1 нашла бы для настоящих ЕКБ-сделок,
|
||||||
|
-- сломав намеренное исключение. Теперь исключение и ключ — одно выражение.
|
||||||
|
AND NOT (region_code = 66 AND {_CITY_KEY_SQL} = 'Екатеринбург')
|
||||||
|
GROUP BY region_code, {_CITY_KEY_SQL}
|
||||||
),
|
),
|
||||||
tiered AS (
|
tiered AS (
|
||||||
SELECT region_code, city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
|
SELECT c.region_code, c.city, c.ppm2_p1 AS ppm2_min,
|
||||||
|
LEAST(c.ppm2_p99, r.region_ppm2_max) AS ppm2_max, c.n_deals,
|
||||||
'full'::text AS tier
|
'full'::text AS tier
|
||||||
FROM city_stats
|
FROM city_stats c
|
||||||
WHERE n_deals >= 30
|
JOIN region_stats r ON r.region_code = c.region_code
|
||||||
AND ppm2_p99 >= 8000
|
WHERE c.n_deals >= 30
|
||||||
|
AND c.ppm2_p99 >= 8000
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
SELECT region_code, city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max,
|
SELECT c.region_code, c.city,
|
||||||
n_deals, 'rough'::text AS tier
|
LEAST(c.ppm2_p1, r.region_ppm2_max - 100000) AS ppm2_min,
|
||||||
FROM city_stats
|
r.region_ppm2_max AS ppm2_max,
|
||||||
WHERE n_deals BETWEEN 10 AND 29
|
c.n_deals, 'rough'::text AS tier
|
||||||
|
FROM city_stats c
|
||||||
|
JOIN region_stats r ON r.region_code = c.region_code
|
||||||
|
WHERE c.n_deals BETWEEN 10 AND 29
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max,
|
SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min,
|
||||||
|
r.region_ppm2_max AS ppm2_max,
|
||||||
c.n_deals, 'region_fallback'::text AS tier
|
c.n_deals, 'region_fallback'::text AS tier
|
||||||
FROM city_stats c
|
FROM city_stats c
|
||||||
JOIN region_stats r ON r.region_code = c.region_code
|
JOIN region_stats r ON r.region_code = c.region_code
|
||||||
|
|
|
||||||
|
|
@ -493,7 +493,7 @@ async def run_domclick_detail_backfill(
|
||||||
# ниже. Причина не теряется: в записи прогона стоит
|
# ниже. Причина не теряется: в записи прогона стоит
|
||||||
# no_proxy_stop=1 и mark_failed с текстом про пул.
|
# no_proxy_stop=1 и mark_failed с текстом про пул.
|
||||||
counters.failed += 1
|
counters.failed += 1
|
||||||
logger.error(
|
logger.warning(
|
||||||
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
||||||
"к площадке не ходили. enriched=%d attempted=%d",
|
"к площадке не ходили. enriched=%d attempted=%d",
|
||||||
run_id,
|
run_id,
|
||||||
|
|
@ -572,7 +572,7 @@ async def run_domclick_detail_backfill(
|
||||||
if consecutive_blocks >= max_consecutive_blocks:
|
if consecutive_blocks >= max_consecutive_blocks:
|
||||||
# #3196: причину больше не выдумываем и не молчим — печатаем
|
# #3196: причину больше не выдумываем и не молчим — печатаем
|
||||||
# перепись диагнозов по HTTP-статусам этого прогона.
|
# перепись диагнозов по HTTP-статусам этого прогона.
|
||||||
logger.error(
|
logger.warning(
|
||||||
"domclick_detail_backfill: run_id=%d ABORT -- %d consecutive "
|
"domclick_detail_backfill: run_id=%d ABORT -- %d consecutive "
|
||||||
"blocks, диагнозы: %s. enriched=%d attempted=%d",
|
"blocks, диагнозы: %s. enriched=%d attempted=%d",
|
||||||
run_id,
|
run_id,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
Запуск (каталог — УЖЕ распакованный ГАР региона, напр. папка `66/` из gar_xml.zip):
|
Запуск (каталог — УЖЕ распакованный ГАР региона, напр. папка `66/` из gar_xml.zip):
|
||||||
|
|
||||||
python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01
|
python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01
|
||||||
|
python -m app.tasks.gar_flats_load --dir /data/gar/77 --region 77 --version 2026-09-11
|
||||||
|
python -m app.tasks.gar_flats_load --dir /data/gar/50 --region 50 --version 2026-09-11
|
||||||
|
|
||||||
Делает два шага в одной транзакционной сессии:
|
Делает два шага в одной транзакционной сессии:
|
||||||
1. load_gar_region — стриминговый парс XML → UPSERT gar_house_flats (коммит).
|
1. load_gar_region — стриминговый парс XML → UPSERT gar_house_flats (коммит).
|
||||||
|
|
@ -11,11 +13,19 @@
|
||||||
Ре-матч без повторного парса многогигабайтного XML (gar_house_flats уже загружена):
|
Ре-матч без повторного парса многогигабайтного XML (gar_house_flats уже загружена):
|
||||||
|
|
||||||
python -m app.tasks.gar_flats_load --match-only --region 66
|
python -m app.tasks.gar_flats_load --match-only --region 66
|
||||||
|
python -m app.tasks.gar_flats_load --match-only --region 77
|
||||||
|
python -m app.tasks.gar_flats_load --match-only --region 50
|
||||||
|
|
||||||
В режиме `--match-only` шаг парса/загрузки пропускается целиком; `--dir` не требуется.
|
В режиме `--match-only` шаг парса/загрузки пропускается целиком; `--dir` не требуется.
|
||||||
|
|
||||||
Многогигабайтный ДАМП качается/распаковывается отдельно (ops-шаг, см.
|
Многогигабайтный ДАМП качается/распаковывается отдельно (ops-шаг, см.
|
||||||
docs/gar-flats-runbook.md) — этот лоадер потребляет уже распакованные XML локально.
|
docs/gar-flats-runbook.md) — этот лоадер потребляет уже распакованные XML локально.
|
||||||
|
|
||||||
|
Город-фильтр матча (--city) по умолчанию НЕ вводится руками на каждый запуск — берётся
|
||||||
|
ПО РЕГИОНУ (см. app.services.gar_flats_loader.default_city_filter_for_region): region 66
|
||||||
|
получает byte-for-byte прежний фильтр «Екатеринбург», остальные регионы (77, 50 и любой
|
||||||
|
новый) — без фильтра. `--city ""` явно отключает фильтр для ЛЮБОГО региона (в т.ч. 66);
|
||||||
|
`--city "Имя"` — явный override.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -25,21 +35,23 @@ import logging
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.services.gar_flats_loader import load_gar_region, match_houses_to_gar
|
from app.services.gar_flats_loader import (
|
||||||
|
CITY_FILTER_AUTO,
|
||||||
|
CityFilterAutoType,
|
||||||
|
default_city_filter_for_region,
|
||||||
|
load_gar_region,
|
||||||
|
match_houses_to_gar,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Город-фильтр матча по умолчанию (см. match_houses_to_gar): ЕКБ-restricted против
|
|
||||||
# cross-town коллизий в region 66. Пустая строка в --city → None (фильтр отключён).
|
|
||||||
DEFAULT_CITY_FILTER = "Екатеринбург"
|
|
||||||
|
|
||||||
|
|
||||||
def run_gar_flats_load(
|
def run_gar_flats_load(
|
||||||
dir_path: str,
|
dir_path: str,
|
||||||
region_code: str,
|
region_code: str,
|
||||||
gar_version: str,
|
gar_version: str,
|
||||||
*,
|
*,
|
||||||
city_filter: str | None = DEFAULT_CITY_FILTER,
|
city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Парс+UPSERT (load_gar_region) затем матч (match_houses_to_gar). Возвращает счётчики."""
|
"""Парс+UPSERT (load_gar_region) затем матч (match_houses_to_gar). Возвращает счётчики."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
@ -67,7 +79,7 @@ def run_gar_flats_load(
|
||||||
|
|
||||||
|
|
||||||
def run_gar_match_only(
|
def run_gar_match_only(
|
||||||
region_code: str, *, city_filter: str | None = DEFAULT_CITY_FILTER
|
region_code: str, *, city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Только ре-матч уже загруженного gar_house_flats → houses (без парса XML).
|
"""Только ре-матч уже загруженного gar_house_flats → houses (без парса XML).
|
||||||
|
|
||||||
|
|
@ -108,8 +120,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--city",
|
"--city",
|
||||||
default=DEFAULT_CITY_FILTER,
|
default=None,
|
||||||
help="город-фильтр матча (ILIKE по full_address); пусто = без ограничения (не-ЕКБ)",
|
help=(
|
||||||
|
"город-фильтр матча (ILIKE по full_address); по умолчанию берётся ПО РЕГИОНУ "
|
||||||
|
"(--region) — см. default_city_filter_for_region; пустая строка явно отключает "
|
||||||
|
"фильтр для ЛЮБОГО региона"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
@ -122,8 +138,13 @@ def main() -> None:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Пустой --city → None (фильтр отключён).
|
# --city не передан явно (argparse default=None) → дефолт ПО РЕГИОНУ (66 → «Екатеринбург»
|
||||||
city_filter = args.city or None
|
# byte-for-byte как раньше, остальные — без фильтра). Передан явно (в т.ч. "") →
|
||||||
|
# уважаем волю вызывающего: "" → None (фильтр отключён), непустая строка → override.
|
||||||
|
if args.city is None:
|
||||||
|
city_filter: str | None = default_city_filter_for_region(args.region)
|
||||||
|
else:
|
||||||
|
city_filter = args.city or None
|
||||||
|
|
||||||
if args.match_only:
|
if args.match_only:
|
||||||
run_gar_match_only(args.region, city_filter=city_filter)
|
run_gar_match_only(args.region, city_filter=city_filter)
|
||||||
|
|
|
||||||
105
tradein-mvp/backend/app/tasks/glitchtip_alert_retry.py
Normal file
105
tradein-mvp/backend/app/tasks/glitchtip_alert_retry.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""Фоновая пересылка GlitchTip-алерта в Telegram после отказа синхронной попытки.
|
||||||
|
|
||||||
|
Контекст (#3471, #3157, #3456). GlitchTip-вебхуки НЕ ретраятся — сам GlitchTip
|
||||||
|
безусловно помечает уведомление ``is_sent`` сразу после HTTP-ответа приёмника
|
||||||
|
(upstream-поведение, см. #3157), поэтому если синхронная пересылка в Telegram
|
||||||
|
(``app.api.v1.glitchtip``) не удалась, повторной доставки от GlitchTip не будет
|
||||||
|
никогда — текст алерта исчезает бесследно. Ответ 502 на отказ Telegram остаётся
|
||||||
|
как есть (задуман осознанно, #3456: честный сигнал отправителю, а не тихий
|
||||||
|
проглот) — меняется то, что происходит С ТЕКСТОМ алерта после этого отказа.
|
||||||
|
|
||||||
|
Почему не Celery. В tradein-mvp нет очереди с воркером: ни ``app/celery_app.py``,
|
||||||
|
ни зависимости ``celery`` в ``backend/pyproject.toml`` не существует (проверено
|
||||||
|
при работе над #3471) — попытка ``from celery import ...`` здесь упала бы
|
||||||
|
``ModuleNotFoundError``. Бутстрап полноценного Celery-воркера — новый контейнер и
|
||||||
|
брокер, инфраструктурное решение вне границ этой задачи. Единственный доступный
|
||||||
|
внутри границ задачи (``app/api/v1/glitchtip.py`` + ``app/tasks/**``) механизм
|
||||||
|
«не блокировать интерактивный ответ, но не потерять текст» — Starlette
|
||||||
|
``BackgroundTasks``: выполняется ПОСЛЕ отправки HTTP-ответа тем же процессом, вне
|
||||||
|
узкого интерактивного бюджета (``_INTERACTIVE_SEND_TIMEOUT_S=8s`` в glitchtip.py),
|
||||||
|
поэтому здесь можно позволить себе штатную "воркерную" ретрай-политику клиента
|
||||||
|
(``TelegramClient.send_message`` без явных ``timeout``/``max_retries`` — 5 попыток,
|
||||||
|
backoff до 30s, см. ``app.services.tgbot.client``), плюс собственный внешний
|
||||||
|
потолок ниже.
|
||||||
|
|
||||||
|
Компромисс, честно: BackgroundTasks не переживает рестарт процесса (это не
|
||||||
|
персистентная очередь) — если tradein-backend упадёт ровно между отказом
|
||||||
|
синхронной попытки и завершением фоновой, текст всё-таки потеряется. Событие
|
||||||
|
редкое (одно с начала эксплуатации, TRADE-IN-3F7, 28.08.2026), а сеть до Telegram
|
||||||
|
теряет отдельные запросы, а не рвётся на минуты (замер 12.09: 9/12 успешных
|
||||||
|
``getMe``) — штатной ретрай-политики клиента обычно достаточно без внешнего
|
||||||
|
потолка вовсе. Персистентная очередь (переживающая рестарт) требует
|
||||||
|
Celery/Redis-воркера — отдельное инфраструктурное решение.
|
||||||
|
|
||||||
|
Идемпотентность настолько, насколько дёшево. Текст между попытками не
|
||||||
|
пересобирается (переиспользуется уже отформатированный ``text`` из
|
||||||
|
``glitchtip.py`` — никакого дублирования форматирования). Полной идемпотентности
|
||||||
|
нет и быть не может дёшево: Telegram ``sendMessage`` не идемпотентен сам по себе
|
||||||
|
(повтор создаёт НОВОЕ сообщение, не апдейтит старое) — именно поэтому внешний
|
||||||
|
потолок попыток мал (``_MAX_ATTEMPTS``), а не «ретраить пока не получится».
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.services.tgbot.client import TelegramClient, TelegramError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["retry_forward_alert"]
|
||||||
|
|
||||||
|
# Внешний потолок ПОВЕРХ штатной ретрай-политики клиента (5 попыток внутри одного
|
||||||
|
# send_message с backoff до 30s) — защита от «недоставляемый алерт крутится в фоне
|
||||||
|
# вечно»: если сеть до Telegram не восстановилась за это время, сдаёмся и логируем
|
||||||
|
# ERROR с текстом, а не повторяем бесконечно.
|
||||||
|
_MAX_ATTEMPTS = 3
|
||||||
|
_RETRY_DELAY_S = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_forward_alert(
|
||||||
|
client: TelegramClient,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
text: str,
|
||||||
|
message_thread_id: int | None,
|
||||||
|
) -> None:
|
||||||
|
"""Досылает уже отформатированный текст алерта после отказа синхронной попытки.
|
||||||
|
|
||||||
|
``client`` — ТОТ ЖЕ общий клиент приложения, что и в синхронном пути
|
||||||
|
(``get_telegram_client()`` в ``glitchtip.py``), а не новый инстанс: он живёт в
|
||||||
|
lifespan ради keep-alive-соединения (см. docstring ``glitchtip.py``).
|
||||||
|
"""
|
||||||
|
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
||||||
|
try:
|
||||||
|
await client.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=text,
|
||||||
|
message_thread_id=message_thread_id,
|
||||||
|
# Без явных timeout/max_retries — штатная "воркерная" политика
|
||||||
|
# клиента (см. докстринг модуля).
|
||||||
|
)
|
||||||
|
except TelegramError:
|
||||||
|
if attempt == _MAX_ATTEMPTS:
|
||||||
|
logger.error(
|
||||||
|
"glitchtip alert retry: не удалось доставить алерт в Telegram "
|
||||||
|
"после %d попыток — текст потерян: %r",
|
||||||
|
_MAX_ATTEMPTS,
|
||||||
|
text[:200],
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
logger.warning(
|
||||||
|
"glitchtip alert retry: попытка %d/%d не удалась, повтор через %.0fs",
|
||||||
|
attempt,
|
||||||
|
_MAX_ATTEMPTS,
|
||||||
|
_RETRY_DELAY_S,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(_RETRY_DELAY_S)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"glitchtip alert retry: доставлено фоном с попытки %d/%d", attempt, _MAX_ATTEMPTS
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
@ -9,26 +9,36 @@
|
||||||
|
|
||||||
ПРАВИЛО ОТБОРА — ЯВНО И БЕЗ ПОДГОНКИ
|
ПРАВИЛО ОТБОРА — ЯВНО И БЕЗ ПОДГОНКИ
|
||||||
------------------------------------
|
------------------------------------
|
||||||
Отбираем N строк ключом::
|
Витрина показывает ПОЛОСУ РАСХОЖДЕНИЯ, а не всю сверку. С 2026-09-12 решением
|
||||||
|
владельца продукта на витрину попадают только сделки, у которых расхождение
|
||||||
|
прогноза с ценой ДКП лежит в пределах `BAND_MIN_ERR_PCT`..`BAND_MAX_ERR_PCT`
|
||||||
|
(−5 %..+20 % включительно). Оставшиеся `limit` строк ранжируются ключом::
|
||||||
|
|
||||||
(полнота данных ↓, свежесть квартала ↓, id сделки ↓)
|
(полнота данных ↓, свежесть квартала ↓, id сделки ↓)
|
||||||
|
|
||||||
Величина ошибки в ключе НЕ УЧАСТВУЕТ и участвовать не должна. Отбор по малой
|
ЭТО ОТБОР ПОКАЗАТЕЛЬНЫХ СТРОК, И НАЗЫВАТЬ ЕГО НАДО ТАК. До 2026-09-12 здесь
|
||||||
ошибке превращает витрину в рекламу: показанные 20 строк перестают быть
|
не было ни фильтра, ни слагаемого ошибки в ключе, и подпись витрины это прямо
|
||||||
выборкой из работы оценщика и становятся её лучшим хвостом, а посетитель
|
утверждала. Теперь утверждать это нельзя: строки с промахом крупнее полосы в
|
||||||
читает их как «вот так МЕРА обычно и попадает». Это тот самый случай, когда
|
данных есть (на проде 30.08.2026 из показанных двадцати вне полосы было
|
||||||
код формально работает, а продукт врёт. Проверяется тестом
|
двенадцать — от −27,9 % до +75,7 %), и они не показываются. Поэтому полоса
|
||||||
`test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`.
|
названа в `REJECTION_RULE`, которое едет на фронт вместе со счётчиками
|
||||||
|
прогона, и в подписи под таблицей рядом с медианой расхождения ПО ВСЕЙ
|
||||||
|
СВЕРКЕ: два числа рядом не дают прочитать двадцать отобранных строк как
|
||||||
|
«вот так МЕРА обычно и попадает».
|
||||||
|
|
||||||
ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ — И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь
|
ЧТО ЭТО НЕ ОТМЕНЯЕТ. Внутри полосы отбор по величине ошибки по-прежнему
|
||||||
жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ
|
запрещён — иначе витрина показывала бы лучший хвост уже самой полосы
|
||||||
до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в
|
(`test_selection_ignores_error_magnitude`). Счётчики прогона считаются ДО
|
||||||
ключе и отбор по ошибке в фильтре — одно и то же действие, и второе даже
|
полосы: `eligible` — сколько строк прогон вообще собрал, `written` — сколько
|
||||||
злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение
|
из них прошло полосу и поместилось в `limit`. Разница между ними видна
|
||||||
больше 40% — это почти всегда занижение ДКП ради налога» не держится: см.
|
посетителю, и она честная ровно потому, что рядом сказано, чем именно
|
||||||
следующий раздел, грубые занижения вырезаны выше по потоку и по свойству
|
отобраны показанные. Если в полосу попало меньше `limit` строк — показываем
|
||||||
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
|
сколько есть; добирать соседями по ошибке нельзя, это вернуло бы отбор по
|
||||||
квартала, нет площади) — «число некрасивое» причиной не является.
|
величине ошибки в обход полосы.
|
||||||
|
|
||||||
|
Отбраковка по «данных нет» (нет прогноза, нет квартала, нет площади) осталась
|
||||||
|
прежней и живёт в `build_row`: строка вне полосы ОСТАЁТСЯ кандидатом и
|
||||||
|
попадает в счётчик `eligible`, её снимает отбор, а не отбраковка.
|
||||||
|
|
||||||
Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность,
|
Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность,
|
||||||
схема улицы), у строки заполнено. Свежесть — порядок квартала сделки.
|
схема улицы), у строки заполнено. Свежесть — порядок квартала сделки.
|
||||||
|
|
@ -57,8 +67,10 @@
|
||||||
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
|
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
|
||||||
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
|
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
|
||||||
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
|
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
|
||||||
всё, что после этого дало большую ошибку, — работа оценщика, и витрина
|
всё, что после этого дало большую ошибку, — работа оценщика. С 2026-09-12
|
||||||
обязана её показать. Своей копии диапазона здесь нет намеренно: прежние
|
такая строка на витрину не выходит (полоса), но остаётся в `eligible` и
|
||||||
|
в подписи названа отобранной, а не несуществующей. Своей копии диапазона
|
||||||
|
здесь нет намеренно: прежние
|
||||||
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
|
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
|
||||||
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх
|
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх
|
||||||
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
|
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
|
||||||
|
|
@ -75,7 +87,10 @@
|
||||||
ошибки, — правило отбора выше не нарушено.
|
ошибки, — правило отбора выше не нарушено.
|
||||||
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
||||||
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
||||||
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
|
сделок рассмотрено, сколько строк прогон собрал и по какому правилу из
|
||||||
|
них отобраны показанные. С появлением полосы это перестало быть
|
||||||
|
страховкой и стало обязательным: без счётчиков и правила отобранная
|
||||||
|
двадцатка читается как вся сверка. Поэтому итог
|
||||||
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
|
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
|
||||||
ручкой `/api/public/mera/showcase` вместе со строками.
|
ручкой `/api/public/mera/showcase` вместе со строками.
|
||||||
|
|
||||||
|
|
@ -104,18 +119,47 @@ from app.services.street_scheme import StreetIndex, build_street_scheme, load_st
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
|
# ── Полоса расхождения: что показываем и что об этом сказано ─────────────────
|
||||||
#
|
#
|
||||||
# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный
|
# Границы ВКЛЮЧИТЕЛЬНЫЕ. Полоса несимметрична намеренно: решение владельца от
|
||||||
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
|
# 2026-09-12 — показывать сделки, где МЕРА не занизила больше чем на 5 % и не
|
||||||
# значило бы завести проверку, которая в проде не срабатывает никогда.
|
# завысила больше чем на 20 %.
|
||||||
|
BAND_MIN_ERR_PCT = -5.0
|
||||||
|
BAND_MAX_ERR_PCT = 20.0
|
||||||
|
|
||||||
|
# Подпись полосы ВЫВОДИТСЯ из границ, а не вписывается рядом: «−5 %…+20 %» в
|
||||||
|
# тексте и `>= -5.0` в коде — две независимые величины, и разъедутся они
|
||||||
|
# ровно тогда, когда порог однажды подвинут.
|
||||||
|
BAND_LABEL = f"от {BAND_MIN_ERR_PCT:+.0f} % до {BAND_MAX_ERR_PCT:+.0f} % включительно"
|
||||||
|
|
||||||
|
|
||||||
|
def in_band(err_pct: float) -> bool:
|
||||||
|
"""Попадает ли расхождение в показываемую полосу (границы включительно)."""
|
||||||
|
return BAND_MIN_ERR_PCT <= err_pct <= BAND_MAX_ERR_PCT
|
||||||
|
|
||||||
|
|
||||||
|
# ── Правило отбора и отбраковки: одна формулировка, она же едет на фронт ──────
|
||||||
|
#
|
||||||
|
# Санитарный диапазон ₽/м² применён выше по потоку, в `_load_sample`;
|
||||||
|
# дублировать его тут значило бы завести проверку, которая в проде не
|
||||||
|
# срабатывает никогда.
|
||||||
|
#
|
||||||
|
# ТЕКСТ ОБЯЗАН НАЗЫВАТЬ ПОЛОСУ. Пока фильтра не было, здесь стояло «величина
|
||||||
|
# отклонения на отбор и отбраковку не влияет — иначе витрина показывала бы
|
||||||
|
# лучший хвост, а не работу расчёта». С фильтром эта фраза стала ложью ровно
|
||||||
|
# про то, чего опасалась, поэтому она снята, а не смягчена.
|
||||||
REJECTION_RULE = (
|
REJECTION_RULE = (
|
||||||
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
|
f"На витрине — ОТОБРАННАЯ полоса расхождения, а не вся сверка: показаны "
|
||||||
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
|
f"только сделки, у которых расхождение прогноза с ценой ДКП лежит {BAND_LABEL}. "
|
||||||
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
|
"Промахи крупнее полосы в данных есть, и здесь их не видно — судить по этим "
|
||||||
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
|
"строкам о точности расчёта нельзя, для этого есть медиана расхождения по "
|
||||||
"диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён "
|
"всей сверке. Внутри полосы порядок задают полнота данных и свежесть "
|
||||||
"к выборке до расчёта, по цене самой сделки."
|
"квартала: величина отклонения на него не влияет, лучший хвост самой полосы "
|
||||||
|
"витрина тоже не показывает. Кроме полосы строку снимает только отсутствие "
|
||||||
|
"данных: расчёт МЕРЫ не дал ожидаемой цены продажи (мало аналогов), "
|
||||||
|
"неизвестен квартал сделки или площадь. Санитарный диапазон цены сделки "
|
||||||
|
"(30 000–600 000 ₽/м² для Екатеринбурга) применён к выборке до расчёта, по "
|
||||||
|
"цене самой сделки."
|
||||||
)
|
)
|
||||||
|
|
||||||
NOTE = (
|
NOTE = (
|
||||||
|
|
@ -184,7 +228,7 @@ def completeness(row: ShowcaseRow) -> int:
|
||||||
|
|
||||||
|
|
||||||
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
||||||
"""Ключ отбора. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге модуля."""
|
"""Ключ ранжирования. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге."""
|
||||||
return (
|
return (
|
||||||
-completeness(row),
|
-completeness(row),
|
||||||
-(row.deal_date or date.min).toordinal(),
|
-(row.deal_date or date.min).toordinal(),
|
||||||
|
|
@ -193,8 +237,25 @@ def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
||||||
|
|
||||||
|
|
||||||
def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]:
|
def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]:
|
||||||
"""Отобрать `limit` строк по полноте и свежести (НЕ по величине ошибки)."""
|
"""Строки полосы −5 %..+20 %, до `limit` штук, по полноте и свежести.
|
||||||
return sorted(rows, key=_sort_key)[:limit]
|
|
||||||
|
ДВА ДЕЙСТВИЯ, И ОНИ РАЗНЫЕ. Сначала ФИЛЬТР по величине расхождения
|
||||||
|
(`in_band`) — это и есть «витрина показывает отобранную полосу, а не всю
|
||||||
|
сверку», названное так же в `REJECTION_RULE` и в подписи под таблицей.
|
||||||
|
Потом РАНЖИРОВАНИЕ уцелевших по полноте данных и свежести квартала —
|
||||||
|
внутри полосы величина ошибки на порядок не влияет, иначе показывался бы
|
||||||
|
лучший хвост уже самой полосы.
|
||||||
|
|
||||||
|
Фильтр стоит ЗДЕСЬ, а не в `build_row`, намеренно: строка вне полосы
|
||||||
|
обязана остаться кандидатом и попасть в счётчик `eligible`. Отбраковав её
|
||||||
|
раньше, мы получили бы «показано 20 из 20 годных» — счётчик, из которого
|
||||||
|
отбор не виден вообще.
|
||||||
|
|
||||||
|
В полосе меньше `limit` строк — возвращаем сколько есть. Добирать
|
||||||
|
ближайшими по ошибке нельзя: это тот же отбор по величине ошибки, просто
|
||||||
|
с другой стороны.
|
||||||
|
"""
|
||||||
|
return sorted((r for r in rows if in_band(r.err_pct)), key=_sort_key)[:limit]
|
||||||
|
|
||||||
|
|
||||||
def build_row(
|
def build_row(
|
||||||
|
|
@ -217,8 +278,11 @@ def build_row(
|
||||||
|
|
||||||
Причины отказа ИСЧЕРПЫВАЮЩИЕ и все — «данных нет»: спайн не дал ожидаемой
|
Причины отказа ИСЧЕРПЫВАЮЩИЕ и все — «данных нет»: спайн не дал ожидаемой
|
||||||
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
|
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
|
||||||
(делить не на что). Величина отклонения причиной НЕ является ни при каких
|
(делить не на что). Величина отклонения причиной отказа НЕ является ни при
|
||||||
значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
|
каких значениях: строка с любым промахом становится кандидатом и попадает
|
||||||
|
в счётчик `eligible`. Полоса, по которой из кандидатов отбираются
|
||||||
|
показанные, применяется позже и в другом месте — `select_rows`; здесь её
|
||||||
|
нет намеренно, иначе отбор перестал бы быть виден в счётчиках.
|
||||||
|
|
||||||
ФАКТ — ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
|
ФАКТ — ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
|
||||||
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
|
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
|
||||||
|
|
@ -384,9 +448,14 @@ def refresh_landing_showcase_deals(
|
||||||
priced из них оценщик дал ожидаемую цену продажи
|
priced из них оценщик дал ожидаемую цену продажи
|
||||||
no_prediction не дал (мало аналогов / спайн упал)
|
no_prediction не дал (мало аналогов / спайн упал)
|
||||||
incomplete цена есть, но нет квартала/площади — строку не собрать
|
incomplete цена есть, но нет квартала/площади — строку не собрать
|
||||||
eligible годных строк ВСЕГО (никакого отсева по ошибке нет)
|
eligible строк СОБРАНО всего, ДО полосы (данных хватило)
|
||||||
written из них показано (обрезано по `limit`)
|
written из них показано: прошли полосу и поместились в `limit`
|
||||||
with_district у скольких показанных удалось определить район
|
with_district у скольких показанных удалось определить район
|
||||||
|
|
||||||
|
`eligible` минус `written` — это НЕ «столько не поместилось»: с 2026-09-12
|
||||||
|
в разницу входят и строки вне полосы −5 %..+20 %. Поэтому подпись под
|
||||||
|
таблицей называет `eligible` собранными строками, а чем отобраны
|
||||||
|
показанные — говорит `REJECTION_RULE`, который едет тем же ответом.
|
||||||
"""
|
"""
|
||||||
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
|
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
|
||||||
# его зависимостями, а web-процессу это на импорте приложения не нужно.
|
# его зависимостями, а web-процессу это на импорте приложения не нужно.
|
||||||
|
|
@ -444,6 +513,16 @@ def refresh_landing_showcase_deals(
|
||||||
candidates.append(row)
|
candidates.append(row)
|
||||||
|
|
||||||
chosen = select_rows(candidates, limit)
|
chosen = select_rows(candidates, limit)
|
||||||
|
# Сколько собранных строк вообще попало в полосу — в лог, а не в счётчики:
|
||||||
|
# колонки под него в `landing_showcase_runs` нет, а без него по `written`
|
||||||
|
# не отличить «полоса оставила мало» от «упёрлись в limit».
|
||||||
|
logger.info(
|
||||||
|
"в полосе %s: %d из %d собранных, показано %d",
|
||||||
|
BAND_LABEL,
|
||||||
|
sum(1 for r in candidates if in_band(r.err_pct)),
|
||||||
|
len(candidates),
|
||||||
|
len(chosen),
|
||||||
|
)
|
||||||
schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
|
schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
|
||||||
|
|
||||||
db.execute(_DELETE_SQL)
|
db.execute(_DELETE_SQL)
|
||||||
|
|
@ -488,7 +567,7 @@ def refresh_landing_showcase_deals(
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
|
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
|
||||||
"годных=%d записано=%d с_районом=%d",
|
"собрано=%d записано=%d с_районом=%d",
|
||||||
counters["considered"],
|
counters["considered"],
|
||||||
counters["priced"],
|
counters["priced"],
|
||||||
counters["no_prediction"],
|
counters["no_prediction"],
|
||||||
|
|
|
||||||
910
tradein-mvp/backend/app/tasks/msk_raw_import.py
Normal file
910
tradein-mvp/backend/app/tasks/msk_raw_import.py
Normal file
|
|
@ -0,0 +1,910 @@
|
||||||
|
"""Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50).
|
||||||
|
|
||||||
|
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
|
||||||
|
несёт `payload` — сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
|
||||||
|
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
|
||||||
|
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`.
|
||||||
|
|
||||||
|
Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для
|
||||||
|
Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
|
||||||
|
опознаётся — байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом
|
||||||
|
— 35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox — 17 576 (это
|
||||||
|
Московская область, регион 50); без округа вне bbox — 7 337. Отдельно 212
|
||||||
|
карточек с адресом вида «Екатеринбург (Cian)» — артефакт парсера, считаются
|
||||||
|
своим счётчиком, чтобы не растворяться в «не целевой регион».
|
||||||
|
|
||||||
|
Область (регион 50) у Циана в адресе НЕ видна вовсе — берём по ПОДДОМЕНУ
|
||||||
|
`source_url` (`https://<sub>.cian.ru/...`): `sub != "www"` ⇒ область. Замер по
|
||||||
|
`msk_raw.cian_latest` 12.09.2026: `www` — 38 030 карточек, из них 36 569 с
|
||||||
|
префиксом округа (это Москва); все прочие поддомены (krasnogorsk 2065,
|
||||||
|
balashikha 1828, vidnoye 1802, lyubertsy 1498, zvenigorod 1420, khimki 1306,
|
||||||
|
mytishchi 1288, podolsk 756, odintsovo 738, …) — 0 карточек с префиксом округа,
|
||||||
|
итого не-www ≈ 24 784. Поддомен и префикс округа нигде не противоречат друг
|
||||||
|
другу, поэтому Москва остаётся на префиксе округа (не трогаем), а область —
|
||||||
|
на поддомене. Поддомен не распознался (нет source_url / хост не `*.cian.ru`) →
|
||||||
|
карточка НЕ область (консервативно, счётчик «не целевой регион»).
|
||||||
|
|
||||||
|
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес — голая улица с
|
||||||
|
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
|
||||||
|
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
|
||||||
|
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр —
|
||||||
|
для ЛЮБОГО целевого региона.
|
||||||
|
|
||||||
|
Два сигнала, и оба нужны — ни один по отдельности не годится.
|
||||||
|
|
||||||
|
1. Город по версии самого Авито — слаг в `source_url`
|
||||||
|
(`avito.ru/<slug>/kvartiry/...`). Заполнен у 100% карточек, стоит 0 вызовов:
|
||||||
|
`moskva` — 21 841 карточка, остальное — муниципалитеты области (balashiha,
|
||||||
|
himki, podolsk, …). Это единственный ТОЧНЫЙ признак населённого пункта, но
|
||||||
|
регион по нему не выводится: Троицк/Щербинка/Коммунарка/Московский/
|
||||||
|
Зеленоград — свои слаги, а регион у них 77 (Новая Москва и ЗелАО).
|
||||||
|
|
||||||
|
2. Координаты и регион — DaData /suggest/address с `locations`-констрейнтом.
|
||||||
|
Слаг сужает констрейнт (`moskva` → только регион «Москва», иначе — «Москва»
|
||||||
|
И «Московская» разом), но РЕГИОН БЕРЁТСЯ ИЗ ОТВЕТА (`region_kladr_id`), а не
|
||||||
|
из слага — иначе Новая Москва уехала бы в область.
|
||||||
|
|
||||||
|
Почему не наоборот (только геокод, без слага). Замер на 200 случайных адресах
|
||||||
|
Авито с констрейнтом «Москва + Московская»: дом с координатами нашёлся у 184
|
||||||
|
(92%), но верхний кандидат разошёлся со слагом у 28 из 184 (15%) — и почти
|
||||||
|
всегда в пользу Москвы («пр-т Мира,19» при слаге fryazino → «г Москва, пр-кт
|
||||||
|
Мира, 19»). Голый адрес без города DaData тянет в столицу; 15% чужих домов в
|
||||||
|
регионе 77 — ровно та ошибка, ради которой стоял `--allow-unfiltered`.
|
||||||
|
С сужением по слагу (`moskva` → регион «Москва») резолв 77 из 82 (94%),
|
||||||
|
qc_geo=0 у 97% найденных.
|
||||||
|
|
||||||
|
Что куда едет:
|
||||||
|
* регион == `--region` → в `listings`, С координатами (geom есть сразу,
|
||||||
|
radius-подбор аналогов работает без ожидания `geocode_missing`);
|
||||||
|
* адрес разрешился, но регион ответа не совпал с `--region` → не пишется,
|
||||||
|
лежит не в воздухе, а строкой в `msk_raw.avito_geocode` — прогон с другим
|
||||||
|
`--region` подхватит её из кэша без единого внешнего вызова;
|
||||||
|
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33») →
|
||||||
|
свой счётчик, карточка не пишется.
|
||||||
|
|
||||||
|
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
|
||||||
|
целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает.
|
||||||
|
|
||||||
|
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
|
||||||
|
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
|
||||||
|
то есть по ссылке на ЖК, а не по продаже застройщиком. Замер по всем 60 464:
|
||||||
|
`raw_payload.is_from_developer` = true у НУЛЯ карточек, false у 29 000,
|
||||||
|
отсутствует у 31 464 — застройщик в этом корпусе не продаёт ничего, это вся
|
||||||
|
вторичка. В estimator'е стоит гвард (`estimator.py:5992-5995`): в аналоги идут
|
||||||
|
только строки с `listing_segment IS NULL` или 'vtorichka'. Скопируй мы метку
|
||||||
|
кита — 29 000 карточек выпали бы из подбора. Поэтому метка считается заново:
|
||||||
|
is_from_developer is True → 'novostroyki', иначе → 'vtorichka'.
|
||||||
|
|
||||||
|
Идемпотентность — на стороне `save_listings`: он делает upsert
|
||||||
|
`ON CONFLICT (dedup_hash) DO UPDATE` плюс reconcile-UPDATE по
|
||||||
|
`(source, source_id)` на случай дрейфа хеша. `dedup_hash` = sha256(source +
|
||||||
|
source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), цена в ключ не
|
||||||
|
входит. Повторный прогон поэтому обновляет те же строки, а не плодит дубли;
|
||||||
|
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
|
||||||
|
одинаковы.
|
||||||
|
|
||||||
|
Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и
|
||||||
|
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
|
||||||
|
вторым компонентом. Замер по 21 393 карточкам первого прохода — ровно два
|
||||||
|
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
|
||||||
|
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
|
||||||
|
|
||||||
|
Отбор региона (source=domclick) стоит ноль вызовов, но таблица одна на ДВА
|
||||||
|
РАЗНЫХ прогона сборщика с разными GUID: московский (батч
|
||||||
|
`msk-serp-domclick-20260912`) и областной (отдельный запуск, батч
|
||||||
|
`mo-serp-domclick-20260912`) — оба пишут в один и тот же
|
||||||
|
`msk_raw.domclick_cards`. Колонки региона в таблице НЕТ, а вью
|
||||||
|
`msk_raw.domclick_latest` отдаёт обе партии вперемешку — курсор по `id` не
|
||||||
|
различает, из какого прогона строка. Поэтому фильтр по адресу здесь не
|
||||||
|
опциональная вторая линия, а единственный способ развести регионы.
|
||||||
|
|
||||||
|
Регион читается ПЕРВЫМ компонентом адреса. Замер живьём на API ДомКлика
|
||||||
|
12.09.2026: московские карточки — «Москва, Генерала Дорохова проспект, 49» и
|
||||||
|
подобные, первый компонент «Москва» у всех 22 836 карточек прод-корпуса;
|
||||||
|
областные карточки — «Московская область, Химки, 7-й м-н, проспект
|
||||||
|
Мельникова, 33», «Московская область, Одинцовский городской округ,
|
||||||
|
Звенигород, 3-й м-н, 28» и подобные, первый компонент «Московская область» у
|
||||||
|
всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и
|
||||||
|
симметричное `is_moscow_yandex_address`/`is_oblast_yandex_address`. Новая
|
||||||
|
Москва приходит как «Москва, пос. Птичное, …» — посёлок стоит вторым
|
||||||
|
компонентом, первый по-прежнему «Москва», регион 77 не ломается. Координаты
|
||||||
|
заполнены у 100% карточек в обоих прогонах.
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
python -m app.tasks.msk_raw_import --dry-run
|
||||||
|
python -m app.tasks.msk_raw_import --limit 500
|
||||||
|
python -m app.tasks.msk_raw_import --source yandex
|
||||||
|
python -m app.tasks.msk_raw_import --source yandex --region 50
|
||||||
|
python -m app.tasks.msk_raw_import --source cian --region 50
|
||||||
|
python -m app.tasks.msk_raw_import --source domclick
|
||||||
|
python -m app.tasks.msk_raw_import --source domclick --region 50
|
||||||
|
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
|
||||||
|
python -m app.tasks.msk_raw_import --source avito --geocode --region 50
|
||||||
|
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from scraper_kit.base import ScrapedLot, save_listings
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services import dadata
|
||||||
|
from app.services.geocoder import normalize_address
|
||||||
|
from app.services.regions import REGIONS, is_within_bbox
|
||||||
|
from app.services.scraper_adapters import RealMatcherAdapter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MOSCOW_REGION_CODE = 77
|
||||||
|
MOSCOW_CITY = "Москва"
|
||||||
|
# Московская область в реестре `app.services.regions` заведена, но своего
|
||||||
|
# единого города у неё нет (`canonical_city is None`) — в listings.city для
|
||||||
|
# неё пишем None (см. `import_msk_raw`, `save_listings` его COALESCE'ит).
|
||||||
|
OBLAST_REGION_CODE = 50
|
||||||
|
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
|
||||||
|
DEFAULT_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
|
||||||
|
AVITO_MOSCOW_SLUG = "moskva"
|
||||||
|
# Имена регионов для DaData-констрейнта `locations`. DaData хранит имя БЕЗ типа
|
||||||
|
# («Москва», «Московская»), тип лежит отдельно в `region_type` — с типом
|
||||||
|
# hard-фильтр молча схлопывает выдачу в ноль (та же грабля, что в
|
||||||
|
# `geocoder.SVERDLOVSK_OBLAST_REGION`).
|
||||||
|
DADATA_MOSCOW_REGION = "Москва"
|
||||||
|
DADATA_OBLAST_REGION = "Московская"
|
||||||
|
# Первые две цифры КЛАДР региона в ответе DaData → код региона.
|
||||||
|
_KLADR_TO_REGION = {"77": MOSCOW_REGION_CODE, "50": OBLAST_REGION_CODE}
|
||||||
|
# Дневной потолок внешних вызовов. Free tier DaData /suggest — 10 000/сутки на
|
||||||
|
# аккаунт, и тот же аккаунт обслуживает автокомплит формы оценки; берём с запасом.
|
||||||
|
DEFAULT_GEOCODE_LIMIT = 9000
|
||||||
|
# Сколько запросов к DaData держим в полёте одновременно. Больше смысла нет:
|
||||||
|
# упираемся не в нас, а в квоту.
|
||||||
|
_GEOCODE_CONCURRENCY = 6
|
||||||
|
|
||||||
|
# Префиксы административных округов Москвы — единственный признак города в адресе
|
||||||
|
# карточки Циана (сам город в адрес не попадает).
|
||||||
|
MOSCOW_OKRUGS = (
|
||||||
|
"ЦАО",
|
||||||
|
"САО",
|
||||||
|
"СВАО",
|
||||||
|
"ВАО",
|
||||||
|
"ЮВАО",
|
||||||
|
"ЮАО",
|
||||||
|
"ЮЗАО",
|
||||||
|
"ЗАО",
|
||||||
|
"СЗАО",
|
||||||
|
"ЗелАО",
|
||||||
|
"НАО",
|
||||||
|
"ТАО",
|
||||||
|
)
|
||||||
|
# Lookahead вместо \b: следом за округом идёт запятая/пробел, но НЕ буква — иначе
|
||||||
|
# «ЗАО» матчило бы начало гипотетического «ЗАОзёрная».
|
||||||
|
_MOSCOW_OKRUG_RE = re.compile(
|
||||||
|
r"^(?:" + "|".join(MOSCOW_OKRUGS) + r")(?![А-Яа-яЁёA-Za-z])",
|
||||||
|
)
|
||||||
|
# Артефакт парсера: адрес вида «Екатеринбург (Cian)» в московском корпусе.
|
||||||
|
_ARTIFACT_RE = re.compile(r"Екатеринбург", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Вью-источники. Только whitelist: имя подставляется в SQL текстом, параметром
|
||||||
|
# идентификатор не передать.
|
||||||
|
SOURCE_VIEWS = {
|
||||||
|
"cian": "msk_raw.cian_latest",
|
||||||
|
"avito": "msk_raw.avito_latest",
|
||||||
|
"yandex": "msk_raw.yandex_latest",
|
||||||
|
"domclick": "msk_raw.domclick_latest",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PAGE_SQL = """
|
||||||
|
SELECT id, payload
|
||||||
|
FROM {view}
|
||||||
|
WHERE id > :after
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Пред-геокод Авито ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GeoPoint:
|
||||||
|
"""Разрешённая точка адреса Авито. `region_code` — из ответа, не из слага."""
|
||||||
|
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
region_code: int
|
||||||
|
full_address: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GeocodeBudget:
|
||||||
|
"""Дневной потолок внешних вызовов на прогон.
|
||||||
|
|
||||||
|
Кончился — не падаем, а перестаём спрашивать: карточки без точки просто не
|
||||||
|
пишутся в этот заход, а на следующем подхватятся с того же места (уже
|
||||||
|
разрешённые адреса лежат в кэше и квоты не стоят).
|
||||||
|
"""
|
||||||
|
|
||||||
|
remaining: int
|
||||||
|
spent: int = 0
|
||||||
|
exhausted: bool = False
|
||||||
|
|
||||||
|
def take(self, n: int) -> int:
|
||||||
|
"""Сколько из `n` запросов позволено сделать сейчас."""
|
||||||
|
allowed = max(0, min(n, self.remaining))
|
||||||
|
if allowed < n:
|
||||||
|
self.exhausted = True
|
||||||
|
self.remaining -= allowed
|
||||||
|
self.spent += allowed
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
|
# Кэш пред-геокода. Живёт в `msk_raw` (рядом с сырьём, а не в прикладной схеме):
|
||||||
|
# это свойство КОРПУСА, а не приложения, и переживает пересбор listings.
|
||||||
|
# `geocode_cache` приложения сознательно не трогаем — там другой ключ (адрес +
|
||||||
|
# city_hint) и другой TTL, а нам нужен слаг в ключе и код региона в значении.
|
||||||
|
# Он же — «полка ожидания» для области: строки с region_code=50 никуда не
|
||||||
|
# пишутся, но остаются разрешёнными, и когда регион 50 появится в реестре,
|
||||||
|
# прогон по ним не потратит ни одного внешнего вызова.
|
||||||
|
#
|
||||||
|
# Строка с lat IS NULL — ОТРИЦАТЕЛЬНЫЙ результат («DaData дома не знает»), и он
|
||||||
|
# тоже кэшируется: без этого каждый повторный прогон заново тратил бы квоту на
|
||||||
|
# те же ~8% неразрешимых адресов (ЖК без улицы, «Мкр-н имени В.Н. Махалина,
|
||||||
|
# 33»). Передумать можно руками:
|
||||||
|
# DELETE FROM msk_raw.avito_geocode WHERE lat IS NULL.
|
||||||
|
_GEO_CACHE_DDL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS msk_raw.avito_geocode (
|
||||||
|
cache_key text PRIMARY KEY,
|
||||||
|
slug text,
|
||||||
|
address text NOT NULL,
|
||||||
|
lat double precision,
|
||||||
|
lon double precision,
|
||||||
|
region_code smallint,
|
||||||
|
full_address text,
|
||||||
|
resolved_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
_GEO_CACHE_EXISTS = "SELECT to_regclass('msk_raw.avito_geocode')"
|
||||||
|
_GEO_CACHE_SELECT = """
|
||||||
|
SELECT cache_key, lat, lon, region_code, full_address
|
||||||
|
FROM msk_raw.avito_geocode
|
||||||
|
WHERE cache_key = ANY(:keys)
|
||||||
|
"""
|
||||||
|
_GEO_CACHE_UPSERT = """
|
||||||
|
INSERT INTO msk_raw.avito_geocode
|
||||||
|
(cache_key, slug, address, lat, lon, region_code, full_address)
|
||||||
|
VALUES (:key, :slug, :address, :lat, :lon, :region, :full)
|
||||||
|
ON CONFLICT (cache_key) DO UPDATE
|
||||||
|
SET lat = EXCLUDED.lat,
|
||||||
|
lon = EXCLUDED.lon,
|
||||||
|
region_code = EXCLUDED.region_code,
|
||||||
|
full_address = EXCLUDED.full_address,
|
||||||
|
resolved_at = NOW()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def avito_city_slug(payload: dict) -> str | None:
|
||||||
|
"""Слаг города из `source_url`: `avito.ru/<slug>/kvartiry/...` → `<slug>`.
|
||||||
|
|
||||||
|
Единственный признак населённого пункта, который Авито отдаёт честно и
|
||||||
|
даром. Регион из него НЕ выводится (см. докстринг модуля) — он лишь сужает
|
||||||
|
констрейнт геокодера.
|
||||||
|
"""
|
||||||
|
url = payload.get("source_url")
|
||||||
|
if not isinstance(url, str) or not url:
|
||||||
|
return None
|
||||||
|
parts = [p for p in urlsplit(url).path.split("/") if p]
|
||||||
|
return parts[0] if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def geo_cache_key(address: str, slug: str | None) -> str:
|
||||||
|
"""Ключ кэша.
|
||||||
|
|
||||||
|
Слаг — ЧАСТЬ ключа, не украшение: один и тот же текст адреса встречается в
|
||||||
|
разных муниципалитетах (замер: 1110 адресов из 21 570 живут сразу под
|
||||||
|
несколькими слагами), и это РАЗНЫЕ дома.
|
||||||
|
"""
|
||||||
|
return f"{slug or '-'}|{normalize_address(address)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _dadata_regions_for(slug: str | None) -> list[str]:
|
||||||
|
"""Констрейнт `locations` по слагу.
|
||||||
|
|
||||||
|
Москва — только столица; иначе оба региона, потому что слаг может оказаться
|
||||||
|
Новой Москвой (Троицк/Щербинка/Коммунарка/Московский) или Зеленоградом — у
|
||||||
|
них свои слаги, а регион 77.
|
||||||
|
"""
|
||||||
|
if slug == AVITO_MOSCOW_SLUG:
|
||||||
|
return [DADATA_MOSCOW_REGION]
|
||||||
|
return [DADATA_MOSCOW_REGION, DADATA_OBLAST_REGION]
|
||||||
|
|
||||||
|
|
||||||
|
def _point_from_suggestions(suggestions: list) -> GeoPoint | None:
|
||||||
|
"""Лучший ДОМ с координатами из выдачи DaData, иначе None.
|
||||||
|
|
||||||
|
Берём только house-level (`kind == 'house'`) с координатами: улица/город
|
||||||
|
дают точку в середине улицы или в центре НП — для radius-подбора аналогов
|
||||||
|
это хуже, чем отсутствие точки (сосед через квартал попадёт в выборку, а
|
||||||
|
настоящий сосед — нет). Регион — первые две цифры КЛАДР ответа.
|
||||||
|
"""
|
||||||
|
for s in suggestions:
|
||||||
|
if s.kind != "house" or s.lat is None or s.lon is None:
|
||||||
|
continue
|
||||||
|
region = _KLADR_TO_REGION.get((s.kladr_id or "")[:2])
|
||||||
|
if region is None:
|
||||||
|
continue # ни 77, ни 50 — констрейнт пробит, такой ответ не берём
|
||||||
|
return GeoPoint(
|
||||||
|
lat=float(s.lat),
|
||||||
|
lon=float(s.lon),
|
||||||
|
region_code=region,
|
||||||
|
full_address=s.unrestricted_value,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _geocode_many(items: list[tuple[str, str, str | None]]) -> dict[str, GeoPoint | None]:
|
||||||
|
"""`[(cache_key, address, slug)]` → `{cache_key: GeoPoint | None}`.
|
||||||
|
|
||||||
|
Параллелим с потолком `_GEOCODE_CONCURRENCY` — упираемся в квоту, а не в нас.
|
||||||
|
Отказ DaData (сеть/429/401) выглядит как пустая выдача: `suggest_addresses`
|
||||||
|
гасит исключения сам и возвращает []. Такой адрес получит None — и он, как и
|
||||||
|
честное «дома не знаю», уедет в кэш отрицательным. Отсюда правило прогона:
|
||||||
|
увидел в итоге всплеск `не разрешён` — сначала проверь логи DaData, потом
|
||||||
|
чисти отрицательные строки кэша (SQL выше), иначе разовый 429 замолчит
|
||||||
|
адреса до ручной чистки.
|
||||||
|
"""
|
||||||
|
sem = asyncio.Semaphore(_GEOCODE_CONCURRENCY)
|
||||||
|
|
||||||
|
async def one(key: str, address: str, slug: str | None) -> tuple[str, GeoPoint | None]:
|
||||||
|
async with sem:
|
||||||
|
found = await dadata.suggest_addresses(
|
||||||
|
address, limit=10, regions=_dadata_regions_for(slug)
|
||||||
|
)
|
||||||
|
return key, _point_from_suggestions(found)
|
||||||
|
|
||||||
|
done = await asyncio.gather(*(one(k, a, sl) for k, a, sl in items))
|
||||||
|
return dict(done)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_points(
|
||||||
|
db: Session,
|
||||||
|
rows: list,
|
||||||
|
budget: GeocodeBudget,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> dict[str, GeoPoint | None]:
|
||||||
|
"""Точки для всех адресов страницы: сначала кэш, остаток — у DaData.
|
||||||
|
|
||||||
|
Ключ дедуплицируется в пределах страницы: одна и та же связка слаг+адрес
|
||||||
|
(несколько квартир в одном доме — обычное дело, 50 335 карточек на 21 570
|
||||||
|
адресов) стоит ОДИН внешний вызов.
|
||||||
|
"""
|
||||||
|
wanted: dict[str, tuple[str, str | None]] = {}
|
||||||
|
for row in rows:
|
||||||
|
payload = row["payload"] or {}
|
||||||
|
address = payload.get("address")
|
||||||
|
if not address or is_artifact_address(address):
|
||||||
|
continue
|
||||||
|
slug = avito_city_slug(payload)
|
||||||
|
wanted.setdefault(geo_cache_key(address, slug), (address, slug))
|
||||||
|
if not wanted:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
points: dict[str, GeoPoint | None] = {}
|
||||||
|
# Кэш создаётся только боевым прогоном (`geocode and not dry_run`), поэтому на
|
||||||
|
# первом `--dry-run` таблицы ещё нет и SELECT по ней роняет весь замер — то
|
||||||
|
# есть ломается ровно та репетиция, ради которой сухой прогон и существует.
|
||||||
|
# Проверяем наличие отношения, а не ловим исключение: в Postgres упавший
|
||||||
|
# оператор кладёт транзакцию целиком, и except потребовал бы rollback.
|
||||||
|
cache_exists = db.execute(text(_GEO_CACHE_EXISTS)).scalar() is not None
|
||||||
|
cached = (
|
||||||
|
db.execute(text(_GEO_CACHE_SELECT), {"keys": list(wanted)}).mappings().all()
|
||||||
|
if cache_exists
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
for row in cached:
|
||||||
|
points[row["cache_key"]] = (
|
||||||
|
None
|
||||||
|
if row["lat"] is None
|
||||||
|
else GeoPoint(
|
||||||
|
lat=row["lat"],
|
||||||
|
lon=row["lon"],
|
||||||
|
region_code=row["region_code"],
|
||||||
|
full_address=row["full_address"] or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
misses = [(key, *wanted[key]) for key in wanted if key not in points]
|
||||||
|
if not misses:
|
||||||
|
return points
|
||||||
|
allowed = budget.take(len(misses))
|
||||||
|
if allowed == 0:
|
||||||
|
return points
|
||||||
|
fresh = asyncio.run(_geocode_many(misses[:allowed]))
|
||||||
|
points.update(fresh)
|
||||||
|
if dry_run:
|
||||||
|
return points # замер не пишет даже кэш — прогон остаётся повторяемым
|
||||||
|
for key, point in fresh.items():
|
||||||
|
address, slug = wanted[key]
|
||||||
|
db.execute(
|
||||||
|
text(_GEO_CACHE_UPSERT),
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"slug": slug,
|
||||||
|
"address": address,
|
||||||
|
"lat": None if point is None else point.lat,
|
||||||
|
"lon": None if point is None else point.lon,
|
||||||
|
"region": None if point is None else point.region_code,
|
||||||
|
"full": None if point is None else point.full_address,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportCounters:
|
||||||
|
"""Разбор прогона. Числа обязаны сходиться, см. `check()`."""
|
||||||
|
|
||||||
|
read: int = 0
|
||||||
|
skipped_artifact: int = 0
|
||||||
|
# Карточка сама говорит про другой регион (префикс округа / поддомен
|
||||||
|
# Циана / компонент адреса не совпал с целевым `--region`).
|
||||||
|
skipped_not_target_region: int = 0
|
||||||
|
skipped_invalid: int = 0
|
||||||
|
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой
|
||||||
|
# регион — адрес разрешён, дом реальный, просто регион в ответе DaData не
|
||||||
|
# совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился
|
||||||
|
# бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а
|
||||||
|
# не «в целевом регионе стало меньше домов».
|
||||||
|
skipped_geo_other_region: int = 0
|
||||||
|
skipped_ungeocoded: int = 0
|
||||||
|
selected: int = 0
|
||||||
|
inserted: int = 0
|
||||||
|
updated: int = 0
|
||||||
|
geocode_calls: int = 0 # фактически потраченных внешних вызовов
|
||||||
|
geocode_budget_exhausted: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def written(self) -> int:
|
||||||
|
return self.inserted + self.updated
|
||||||
|
|
||||||
|
@property
|
||||||
|
def writer_skipped(self) -> int:
|
||||||
|
"""Отобрано, но писатель строку не тронул.
|
||||||
|
|
||||||
|
`save_listings` возвращает только (inserted, updated); неизменные строки,
|
||||||
|
уже виденные сегодня, он пропускает своим гейтом (#2992). Остаток честно
|
||||||
|
показываем отдельно, а не растворяем в «записано».
|
||||||
|
"""
|
||||||
|
return self.selected - self.written
|
||||||
|
|
||||||
|
def check(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.read
|
||||||
|
== self.selected
|
||||||
|
+ self.skipped_artifact
|
||||||
|
+ self.skipped_not_target_region
|
||||||
|
+ self.skipped_geo_other_region
|
||||||
|
+ self.skipped_ungeocoded
|
||||||
|
+ self.skipped_invalid
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_artifact_address(address: str | None) -> bool:
|
||||||
|
"""Адрес чужого города в московском корпусе (артефакт парсера)."""
|
||||||
|
return bool(address) and _ARTIFACT_RE.search(address) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_moscow_address(address: str | None) -> bool:
|
||||||
|
"""Москва опознаётся префиксом административного округа."""
|
||||||
|
if not address:
|
||||||
|
return False
|
||||||
|
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def cian_subdomain(payload: dict) -> str | None:
|
||||||
|
"""Поддомен `source_url` Циана: `https://<sub>.cian.ru/...` → `<sub>`.
|
||||||
|
|
||||||
|
Адрес карточки регион 50 не выдаёт вовсе (см. докстринг модуля), поэтому
|
||||||
|
область читается из URL. Поддомен и префикс округа не противоречат друг
|
||||||
|
другу ни в одной карточке (замер по `msk_raw.cian_latest`, 12.09.2026):
|
||||||
|
`www` — 38 030 карточек (36 569 с префиксом округа, это Москва); все
|
||||||
|
прочие поддомены (krasnogorsk, balashikha, vidnoye, lyubertsy, zvenigorod,
|
||||||
|
khimki, mytishchi, podolsk, odintsovo, …) — 0 карточек с префиксом округа.
|
||||||
|
Хост не `*.cian.ru` или `source_url` отсутствует → None (консервативно:
|
||||||
|
региону не сопоставляем).
|
||||||
|
"""
|
||||||
|
url = payload.get("source_url")
|
||||||
|
if not isinstance(url, str) or not url:
|
||||||
|
return None
|
||||||
|
# `hostname`, а не `netloc`: он уже без порта и userinfo и в нижнем
|
||||||
|
# регистре — иначе гипотетический `www.cian.ru:443` промахнулся бы мимо
|
||||||
|
# суффикса и уехал в «не целевой регион».
|
||||||
|
host = urlsplit(url).hostname or ""
|
||||||
|
if not host.endswith(".cian.ru"):
|
||||||
|
return None
|
||||||
|
sub = host[: -len(".cian.ru")]
|
||||||
|
return sub or None
|
||||||
|
|
||||||
|
|
||||||
|
def is_cian_oblast_payload(payload: dict) -> bool:
|
||||||
|
"""Регион 50 у Циана: любой поддомен, кроме `www` (см. `cian_subdomain`)."""
|
||||||
|
sub = cian_subdomain(payload)
|
||||||
|
return sub is not None and sub != "www"
|
||||||
|
|
||||||
|
|
||||||
|
def is_moscow_yandex_address(address: str | None) -> bool:
|
||||||
|
"""У Яндекса регион — второй компонент полного адреса.
|
||||||
|
|
||||||
|
Адрес приходит нормализованным и с городом: «Россия, Москва, Коробейников
|
||||||
|
переулок, 1». Замер по 21 393 карточкам первого прохода: во втором
|
||||||
|
компоненте ровно ДВА значения — «Москва» 10 610 и «Московская область»
|
||||||
|
10 783, третьего не встречается. Поэтому ни префикса округа (как у Циана),
|
||||||
|
ни внешнего геокода (как у Авито) источнику не нужно: разделение 77 и 50
|
||||||
|
читается из самой карточки и стоит ноль вызовов.
|
||||||
|
|
||||||
|
Новая Москва отдельным значением НЕ приходит — Троицк и Зеленоград Яндекс
|
||||||
|
кладёт под «Москва», что совпадает с кодом региона 77.
|
||||||
|
"""
|
||||||
|
parts = [part.strip() for part in (address or "").split(",")]
|
||||||
|
return len(parts) > 1 and parts[1] == "Москва"
|
||||||
|
|
||||||
|
|
||||||
|
def is_oblast_yandex_address(address: str | None) -> bool:
|
||||||
|
"""Регион 50 у Яндекса: второй компонент адреса — «Московская область».
|
||||||
|
|
||||||
|
Симметрично `is_moscow_yandex_address`: во втором компоненте встречаются
|
||||||
|
ровно два значения (см. докстринг модуля), третьего нет — среди карточек
|
||||||
|
Яндекса «не Москва» и означает «область».
|
||||||
|
"""
|
||||||
|
parts = [part.strip() for part in (address or "").split(",")]
|
||||||
|
return len(parts) > 1 and parts[1] == "Московская область"
|
||||||
|
|
||||||
|
|
||||||
|
def is_moscow_domclick_address(address: str | None) -> bool:
|
||||||
|
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
|
||||||
|
|
||||||
|
Источник собран запросом с GUID-ом Москвы в параметре address и дополнительно
|
||||||
|
отфильтрован по bbox на стороне сборщика, так что областных карточек в сырье
|
||||||
|
и не должно быть. Фильтр здесь — вторая линия: сменится GUID в сборщике или
|
||||||
|
появится второй город в той же таблице — импорт не потащит его в Москву
|
||||||
|
молча. Замер по 5 024 карточкам первого прохода: первый компонент имеет ровно
|
||||||
|
одно значение, «Москва», областных нет ни одной.
|
||||||
|
|
||||||
|
Новая Москва отдельным значением НЕ приходит: «Москва, x. Ильичевка, …»,
|
||||||
|
«Москва, пос. Птичное, …» — посёлок стоит ВТОРЫМ компонентом, первый всегда
|
||||||
|
город, что совпадает с кодом региона 77.
|
||||||
|
"""
|
||||||
|
parts = [part.strip() for part in (address or "").split(",")]
|
||||||
|
return bool(parts) and parts[0] == "Москва"
|
||||||
|
|
||||||
|
|
||||||
|
def is_oblast_domclick_address(address: str | None) -> bool:
|
||||||
|
"""Регион 50 у ДомКлика: первый компонент адреса — «Московская область».
|
||||||
|
|
||||||
|
Симметрично `is_moscow_domclick_address`. Таблица `msk_raw.domclick_cards`
|
||||||
|
копит ДВА разных прогона сборщика (московский батч
|
||||||
|
`msk-serp-domclick-20260912`, областной `mo-serp-domclick-20260912`) без
|
||||||
|
своей колонки региона, а вью `msk_raw.domclick_latest` отдаёт обе партии
|
||||||
|
вперемешку — фильтр по адресу обязателен, не опционален. Замер живьём на
|
||||||
|
API ДомКлика 12.09.2026: «Московская область, Химки, 7-й м-н, проспект
|
||||||
|
Мельникова, 33», «Московская область, Одинцовский городской округ,
|
||||||
|
Звенигород, 3-й м-н, 28» и подобные — первый компонент «Московская
|
||||||
|
область» у всех 140 карточек выборки с семи разных смещений выдачи.
|
||||||
|
"""
|
||||||
|
parts = [part.strip() for part in (address or "").split(",")]
|
||||||
|
return bool(parts) and parts[0] == "Московская область"
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_point(payload: dict) -> tuple[float, float] | None:
|
||||||
|
"""(lat, lon) из сырья ДомКлика, если сборщик их положил и они читаются."""
|
||||||
|
try:
|
||||||
|
return float(payload["lat"]), float(payload["lon"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_oblast_domclick_payload(payload: dict) -> bool:
|
||||||
|
"""Область у ДомКлика: префикс адреса ИЛИ координата внутри bbox области.
|
||||||
|
|
||||||
|
Одного префикса мало. Замер на собранном корпусе 12.09.2026 (2 961 карточка
|
||||||
|
областного батча): «Московская область» стоит первым компонентом у 2 960, а
|
||||||
|
у одной — «Можайский муниципальный округ, д. Семёновское, 1», 55.5116/35.8293.
|
||||||
|
Это настоящая область (Можайск), и строгий префикс выбросил бы её молча.
|
||||||
|
Московский батч тем же замером даёт «Москва» первым компонентом у ВСЕХ
|
||||||
|
22 836 карточек, поэтому явный отказ Москве идёт раньше гео-ветки и bbox
|
||||||
|
Москвы (вложенный в областной) не может протащить столичную карточку в 50.
|
||||||
|
"""
|
||||||
|
address = payload.get("address")
|
||||||
|
if is_oblast_domclick_address(address):
|
||||||
|
return True
|
||||||
|
if is_moscow_domclick_address(address):
|
||||||
|
return False
|
||||||
|
point = _payload_point(payload)
|
||||||
|
if point is None:
|
||||||
|
return False
|
||||||
|
return is_within_bbox(point[0], point[1], REGIONS[OBLAST_REGION_CODE].bbox_region)
|
||||||
|
|
||||||
|
|
||||||
|
def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]:
|
||||||
|
"""Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра)."""
|
||||||
|
return lambda payload: fn(payload.get("address"))
|
||||||
|
|
||||||
|
|
||||||
|
# Реестр (source, целевой регион) → фильтр по ВСЕМУ payload'у, не только
|
||||||
|
# адресу: у Циана признак региона 50 лежит в `source_url`, адрес про него
|
||||||
|
# молчит. Ключа нет только для источника, который вообще не умеет отличать
|
||||||
|
# регион без пред-геокода (avito, для ЛЮБОГО региона) — для всех прочих пар
|
||||||
|
# фильтр обязан быть в реестре явно.
|
||||||
|
REGION_FILTERS: dict[tuple[str, int], Callable[[dict], bool]] = {
|
||||||
|
("cian", MOSCOW_REGION_CODE): _by_address(is_moscow_address),
|
||||||
|
("cian", OBLAST_REGION_CODE): is_cian_oblast_payload,
|
||||||
|
("yandex", MOSCOW_REGION_CODE): _by_address(is_moscow_yandex_address),
|
||||||
|
("yandex", OBLAST_REGION_CODE): _by_address(is_oblast_yandex_address),
|
||||||
|
("domclick", MOSCOW_REGION_CODE): _by_address(is_moscow_domclick_address),
|
||||||
|
("domclick", OBLAST_REGION_CODE): is_oblast_domclick_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def recompute_listing_segment(payload: dict) -> str:
|
||||||
|
"""Заново считаем сегмент: 'novostroyki' только при продаже застройщиком.
|
||||||
|
|
||||||
|
Обоснование — в докстринге модуля: метка кита означает лишь ссылку на ЖК.
|
||||||
|
"""
|
||||||
|
raw = payload.get("raw_payload") or {}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return "vtorichka"
|
||||||
|
return "novostroyki" if raw.get("is_from_developer") is True else "vtorichka"
|
||||||
|
|
||||||
|
|
||||||
|
def build_lot(payload: dict) -> ScrapedLot:
|
||||||
|
"""`payload` → `ScrapedLot` с пересчитанным сегментом.
|
||||||
|
|
||||||
|
Ключи, которых в модели нет, отбрасываем явно (по `model_fields`), а не
|
||||||
|
полагаемся на настройку extra у pydantic-модели.
|
||||||
|
"""
|
||||||
|
known = {k: v for k, v in payload.items() if k in ScrapedLot.model_fields}
|
||||||
|
known["listing_segment"] = recompute_listing_segment(payload)
|
||||||
|
return ScrapedLot(**known)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_pages(db: Session, view: str, *, batch_size: int, limit: int | None):
|
||||||
|
"""Keyset-пагинация по `id` — весь корпус в память не тянем."""
|
||||||
|
after = 0
|
||||||
|
taken = 0
|
||||||
|
sql = text(_PAGE_SQL.format(view=view))
|
||||||
|
while True:
|
||||||
|
page_size = batch_size
|
||||||
|
if limit is not None:
|
||||||
|
page_size = min(batch_size, limit - taken)
|
||||||
|
if page_size <= 0:
|
||||||
|
return
|
||||||
|
rows = db.execute(sql, {"after": after, "limit": page_size}).mappings().all()
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
after = rows[-1]["id"]
|
||||||
|
taken += len(rows)
|
||||||
|
yield rows
|
||||||
|
|
||||||
|
|
||||||
|
def import_msk_raw(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
source: str = "cian",
|
||||||
|
region: int = MOSCOW_REGION_CODE,
|
||||||
|
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||||
|
limit: int | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
allow_unfiltered: bool = False,
|
||||||
|
geocode: bool = False,
|
||||||
|
geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
|
||||||
|
) -> ImportCounters:
|
||||||
|
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
|
||||||
|
if region not in SUPPORTED_REGIONS:
|
||||||
|
raise SystemExit(f"region={region}: регион не поддержан, доступны {SUPPORTED_REGIONS}")
|
||||||
|
|
||||||
|
view = SOURCE_VIEWS[source]
|
||||||
|
counters = ImportCounters()
|
||||||
|
matcher = RealMatcherAdapter()
|
||||||
|
budget = GeocodeBudget(remaining=max(0, geocode_limit))
|
||||||
|
|
||||||
|
# Источник, который сам говорит про целевой регион: у Циана это префикс
|
||||||
|
# округа/поддомен, у Яндекса — второй компонент адреса, у ДомКлика —
|
||||||
|
# первый. Авито не говорит ничего ни для какого региона, ему нужен
|
||||||
|
# пред-геокод, поэтому в реестре его нет вовсе.
|
||||||
|
city_filter = REGION_FILTERS.get((source, region))
|
||||||
|
if geocode and city_filter is not None:
|
||||||
|
# Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту
|
||||||
|
# незачем.
|
||||||
|
raise SystemExit(f"source={source}: --geocode нужен только для avito")
|
||||||
|
if city_filter is None and not geocode:
|
||||||
|
# Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от
|
||||||
|
# прочих: ни города в адресе, ни координат. Пишем только по явному
|
||||||
|
# разрешению.
|
||||||
|
if not (dry_run or allow_unfiltered):
|
||||||
|
raise SystemExit(
|
||||||
|
f"source={source}: адрес не содержит признака города, регион "
|
||||||
|
f"{region} от прочих не отличить. Нужен --geocode (штатный путь), "
|
||||||
|
"--allow-unfiltered (аварийный) или --dry-run."
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет "
|
||||||
|
"вовсе; строки лягут без geom и вперемешку с прочими регионами",
|
||||||
|
source,
|
||||||
|
region,
|
||||||
|
)
|
||||||
|
if geocode and not dry_run:
|
||||||
|
db.execute(text(_GEO_CACHE_DDL))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
for rows in _iter_pages(db, view, batch_size=batch_size, limit=limit):
|
||||||
|
points = _resolve_points(db, rows, budget, dry_run=dry_run) if geocode else {}
|
||||||
|
lots: list[ScrapedLot] = []
|
||||||
|
for row in rows:
|
||||||
|
counters.read += 1
|
||||||
|
payload = row["payload"] or {}
|
||||||
|
address = payload.get("address")
|
||||||
|
if is_artifact_address(address):
|
||||||
|
counters.skipped_artifact += 1
|
||||||
|
continue
|
||||||
|
if city_filter is not None and not city_filter(payload):
|
||||||
|
counters.skipped_not_target_region += 1
|
||||||
|
continue
|
||||||
|
if geocode:
|
||||||
|
point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
|
||||||
|
if point is None:
|
||||||
|
counters.skipped_ungeocoded += 1
|
||||||
|
continue
|
||||||
|
if point.region_code != region:
|
||||||
|
counters.skipped_geo_other_region += 1
|
||||||
|
continue
|
||||||
|
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
|
||||||
|
# трогаем, пересбор корпуса от этого не зависит. geom появляется
|
||||||
|
# сразу — карточка идёт в radius-подбор аналогов, не дожидаясь
|
||||||
|
# ночного `geocode_missing`.
|
||||||
|
payload = {
|
||||||
|
**payload,
|
||||||
|
"lat": point.lat,
|
||||||
|
"lon": point.lon,
|
||||||
|
"geo_precision": "house",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
lots.append(build_lot(payload))
|
||||||
|
except ValidationError as exc:
|
||||||
|
counters.skipped_invalid += 1
|
||||||
|
logger.warning("msk_raw id=%s не собрался в ScrapedLot: %s", row["id"], exc)
|
||||||
|
|
||||||
|
counters.selected += len(lots)
|
||||||
|
if dry_run or not lots:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# У региона 50 своего единого города нет (`canonical_city is None` в
|
||||||
|
# реестре regions) — пишем city=None, `save_listings` его COALESCE'ит
|
||||||
|
# и существующее значение не затирает. Подбор аналогов не страдает:
|
||||||
|
# он радиусный (ST_DWithin), а не по городу; ценовая полоса ДКП
|
||||||
|
# ключуется на `deals.city`, а не на `listings.city`.
|
||||||
|
city = MOSCOW_CITY if region == MOSCOW_REGION_CODE else None
|
||||||
|
inserted, updated = save_listings(
|
||||||
|
db,
|
||||||
|
lots,
|
||||||
|
matcher=matcher,
|
||||||
|
region_code=region,
|
||||||
|
city=city,
|
||||||
|
)
|
||||||
|
counters.inserted += inserted
|
||||||
|
counters.updated += updated
|
||||||
|
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
|
||||||
|
logger.info(
|
||||||
|
"msk_raw %s region=%d: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
|
||||||
|
source,
|
||||||
|
region,
|
||||||
|
counters.read,
|
||||||
|
counters.selected,
|
||||||
|
counters.written,
|
||||||
|
counters.inserted,
|
||||||
|
counters.updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
counters.geocode_calls = budget.spent
|
||||||
|
counters.geocode_budget_exhausted = budget.exhausted
|
||||||
|
if budget.exhausted:
|
||||||
|
logger.warning(
|
||||||
|
"msk_raw %s: дневной бюджет геокода (%d) исчерпан — остаток корпуса "
|
||||||
|
"подхватит следующий прогон, разрешённые адреса уже в кэше",
|
||||||
|
source,
|
||||||
|
geocode_limit,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
|
||||||
|
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d "
|
||||||
|
"геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
|
||||||
|
"геокод-вызовов=%d | сходится=%s",
|
||||||
|
source,
|
||||||
|
region,
|
||||||
|
" (dry-run)" if dry_run else "",
|
||||||
|
counters.read,
|
||||||
|
counters.selected,
|
||||||
|
counters.written,
|
||||||
|
counters.inserted,
|
||||||
|
counters.updated,
|
||||||
|
counters.writer_skipped if not dry_run else 0,
|
||||||
|
counters.skipped_not_target_region,
|
||||||
|
counters.skipped_geo_other_region,
|
||||||
|
counters.skipped_ungeocoded,
|
||||||
|
counters.skipped_artifact,
|
||||||
|
counters.skipped_invalid,
|
||||||
|
counters.geocode_calls,
|
||||||
|
counters.check(),
|
||||||
|
)
|
||||||
|
return counters
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Импорт сырья msk_raw в listings (регион задаётся --region, по умолчанию 77)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
|
||||||
|
parser.add_argument(
|
||||||
|
"--region",
|
||||||
|
type=int,
|
||||||
|
choices=SUPPORTED_REGIONS,
|
||||||
|
default=MOSCOW_REGION_CODE,
|
||||||
|
help=f"целевой регион: {MOSCOW_REGION_CODE} — Москва, {OBLAST_REGION_CODE} — область",
|
||||||
|
)
|
||||||
|
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
|
||||||
|
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
|
||||||
|
parser.add_argument(
|
||||||
|
"--allow-unfiltered",
|
||||||
|
action="store_true",
|
||||||
|
help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--geocode",
|
||||||
|
action="store_true",
|
||||||
|
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
|
||||||
|
"в listings уходит только целевой регион (--region), прочее пропускается",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--geocode-limit",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_GEOCODE_LIMIT,
|
||||||
|
help=f"потолок внешних вызовов за прогон (по умолчанию {DEFAULT_GEOCODE_LIMIT}; "
|
||||||
|
"free tier DaData — 10 000/сутки на аккаунт, его же ест автокомплит формы)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
import_msk_raw(
|
||||||
|
db,
|
||||||
|
source=args.source,
|
||||||
|
region=args.region,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
limit=args.limit,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
allow_unfiltered=args.allow_unfiltered,
|
||||||
|
geocode=args.geocode,
|
||||||
|
geocode_limit=args.geocode_limit,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -54,6 +54,50 @@ dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблиц
|
||||||
т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал»
|
т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал»
|
||||||
имеет смысл после 3 наблюдённых публикаций (ориентир — ноябрь 2026).
|
имеет смысл после 3 наблюдённых публикаций (ориентир — ноябрь 2026).
|
||||||
|
|
||||||
|
РЯДОВ ТЕПЕРЬ НЕСКОЛЬКО (#3051). sber_price_index ключуется текстовой колонкой city,
|
||||||
|
и с #3051 оценщик выбирает ряд по region_code сделки: 66 → «Свердловская область»,
|
||||||
|
77 → «Москва», остальное → «Россия» (estimator.SBER_MONITORED_REGIONS). Монитор,
|
||||||
|
следивший ровно за свердловским рядом, пропустил бы пропажу московского — а под
|
||||||
|
ним 212 937 сделок региона 77. Теперь опрашиваются ВСЕ ряды из того же кортежа.
|
||||||
|
|
||||||
|
КОМПРОМИСС, честно. Второго независимого вердикта тут нет и быть не может: stale
|
||||||
|
считается по такту ЗАГРУЗКИ (sber_index_pull тянет все 3 табло × 3 региона одним
|
||||||
|
прогоном, errors — счётчик по всему прогону), поэтому для всех рядов он ОДИН И ТОТ ЖЕ
|
||||||
|
по построению. Многорядность ловит другое — ПРОПАЖУ ряда. Ранний выход с mark_failed
|
||||||
|
остался РОВНО за прежним случаем: нет ряда региона по умолчанию (свердловского) —
|
||||||
|
вердикт считать не из чего. Пропажа ЛЮБОГО другого ряда его больше не подавляет:
|
||||||
|
иначе переименование «Москва» → «г. Москва» отключало бы мониторинг Екатеринбурга
|
||||||
|
(latest_*=0, age_days=0, alert=0 — свежесть 66 не считалась вовсе), да ещё и с ложным
|
||||||
|
текстом «sber_price_index empty», хотя таблица непуста. Теперь пропажа обязательного
|
||||||
|
ряда 77 — свой ERROR с ИМЕНЕМ ряда при done-прогоне; нет фолбэчного «Россия» →
|
||||||
|
WARNING (по нему сегодня не считается ни одна сделка).
|
||||||
|
|
||||||
|
ЧЕМ ИМЕННО ЗДЕСЬ АЛЕРТЯТ (честно, не путать со счётчиком). Канал тревоги в проекте
|
||||||
|
ровно один и тот же у всех соседей — ERROR-запись логгера, которую LoggingIntegration
|
||||||
|
(event_level=ERROR) превращает в событие GlitchTip; это и проверяется в
|
||||||
|
tests/test_alerts_become_events.py — по ФАКТУ СОБЫТИЯ, а не по levelno. Счётчиков
|
||||||
|
прогона (scrape_runs.counters) не читает ни одно правило алертинга: единственный их
|
||||||
|
потребитель, стрик-алерт, смотрит на status прогона, а не на ключи counters. Поэтому
|
||||||
|
`alert_regions_missing` — НАБЛЮДЕНИЕ для ретроспективы по scrape_runs (как
|
||||||
|
regions_missing и age_days_max), а НЕ канал тревоги; обещание «свой алерт по счётчику»
|
||||||
|
было неправдой и убрано. Тревога по пропавшему ряду держится на ERROR выше, и именно
|
||||||
|
это проверяется тестом через тот же харнесс событий, что у соседей.
|
||||||
|
Расхождение latest-периодов между регионами кладётся в counters
|
||||||
|
(age_days_max) и в лог как НАБЛЮДЕНИЕ, но алертом не становится: источник вправе
|
||||||
|
публиковать регионы вразнобой, а частоту таких расхождений мы не мерили — заводить
|
||||||
|
порог без замера значит повторить дефект #2846 (порог внутри рабочего диапазона).
|
||||||
|
Семантика вердикта и ключи counters свердловского ряда не изменились.
|
||||||
|
|
||||||
|
Изоляция чужих рядов доведена до конца: не только пустая выборка, но и ИСКЛЮЧЕНИЕ
|
||||||
|
на запросе чужого ряда (таймаут, обрыв соединения посреди обхода) больше не уходит
|
||||||
|
во внешний except с mark_failed — каждый чужой ряд опрашивается в своём try, сбой
|
||||||
|
попадает в лог и в regions_missing. Наружу поднимается только сбой на ряде региона
|
||||||
|
по умолчанию: вердикт всё равно не из чего считать. Свой try без ОТКАТА эту изоляцию
|
||||||
|
не давал: ошибка драйвера деактивирует транзакцию Session, и следующий же запрос
|
||||||
|
(за интервалом загрузки) падает с PendingRollbackError — исключение не
|
||||||
|
распространялось, зато сессия оставалась испорченной, и вердикт по свердловскому ряду
|
||||||
|
терялся ровно как раньше. Поэтому в per-region except стоит db.rollback().
|
||||||
|
|
||||||
ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с
|
ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с
|
||||||
LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще.
|
LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще.
|
||||||
|
|
||||||
|
|
@ -75,7 +119,12 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION
|
from app.services.estimator import (
|
||||||
|
SBER_COEFF_DASHBOARDS,
|
||||||
|
SBER_MONITORED_REGIONS,
|
||||||
|
SBER_REQUIRED_REGIONS,
|
||||||
|
SBER_TIME_ADJUST_REGION,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -170,16 +219,19 @@ def evaluate_sber_freshness(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None:
|
def _load_estimator_dashboard(
|
||||||
"""Табло, которое возьмёт оценщик, и его latest период.
|
db: Session, city: str = SBER_TIME_ADJUST_REGION
|
||||||
|
) -> tuple[str, date] | None:
|
||||||
|
"""Табло, которое возьмёт оценщик для ряда `city`, и его latest период.
|
||||||
|
|
||||||
Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло
|
Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло
|
||||||
из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло.
|
из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло.
|
||||||
|
|
||||||
|
#3051: `city` — имя ряда (sber_price_index.city), дефолт — свердловский, чтобы
|
||||||
|
вызов без аргумента остался прежним.
|
||||||
"""
|
"""
|
||||||
for dash in SBER_COEFF_DASHBOARDS:
|
for dash in SBER_COEFF_DASHBOARDS:
|
||||||
row = db.execute(
|
row = db.execute(_LATEST_PERIOD_SQL, {"city": city, "dash": dash}).first()
|
||||||
_LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash}
|
|
||||||
).first()
|
|
||||||
latest = row.latest if row is not None else None
|
latest = row.latest if row is not None else None
|
||||||
if latest is not None:
|
if latest is not None:
|
||||||
return dash, latest
|
return dash, latest
|
||||||
|
|
@ -230,25 +282,177 @@ def check_sber_freshness(
|
||||||
"pull_lag_days": -1,
|
"pull_lag_days": -1,
|
||||||
"max_pull_lag_days": 0,
|
"max_pull_lag_days": 0,
|
||||||
"alert": 0,
|
"alert": 0,
|
||||||
|
# #3051: сколько рядов из SBER_MONITORED_REGIONS не нашлось и каков худший
|
||||||
|
# возраст среди найденных (наблюдение, не критерий тревоги).
|
||||||
|
"regions_missing": 0,
|
||||||
|
"age_days_max": 0,
|
||||||
|
# Обязательный ряд не наблюдался этим прогоном: пропал из таблицы либо запрос
|
||||||
|
# по нему сорвался. В раннем выходе (нет свердловского) счётчик тоже заполнен.
|
||||||
|
# НАБЛЮДЕНИЕ, а не канал тревоги (см. «ЧЕМ ИМЕННО ЗДЕСЬ АЛЕРТЯТ» в шапке):
|
||||||
|
# counters не читает ни одно правило алертинга, тревогу поднимает ERROR-лог.
|
||||||
|
"alert_regions_missing": 0,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
runs_mod.update_heartbeat(db, run_id, counters)
|
runs_mod.update_heartbeat(db, run_id, counters)
|
||||||
|
|
||||||
found = _load_estimator_dashboard(db)
|
# #3051: опрашиваем ВСЕ ряды, которые способен прочитать оценщик, а не один.
|
||||||
if found is None:
|
# ДЕДУПЛИКАЦИЯ обязательна: SBER_MONITORED_REGIONS — кортеж ИМЁН рядов, а карта
|
||||||
|
# оценщика вправе свести два region_code на одно имя (заведём регион, чей ряд
|
||||||
|
# совпал с фолбэчной «Россией» — кортеж станет длиннее на элемент, а РАЗЛИЧНЫХ
|
||||||
|
# рядов останется столько же). Считать пропажи по длине кортежа значило бы
|
||||||
|
# залипнуть на regions_missing=1 навсегда при всех живых рядах.
|
||||||
|
monitored_regions = tuple(dict.fromkeys(SBER_MONITORED_REGIONS))
|
||||||
|
|
||||||
|
found_by_region: dict[str, tuple[str, date]] = {}
|
||||||
|
probe_failed: list[str] = [] # ряд не удалось СПРОСИТЬ (не то же, что «нет ряда»)
|
||||||
|
for region in monitored_regions:
|
||||||
|
# КАЖДЫЙ ЧУЖОЙ РЯД — В СВОЁМ try. Раньше весь обход шёл под общим except:
|
||||||
|
# таймаут или обрыв соединения на запросе московского ряда улетал наружу,
|
||||||
|
# давал mark_failed и повторный подъём — и вердикт по свердловскому ряду
|
||||||
|
# снова не считался, хотя сам ряд на месте. Это тот же дефект, что чинили
|
||||||
|
# ранним выходом по ПУСТОЙ выборке, только по ветке ИСКЛЮЧЕНИЯ.
|
||||||
|
try:
|
||||||
|
got = _load_estimator_dashboard(db, region)
|
||||||
|
except Exception:
|
||||||
|
if region == SBER_TIME_ADJUST_REGION:
|
||||||
|
# Ряд региона по умолчанию — единственный источник вердикта:
|
||||||
|
# его сбой подавлять нечего и незачем, отдаём во внешний except
|
||||||
|
# (там и откат, и mark_failed).
|
||||||
|
raise
|
||||||
|
# ОТКАТ, А НЕ ПРОСТО continue. Прошлый круг изолировал РАСПРОСТРАНЕНИЕ
|
||||||
|
# исключения, но не ПОРЧУ СЕССИИ — это разные вещи, и второго мало.
|
||||||
|
# Настоящая ошибка драйвера (таймаут инструкции, обрыв соединения)
|
||||||
|
# ДЕАКТИВИРУЕТ транзакцию Session: следующий запрос падает с
|
||||||
|
# PendingRollbackError, даже не дойдя до БД. Без отката изоляция была
|
||||||
|
# мнимой — цикл шёл дальше, но первый же запрос ЗА ИНТЕРВАЛОМ ЗАГРУЗКИ
|
||||||
|
# падал на испорченной сессии, улетал во внешний except, давал
|
||||||
|
# mark_failed и подъём: вердикт по свердловскому ряду опять не доезжал.
|
||||||
|
# Пустая выборка этого не воспроизводит вовсе — тест обязан имитировать
|
||||||
|
# именно ошибку драйвера (tests/test_sber_region_series_3051.py).
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
# Откат не прошёл — мертво соединение целиком, а не один запрос:
|
||||||
|
# вердикт всё равно считать не из чего, отдаём наружу.
|
||||||
|
logger.exception(
|
||||||
|
"sber freshness: откат сессии после сбоя на ряде %s не прошёл — "
|
||||||
|
"соединение непригодно, вердикт по %s не считаем",
|
||||||
|
region,
|
||||||
|
SBER_TIME_ADJUST_REGION,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
probe_failed.append(region)
|
||||||
|
logger.exception(
|
||||||
|
"sber freshness: запрос ряда СберИндекса %s сорвался — сессия "
|
||||||
|
"откачена, ряд помечен ненаблюдённым, вердикт по %s считаем дальше",
|
||||||
|
region,
|
||||||
|
SBER_TIME_ADJUST_REGION,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if got is not None:
|
||||||
|
found_by_region[region] = got
|
||||||
|
counters["regions_missing"] = len(monitored_regions) - len(found_by_region)
|
||||||
|
|
||||||
|
# Обязательный ряд может быть не наблюдён по двум разным причинам: его нет в
|
||||||
|
# таблице (переименование в источнике) или запрос по нему сорвался. Причину
|
||||||
|
# разделяет ЛОГ; в counters она не ветвится — новых ключей не заводим, а для
|
||||||
|
# тревоги оба случая равнозначны: обязательного ряда за этот прогон нет.
|
||||||
|
missing_required = [
|
||||||
|
r for r in SBER_REQUIRED_REGIONS if r not in found_by_region and r not in probe_failed
|
||||||
|
]
|
||||||
|
# НЕ то же самое, что missing_required: сюда попадает и ряд, который не удалось
|
||||||
|
# СПРОСИТЬ. Для тревоги эти случаи равнозначны («обязательного ряда за этот
|
||||||
|
# прогон нет»), поэтому счётчик считается по required_unseen, а не по
|
||||||
|
# missing_required; причину разделяет лог (ERROR ниже vs logger.exception в цикле).
|
||||||
|
required_unseen = [r for r in SBER_REQUIRED_REGIONS if r not in found_by_region]
|
||||||
|
|
||||||
|
# ДО РАННЕГО ВЫХОДА, а не после. Раньше этот блок стоял ниже возврата, то есть
|
||||||
|
# в ветке раннего выхода не выполнялся НИКОГДА: при одновременной пропаже
|
||||||
|
# свердловского и московского рядов в мониторинг уходило сообщение только про
|
||||||
|
# свердловский, хотя под московским 212 937 сделок региона 77 и это отдельный
|
||||||
|
# дефект с отдельной починкой. Теперь про КАЖДЫЙ пропавший обязательный ряд
|
||||||
|
# сообщение уходит всегда, а ранний выход остаётся только вопросом вердикта.
|
||||||
|
#
|
||||||
|
# Почему ERROR при done-прогоне, а не mark_failed (когда свердловский на месте):
|
||||||
|
# (а) вердикт по 66 уже посчитан и обязан доехать до дашборда, а counters
|
||||||
|
# упавшего прогона там не читаются — ровно эта подмена и превращала
|
||||||
|
# пропажу Москвы в отключение мониторинга Екатеринбурга;
|
||||||
|
# (б) mark_failed виден только стрик-алерту, т.е. на третьи сутки, а ERROR
|
||||||
|
# уходит в GlitchTip тем же прогоном (#2674);
|
||||||
|
# (в) отдельный ключ counters не перегружает `alert`, который значит «загрузка
|
||||||
|
# отстала»: это другой дефект, чинится в другом месте (имя ряда).
|
||||||
|
# Ряд региона ПО УМОЛЧАНИЮ разбирается отдельной веткой ниже (ранний выход),
|
||||||
|
# и у неё свой ERROR. Без этого условия одновременная пропажа обоих рядов
|
||||||
|
# давала ДВА события об одном факте — лишняя issue в GlitchTip, не сигнал.
|
||||||
|
default_found = found_by_region.get(SBER_TIME_ADJUST_REGION)
|
||||||
|
if missing_required and default_found is not None:
|
||||||
|
logger.error(
|
||||||
|
"sber freshness: пропал обязательный ряд СберИндекса %s — %s. "
|
||||||
|
"По этим регионам есть сделки, а time-поправку взять неоткуда: "
|
||||||
|
"проверь имя ряда в источнике (переименование city) и карту "
|
||||||
|
"estimator._SBER_REGION_SERIES",
|
||||||
|
missing_required,
|
||||||
|
# «Таблица НЕ пуста» — утверждение о факте, поэтому только когда хоть
|
||||||
|
# один ряд действительно прочитан: прежний безусловный текст врал.
|
||||||
|
f"таблица НЕ пуста, остальные ряды на месте ({sorted(found_by_region)})"
|
||||||
|
if found_by_region
|
||||||
|
else "ни одного ряда прочитать не удалось",
|
||||||
|
)
|
||||||
|
|
||||||
|
missing_optional = [
|
||||||
|
r
|
||||||
|
for r in monitored_regions
|
||||||
|
if r not in found_by_region and r not in SBER_REQUIRED_REGIONS and r not in probe_failed
|
||||||
|
]
|
||||||
|
if missing_optional:
|
||||||
|
# WARNING (не ERROR): это фолбэчный ряд для региона вне карты оценщика —
|
||||||
|
# сегодня по нему не считается ни одна сделка, ронять монитор незачем.
|
||||||
|
logger.warning(
|
||||||
|
"sber freshness: нет фолбэчной серии %s — регион вне "
|
||||||
|
"estimator._SBER_REGION_SERIES останется без time-поправки",
|
||||||
|
missing_optional,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ранний выход — ТОЛЬКО ради того случая, ради которого он и заводился:
|
||||||
|
# у оценщика нет серии по региону ПО УМОЛЧАНИЮ, считать вердикт не из чего.
|
||||||
|
# Пропажа любого другого ряда его больше не подавляет (см. шапку).
|
||||||
|
if default_found is None:
|
||||||
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
|
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
|
||||||
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
|
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
|
||||||
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
|
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
|
||||||
|
# Единственное событие этой ветки: называет и ряд по умолчанию, и все
|
||||||
|
# прочие пропавшие обязательные ряды — блок missing_required выше
|
||||||
|
# здесь намеренно молчит, чтобы не дублировать issue.
|
||||||
logger.error(
|
logger.error(
|
||||||
"sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк "
|
"sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк "
|
||||||
"для region=%s (вторичка); оценить нечего",
|
"для region=%s (вторичка); оценить нечего. Пропавшие обязательные "
|
||||||
|
"ряды целиком: %s",
|
||||||
list(SBER_COEFF_DASHBOARDS),
|
list(SBER_COEFF_DASHBOARDS),
|
||||||
SBER_TIME_ADJUST_REGION,
|
SBER_TIME_ADJUST_REGION,
|
||||||
|
missing_required or [SBER_TIME_ADJUST_REGION],
|
||||||
|
)
|
||||||
|
# Новые счётчики заполняем и ЗДЕСЬ. Нули по ним делали ранний выход слепым:
|
||||||
|
# одновременная пропажа свердловского и московского рядов выглядела ровно
|
||||||
|
# как пропажа одного свердловского (alert_regions_missing=0, age_days_max=0),
|
||||||
|
# хотя второй дефект — отдельный и по нему 212 937 сделок региона 77.
|
||||||
|
counters["alert_regions_missing"] = int(bool(required_unseen))
|
||||||
|
counters["age_days_max"] = max(
|
||||||
|
((now.date() - d).days for _, d in found_by_region.values()), default=0
|
||||||
|
)
|
||||||
|
runs_mod.mark_failed(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
# Текст называет КОНКРЕТНЫЙ ряд: «таблица пуста» было ложью — в ней
|
||||||
|
# могут лежать все остальные регионы.
|
||||||
|
f"sber_price_index: нет серии '{SBER_TIME_ADJUST_REGION}' "
|
||||||
|
f"в табло {list(SBER_COEFF_DASHBOARDS)}",
|
||||||
|
counters,
|
||||||
)
|
)
|
||||||
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
|
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
dashboard, latest = found
|
# Вердикт — по свердловскому ряду, как и до #3051 (см. КОМПРОМИСС в шапке:
|
||||||
|
# такт загрузки общий для всех рядов, второго независимого вердикта нет).
|
||||||
|
dashboard, latest = default_found
|
||||||
last_pull_row = db.execute(
|
last_pull_row = db.execute(
|
||||||
_LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}
|
_LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}
|
||||||
).first()
|
).first()
|
||||||
|
|
@ -267,8 +471,23 @@ def check_sber_freshness(
|
||||||
"pull_lag_days": verdict.pull_lag_days,
|
"pull_lag_days": verdict.pull_lag_days,
|
||||||
"max_pull_lag_days": verdict.max_pull_lag_days,
|
"max_pull_lag_days": verdict.max_pull_lag_days,
|
||||||
"alert": int(verdict.stale),
|
"alert": int(verdict.stale),
|
||||||
|
"regions_missing": counters["regions_missing"],
|
||||||
|
"age_days_max": max((now.date() - d).days for _, d in found_by_region.values()),
|
||||||
|
"alert_regions_missing": int(bool(required_unseen)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# #3051: наблюдение по всем рядам — расхождение latest между регионами видно
|
||||||
|
# в логе, но алертом не становится (порог без замера = дефект #2846).
|
||||||
|
logger.info(
|
||||||
|
"sber freshness: ряды оценщика — %s",
|
||||||
|
"; ".join(
|
||||||
|
f"{r}: {found_by_region[r][1]} ({found_by_region[r][0]})"
|
||||||
|
if r in found_by_region
|
||||||
|
else (f"{r}: СБОЙ ЗАПРОСА" if r in probe_failed else f"{r}: НЕТ")
|
||||||
|
for r in monitored_regions
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if verdict.stale:
|
if verdict.stale:
|
||||||
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR).
|
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR).
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,7 @@ async def run_yandex_detail_backfill(
|
||||||
)
|
)
|
||||||
if consecutive_blocks >= max_consecutive_blocks:
|
if consecutive_blocks >= max_consecutive_blocks:
|
||||||
aborted_by_blocks = True
|
aborted_by_blocks = True
|
||||||
logger.error(
|
logger.warning(
|
||||||
"yandex_detail_backfill: run_id=%d ABORT -- %d consecutive "
|
"yandex_detail_backfill: run_id=%d ABORT -- %d consecutive "
|
||||||
"non-200 responses. enriched=%d attempted=%d",
|
"non-200 responses. enriched=%d attempted=%d",
|
||||||
run_id,
|
run_id,
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,7 @@ async def enrich_yandex_newbuilding_sweep(
|
||||||
if caused_by_no_proxy(exc):
|
if caused_by_no_proxy(exc):
|
||||||
result.no_proxy_stop = True
|
result.no_proxy_stop = True
|
||||||
result.failed_resolve += 1
|
result.failed_resolve += 1
|
||||||
logger.error(
|
logger.warning(
|
||||||
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
|
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
|
||||||
"house_id=%s processed=%d succeeded=%d",
|
"house_id=%s processed=%d succeeded=%d",
|
||||||
house_id,
|
house_id,
|
||||||
|
|
@ -419,7 +419,7 @@ async def enrich_yandex_newbuilding_sweep(
|
||||||
if caused_by_no_proxy(exc): # #3197 — см. блок resolve выше
|
if caused_by_no_proxy(exc): # #3197 — см. блок resolve выше
|
||||||
result.no_proxy_stop = True
|
result.no_proxy_stop = True
|
||||||
result.failed_fetch += 1
|
result.failed_fetch += 1
|
||||||
logger.error(
|
logger.warning(
|
||||||
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
|
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
|
||||||
"house_id=%s processed=%d succeeded=%d",
|
"house_id=%s processed=%d succeeded=%d",
|
||||||
house_id,
|
house_id,
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,12 @@ import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown
|
from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown
|
||||||
from app.services.tgbot.bridge import run_poll_loop
|
from app.services.tgbot.bridge import run_poll_loop
|
||||||
from app.services.tgbot.client import TelegramClient
|
from app.services.tgbot.client import TelegramClient, verify_chat_and_topic
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -57,18 +56,20 @@ if settings.glitchtip_dsn:
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
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.types import Event, Hint
|
||||||
|
|
||||||
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_pii_event,
|
scrub_pii_event,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
|
def _before_send(event: Event, hint: Hint) -> Event | None:
|
||||||
"""Композиция платёжный body-wipe (PR-D2) + PII-scrub (form-данные) +
|
"""Композиция payments-disabled drop (#3471) + платёжный body-wipe (PR-D2) +
|
||||||
Telegram bot-токен redaction (#tgsupport review). Токен утекает ДВУМЯ
|
PII-scrub (form-данные) + Telegram bot-токен redaction (#tgsupport review).
|
||||||
независимыми векторами, которые `include_local_variables=False` ниже и
|
Токен утекает ДВУМЯ независимыми векторами, которые
|
||||||
этот хук закрывают вместе:
|
`include_local_variables=False` ниже и этот хук закрывают вместе:
|
||||||
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
|
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
|
||||||
locals (`self._base`/`url` в `TelegramClient._request`) в traceback —
|
locals (`self._base`/`url` в `TelegramClient._request`) в traceback —
|
||||||
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
|
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
|
||||||
|
|
@ -78,18 +79,22 @@ if settings.glitchtip_dsn:
|
||||||
— belt-and-suspenders на случай #1 (если include_local_variables
|
— belt-and-suspenders на случай #1 (если include_local_variables
|
||||||
случайно вернут) И на span data.
|
случайно вернут) И на span data.
|
||||||
|
|
||||||
Платёжный body-wipe — belt-and-suspenders: этот процесс не держит ASGI-
|
Payments-disabled drop и платёжный body-wipe — belt-and-suspenders: этот
|
||||||
приложения (нет `request` в event сегодня), но тот же обработчик передан
|
процесс не держит ASGI-приложения (нет `request`/HTTPException в event
|
||||||
ОБОИМ каналам ниже (before_send/before_send_transaction) ради единообразия
|
сегодня, реальный источник 503 — app/main.py), но тот же обработчик
|
||||||
со всеми точками инициализации sentry_sdk в проекте (см. app/main.py).
|
передан ОБОИМ каналам ниже (before_send/before_send_transaction) ради
|
||||||
|
единообразия со всеми точками инициализации sentry_sdk в проекте.
|
||||||
"""
|
"""
|
||||||
scrubbed = scrub_payment_request_body(event, hint)
|
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
|
||||||
|
if dropped is None:
|
||||||
|
return None
|
||||||
|
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
return redact_telegram_bot_token(scrubbed, hint)
|
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=settings.glitchtip_dsn,
|
dsn=settings.glitchtip_dsn,
|
||||||
|
|
@ -114,8 +119,34 @@ def _should_run() -> bool:
|
||||||
|
|
||||||
|
|
||||||
async def _run_bridge() -> None:
|
async def _run_bridge() -> None:
|
||||||
client = TelegramClient(settings.telegram_bot_token)
|
# `async with` — чтобы пул keep-alive соединений закрывался при любом выходе
|
||||||
await run_poll_loop(client, SessionLocal)
|
# из поллинга (кооперативный drain по SIGTERM, hard-cancel, исключение).
|
||||||
|
# Клиент один на весь процесс: пересоздание на запрос убивало keep-alive и
|
||||||
|
# заставляло каждый long-poll начинаться с TCP+TLS-хендшейка.
|
||||||
|
async with TelegramClient(
|
||||||
|
settings.telegram_bot_token,
|
||||||
|
relay_base_url=settings.telegram_relay_base_url,
|
||||||
|
relay_secret=settings.telegram_relay_secret,
|
||||||
|
# Бот-роль (review H2, #3471) — своя, меньшая доля общего бюджета
|
||||||
|
# группы; см. докстринг настройки в app.core.config.
|
||||||
|
group_rate_limit_per_minute=settings.telegram_group_rate_limit_bot_per_minute,
|
||||||
|
) as client:
|
||||||
|
# Startup-проверка (#3471): убеждаемся ОДИН раз, что чат/тема живы,
|
||||||
|
# прежде чем уходить в бесконечный poll loop. Не блокирует и не роняет
|
||||||
|
# запуск при неудаче — см. докстринг `verify_chat_and_topic`.
|
||||||
|
await verify_chat_and_topic(
|
||||||
|
client,
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
topic_id=settings.telegram_support_topic_id,
|
||||||
|
label="support",
|
||||||
|
)
|
||||||
|
await verify_chat_and_topic(
|
||||||
|
client,
|
||||||
|
chat_id=settings.telegram_alerts_chat_id,
|
||||||
|
topic_id=settings.telegram_alerts_topic_id,
|
||||||
|
label="alerts",
|
||||||
|
)
|
||||||
|
await run_poll_loop(client, SessionLocal)
|
||||||
|
|
||||||
|
|
||||||
async def _await_bridge(task: asyncio.Task[None]) -> None:
|
async def _await_bridge(task: asyncio.Task[None]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
-- 299_msk_raw_cian_domclick_yandex_cards.sql
|
||||||
|
--
|
||||||
|
-- Контекст: msk_raw.avito_cards уже хранит сырые SERP-карточки Авито (Москва+МО,
|
||||||
|
-- вторичка), заливаемые ручным локальным сборщиком scripts/local-avito-msk/collect.py.
|
||||||
|
-- Эта миграция заводит зеркальные raw-таблицы под ЦИАН, Домклик и Яндекс.Недвижимость
|
||||||
|
-- по той же структуре и с тем же набором индексов (префикс площадки вместо avito_),
|
||||||
|
-- под будущие аналогичные локальные сборщики для этих площадок.
|
||||||
|
--
|
||||||
|
-- batch_id у всех трёх таблиц ссылается на существующую msk_raw.batches(batch_id)
|
||||||
|
-- (как и у avito_cards) — сборщик обязан сначала вставить строку в batches,
|
||||||
|
-- потом заливать карточки в рамках этого batch_id.
|
||||||
|
--
|
||||||
|
-- Тип source_id решён отдельно на каждую площадку по факту из парсеров
|
||||||
|
-- (packages/scraper-kit/src/scraper_kit/providers/{cian,domclick,yandex}/serp.py):
|
||||||
|
-- * cian -> bigint: source_id = str(offer.get("cianId") or offer.get("id")),
|
||||||
|
-- cianId у ЦИАН всегда числовой, используется как есть в URL
|
||||||
|
-- (/sale/flat/<cianId>/).
|
||||||
|
-- * domclick -> bigint: source_id = str(item.get("id")), id у Домклика — числовой
|
||||||
|
-- offer id (как в URL карточки), аналогично Авито/ЦИАН.
|
||||||
|
-- * yandex -> text: source_id = offer_id = str(entity.get("offerId") or ""),
|
||||||
|
-- в парсере есть отдельный _to_bigint() для price-полей, но offerId
|
||||||
|
-- сознательно НЕ приводится через него и остаётся строкой — числовой
|
||||||
|
-- формат offerId у Яндекс.Недвижимости не гарантирован.
|
||||||
|
--
|
||||||
|
-- id — именно bigserial, а НЕ 'GENERATED ALWAYS AS IDENTITY'. Сборщик заливает
|
||||||
|
-- карточки через CREATE TEMP TABLE _stg (LIKE msk_raw.<table> INCLUDING DEFAULTS INCLUDING IDENTITY)
|
||||||
|
-- и \copy без колонки id: LIKE ... INCLUDING DEFAULTS переносит в _stg обычный
|
||||||
|
-- DEFAULT (nextval у bigserial), но НЕ переносит identity-свойство, а NOT NULL
|
||||||
|
-- переносится всегда — с identity каждый батч падал бы на not-null violation по id.
|
||||||
|
-- Прод-таблица msk_raw.avito_cards заведена так же:
|
||||||
|
-- id bigint NOT NULL DEFAULT nextval('msk_raw.avito_cards_id_seq'::regclass).
|
||||||
|
--
|
||||||
|
-- Идемпотентно: повторный запуск безопасен (IF NOT EXISTS везде).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
-- Схема и две первые таблицы заводились на проде РУКАМИ 08.09, мимо линейки
|
||||||
|
-- миграций: сбор сырья стартовал раньше, чем модель данных, и msk_raw сознательно
|
||||||
|
-- жила вне приложения (её никто в проде не читает, переезжает одним
|
||||||
|
-- `pg_dump -n msk_raw`). Из-за этого миграция роняла CI на чистой базе —
|
||||||
|
-- «schema "msk_raw" does not exist», а до тестов дело не доходило вовсе.
|
||||||
|
--
|
||||||
|
-- Поэтому DDL продовских объектов повторён здесь идемпотентно: на проде это
|
||||||
|
-- no-op (всё уже есть), на чистой базе — единственное место, где msk_raw
|
||||||
|
-- появляется. Определения сняты с прода `pg_dump -s -n msk_raw`, чтобы CI и
|
||||||
|
-- прод не разъехались молча; `batches` нужна и по существу — на неё ссылается
|
||||||
|
-- внешний ключ всех трёх таблиц ниже.
|
||||||
|
CREATE SCHEMA IF NOT EXISTS msk_raw;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS msk_raw.batches (
|
||||||
|
batch_id text PRIMARY KEY,
|
||||||
|
kind text NOT NULL DEFAULT 'serp',
|
||||||
|
query text,
|
||||||
|
started_at timestamptz,
|
||||||
|
finished_at timestamptz,
|
||||||
|
rows_sent integer,
|
||||||
|
rows_new integer,
|
||||||
|
notes text,
|
||||||
|
uploaded_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS msk_raw.avito_cards (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source_id bigint NOT NULL,
|
||||||
|
observed_at timestamptz NOT NULL,
|
||||||
|
batch_id text NOT NULL REFERENCES msk_raw.batches(batch_id),
|
||||||
|
kind text NOT NULL DEFAULT 'serp',
|
||||||
|
url text,
|
||||||
|
price numeric,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
UNIQUE (source_id, batch_id, kind)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS avito_cards_observed_idx
|
||||||
|
ON msk_raw.avito_cards (observed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS avito_cards_payload_gin
|
||||||
|
ON msk_raw.avito_cards USING gin (payload jsonb_path_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS avito_cards_source_observed_idx
|
||||||
|
ON msk_raw.avito_cards (source_id, observed_at DESC);
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW msk_raw.avito_latest AS
|
||||||
|
SELECT DISTINCT ON (source_id) id, source_id, observed_at, batch_id, kind, url, price, payload
|
||||||
|
FROM msk_raw.avito_cards ORDER BY source_id, observed_at DESC;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS msk_raw.cian_cards (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source_id bigint NOT NULL,
|
||||||
|
observed_at timestamptz NOT NULL,
|
||||||
|
batch_id text NOT NULL REFERENCES msk_raw.batches(batch_id),
|
||||||
|
kind text NOT NULL DEFAULT 'serp',
|
||||||
|
url text,
|
||||||
|
price numeric,
|
||||||
|
payload jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS cian_cards_observed_at_idx
|
||||||
|
ON msk_raw.cian_cards (observed_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS cian_cards_payload_gin_idx
|
||||||
|
ON msk_raw.cian_cards USING gin (payload jsonb_path_ops);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS cian_cards_source_id_batch_id_kind_uidx
|
||||||
|
ON msk_raw.cian_cards (source_id, batch_id, kind);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS cian_cards_source_id_observed_at_idx
|
||||||
|
ON msk_raw.cian_cards (source_id, observed_at DESC);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS msk_raw.domclick_cards (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source_id bigint NOT NULL,
|
||||||
|
observed_at timestamptz NOT NULL,
|
||||||
|
batch_id text NOT NULL REFERENCES msk_raw.batches(batch_id),
|
||||||
|
kind text NOT NULL DEFAULT 'serp',
|
||||||
|
url text,
|
||||||
|
price numeric,
|
||||||
|
payload jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS domclick_cards_observed_at_idx
|
||||||
|
ON msk_raw.domclick_cards (observed_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS domclick_cards_payload_gin_idx
|
||||||
|
ON msk_raw.domclick_cards USING gin (payload jsonb_path_ops);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS domclick_cards_source_id_batch_id_kind_uidx
|
||||||
|
ON msk_raw.domclick_cards (source_id, batch_id, kind);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS domclick_cards_source_id_observed_at_idx
|
||||||
|
ON msk_raw.domclick_cards (source_id, observed_at DESC);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS msk_raw.yandex_cards (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source_id text NOT NULL,
|
||||||
|
observed_at timestamptz NOT NULL,
|
||||||
|
batch_id text NOT NULL REFERENCES msk_raw.batches(batch_id),
|
||||||
|
kind text NOT NULL DEFAULT 'serp',
|
||||||
|
url text,
|
||||||
|
price numeric,
|
||||||
|
payload jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS yandex_cards_observed_at_idx
|
||||||
|
ON msk_raw.yandex_cards (observed_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS yandex_cards_payload_gin_idx
|
||||||
|
ON msk_raw.yandex_cards USING gin (payload jsonb_path_ops);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS yandex_cards_source_id_batch_id_kind_uidx
|
||||||
|
ON msk_raw.yandex_cards (source_id, batch_id, kind);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS yandex_cards_source_id_observed_at_idx
|
||||||
|
ON msk_raw.yandex_cards (source_id, observed_at DESC);
|
||||||
|
|
||||||
|
-- Вью «последнее наблюдение по объявлению» — по образцу существующей
|
||||||
|
-- msk_raw.avito_latest (тот же DISTINCT ON и тот же порядок колонок).
|
||||||
|
CREATE OR REPLACE VIEW msk_raw.cian_latest AS
|
||||||
|
SELECT DISTINCT ON (source_id) id, source_id, observed_at, batch_id, kind, url, price, payload
|
||||||
|
FROM msk_raw.cian_cards ORDER BY source_id, observed_at DESC;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW msk_raw.domclick_latest AS
|
||||||
|
SELECT DISTINCT ON (source_id) id, source_id, observed_at, batch_id, kind, url, price, payload
|
||||||
|
FROM msk_raw.domclick_cards ORDER BY source_id, observed_at DESC;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW msk_raw.yandex_latest AS
|
||||||
|
SELECT DISTINCT ON (source_id) id, source_id, observed_at, batch_id, kind, url, price, payload
|
||||||
|
FROM msk_raw.yandex_cards ORDER BY source_id, observed_at DESC;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
-- 300_sales_vs_listings_deals_rooms_drop.sql
|
||||||
|
-- Purpose: #3451 — TVF street_sales_vs_listings() фильтровала сделки по `d.rooms`,
|
||||||
|
-- а `deals.rooms` у источника 'rosreestr' — НЕ комнатность, а бакет площади.
|
||||||
|
-- Импортёр (tradein-mvp/deploy/import-rosreestr.sh) пишет туда
|
||||||
|
-- `CASE WHEN area < 30 THEN 0 WHEN area < 44 THEN 1 WHEN area < 62 THEN 2
|
||||||
|
-- WHEN area < 85 THEN 3 ELSE 4 END`
|
||||||
|
-- — прод: 321 559 строк из 321 560 удовлетворяют `rooms == area_bucket(area_m2)`,
|
||||||
|
-- max(rooms) = 4 (пятикомнатных в данных не бывает по построению). То есть предикат
|
||||||
|
-- работал ВТОРЫМ фильтром по площади и спорил с полосой ±tolerance, которую функция
|
||||||
|
-- считает сама: клиент 49 м² / 1к получал полосу 41.7–56.4 м², но `d.rooms = 1`
|
||||||
|
-- оставлял из неё только < 44 м².
|
||||||
|
--
|
||||||
|
-- Ровно эта патология снята в #3256 (PR #3445) на четырёх сделочных площадках
|
||||||
|
-- эстиматора; здесь — последний оставшийся потребитель.
|
||||||
|
--
|
||||||
|
-- КЛЮЧ АСИММЕТРИЧНЫЙ, копипастой из #3256 не чинится:
|
||||||
|
-- - `d.rooms = p_rooms` в window_deals — СНЯТ (синтетика из площади);
|
||||||
|
-- - `l.rooms = p_rooms` в window_listings — ОСТАЁТСЯ: у объявлений комнатность
|
||||||
|
-- настоящая (приходит с карточки), и это единственный признак ассортимента
|
||||||
|
-- на листинговой стороне.
|
||||||
|
--
|
||||||
|
-- Прод-замер (2026-09-12, БД tradein, 1160 реальных клиентских запросов из
|
||||||
|
-- trade_in_estimates; улица извлеклась у 954, у 206 — известная H1 «адрес вне
|
||||||
|
-- словаря», к этой правке отношения не имеет). Считалось тем же путём, что у
|
||||||
|
-- продукта: street/city резолвятся extract_street_name()/_resolve_target_city():
|
||||||
|
-- - непустой ответ /sales-vs-listings: 805 (84.4 %) → 899 (94.2 %), впервые
|
||||||
|
-- непустых 94 клиента;
|
||||||
|
-- - сделок в выборке суммарно: 68 147 → 88 816;
|
||||||
|
-- - из них с подобранным объявлением (то, что реально показывается парами):
|
||||||
|
-- 30 830 → 39 193;
|
||||||
|
-- - выборка не сократилась НИ У КОГО (0 из 954) — предикат умел только резать.
|
||||||
|
-- Прогноз из #3451 был «те же 180 клиентов»; измеренная величина — 94. Разница в
|
||||||
|
-- том, что оценка 180 бралась по коридору эстиматора (другие period/tolerance и
|
||||||
|
-- другой street-pattern), а не по этой витрине; в файл кладётся измеренное.
|
||||||
|
--
|
||||||
|
-- NULL здесь не появляется: ветка ELSE в CASE импортёра ловит и NULL-площадь
|
||||||
|
-- (все WHEN дают NULL → ELSE 4), прод подтверждает 0 NULL в deals.rooms при
|
||||||
|
-- source='rosreestr'. Поэтому `deal_rooms: int` в SalesListingPair остаётся
|
||||||
|
-- обязательным полем — контракт API не меняется.
|
||||||
|
--
|
||||||
|
-- Что НЕ меняется и почему:
|
||||||
|
-- - Сигнатура функции — те же 7 аргументов и те же типы, что после м.205/211.
|
||||||
|
-- CREATE OR REPLACE с ИЗМЕНЁННЫМ списком типов создал бы ВТОРУЮ перегрузку
|
||||||
|
-- вместо замены (грабли #2627) — здесь список побайтово тот же.
|
||||||
|
-- - `is_active` по-прежнему НЕ фильтруется (снятые объявления и есть материал
|
||||||
|
-- пейринга — объявление снимают ПОСЛЕ продажи), см. шапки 067/205/211.
|
||||||
|
-- - Сегментный гард #2660/#1186 и city-предикаты #2583 H4 перенесены дословно.
|
||||||
|
-- - Caller (app/api/v1/trade_in.py, /sales-vs-listings) не меняется.
|
||||||
|
--
|
||||||
|
-- ЗАВИСИМОСТИ: 211 (текущее тело + 7-арг сигнатура). Deploy order: после 299.
|
||||||
|
-- Идемпотентность: CREATE OR REPLACE + COMMENT ON — re-run safe.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION street_sales_vs_listings(
|
||||||
|
p_street_pattern text,
|
||||||
|
p_area_m2 numeric,
|
||||||
|
p_rooms integer,
|
||||||
|
p_window_days integer DEFAULT 180,
|
||||||
|
p_area_tolerance numeric DEFAULT 0.15,
|
||||||
|
p_period_months integer DEFAULT 24,
|
||||||
|
p_target_city text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
deal_id bigint,
|
||||||
|
deal_date date,
|
||||||
|
deal_price_rub bigint,
|
||||||
|
deal_price_per_m2 integer,
|
||||||
|
deal_area_m2 numeric,
|
||||||
|
deal_rooms integer,
|
||||||
|
deal_floor integer,
|
||||||
|
deal_address text,
|
||||||
|
listing_id bigint,
|
||||||
|
listing_source text,
|
||||||
|
listing_source_url text,
|
||||||
|
listing_date date,
|
||||||
|
listing_price_rub bigint,
|
||||||
|
listing_price_per_m2 integer,
|
||||||
|
listing_area_m2 numeric,
|
||||||
|
days_listing_to_deal integer,
|
||||||
|
discount_pct numeric
|
||||||
|
)
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
WITH window_deals AS (
|
||||||
|
-- Сделки в улице + период. Фильтр по area + (#2583 H4) city.
|
||||||
|
-- Предиката по d.rooms здесь НЕТ — #3256/#3451: deals.rooms у источника
|
||||||
|
-- 'rosreestr' не комнатность, а бакет площади (см. шапку файла).
|
||||||
|
SELECT
|
||||||
|
d.id AS deal_id,
|
||||||
|
d.deal_date AS deal_date,
|
||||||
|
d.price_rub AS deal_price_rub,
|
||||||
|
d.price_per_m2 AS deal_price_per_m2,
|
||||||
|
d.area_m2 AS deal_area_m2,
|
||||||
|
d.rooms AS deal_rooms,
|
||||||
|
d.floor AS deal_floor,
|
||||||
|
d.address AS deal_address
|
||||||
|
FROM deals d
|
||||||
|
WHERE d.source = 'rosreestr'
|
||||||
|
AND d.address ILIKE p_street_pattern
|
||||||
|
AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
|
||||||
|
AND p_area_m2 * (1.0 + p_area_tolerance)
|
||||||
|
AND d.deal_date > NOW() - (p_period_months || ' months')::interval
|
||||||
|
AND d.price_rub > 0
|
||||||
|
-- #2583 H4: deals.city заполнена на 100% — строгое равенство.
|
||||||
|
-- NULL p_target_city (город вне словаря) → фильтр не применяется.
|
||||||
|
AND (p_target_city IS NULL OR LOWER(d.city) = LOWER(p_target_city))
|
||||||
|
),
|
||||||
|
window_listings AS (
|
||||||
|
-- Кандидаты-listings на той же улице, rooms exact (у ОБЪЯВЛЕНИЙ комнатность
|
||||||
|
-- настоящая — предикат законен и остаётся, #3451), area ±tolerance,
|
||||||
|
-- (#2583 H4) тот же город что deals-сторона, (#2660) только вторичка.
|
||||||
|
SELECT
|
||||||
|
l.id AS listing_id,
|
||||||
|
l.source AS listing_source,
|
||||||
|
l.source_url AS listing_source_url,
|
||||||
|
l.listing_date AS listing_date,
|
||||||
|
l.price_rub AS listing_price_rub,
|
||||||
|
l.price_per_m2 AS listing_price_per_m2,
|
||||||
|
l.area_m2 AS listing_area_m2,
|
||||||
|
l.rooms AS listing_rooms,
|
||||||
|
COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date
|
||||||
|
FROM listings l
|
||||||
|
WHERE l.address ILIKE p_street_pattern
|
||||||
|
AND l.rooms = p_rooms
|
||||||
|
AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
|
||||||
|
AND p_area_m2 * (1.0 + p_area_tolerance)
|
||||||
|
AND l.price_rub > 0
|
||||||
|
AND COALESCE(l.listing_date, l.scraped_at::date)
|
||||||
|
> NOW() - ((p_period_months + 6) || ' months')::interval
|
||||||
|
-- #2583 H4: listings.city заполнена ЧАСТИЧНО (прод: avito 63%,
|
||||||
|
-- yandex 19%, cian 4.6%, domklik 0.6%, n1 0%) — NULL считается "своим"
|
||||||
|
-- (симметрично asking_to_sold_ratio.py #2583 H2), иначе строгий
|
||||||
|
-- фильтр выбросил бы почти все listings кроме avito.
|
||||||
|
AND (p_target_city IS NULL OR l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city))
|
||||||
|
-- #2660 novostroyki guard (#1186): к ДКП-сделке вторички нельзя
|
||||||
|
-- подставлять лот застройщика — девелоперский прайс не торгуется и
|
||||||
|
-- уводит показываемый «медианный торг». Прод: 27.3% кандидатов —
|
||||||
|
-- первичка. NULL = legacy вторичка до м.011, оставляем.
|
||||||
|
AND (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka')
|
||||||
|
),
|
||||||
|
paired AS (
|
||||||
|
-- LEFT JOIN: сохраняем все сделки даже если нет listing match.
|
||||||
|
-- Для каждой сделки выбираем listing с listing_date ближайший
|
||||||
|
-- к deal_date (предпочтительно перед сделкой).
|
||||||
|
SELECT DISTINCT ON (wd.deal_id)
|
||||||
|
wd.deal_id,
|
||||||
|
wd.deal_date,
|
||||||
|
wd.deal_price_rub,
|
||||||
|
wd.deal_price_per_m2,
|
||||||
|
wd.deal_area_m2,
|
||||||
|
wd.deal_rooms,
|
||||||
|
wd.deal_floor,
|
||||||
|
wd.deal_address,
|
||||||
|
wl.listing_id,
|
||||||
|
wl.listing_source,
|
||||||
|
wl.listing_source_url,
|
||||||
|
wl.listing_date,
|
||||||
|
wl.listing_price_rub,
|
||||||
|
wl.listing_price_per_m2,
|
||||||
|
wl.listing_area_m2,
|
||||||
|
(wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal,
|
||||||
|
CASE
|
||||||
|
WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0
|
||||||
|
THEN ROUND(
|
||||||
|
(wd.deal_price_rub - wl.listing_price_rub)::numeric
|
||||||
|
/ wl.listing_price_rub * 100,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
ELSE NULL
|
||||||
|
END AS discount_pct
|
||||||
|
FROM window_deals wd
|
||||||
|
LEFT JOIN window_listings wl
|
||||||
|
ON wl.listing_event_date
|
||||||
|
BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date
|
||||||
|
AND (wd.deal_date + interval '30 days')::date
|
||||||
|
ORDER BY
|
||||||
|
wd.deal_id,
|
||||||
|
-- prefer listing event дата перед сделкой и ближе к ней
|
||||||
|
CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END,
|
||||||
|
CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END,
|
||||||
|
ABS((wd.deal_date - wl.listing_event_date))
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
deal_id,
|
||||||
|
deal_date,
|
||||||
|
deal_price_rub,
|
||||||
|
deal_price_per_m2,
|
||||||
|
deal_area_m2,
|
||||||
|
deal_rooms,
|
||||||
|
deal_floor,
|
||||||
|
deal_address,
|
||||||
|
listing_id,
|
||||||
|
listing_source,
|
||||||
|
listing_source_url,
|
||||||
|
listing_date,
|
||||||
|
listing_price_rub,
|
||||||
|
listing_price_per_m2,
|
||||||
|
listing_area_m2,
|
||||||
|
days_listing_to_deal,
|
||||||
|
discount_pct
|
||||||
|
FROM paired
|
||||||
|
ORDER BY deal_date DESC;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer, text) IS
|
||||||
|
'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1, '
|
||||||
|
'city-filter #2583 H4 (миграция 205), segment-guard #2660/#1186 (миграция 211), '
|
||||||
|
'снятие предиката по d.rooms #3451/#3256 (миграция 300). '
|
||||||
|
'Per-street matching: address ILIKE, area ±tolerance, rooms exact ТОЛЬКО на '
|
||||||
|
'listings-стороне (deals.rooms у rosreestr — синтетика из площади), window_days '
|
||||||
|
'до даты сделки (+30д grace), city-scope (p_target_city, deals строго / listings '
|
||||||
|
'терпимо к NULL), listings — только вторичка (listing_segment IS NULL или '
|
||||||
|
'vtorichka). Возвращает LEFT JOIN — сделки без listing match имеют '
|
||||||
|
'listing_* = NULL. discount_pct = (deal - listing) / listing * 100. '
|
||||||
|
'is_active намеренно НЕ фильтруется: снятые объявления и есть материал пейринга.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
-- 301_web_support_message_idempotency_key.sql
|
||||||
|
-- Идемпотентность отправки веб-сообщения в поддержку (#3471 retry-storm):
|
||||||
|
-- канал Selectel -> api.telegram.org теряет заметную долю коротких запросов
|
||||||
|
-- (см. app/api/v1/support.py, _INTERACTIVE_SEND_TIMEOUT_S), поэтому повтор
|
||||||
|
-- клиента (fetch-ретрай/двойной клик/переотправка по таймауту) — обычное
|
||||||
|
-- дело. Раньше повтор создавал ВТОРУЮ строку в web_support_messages и ВТОРОЕ
|
||||||
|
-- зеркало в support-топике.
|
||||||
|
--
|
||||||
|
-- idempotency_key — client-provided (заголовок Idempotency-Key) ИЛИ
|
||||||
|
-- детерминированный fallback-отпечаток sha256(identity|текст|минутное окно)
|
||||||
|
-- для клиентов без заголовка (app/api/v1/support.py::_resolve_idempotency_key).
|
||||||
|
-- Всегда opaque-строка (клиентский токен или hex-хэш) — ТЕЛО СООБЩЕНИЯ сюда
|
||||||
|
-- никогда не попадает в открытом виде, только его отпечаток.
|
||||||
|
--
|
||||||
|
-- Scoped к (thread_id, direction='in'): идемпотентность осмысленна только для
|
||||||
|
-- inbound (клиентских) сообщений — direction='out' (ответ оператора) её не
|
||||||
|
-- требует, там уже есть своя partial-уникальность на topic_message_id (187).
|
||||||
|
-- thread_id, а не username/anon-token: таблица не хранит identity напрямую,
|
||||||
|
-- а тред уже гарантированно создан (`get_or_create_thread`) к моменту INSERT
|
||||||
|
-- (см. H1 в app/api/v1/support.py — тред создаётся ДО record_inbound).
|
||||||
|
--
|
||||||
|
-- Гонку двух одновременных запросов с одним ключом закрывает САМ уникальный
|
||||||
|
-- индекс + `INSERT ... ON CONFLICT DO NOTHING` в
|
||||||
|
-- web_support_storage.record_inbound (не read-then-write) — на конфликте
|
||||||
|
-- вызывающая сторона дочитывает уже вставленную строку и возвращает её тем
|
||||||
|
-- же ответом (тот же id), а не создаёт вторую строку.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY: ADD COLUMN/CREATE INDEX IF NOT EXISTS — безопасный re-run.
|
||||||
|
-- Зависимости: 187_web_support_chat.sql (таблица web_support_messages).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Блокирующий DDL не должен ждать чужую сессию бесконечно: без этого
|
||||||
|
-- ALTER встаёт в очередь за долгим запросом и уводит за собой ВСЕ
|
||||||
|
-- последующие обращения к таблице (#2752). Пять секунд — не успел взять
|
||||||
|
-- лок, деплой падает честно, а прод продолжает работать.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
ALTER TABLE web_support_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS idempotency_key text;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'web_support_messages_idempotency_key_len_chk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE web_support_messages
|
||||||
|
ADD CONSTRAINT web_support_messages_idempotency_key_len_chk
|
||||||
|
CHECK (idempotency_key IS NULL OR char_length(idempotency_key) BETWEEN 1 AND 160);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN web_support_messages.idempotency_key IS 'Ключ идемпотентности inbound-отправки (#3471) — client-provided заголовок Idempotency-Key ("client:...") ИЛИ sha256-отпечаток thread+текст+минутное окно ("auto:...") для клиентов без заголовка. NULL для direction=''out'' и для строк до этой миграции.';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_thread_idempotency_uq
|
||||||
|
ON web_support_messages (thread_id, idempotency_key)
|
||||||
|
WHERE idempotency_key IS NOT NULL AND direction = 'in';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
-- 302_scrape_schedules_seed_rosreestr_dkp_50.sql
|
||||||
|
-- Seed-строка scrape_schedules для региона 50 (Московская область) — #3051, трек МО.
|
||||||
|
--
|
||||||
|
-- Dependencies: 289_rosreestr_fdw_msk_columns_seed77.sql (та же таблица, тот же
|
||||||
|
-- формат source/default_params).
|
||||||
|
-- Apply after: 301_web_support_message_idempotency_key.sql
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- Код-часть импорта уже параметризована регионом (`scheduler.py::_job_rosreestr_dkp`
|
||||||
|
-- читает `region_code` из default_params и валидирует его по `app.services.regions`),
|
||||||
|
-- а wildcard-хендлер `rosreestr_dkp_import_*` резолвит любое имя с суффиксом кода.
|
||||||
|
-- Регион 50 заведён в реестре и уже на проде, поэтому включение области стоит ровно
|
||||||
|
-- одной строки расписания — новой логики не требуется.
|
||||||
|
--
|
||||||
|
-- У региона 50 `canonical_city IS NULL`, то есть он идёт по ветке региона 66:
|
||||||
|
-- `deals.city` берётся из источника (муниципалитет — Балашиха, Химки, Подольск),
|
||||||
|
-- `raw_payload` не заполняется, строки с пустым city отбрасываются. Для области это
|
||||||
|
-- и есть верное поведение: единого города у региона нет, подставлять нечего.
|
||||||
|
--
|
||||||
|
-- Замер по FDW (2026-09-12, прод): под полным WHERE импорта регион 50 даёт
|
||||||
|
-- 113 351 сделку ДКП с 2024-01-01 — корпус того же порядка, что московский.
|
||||||
|
--
|
||||||
|
-- Строка ВЫКЛЮЧЕНА (enabled=false) — ровно как seed 77 в миграции 289: миграция
|
||||||
|
-- заводит расписание, включение и первый прогон остаются отдельным решением
|
||||||
|
-- main-сессии. Окно 4-6 UTC совпадает с окнами регионов 66 и 77; прогоны
|
||||||
|
-- сериализуются планировщиком, а первая полная заливка 77 заняла 3 минуты на
|
||||||
|
-- 212 937 строк, так что третий регион в то же окно помещается с запасом.
|
||||||
|
--
|
||||||
|
-- ИДЕМПОТЕНТНОСТЬ: ON CONFLICT (source) DO NOTHING — повторный прогон no-op и,
|
||||||
|
-- что важнее, НЕ сбрасывает enabled обратно в false после ручного включения.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'rosreestr_dkp_import_50',
|
||||||
|
false,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
'{"region_code": 50, "since": "2024-01-01", "batch_size": 2000}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
-- 303_scrape_schedules_seed_landing_showcase_deals.sql
|
||||||
|
-- Расписание для пересчёта витрины сделок публичного лэндинга (issue #3469).
|
||||||
|
--
|
||||||
|
-- ЧТО БЫЛО. Задача `landing_showcase_deals` (миграции 276/277, таблицы
|
||||||
|
-- landing_showcase_deals + landing_showcase_runs) в scrape_schedules НЕ СТОЯЛА:
|
||||||
|
-- `SELECT * FROM scrape_schedules WHERE source LIKE '%showcase%'` — 0 строк
|
||||||
|
-- (замер на проде 12.09.2026). Пересчёт был ручным шагом, и за всё время его
|
||||||
|
-- запускали четырежды; на 12.09 лэндинг показывал прогон от 30.08 — тринадцать
|
||||||
|
-- суток. Handler в реестре тоже отсутствовал, то есть строка расписания без
|
||||||
|
-- него не помогла бы: обе половины регистрации задачи (Handler в
|
||||||
|
-- app/services/product_handlers.py + вот эта строка) едут одним PR.
|
||||||
|
--
|
||||||
|
-- ТАКТ — СУТКИ, И СЧИТАЕТСЯ ОН НЕ ОТ ДАННЫХ, А ОТ КОДА.
|
||||||
|
-- Вход витрины — ДКП-сделки Росреестра, они приезжают ПОКВАРТАЛЬНО, и по
|
||||||
|
-- входу хватило бы такта в квартал. Но витрина показывает не сделки, а
|
||||||
|
-- РАСХОЖДЕНИЕ прогноза МЕРЫ с ценой сделки, а прогноз пересчитывается тем же
|
||||||
|
-- спайном оценщика, что и боевой расчёт: любая правка оценщика, коэффициентов
|
||||||
|
-- СберИндекса, набора активных объявлений или правила отбора (миграция 276,
|
||||||
|
-- полоса −5..+20 % от 12.09.2026) меняет ЧИСЛА на странице, не трогая ни одной
|
||||||
|
-- сделки. Деплой у продукта чаще, чем квартал, — поэтому такт суточный: столько
|
||||||
|
-- живёт окно «код уже другой, а витрина ещё прежняя». Прогон дешёвый и без
|
||||||
|
-- внешних вызовов (200 сделок через спайн + запись 20 строк, ~минуты CPU
|
||||||
|
-- ночью), так что цена суточного такта — та же, что у соседнего
|
||||||
|
-- landing_stats_refresh (миграция 275).
|
||||||
|
--
|
||||||
|
-- ЭТА ЖЕ СТРОКА ЗАВОДИТ ВИТРИНУ В МОНИТОР СВЕЖЕСТИ. Сводка просроченных
|
||||||
|
-- источников (`emit_stale_digest`, scraper_kit/orchestration/scheduler.py,
|
||||||
|
-- #2670) ходит по ВКЛЮЧЁННЫМ расписаниям и бьёт тревогу (logger.error →
|
||||||
|
-- GlitchTip), когда источник не приносил данных дольше
|
||||||
|
-- STALE_DIGEST_INTERVAL_FACTOR × его такта — то есть здесь дольше ТРЁХ СУТОК.
|
||||||
|
-- Отдельного монитора для витрины не заводится намеренно: её молчание было
|
||||||
|
-- невидимо ровно потому, что источника не существовало для сводки, а не потому,
|
||||||
|
-- что сводка не умеет про него говорить (живой пример с прода 12.09.2026:
|
||||||
|
-- «1 источников не собирают дольше 3× своего такта — avito_newbuilding_sweep
|
||||||
|
-- 3.5d/1d»). interval_days в default_params стоит ЯВНО — им же сводка считает
|
||||||
|
-- порог (`_schedule_interval_days`), и умолчание «1» лучше не подразумевать.
|
||||||
|
--
|
||||||
|
-- ОКНО 06:00–07:00 UTC (11:00–12:00 по Екатеринбургу): после импорта сделок
|
||||||
|
-- Росреестра (rosreestr_dkp_import, окно 04–06) и после landing_stats_refresh
|
||||||
|
-- (05–06) — витрина считается по уже обновлённым за ночь данным; и за два часа
|
||||||
|
-- до deals_freshness_monitor (08–09), так что утренний пересчёт успевает
|
||||||
|
-- сняться с просрочки до утренней же проверки.
|
||||||
|
--
|
||||||
|
-- enabled=true — как у landing_stats_refresh: задача только читает базу и
|
||||||
|
-- перезаписывает две свои маленькие таблицы, внешних вызовов нет, цена ошибки —
|
||||||
|
-- минуты CPU. Дожидаться ручного включения тут значило бы оставить дефект
|
||||||
|
-- #3469 на месте, просто под другой причиной.
|
||||||
|
--
|
||||||
|
-- next_run_at на завтра 06:00 UTC — прогон не выстреливает в момент деплоя
|
||||||
|
-- (образец: 162_seed_deals_freshness_monitor.sql, 275_landing_stats.sql).
|
||||||
|
--
|
||||||
|
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 276/277
|
||||||
|
-- (таблицы витрины), Handler 'landing_showcase_deals' в product_handlers.py.
|
||||||
|
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
-- Конвенция проекта (#2752): блокирующий DDL/DML под lock_timeout.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
next_run_at,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'landing_showcase_deals',
|
||||||
|
true,
|
||||||
|
6,
|
||||||
|
7,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
|
||||||
|
'{"interval_days": 1, "sample": 200, "limit": 20}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
-- 304_asking_to_sold_ratios_region.sql
|
||||||
|
-- asking_to_sold_ratios: ключ (region_code, rooms_bucket, district) — #3512.
|
||||||
|
--
|
||||||
|
-- ПРОБЛЕМА. asking_to_sold_ratios (миграция 080) несёт РОВНО 6 строк (per area-бакет
|
||||||
|
-- 0..4 + global -1), все с district='' — колонки региона в таблице НЕТ ВООБЩЕ (проверено
|
||||||
|
-- на проде 13.09.2026: `\d asking_to_sold_ratios` без region_code). Деривация
|
||||||
|
-- (app/tasks/asking_to_sold_ratio.py) скоупит ОБЕ стороны расчёта на
|
||||||
|
-- `city ILIKE '%Екатеринбург%'` — ratio физически посчитан только по рынку ЕКБ. Но
|
||||||
|
-- estimator.py (_get_asking_sold_ratio) читает строку по `rooms_bucket AND district=''`
|
||||||
|
-- БЕЗ единого понятия региона запроса — тот же коэффициент домножает asking-цену ЛЮБОГО
|
||||||
|
-- адреса, включая Москву (region_code=77) и Московскую обл. (region_code=50). Рынки
|
||||||
|
-- разные на порядок: медиана ДКП за 12 мес по region_code — 66: 90 278 ₽/м²,
|
||||||
|
-- 50: 161 176 ₽/м², 77: 322 976 ₽/м² (замер 13.09.2026) — екатеринбургский sold/asking
|
||||||
|
-- коэффициент, применённый к московской asking-цене, calibration mismatch, а не оценка.
|
||||||
|
--
|
||||||
|
-- ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ (только схема — derivation региона 66 остаётся байт-в-байт
|
||||||
|
-- прежней, деривация ДРУГИХ регионов теперь считается отдельным SQL-путём в
|
||||||
|
-- app/tasks/asking_to_sold_ratio.py, сама эта миграция ничего не пересчитывает):
|
||||||
|
-- 1. ADD COLUMN region_code int NOT NULL DEFAULT 66 — существующие 6 строк ВСЕ
|
||||||
|
-- екатеринбургские (единственная деривация на момент 080/098/109), DEFAULT
|
||||||
|
-- backfill'ит их на 66 автоматически при ALTER (Postgres 11+ — DEFAULT на
|
||||||
|
-- NOT NULL столбце без DEFAULT ранее вычисляется один раз, без rewrite всей
|
||||||
|
-- таблицы, но здесь таблица микроскопическая — 6 строк, cost неощутим в любом
|
||||||
|
-- случае).
|
||||||
|
-- 2. PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district) — строки
|
||||||
|
-- теперь живут per (регион, area-бакет, district). district остаётся зарезервирован
|
||||||
|
-- под #647 (гео-районы ВНУТРИ региона), всегда ''.
|
||||||
|
--
|
||||||
|
-- ПОЧЕМУ НЕ НОВАЯ ПАРАЛЛЕЛЬНАЯ ТАБЛИЦА (в отличие от 098 tiered-подхода). Там
|
||||||
|
-- параллельная таблица защищала fetchone()-safety при flag-OFF откате (#928). Здесь
|
||||||
|
-- отката не предполагается: region_code — обязательный параметр запроса везде, где
|
||||||
|
-- ratio читается (estimator._get_asking_sold_ratio), и ADD COLUMN NOT NULL DEFAULT 66
|
||||||
|
-- не меняет СУЩЕСТВУЮЩИЕ 6 строк по значению (только добавляет им явный регион) —
|
||||||
|
-- старый flag-OFF путь `WHERE rooms_bucket=B AND district=''` для региона 66
|
||||||
|
-- продолжает возвращать ТУ ЖЕ строку, если добавить `AND region_code=66` (что и
|
||||||
|
-- делает estimator.py в этом же PR).
|
||||||
|
--
|
||||||
|
-- ИДЕМПОТЕНТНОСТЬ: ADD COLUMN IF NOT EXISTS — повторный прогон no-op на колонке.
|
||||||
|
-- PK-guard (как 298_deal_city_price_bands_region.sql) смотрит ТЕКУЩЕЕ определение PK
|
||||||
|
-- через pg_get_constraintdef и меняет его ТОЛЬКО если это ещё старый
|
||||||
|
-- PK(rooms_bucket, district) — повторный прогон видит PK(region_code, rooms_bucket,
|
||||||
|
-- district) и ничего не трогает. strict/exit-1 на деплое — без SET LOCAL lock_timeout
|
||||||
|
-- ALTER TABLE мог бы зависнуть под конкурентным чтением; таблица in-memory-масштаба
|
||||||
|
-- (6 строк), но конвенция проекта (#2752) требует потолок на любой блокирующий DDL.
|
||||||
|
--
|
||||||
|
-- ЗАВИСИМОСТИ: 080_asking_to_sold_ratios.sql (таблица, PK(rooms_bucket, district)),
|
||||||
|
-- 002_core_tables.sql (deals.region_code, listings.region_code — обе уже существуют).
|
||||||
|
-- Apply after: 303_scrape_schedules_seed_landing_showcase_deals.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
ALTER TABLE asking_to_sold_ratios
|
||||||
|
ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN asking_to_sold_ratios.region_code IS
|
||||||
|
'Регион коэффициента (deals.region_code / listings.region_code, #3512). '
|
||||||
|
'Существующие строки (посчитаны деривацией, скоупленной на ЕКБ, #C2) — 66. '
|
||||||
|
'Часть составного PK (region_code, rooms_bucket, district).';
|
||||||
|
|
||||||
|
-- PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district), идемпотентно:
|
||||||
|
-- смотрим ТЕКУЩЕЕ определение PK и меняем его только если это ещё старый PK без региона.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_pk_def text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_get_constraintdef(oid) INTO v_pk_def
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'asking_to_sold_ratios'::regclass
|
||||||
|
AND contype = 'p';
|
||||||
|
|
||||||
|
IF v_pk_def = 'PRIMARY KEY (rooms_bucket, district)' THEN
|
||||||
|
ALTER TABLE asking_to_sold_ratios DROP CONSTRAINT asking_to_sold_ratios_pkey;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'asking_to_sold_ratios'::regclass
|
||||||
|
AND contype = 'p'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE asking_to_sold_ratios
|
||||||
|
ADD CONSTRAINT asking_to_sold_ratios_pkey
|
||||||
|
PRIMARY KEY (region_code, rooms_bucket, district);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -78,7 +78,42 @@ CAVEATS (read these before trusting the numbers)
|
||||||
MAPE и бьёт по классам с редкой застройкой сильнее прочих: часть перекоса
|
MAPE и бьёт по классам с редкой застройкой сильнее прочих: часть перекоса
|
||||||
по 4+ комнатам — цена такой привязки, а не ошибка модели. Любой замер, где
|
по 4+ комнатам — цена такой привязки, а не ошибка модели. Любой замер, где
|
||||||
сделка связывается с КОНКРЕТНЫМ зданием по геометрии (материал стен,
|
сделка связывается с КОНКРЕТНЫМ зданием по геометрии (материал стен,
|
||||||
этажность, цена собственного дома), этим скомпрометирован.
|
этажность, цена собственного дома), этим скомпрометирован. СКЛАДЫВАЕТСЯ
|
||||||
|
с (e) ниже (неправильное МЕСТО + неправильный СЕГМЕНТ), см. там же.
|
||||||
|
(e) АНАЛОГИ ПОДБИРАЮТСЯ ПО СИНТЕТИЧЕСКОЙ КОМНАТНОСТИ (#3256). `deals.rooms` —
|
||||||
|
не комнатность, а бакет площади (границы 30/44/62/85, см. import-rosreestr.sh
|
||||||
|
и asking_to_sold_ratio.area_bucket): прод-замер 2026-09-11 — 321 559 из
|
||||||
|
321 560 сделок удовлетворяют rooms == area_bucket(area_m2), max(rooms) = 4.
|
||||||
|
Харнес отдаёт это значение в `_fetch_analogs(rooms=deal.rooms)`, который
|
||||||
|
матчит его с РЕАЛЬНОЙ `listings.rooms`. В прод приходит настоящая комнатность
|
||||||
|
клиента, т.е. для сделок ≥85 м² харнес меряет ДРУГОЙ пул аналогов, чем прод:
|
||||||
|
4-комнатные объявления вместо 3-комнатных. Внутри полосы 85-120 м² медиана
|
||||||
|
₽/м² по комнатам — 203 692 / 162 896 / 129 735 / 100 000 (2/3/4/5 комнат),
|
||||||
|
шаг 20-26%. Прод-фикстура 11.09 это подтверждает: медиана аналогов бакета
|
||||||
|
«4 >=85» = 130 000 ₽/м², т.е. ровно 4-комнатная полоса, хотя 66% вторички
|
||||||
|
этого метража — 3-комнатная. СЛЕДСТВИЕ: бакеты `per_area_bucket` «3 62-85»
|
||||||
|
и «4 >=85» НЕ ГОДЯТСЯ как цель калибровки `asking_to_sold_ratios` — они
|
||||||
|
меряют смещение чужого пула аналогов, а не промах коэффициента.
|
||||||
|
ОСТАЛЬНЫЕ БАКЕТЫ НЕ «ЧИСТЫ», а лишь МЕНЬШЕ СМЕЩЕНЫ. Прод-замер 2026-09-12
|
||||||
|
по ТОМУ ЖЕ пулу, который видит `_fetch_analogs` (is_active, свежесть
|
||||||
|
LISTINGS_FRESH_DAYS=14, вторичка, регион 66), доля объявлений с
|
||||||
|
`rooms == area_bucket(area_m2)`:
|
||||||
|
бакет 0 — 69.8% (n=2536), 1 — 63.5% (n=5977), 2 — 60.1% (n=6176),
|
||||||
|
бакет 3 — 54.6% (n=4117), 4 — 30.9% (n=2088).
|
||||||
|
Т.е. в бакетах 0-3 модальная комнатность СОВПАДАЕТ с бакетом (подмена
|
||||||
|
сдвигает пул на соседнюю комнатность у 30-45% лотов, направление в среднем
|
||||||
|
не одностороннее), а в бакете 4 мода — 3 комнаты (54.5% пула), и совпадение
|
||||||
|
всего 30.9%: там подмена систематически ПЕРЕКЛЮЧАЕТ пул на 4-комнатный.
|
||||||
|
NB: #3256 снял синтетический ключ со СДЕЛОЧНОЙ стороны (deals в
|
||||||
|
`_fetch_dkp_corridor`/`_fetch_deals`/`/street-deals`), а этот перекос живёт
|
||||||
|
на ЛИСТИНГОВОЙ стороне харнеса (`_fetch_analogs(rooms=deal.rooms)`) и
|
||||||
|
остаётся в силе.
|
||||||
|
(d) И (e) СКЛАДЫВАЮТСЯ, А НЕ СПОРЯТ: (d) says «сделка привязана к центроиду
|
||||||
|
улицы, а не к дому» — аналоги берутся из неправильного МЕСТА; (e) says
|
||||||
|
«комнатность аналога синтетическая» — из неправильного СЕГМЕНТА. Оба бьют
|
||||||
|
сильнее всего по крупному метражу (≥85 м²), поэтому наблюдаемый перекос
|
||||||
|
«4+ комнаты» — это их СУММА, и списывать его целиком на любой один из них
|
||||||
|
(а тем более на модель) нельзя.
|
||||||
|
|
||||||
PERFORMANCE
|
PERFORMANCE
|
||||||
-----------
|
-----------
|
||||||
|
|
@ -99,6 +134,10 @@ USAGE
|
||||||
|
|
||||||
# oblast D: per-city validation (exact deals.city name, not a slug):
|
# oblast D: per-city validation (exact deals.city name, not a slug):
|
||||||
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
|
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
|
||||||
|
|
||||||
|
# #3520 любой регион (choices come from app.services.regions.REGIONS):
|
||||||
|
# Москва (region_code=77, 212 937 ДКП, 69 138 за последние 12 мес.):
|
||||||
|
python -m scripts.backtest_estimator --region 77 --since 2024-09-01 --sample 300
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -121,6 +160,15 @@ from typing import Any
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.services.regions import REGIONS, region_for_point
|
||||||
|
|
||||||
|
# Регион по умолчанию — Свердловская обл., прежнее (единственное до #3520)
|
||||||
|
# поведение скрипта. Список допустимых значений — РЕЕСТР REGIONS
|
||||||
|
# (app/services/regions.py), а не отдельный литерал: регион появляется в
|
||||||
|
# реестре один раз и автоматически становится доступен и здесь, и в
|
||||||
|
# msk_raw_import, и в самом эстиматоре.
|
||||||
|
DEFAULT_BACKTEST_REGION_CODE = 66
|
||||||
|
|
||||||
|
|
||||||
def _import_estimator() -> tuple[Any, Any]:
|
def _import_estimator() -> tuple[Any, Any]:
|
||||||
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
|
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
|
||||||
|
|
@ -1118,6 +1166,7 @@ _SAMPLE_SQL = text(
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
|
AND region_code = CAST(:region_code AS int)
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1169,6 +1218,7 @@ _SAMPLE_SQL_SCATTERED = text(
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
|
AND region_code = CAST(:region_code AS int)
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1198,6 +1248,7 @@ _SAMPLE_SQL_SCATTERED_CITY = text(
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
|
AND region_code = CAST(:region_code AS int)
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1236,10 +1287,9 @@ _CANDIDATES_SQL = text(
|
||||||
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
|
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
|
||||||
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
|
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
|
||||||
# _resolve_city_ppm2_band); the default (city=None) path never issues this
|
# _resolve_city_ppm2_band); the default (city=None) path never issues this
|
||||||
# query. region_code defaults to 66 (Свердловская обл.) — this script has no
|
# query. region_code (#3520 "любой регион") is threaded from the CLI --region
|
||||||
# region CLI flag yet, so every call is scoped to the oblast, matching every
|
# flag end-to-end now (default 66, Свердловская обл. — unchanged behaviour for
|
||||||
# existing --city invocation (byte-identical to the pre-298 unscoped lookup,
|
# every pre-existing invocation, which never passed --region).
|
||||||
# which only ever saw region-66 rows since the table was oblast-only before).
|
|
||||||
_CITY_PPM2_BAND_SQL = text(
|
_CITY_PPM2_BAND_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT ppm2_min, ppm2_max
|
SELECT ppm2_min, ppm2_max
|
||||||
|
|
@ -1253,10 +1303,13 @@ _CITY_PPM2_BAND_SQL = text(
|
||||||
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
||||||
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
||||||
|
|
||||||
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
|
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object every
|
||||||
before this change — literal identity, not just equal text — so the
|
time — literal identity, not just equal text (asserted by
|
||||||
default CLI invocation (and the frozen EKB regression gate, which never
|
``test_sample_sql_city_none_returns_original_object``) — so callers that
|
||||||
calls this path at all) see byte-identical SQL.
|
cache/compare the query object see a stable reference. All four SQL
|
||||||
|
variants (unscoped/scattered × city/no-city) carry a ``region_code``
|
||||||
|
predicate (#3520 "любой регион") bound from ``_load_sample``'s
|
||||||
|
``region_code`` param, default 66.
|
||||||
|
|
||||||
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
|
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
|
||||||
scopes the sample to that ``deals.city`` value — populated oblast-wide by
|
scopes the sample to that ``deals.city`` value — populated oblast-wide by
|
||||||
|
|
@ -1286,6 +1339,7 @@ def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
||||||
house_type
|
house_type
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
|
AND region_code = CAST(:region_code AS int)
|
||||||
AND geom IS NOT NULL
|
AND geom IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -1313,9 +1367,9 @@ def _resolve_city_ppm2_band(
|
||||||
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
|
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
|
||||||
error → the globals; read-only best-effort, never raises.
|
error → the globals; read-only best-effort, never raises.
|
||||||
|
|
||||||
``region_code`` defaults to 66 (Свердловская обл.) — this script has no
|
``region_code`` defaults to 66 (Свердловская обл.) for backward-compat
|
||||||
region CLI flag yet (out of scope, #3051 sub-PR B); every existing
|
direct callers; ``_load_sample`` (#3520) always passes the actual
|
||||||
--city caller keeps its byte-identical lookup.
|
``--region`` value through explicitly.
|
||||||
"""
|
"""
|
||||||
if city is None:
|
if city is None:
|
||||||
return float(PPM2_MIN), float(PPM2_MAX)
|
return float(PPM2_MIN), float(PPM2_MAX)
|
||||||
|
|
@ -1341,22 +1395,32 @@ def _load_sample(
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
scattered: bool = False,
|
scattered: bool = False,
|
||||||
seed: str = "mera",
|
seed: str = "mera",
|
||||||
|
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
||||||
) -> list[DealSample]:
|
) -> list[DealSample]:
|
||||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
||||||
|
|
||||||
|
``region_code`` (#3520 "любой регион", default 66 — Свердловская обл.)
|
||||||
|
scopes the sample to one ``deals.region_code`` value — REQUIRED once
|
||||||
|
``deals`` carries more than one region (212 937 Москва + 113 351
|
||||||
|
Московская обл. rows alongside 108 623 ЕКБ), otherwise the "unscoped"
|
||||||
|
query would silently mix regions together. Must be a key of
|
||||||
|
``app.services.regions.REGIONS`` — validated by ``main()``/``_parse_args``
|
||||||
|
(CLI ``choices``) so this function only ever sees a supported code.
|
||||||
|
|
||||||
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
|
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
|
||||||
value via ``_sample_sql`` and sources the PPM2 sanity band from
|
value via ``_sample_sql`` and sources the PPM2 sanity band from
|
||||||
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
|
``deal_city_price_bands`` for that (region_code, city) pair
|
||||||
falls back to the module globals). Default None is byte-identical to the
|
(``_resolve_city_ppm2_band``, falls back to the module globals). Default
|
||||||
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
|
None is byte-identical to the pre-oblast-D behaviour: same SQL object,
|
||||||
no extra query.
|
same PPM2_MIN/PPM2_MAX globals, no extra query.
|
||||||
"""
|
"""
|
||||||
if city is None:
|
if city is None:
|
||||||
ppm2_min: float = PPM2_MIN
|
ppm2_min: float = PPM2_MIN
|
||||||
ppm2_max: float = PPM2_MAX
|
ppm2_max: float = PPM2_MAX
|
||||||
else:
|
else:
|
||||||
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city)
|
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city, region_code=region_code)
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
|
"region_code": region_code,
|
||||||
"ppm2_min": ppm2_min,
|
"ppm2_min": ppm2_min,
|
||||||
"ppm2_max": ppm2_max,
|
"ppm2_max": ppm2_max,
|
||||||
"since": since,
|
"since": since,
|
||||||
|
|
@ -1912,8 +1976,21 @@ def _predict_full_spine(
|
||||||
# harness measures the SAME corridor prod actually computes today, not the
|
# harness measures the SAME corridor prod actually computes today, not the
|
||||||
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
|
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
|
||||||
target_city = m._resolve_target_city(deal.address)
|
target_city = m._resolve_target_city(deal.address)
|
||||||
|
# #3520 "любой регион": mirror estimate_quality's OWN region_code resolution
|
||||||
|
# (estimator.py ~4868-4870, region_for_point(geo.lat, geo.lon) with a
|
||||||
|
# DEFAULT_REGION_CODE fallback for points outside every registered bbox) —
|
||||||
|
# without this, _fetch_dkp_corridor's default (region 66) would scope a
|
||||||
|
# Москва/Московская обл. deal's corridor query to the WRONG region and
|
||||||
|
# silently return no rows (or, worse, an unrelated same-named street in 66).
|
||||||
|
target_region = region_for_point(deal.lat, deal.lon)
|
||||||
|
target_region_code = target_region.code if target_region else DEFAULT_BACKTEST_REGION_CODE
|
||||||
dkp_raw = m._fetch_dkp_corridor(
|
dkp_raw = m._fetch_dkp_corridor(
|
||||||
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city
|
db,
|
||||||
|
address=deal.address,
|
||||||
|
rooms=deal.rooms,
|
||||||
|
area=deal.area_m2,
|
||||||
|
city=target_city,
|
||||||
|
region_code=target_region_code,
|
||||||
)
|
)
|
||||||
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
||||||
# estimate_quality — no-area / no-address → ([], None) instead of an
|
# estimate_quality — no-area / no-address → ([], None) instead of an
|
||||||
|
|
@ -2134,6 +2211,7 @@ def run_backtest(
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
scattered: bool = False,
|
scattered: bool = False,
|
||||||
seed: str = "mera",
|
seed: str = "mera",
|
||||||
|
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the full read-only backtest and return a metrics dict.
|
"""Drive the full read-only backtest and return a metrics dict.
|
||||||
|
|
||||||
|
|
@ -2149,12 +2227,29 @@ def run_backtest(
|
||||||
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
||||||
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
||||||
|
|
||||||
|
``region_code`` (#3520, default 66) scopes the deal sample to one
|
||||||
|
``deals.region_code`` — see ``_load_sample``.
|
||||||
|
|
||||||
``city`` (oblast D, default None) scopes the deal sample to one
|
``city`` (oblast D, default None) scopes the deal sample to one
|
||||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||||
(byte-identical to the pre-oblast-D behaviour).
|
(byte-identical to the pre-oblast-D behaviour).
|
||||||
"""
|
"""
|
||||||
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
|
deals = _load_sample(
|
||||||
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
|
db,
|
||||||
|
sample=sample,
|
||||||
|
since=since,
|
||||||
|
city=city,
|
||||||
|
scattered=scattered,
|
||||||
|
seed=seed,
|
||||||
|
region_code=region_code,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d)",
|
||||||
|
len(deals),
|
||||||
|
since,
|
||||||
|
city,
|
||||||
|
region_code,
|
||||||
|
)
|
||||||
|
|
||||||
matched_rows: list[tuple[float, float, int]] = []
|
matched_rows: list[tuple[float, float, int]] = []
|
||||||
matched_ids: list[int] = []
|
matched_ids: list[int] = []
|
||||||
|
|
@ -2229,6 +2324,7 @@ def run_backtest_full(
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
scattered: bool = False,
|
scattered: bool = False,
|
||||||
seed: str = "mera",
|
seed: str = "mera",
|
||||||
|
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
||||||
|
|
||||||
|
|
@ -2254,6 +2350,14 @@ def run_backtest_full(
|
||||||
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
||||||
attached to the returned metrics. Default False → byte-identical prior output.
|
attached to the returned metrics. Default False → byte-identical prior output.
|
||||||
|
|
||||||
|
``region_code`` (#3520, default 66) scopes the deal sample to one
|
||||||
|
``deals.region_code`` — see ``_load_sample``. Independently of this flag,
|
||||||
|
``_predict_full_spine`` ALWAYS resolves each deal's OWN region from its
|
||||||
|
lat/lon via ``regions.region_for_point`` (mirroring prod's
|
||||||
|
``estimate_quality`` region resolution) before calling
|
||||||
|
``_fetch_dkp_corridor``, so the ДКП corridor is scoped correctly even if a
|
||||||
|
sample somehow mixed regions.
|
||||||
|
|
||||||
``city`` (oblast D, default None) scopes the deal sample to one
|
``city`` (oblast D, default None) scopes the deal sample to one
|
||||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||||
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
|
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
|
||||||
|
|
@ -2261,9 +2365,21 @@ def run_backtest_full(
|
||||||
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
||||||
"""
|
"""
|
||||||
est = _import_estimator_full()
|
est = _import_estimator_full()
|
||||||
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
|
deals = _load_sample(
|
||||||
|
db,
|
||||||
|
sample=sample,
|
||||||
|
since=since,
|
||||||
|
city=city,
|
||||||
|
scattered=scattered,
|
||||||
|
seed=seed,
|
||||||
|
region_code=region_code,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
|
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d) [full spine]",
|
||||||
|
len(deals),
|
||||||
|
since,
|
||||||
|
city,
|
||||||
|
region_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
predictions: list[Prediction] = []
|
predictions: list[Prediction] = []
|
||||||
|
|
@ -2447,6 +2563,17 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
|
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
|
||||||
"прогона подряд отличаются только правкой, а не составом выборки.",
|
"прогона подряд отличаются только правкой, а не составом выборки.",
|
||||||
)
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--region",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_BACKTEST_REGION_CODE,
|
||||||
|
choices=sorted(REGIONS),
|
||||||
|
help="#3520 'любой регион': deals.region_code to backtest against (default "
|
||||||
|
f"{DEFAULT_BACKTEST_REGION_CODE} — Свердловская обл., unchanged behaviour). "
|
||||||
|
"Supported values come from the app.services.regions.REGIONS registry — "
|
||||||
|
+ ", ".join(f"{code} ({REGIONS[code].name})" for code in sorted(REGIONS))
|
||||||
|
+ ". An unsupported code is rejected by argparse before any DB call.",
|
||||||
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--city",
|
"--city",
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -2564,9 +2691,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
raise SystemExit("--calibrate-segments is only supported with --engine full")
|
raise SystemExit("--calibrate-segments is only supported with --engine full")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"backtest start: engine=%s sample=%d since=%s radius=%dm "
|
"backtest start: engine=%s region=%d sample=%d since=%s radius=%dm "
|
||||||
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
|
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
|
||||||
args.engine,
|
args.engine,
|
||||||
|
args.region,
|
||||||
args.sample,
|
args.sample,
|
||||||
args.since,
|
args.since,
|
||||||
args.radius,
|
args.radius,
|
||||||
|
|
@ -2589,6 +2717,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
city=args.city,
|
city=args.city,
|
||||||
scattered=(args.spread == "scattered"),
|
scattered=(args.spread == "scattered"),
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
|
region_code=args.region,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
metrics = run_backtest(
|
metrics = run_backtest(
|
||||||
|
|
@ -2601,6 +2730,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
city=args.city,
|
city=args.city,
|
||||||
scattered=(args.spread == "scattered"),
|
scattered=(args.spread == "scattered"),
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
|
region_code=args.region,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
@ -66,11 +67,13 @@ from sqlalchemy.orm import Session
|
||||||
# matches the pattern from backfill_houses_dadata.py) and as a stand-alone file.
|
# matches the pattern from backfill_houses_dadata.py) and as a stand-alone file.
|
||||||
try:
|
try:
|
||||||
from app.core.db import SessionLocal # type: ignore[import-not-found]
|
from app.core.db import SessionLocal # type: ignore[import-not-found]
|
||||||
|
from app.services.regions import REGIONS # type: ignore[import-not-found]
|
||||||
except ImportError: # pragma: no cover — fallback for adhoc invocation
|
except ImportError: # pragma: no cover — fallback for adhoc invocation
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.regions import REGIONS
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -91,6 +94,47 @@ _LOG_EVERY = 1000
|
||||||
# houses (centroid) and deals (lookup) side so garbage never anchors a match.
|
# houses (centroid) and deals (lookup) side so garbage never anchors a match.
|
||||||
_MIN_KEY_LEN = 3
|
_MIN_KEY_LEN = 3
|
||||||
|
|
||||||
|
# Default region. 66 (Свердловская обл.) — единственный регион, на котором
|
||||||
|
# скрипт исторически работал; остаётся умолчанием, чтобы существующие вызовы
|
||||||
|
# без флага вели себя ровно как раньше.
|
||||||
|
_DEFAULT_REGION_CODE = 66
|
||||||
|
|
||||||
|
# Sanity-порог разброса домов ВНУТРИ одного ключа (км). Ключ — (регион, НП,
|
||||||
|
# улица), так что легитимный разброс — это длина одной улицы: даже проспект в
|
||||||
|
# большом городе редко длиннее 10-15 км, а типичная улица — 1-3 км. Разброс
|
||||||
|
# больше порога означает, что в ведро слиплись ОДНОИМЁННЫЕ улицы разных
|
||||||
|
# населённых пунктов (или один НП распознан двумя написаниями) — среднее таких
|
||||||
|
# точек даёт правдоподобную координату ПОСЕРЕДИНЕ между городами, и ошибка
|
||||||
|
# тихая. Такое ведро выбрасывается целиком: дыра в покрытии честнее, чем
|
||||||
|
# сделка, посаженная в поле между Клином и Серпуховом.
|
||||||
|
_DEFAULT_MAX_SPREAD_KM = 5.0
|
||||||
|
|
||||||
|
# Классификация comma-сегмента адреса (см. `_split_place_street`).
|
||||||
|
# Тип населённого пункта в начале сегмента: «г Химки», «пос. Голубое»,
|
||||||
|
# «д Сабурово», «рп Оболенск», «снт Заря». За типом ОБЯЗАН идти буквенный
|
||||||
|
# токен — иначе «д 5» (номер дома) распознался бы как деревня.
|
||||||
|
_LOCALITY_TYPE_RE = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"г|гор|город|пгт|рп|дп|снт|днп|тер"
|
||||||
|
r"|п|пос|поселок|посёлок|п/ст"
|
||||||
|
r"|д|дер|деревня|с|село|сл|слобода|ст|станция|х|хутор|аул"
|
||||||
|
r")\.?\s+(?=[а-яё])",
|
||||||
|
flags=re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сегмент — номер дома/квартиры/корпуса, а не имя: «125», «д 5», «кв 12»,
|
||||||
|
# «корп 3», «литера А». Такие сегменты не могут быть ни НП, ни улицей.
|
||||||
|
_NUMERIC_SEGMENT_RE = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"д|дом|к|кор|корп|корпус|стр|строение|кв|квартира|литера?|уч|участок"
|
||||||
|
r"|пом|помещение|оф|офис|бокс|гараж"
|
||||||
|
# Хвост `$` обязателен: сегмент считается номером, только если КРОМЕ
|
||||||
|
# числа в нём ничего нет. Иначе «8 марта» (числовое имя улицы) было бы
|
||||||
|
# принято за номер дома и улица потерялась бы целиком.
|
||||||
|
r")?\.?\s*\d+[а-яё]?(?:\s*[/-]\s*\d+[а-яё]?)?$",
|
||||||
|
flags=re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Street-key normalization — the crux of match rate
|
# Street-key normalization — the crux of match rate
|
||||||
|
|
@ -109,9 +153,11 @@ _ADMIN_SEGMENT_RES = [
|
||||||
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
|
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
|
||||||
flags=re.UNICODE,
|
flags=re.UNICODE,
|
||||||
),
|
),
|
||||||
re.compile(r"^(?:г|гор|город)\.?\s+[а-яё][а-яё-]+$", flags=re.UNICODE),
|
|
||||||
re.compile(r"^екатеринбург$", flags=re.UNICODE),
|
|
||||||
]
|
]
|
||||||
|
# NB: сегменты-НАСЕЛЁННЫЕ ПУНКТЫ («г. Екатеринбург», «екатеринбург») здесь
|
||||||
|
# больше НЕ выбрасываются — они несут вторую половину ключа и разбираются
|
||||||
|
# `_locality_name` по реестру регионов. Хардкод `^екатеринбург$` уехал туда же:
|
||||||
|
# список городов региона живёт в `REGIONS[region_code].cities`, а не здесь.
|
||||||
|
|
||||||
# Street-type token at the START of the street segment. Stripped because
|
# Street-type token at the START of the street segment. Stripped because
|
||||||
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
|
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
|
||||||
|
|
@ -187,63 +233,176 @@ def _strip_house_tail(segment: str) -> str:
|
||||||
return " ".join(keep).strip()
|
return " ".join(keep).strip()
|
||||||
|
|
||||||
|
|
||||||
def _street_key(address: str | None) -> str:
|
def _is_admin_segment(seg: str) -> bool:
|
||||||
"""Reduce any address to a bare-street-name key for the centroid join.
|
"""Сегмент — страна/регион/район (выбрасывается целиком)."""
|
||||||
|
return any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES)
|
||||||
|
|
||||||
Both sides must produce the SAME key or the join under-matches:
|
|
||||||
'Екатеринбург, ул. Малышева, 125' → 'малышева'
|
|
||||||
'г Екатеринбург, улица Малышева' → 'малышева'
|
|
||||||
'Екатеринбург, Малышева' → 'малышева'
|
|
||||||
'Свердловская обл., Екатеринбург, ул. Большакова, 17' → 'большакова'
|
|
||||||
'улица Яскина, 12 · р-н Октябрьский' → 'яскина'
|
|
||||||
'г. Екатеринбург, проспект Ленина, 50' → 'ленина'
|
|
||||||
'Екатеринбург, ул. 8 Марта, 100' → '8 марта'
|
|
||||||
|
|
||||||
Steps:
|
def _is_numeric_segment(seg: str) -> bool:
|
||||||
1. NFC normalize, lowercase, ё→е (deals/houses differ on ё usage).
|
"""Сегмент — номер дома/квартиры/корпуса («125», «д 5», «кв 12»)."""
|
||||||
2. Drop a trailing district marker (' · р-н ...', ' | ...').
|
return bool(_NUMERIC_SEGMENT_RE.match(seg))
|
||||||
3. Split on commas; drop leading segments that are admin chunks
|
|
||||||
(Россия / region / district / город / Екатеринбург). The first
|
|
||||||
non-admin segment is the street segment.
|
|
||||||
4. Drop apartment/corpus/строение noise inside that segment.
|
|
||||||
5. Strip the street-type token at the start (ул/улица/проспект/...).
|
|
||||||
6. Strip the trailing house number, preserving numeric street names.
|
|
||||||
7. Collapse whitespace.
|
|
||||||
|
|
||||||
Returns '' when nothing usable remains (caller filters by _MIN_KEY_LEN).
|
|
||||||
"""
|
def _normalize_place(value: str | None) -> str:
|
||||||
if not address:
|
"""Нормализованное имя НП: lower, ё→е, без типа («г. Химки» → «химки»)."""
|
||||||
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
|
v = unicodedata.normalize("NFC", value).lower().replace("ё", "е")
|
||||||
|
v = _WS_RE.sub(" ", v).strip()
|
||||||
|
v = _LOCALITY_TYPE_RE.sub("", v)
|
||||||
|
return _WS_RE.sub(" ", v).strip(" .,")
|
||||||
|
|
||||||
|
|
||||||
|
def _locality_name(seg: str, cities: frozenset[str]) -> str | None:
|
||||||
|
"""Имя НП, если сегмент — населённый пункт, иначе None.
|
||||||
|
|
||||||
|
Два признака: явный тип («г Химки», «д Сабурово», «снт Заря») — имя берём
|
||||||
|
как есть; ИЛИ голое имя, которое реестр региона знает как город
|
||||||
|
(`REGIONS[region_code].cities`). Голое незнакомое имя здесь НЕ считается
|
||||||
|
НП — иначе «Малышева, 125» прочиталось бы как НП «Малышева»; такой случай
|
||||||
|
ловит позиционный fallback в `_split_place_street`.
|
||||||
|
"""
|
||||||
|
m = _LOCALITY_TYPE_RE.match(seg)
|
||||||
|
if m:
|
||||||
|
name = seg[m.end() :]
|
||||||
|
else:
|
||||||
|
if _STREET_TYPE_RE.match(seg) or _is_numeric_segment(seg):
|
||||||
|
return None
|
||||||
|
if _WS_RE.sub(" ", seg).strip() not in cities:
|
||||||
|
return None
|
||||||
|
name = seg
|
||||||
|
name = _WS_RE.sub(" ", name).strip(" .,")
|
||||||
|
return name or None
|
||||||
|
|
||||||
|
|
||||||
|
def _split_place_street(
|
||||||
|
address: str | None, region_code: int = _DEFAULT_REGION_CODE
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Разложить адрес на (населённый пункт, улица) — обе половины ключа.
|
||||||
|
|
||||||
|
ПОЧЕМУ НП обязан быть в ключе. Раньше функция звалась `_street_key` и
|
||||||
|
возвращала ГОЛОЕ имя улицы, выбрасывая НП. Для одного города (скрипт жил
|
||||||
|
ЕКБ-only, с хардкодом `^екатеринбург$`) это безвредно. Для Московской
|
||||||
|
области — тихая катастрофа: «Ленина» / «Центральная» / «Советская» есть
|
||||||
|
почти в каждом из ~970 НП области, дома со всех таких улиц слиплись бы в
|
||||||
|
одно ведро, а среднее их координат — правдоподобная точка ПОСЕРЕДИНЕ между
|
||||||
|
городами. Сделка уезжает на десятки километров, и ни одна проверка этого не
|
||||||
|
замечает: координата валидная, внутри области, рядом есть дома.
|
||||||
|
|
||||||
|
Разбор:
|
||||||
|
1. NFC, lower, ё→е, снять хвост-район (' · р-н ...', ' | ...').
|
||||||
|
2. Порезать на comma-сегменты, выбросить страну/регион/район.
|
||||||
|
3. Первый сегмент-НП (`_locality_name`) → place, улицу ищем ПОСЛЕ него.
|
||||||
|
4. Улица — первый не-числовой сегмент из остатка.
|
||||||
|
5. Fallback «НП без типа и вне реестра» («Сабурово, Луговая» — ровно
|
||||||
|
формат deals.address по области): если НП не нашёлся, а не-числовых
|
||||||
|
сегментов >= 2 и первый не начинается с типа улицы — первый считается
|
||||||
|
НП, второй улицей.
|
||||||
|
6. Улицу чистим как раньше: квартира/корпус, тип улицы, номер дома.
|
||||||
|
|
||||||
|
Неизвестный `region_code` -> KeyError реестра, не молчаливый ''.
|
||||||
|
"""
|
||||||
|
cities = REGIONS[region_code].cities
|
||||||
|
if not address:
|
||||||
|
return "", ""
|
||||||
s = unicodedata.normalize("NFC", address).lower().replace("ё", "е")
|
s = unicodedata.normalize("NFC", address).lower().replace("ё", "е")
|
||||||
s = _WS_RE.sub(" ", s).strip()
|
s = _WS_RE.sub(" ", s).strip()
|
||||||
if not s:
|
if not s:
|
||||||
return ""
|
return "", ""
|
||||||
|
|
||||||
# 2. Drop trailing district marker (' · Октябрьский', ' | ...').
|
|
||||||
s = _DISTRICT_SUFFIX_RE.sub("", s)
|
s = _DISTRICT_SUFFIX_RE.sub("", s)
|
||||||
|
|
||||||
# 3. Split on commas, drop leading admin segments. Each segment is matched
|
|
||||||
# whole, so a non-admin street segment is never partially eaten.
|
|
||||||
segments = [seg.strip() for seg in s.split(",") if seg.strip()]
|
segments = [seg.strip() for seg in s.split(",") if seg.strip()]
|
||||||
street_seg = ""
|
non_admin = [seg for seg in segments if not _is_admin_segment(seg)]
|
||||||
for seg in segments:
|
if not non_admin:
|
||||||
if any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES):
|
return "", ""
|
||||||
continue
|
|
||||||
street_seg = seg
|
place = ""
|
||||||
break
|
rest = non_admin
|
||||||
|
for i, seg in enumerate(non_admin):
|
||||||
|
name = _locality_name(seg, cities)
|
||||||
|
if name:
|
||||||
|
place = name
|
||||||
|
rest = non_admin[i + 1 :]
|
||||||
|
break
|
||||||
|
|
||||||
|
street_seg = next((seg for seg in rest if not _is_numeric_segment(seg)), "")
|
||||||
|
|
||||||
|
if not place:
|
||||||
|
usable = [seg for seg in non_admin if not _is_numeric_segment(seg)]
|
||||||
|
if len(usable) >= 2 and not _STREET_TYPE_RE.match(usable[0]):
|
||||||
|
place = _WS_RE.sub(" ", usable[0]).strip(" .,")
|
||||||
|
street_seg = usable[1]
|
||||||
|
|
||||||
if not street_seg:
|
if not street_seg:
|
||||||
return ""
|
return place, ""
|
||||||
|
|
||||||
# 4. Drop apartment/corpus/строение noise inside the street segment.
|
|
||||||
street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip()
|
street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip()
|
||||||
|
|
||||||
# 5. Strip the street-type token if present.
|
|
||||||
street_seg = _STREET_TYPE_RE.sub("", street_seg).strip()
|
street_seg = _STREET_TYPE_RE.sub("", street_seg).strip()
|
||||||
|
|
||||||
# 6. Strip trailing house number (keep '8 марта' style numeric streets).
|
|
||||||
street_seg = _strip_house_tail(street_seg)
|
street_seg = _strip_house_tail(street_seg)
|
||||||
|
return place, _WS_RE.sub(" ", street_seg).strip()
|
||||||
|
|
||||||
return _WS_RE.sub(" ", street_seg).strip()
|
|
||||||
|
def _street_key(address: str | None, region_code: int = _DEFAULT_REGION_CODE) -> str:
|
||||||
|
"""Только уличная половина ключа. Как ключ join'а САМА ПО СЕБЕ не годится
|
||||||
|
(одноимённые улицы разных НП) — см. `_address_key`; оставлена для отчётов
|
||||||
|
и тестов нормализации улицы.
|
||||||
|
|
||||||
|
Примеры (region 66):
|
||||||
|
'Екатеринбург, ул. Малышева, 125' -> 'малышева'
|
||||||
|
'г Екатеринбург, улица Малышева' -> 'малышева'
|
||||||
|
'Свердловская обл., Екатеринбург, ул. Большакова, 17' -> 'большакова'
|
||||||
|
'улица Яскина, 12 · р-н Октябрьский' -> 'яскина'
|
||||||
|
'Екатеринбург, ул. 8 Марта, 100' -> '8 марта'
|
||||||
|
"""
|
||||||
|
return _split_place_street(address, region_code)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _address_key(
|
||||||
|
address: str | None,
|
||||||
|
region_code: int = _DEFAULT_REGION_CODE,
|
||||||
|
city: str | None = None,
|
||||||
|
) -> tuple[int, str, str] | None:
|
||||||
|
"""Составной ключ join'а: (регион, населённый пункт, улица). None — мусор.
|
||||||
|
|
||||||
|
`city` (у deals колонка заполнена на 100% и надёжнее текста адреса)
|
||||||
|
переопределяет НП, разобранный из адреса.
|
||||||
|
|
||||||
|
Пустой НП: у региона С городом-ядром (66) подставляется `city_token` —
|
||||||
|
ровно историческое поведение «всё, что без города, это Екатеринбург». У
|
||||||
|
региона БЕЗ ядра (50) подставлять нечего, и ключ отбрасывается: сделка без
|
||||||
|
распознанного НП лучше останется без координат, чем сядет в случайный
|
||||||
|
город области.
|
||||||
|
"""
|
||||||
|
place, street = _split_place_street(address, region_code)
|
||||||
|
from_city = _normalize_place(city)
|
||||||
|
if from_city:
|
||||||
|
place = from_city
|
||||||
|
if len(street) < _MIN_KEY_LEN:
|
||||||
|
return None
|
||||||
|
if not place:
|
||||||
|
region = REGIONS[region_code]
|
||||||
|
if not region.has_city_core:
|
||||||
|
return None
|
||||||
|
place = region.city_token
|
||||||
|
if len(place) < _MIN_KEY_LEN:
|
||||||
|
return None
|
||||||
|
return (region_code, place, street)
|
||||||
|
|
||||||
|
|
||||||
|
def _spread_km(points: list[tuple[float, float]], lat_c: float, lon_c: float) -> float:
|
||||||
|
"""Максимальное удаление точки ведра от его центроида, км (equirectangular).
|
||||||
|
|
||||||
|
Проекция плоская — на масштабе одного НП (единицы километров) ошибка
|
||||||
|
сотые доли процента, а формула дешевле haversine на каждом из десятков
|
||||||
|
тысяч домов.
|
||||||
|
"""
|
||||||
|
worst = 0.0
|
||||||
|
cos_lat = math.cos(math.radians(lat_c))
|
||||||
|
for lat, lon in points:
|
||||||
|
dy = (lat - lat_c) * 111.32
|
||||||
|
dx = (lon - lon_c) * 111.32 * cos_lat
|
||||||
|
worst = max(worst, math.hypot(dx, dy))
|
||||||
|
return worst
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -253,11 +412,13 @@ def _street_key(address: str | None) -> str:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Centroid:
|
class Centroid:
|
||||||
"""One street's centroid, averaged over all geocoded houses on it."""
|
"""Центроид одного ключа (регион, НП, улица) по домам этого ключа."""
|
||||||
|
|
||||||
lat: float
|
lat: float
|
||||||
lon: float
|
lon: float
|
||||||
house_count: int
|
house_count: int
|
||||||
|
# Максимальное удаление дома ведра от центроида, км (sanity-чек склейки).
|
||||||
|
spread_km: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -266,6 +427,9 @@ class DealRow:
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
address: str | None
|
address: str | None
|
||||||
|
# deals.city — росреестровая колонка; по области заполнена у 100% строк без
|
||||||
|
# geom и надёжнее текста адреса, поэтому переопределяет НП из адреса.
|
||||||
|
city: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -276,6 +440,8 @@ class Stats:
|
||||||
geocoded: int = 0
|
geocoded: int = 0
|
||||||
no_street_match: int = 0
|
no_street_match: int = 0
|
||||||
failed: int = 0
|
failed: int = 0
|
||||||
|
# Вёдер выброшено sanity-чеком разброса (склейка нескольких НП).
|
||||||
|
dropped_spread: int = 0
|
||||||
# street_key → count of deals that had no house centroid (dry-run report).
|
# street_key → count of deals that had no house centroid (dry-run report).
|
||||||
unmatched_streets: Counter[str] = field(default_factory=Counter)
|
unmatched_streets: Counter[str] = field(default_factory=Counter)
|
||||||
|
|
||||||
|
|
@ -285,14 +451,28 @@ class Stats:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _build_centroid_map(db: Session) -> dict[str, Centroid]:
|
def _build_centroid_map(
|
||||||
"""Per-street centroid from houses WHERE geom IS NOT NULL.
|
db: Session,
|
||||||
|
region_code: int = _DEFAULT_REGION_CODE,
|
||||||
|
max_spread_km: float = _DEFAULT_MAX_SPREAD_KM,
|
||||||
|
) -> dict[tuple[int, str, str], Centroid]:
|
||||||
|
"""Центроиды по ключу (регион, НП, улица) из houses с координатами.
|
||||||
|
|
||||||
We read raw (address, lat, lon) and aggregate in Python so the street-key
|
Фильтр региона обязателен: без него карта строится по домам ВСЕХ регионов,
|
||||||
derivation is the SAME code path as the deals side — pushing it into SQL
|
и прогон по одному региону тихо тащит чужие координаты.
|
||||||
would require duplicating the regex logic in plpgsql and risk drift.
|
|
||||||
8,600 rows is trivial to hold in memory.
|
`houses.region_code` добавлен поздней миграцией (272_houses_region_code.sql)
|
||||||
|
и у части строк NULL. Отбрасывать их нельзя (это ударило бы по покрытию 66),
|
||||||
|
поэтому строка без региона принимается по географии — если её координаты
|
||||||
|
внутри `bbox_region` реестра. Это именно гео-проверка, а не догадка о
|
||||||
|
происхождении строки.
|
||||||
|
|
||||||
|
Агрегация в Python, а не в SQL: вывод ключа должен быть ОДНИМ И ТЕМ ЖЕ
|
||||||
|
кодом на обеих сторонах join'а, дублировать регексы в plpgsql — верный
|
||||||
|
дрейф.
|
||||||
"""
|
"""
|
||||||
|
region = REGIONS[region_code]
|
||||||
|
lat_min, lat_max, lon_min, lon_max = region.bbox_region
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -302,32 +482,81 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]:
|
||||||
" AND lat IS NOT NULL "
|
" AND lat IS NOT NULL "
|
||||||
" AND lon IS NOT NULL "
|
" AND lon IS NOT NULL "
|
||||||
" AND address IS NOT NULL "
|
" AND address IS NOT NULL "
|
||||||
" AND length(trim(address)) > 0"
|
" AND length(trim(address)) > 0 "
|
||||||
)
|
" AND ( region_code = CAST(:rc AS smallint) "
|
||||||
|
" OR ( region_code IS NULL "
|
||||||
|
" AND lat BETWEEN CAST(:lat_min AS double precision) "
|
||||||
|
" AND CAST(:lat_max AS double precision) "
|
||||||
|
" AND lon BETWEEN CAST(:lon_min AS double precision) "
|
||||||
|
" AND CAST(:lon_max AS double precision) ) )"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"rc": region_code,
|
||||||
|
"lat_min": lat_min,
|
||||||
|
"lat_max": lat_max,
|
||||||
|
"lon_min": lon_min,
|
||||||
|
"lon_max": lon_max,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# street_key → running [lat_sum, lon_sum, n]
|
acc: dict[tuple[int, str, str], list[tuple[float, float]]] = {}
|
||||||
acc: dict[str, list[float]] = {}
|
|
||||||
for r in rows:
|
for r in rows:
|
||||||
key = _street_key(r["address"])
|
key = _address_key(r["address"], region_code)
|
||||||
if len(key) < _MIN_KEY_LEN:
|
if key is None:
|
||||||
continue
|
continue
|
||||||
bucket = acc.setdefault(key, [0.0, 0.0, 0.0])
|
acc.setdefault(key, []).append((float(r["lat"]), float(r["lon"])))
|
||||||
bucket[0] += float(r["lat"])
|
|
||||||
bucket[1] += float(r["lon"])
|
|
||||||
bucket[2] += 1.0
|
|
||||||
|
|
||||||
return {
|
out: dict[tuple[int, str, str], Centroid] = {}
|
||||||
key: Centroid(lat=lat_sum / n, lon=lon_sum / n, house_count=int(n))
|
dropped = 0
|
||||||
for key, (lat_sum, lon_sum, n) in acc.items()
|
for key, points in acc.items():
|
||||||
}
|
n = len(points)
|
||||||
|
lat_c = sum(lat for lat, _ in points) / n
|
||||||
|
lon_c = sum(lon for _, lon in points) / n
|
||||||
|
spread = _spread_km(points, lat_c, lon_c)
|
||||||
|
if spread > max_spread_km:
|
||||||
|
# Одна «улица» шириной в десятки километров — это не улица, а
|
||||||
|
# слипшиеся одноимённые улицы разных НП (или один НП, записанный
|
||||||
|
# двумя способами). Среднее таких точек — координата в поле между
|
||||||
|
# городами; отдавать её сделке нельзя, ведро выбрасывается.
|
||||||
|
dropped += 1
|
||||||
|
logger.warning(
|
||||||
|
"centroid bucket dropped: key=%s houses=%d spread=%.1f km > %.1f km",
|
||||||
|
key,
|
||||||
|
n,
|
||||||
|
spread,
|
||||||
|
max_spread_km,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
out[key] = Centroid(lat=lat_c, lon=lon_c, house_count=n, spread_km=spread)
|
||||||
|
|
||||||
|
if dropped:
|
||||||
|
logger.warning(
|
||||||
|
"sanity: %d/%d вёдер выброшено по разбросу > %.1f km",
|
||||||
|
dropped,
|
||||||
|
len(acc),
|
||||||
|
max_spread_km,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
|
def _region_predicate(column: str = "region_code") -> str:
|
||||||
"""deals needing coords (lat IS NULL) — resume-safe candidate set.
|
"""SQL-предикат «строка принадлежит региону :rc» для deals.
|
||||||
|
|
||||||
|
`deals.region_code` проставлен миграцией 177_deals_city_region.sql; строки,
|
||||||
|
существовавшие ДО неё, по построению екатеринбургские (в скрипт импорта
|
||||||
|
был зашит префикс 'Екатеринбург, '), поэтому NULL засчитывается региону 66
|
||||||
|
и только ему. Для любого другого региона NULL — не кандидат.
|
||||||
|
"""
|
||||||
|
return f"( {column} = CAST(:rc AS int) OR ( {column} IS NULL AND CAST(:rc AS int) = 66 ) )"
|
||||||
|
|
||||||
|
|
||||||
|
def _select_deals_without_coords(
|
||||||
|
db: Session, limit: int, region_code: int = _DEFAULT_REGION_CODE
|
||||||
|
) -> list[DealRow]:
|
||||||
|
"""deals needing coords (lat IS NULL) в пределах ОДНОГО региона.
|
||||||
|
|
||||||
Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful
|
Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful
|
||||||
UPDATE sets lat NOT NULL, dropping the row out on the next run.
|
UPDATE sets lat NOT NULL, dropping the row out on the next run.
|
||||||
|
|
@ -335,20 +564,21 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT id, address "
|
"SELECT id, address, city "
|
||||||
"FROM deals "
|
"FROM deals "
|
||||||
"WHERE lat IS NULL "
|
"WHERE lat IS NULL "
|
||||||
" AND address IS NOT NULL "
|
" AND address IS NOT NULL "
|
||||||
" AND length(trim(address)) > 0 "
|
" AND length(trim(address)) > 0 "
|
||||||
|
" AND " + _region_predicate() + " "
|
||||||
"ORDER BY id "
|
"ORDER BY id "
|
||||||
"LIMIT CAST(:lim AS int)"
|
"LIMIT CAST(:lim AS int)"
|
||||||
),
|
),
|
||||||
{"lim": limit},
|
{"lim": limit, "rc": region_code},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [DealRow(id=r["id"], address=r["address"]) for r in rows]
|
return [DealRow(id=r["id"], address=r["address"], city=r.get("city")) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -356,7 +586,14 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) -> None:
|
def _update_deal_coords(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
deal_id: int,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
region_code: int = _DEFAULT_REGION_CODE,
|
||||||
|
) -> None:
|
||||||
"""UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills.
|
"""UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills.
|
||||||
|
|
||||||
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
||||||
|
|
@ -370,9 +607,10 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
|
||||||
" SET lat = CAST(:lat AS double precision), "
|
" SET lat = CAST(:lat AS double precision), "
|
||||||
" lon = CAST(:lon AS double precision), "
|
" lon = CAST(:lon AS double precision), "
|
||||||
" geocode_tried_at = NOW() "
|
" geocode_tried_at = NOW() "
|
||||||
" WHERE id = CAST(:id AS bigint)"
|
" WHERE id = CAST(:id AS bigint) "
|
||||||
|
" AND " + _region_predicate()
|
||||||
),
|
),
|
||||||
{"id": deal_id, "lat": lat, "lon": lon},
|
{"id": deal_id, "lat": lat, "lon": lon, "rc": region_code},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -384,10 +622,11 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
|
||||||
def _run_backfill(
|
def _run_backfill(
|
||||||
db: Session,
|
db: Session,
|
||||||
deals: list[DealRow],
|
deals: list[DealRow],
|
||||||
centroids: dict[str, Centroid],
|
centroids: dict[tuple[int, str, str], Centroid],
|
||||||
*,
|
*,
|
||||||
batch: str,
|
batch: str,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
region_code: int = _DEFAULT_REGION_CODE,
|
||||||
) -> Stats:
|
) -> Stats:
|
||||||
"""For each deal, look up its street centroid and UPDATE lat/lon.
|
"""For each deal, look up its street centroid and UPDATE lat/lon.
|
||||||
|
|
||||||
|
|
@ -399,14 +638,14 @@ def _run_backfill(
|
||||||
stats = Stats()
|
stats = Stats()
|
||||||
|
|
||||||
for i, deal in enumerate(deals, start=1):
|
for i, deal in enumerate(deals, start=1):
|
||||||
key = _street_key(deal.address)
|
key = _address_key(deal.address, region_code, city=deal.city)
|
||||||
centroid = centroids.get(key) if len(key) >= _MIN_KEY_LEN else None
|
centroid = centroids.get(key) if key is not None else None
|
||||||
|
|
||||||
if centroid is None:
|
if centroid is None:
|
||||||
stats.no_street_match += 1
|
stats.no_street_match += 1
|
||||||
# Track the raw key (or a sentinel) so the dry-run report can show
|
# Track the raw key (or a sentinel) so the dry-run report can show
|
||||||
# which streets we're missing. Empty key → '<no-street-parsed>'.
|
# which streets we're missing. Empty key → '<no-street-parsed>'.
|
||||||
stats.unmatched_streets[key or "<no-street-parsed>"] += 1
|
stats.unmatched_streets["/".join(key[1:]) if key else "<no-street-parsed>"] += 1
|
||||||
stats.processed += 1
|
stats.processed += 1
|
||||||
if dry_run and i % _LOG_EVERY == 0:
|
if dry_run and i % _LOG_EVERY == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -423,7 +662,13 @@ def _run_backfill(
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
_update_deal_coords(db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon)
|
_update_deal_coords(
|
||||||
|
db,
|
||||||
|
deal_id=deal.id,
|
||||||
|
lat=centroid.lat,
|
||||||
|
lon=centroid.lon,
|
||||||
|
region_code=region_code,
|
||||||
|
)
|
||||||
# Per-row commit so resume picks up exactly where we crashed.
|
# Per-row commit so resume picks up exactly where we crashed.
|
||||||
db.commit()
|
db.commit()
|
||||||
stats.geocoded += 1
|
stats.geocoded += 1
|
||||||
|
|
@ -454,7 +699,7 @@ def _run_backfill(
|
||||||
|
|
||||||
def _report_dry_run(
|
def _report_dry_run(
|
||||||
stats: Stats,
|
stats: Stats,
|
||||||
centroids: dict[str, Centroid],
|
centroids: dict[tuple[int, str, str], Centroid],
|
||||||
*,
|
*,
|
||||||
total_deals_null: int,
|
total_deals_null: int,
|
||||||
candidates: int,
|
candidates: int,
|
||||||
|
|
@ -475,7 +720,7 @@ def _report_dry_run(
|
||||||
|
|
||||||
logger.info("─" * 60)
|
logger.info("─" * 60)
|
||||||
logger.info("DRY-RUN SUMMARY (no DB writes)")
|
logger.info("DRY-RUN SUMMARY (no DB writes)")
|
||||||
logger.info("distinct streets with a house centroid: %d", distinct_streets)
|
logger.info("distinct (region, НП, улица) keys with a house centroid: %d", distinct_streets)
|
||||||
logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned)
|
logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned)
|
||||||
logger.info("deals matched to a centroid: %d", matched)
|
logger.info("deals matched to a centroid: %d", matched)
|
||||||
logger.info("deals with no street match: %d", stats.no_street_match)
|
logger.info("deals with no street match: %d", stats.no_street_match)
|
||||||
|
|
@ -486,7 +731,7 @@ def _report_dry_run(
|
||||||
projected,
|
projected,
|
||||||
match_rate * 100.0,
|
match_rate * 100.0,
|
||||||
)
|
)
|
||||||
logger.info("top-10 unmatched deal streets (by row count):")
|
logger.info("top-10 unmatched deal keys НП/улица (by row count):")
|
||||||
for street, cnt in stats.unmatched_streets.most_common(10):
|
for street, cnt in stats.unmatched_streets.most_common(10):
|
||||||
logger.info(" %6d %s", cnt, street)
|
logger.info(" %6d %s", cnt, street)
|
||||||
logger.info("─" * 60)
|
logger.info("─" * 60)
|
||||||
|
|
@ -497,13 +742,15 @@ def _report_dry_run(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _count_deals_null(db: Session) -> int:
|
def _count_deals_null(db: Session, region_code: int = _DEFAULT_REGION_CODE) -> int:
|
||||||
"""Full count of deals WHERE lat IS NULL — denominator for projection."""
|
"""Full count of deals WHERE lat IS NULL в этом регионе — знаменатель."""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT count(*) AS n FROM deals "
|
"SELECT count(*) AS n FROM deals "
|
||||||
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0"
|
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0 "
|
||||||
)
|
" AND " + _region_predicate()
|
||||||
|
),
|
||||||
|
{"rc": region_code},
|
||||||
).first()
|
).first()
|
||||||
return int(row[0]) if row else 0
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
|
|
@ -527,6 +774,25 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
default=f"deals_geo_{date.today().isoformat()}",
|
default=f"deals_geo_{date.today().isoformat()}",
|
||||||
help="Log batch label (does not affect DB filters — logs only).",
|
help="Log batch label (does not affect DB filters — logs only).",
|
||||||
)
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--region-code",
|
||||||
|
type=int,
|
||||||
|
default=_DEFAULT_REGION_CODE,
|
||||||
|
choices=sorted(REGIONS),
|
||||||
|
help=(
|
||||||
|
"Регион прогона (default %(default)s). Отбирает и обновляет ТОЛЬКО "
|
||||||
|
"строки этого региона; входит в ключ join'а."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--max-spread-km",
|
||||||
|
type=float,
|
||||||
|
default=_DEFAULT_MAX_SPREAD_KM,
|
||||||
|
help=(
|
||||||
|
"Порог разброса домов внутри ключа (default %(default)s км). Ведро "
|
||||||
|
"с большим разбросом — склейка нескольких НП, оно выбрасывается."
|
||||||
|
),
|
||||||
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -543,30 +809,47 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
"""CLI entry point. Returns the number of deals geocoded this run."""
|
"""CLI entry point. Returns the number of deals geocoded this run."""
|
||||||
args = _parse_args(argv)
|
args = _parse_args(argv)
|
||||||
logger.info(
|
logger.info(
|
||||||
"starting batch=%s limit=%s dry_run=%s",
|
"starting batch=%s region=%s limit=%s max_spread_km=%s dry_run=%s",
|
||||||
args.batch,
|
args.batch,
|
||||||
|
args.region_code,
|
||||||
args.limit,
|
args.limit,
|
||||||
|
args.max_spread_km,
|
||||||
args.dry_run,
|
args.dry_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(
|
||||||
logger.info("built centroid map: %d distinct streets", len(centroids))
|
db, region_code=args.region_code, max_spread_km=args.max_spread_km
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"built centroid map: %d distinct keys (region %s)", len(centroids), args.region_code
|
||||||
|
)
|
||||||
if not centroids:
|
if not centroids:
|
||||||
logger.warning("no house centroids — houses table has no geocoded rows; nothing to do")
|
logger.warning(
|
||||||
|
"no house centroids for region %s — houses has no geocoded rows there; "
|
||||||
|
"nothing to do",
|
||||||
|
args.region_code,
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
deals = _select_deals_without_coords(db, args.limit)
|
deals = _select_deals_without_coords(db, args.limit, region_code=args.region_code)
|
||||||
logger.info("loaded deals without coords: %d", len(deals))
|
logger.info("loaded deals without coords: %d", len(deals))
|
||||||
if not deals:
|
if not deals:
|
||||||
logger.info("nothing to do — no deals with lat IS NULL and an address")
|
logger.info("nothing to do — no deals with lat IS NULL and an address")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
stats = _run_backfill(db, deals, centroids, batch=args.batch, dry_run=args.dry_run)
|
stats = _run_backfill(
|
||||||
|
db,
|
||||||
|
deals,
|
||||||
|
centroids,
|
||||||
|
batch=args.batch,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
region_code=args.region_code,
|
||||||
|
)
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
total_null = _count_deals_null(db)
|
total_null = _count_deals_null(db, region_code=args.region_code)
|
||||||
_report_dry_run(
|
_report_dry_run(
|
||||||
stats,
|
stats,
|
||||||
centroids,
|
centroids,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@
|
||||||
`--strict-markers` в pyproject.toml не включён, так что незарегистрированный
|
`--strict-markers` в pyproject.toml не включён, так что незарегистрированный
|
||||||
маркер только предупреждал бы) и сторожит глобальное состояние, которое
|
маркер только предупреждал бы) и сторожит глобальное состояние, которое
|
||||||
переживает отдельный тест: общий rate-limiter POST /estimate (см.
|
переживает отдельный тест: общий rate-limiter POST /estimate (см.
|
||||||
`_reset_estimate_rate_limiter`) и слоты проверки пароля (см.
|
`_reset_estimate_rate_limiter`), слоты проверки пароля (см.
|
||||||
`_no_leaked_password_verify_slots`).
|
`_no_leaked_password_verify_slots`) и синглтон Telegram-клиента (см.
|
||||||
|
`_reset_telegram_shared_client`, #3471 — иначе его rate limiter копит
|
||||||
|
реальное время между тестами и вешает прогон).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -48,6 +50,35 @@ def _reset_estimate_rate_limiter() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_telegram_shared_client():
|
||||||
|
"""`app.services.tgbot.shared._client` — модульный синглтон `TelegramClient`
|
||||||
|
(#3471). Его `TelegramGroupRateLimiter` копит РЕАЛЬНЫЕ метки времени
|
||||||
|
(`time.monotonic()`, ничем не замоканные) по `chat_id` за весь pytest-процесс,
|
||||||
|
а не по тесту — а тестовые настройки `telegram_alerts_chat_id`/
|
||||||
|
`telegram_support_chat_id` дефолтятся в 0, так что ЛЮБЫЕ тесты, бьющие в
|
||||||
|
`app.api.v1.support`/`glitchtip` через реальный (не замоканный) shared-клиент,
|
||||||
|
делят ОДИН и тот же ключ бакета. После N-й (лимит роли, по умолчанию 12)
|
||||||
|
такой отправки в пределах 60 реальных секунд следующая уходит в настоящий
|
||||||
|
`asyncio.sleep` до 60с — тест не падает, а зависает, и именно так выглядела
|
||||||
|
смерть CI-джобы на #3494 (обрыв на ~9%, 75с жизни, ни строки об ошибке;
|
||||||
|
`pytest-timeout` затем добивает зависший тест снаружи).
|
||||||
|
|
||||||
|
Фикстура не выключает и не завышает лимит (в проде он ДОЛЖЕН оставаться
|
||||||
|
ниже площадочного потолка) — она просто гарантирует каждому тесту СВЕЖИЙ
|
||||||
|
клиент (и тем самым свежий, пустой `TelegramGroupRateLimiter`), так же как
|
||||||
|
`_reset_estimate_rate_limiter` выше делает для `_estimate_limiter`. Сброс
|
||||||
|
и ДО, и ПОСЛЕ теста — тест мог создать клиент через `get_telegram_client()`,
|
||||||
|
не пройдя явный локальный `_reset_singleton` (см. `test_shared.py`), и не
|
||||||
|
должен оставить накопленное состояние следующему тесту.
|
||||||
|
"""
|
||||||
|
from app.services.tgbot import shared
|
||||||
|
|
||||||
|
shared._client = None
|
||||||
|
yield
|
||||||
|
shared._client = None
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _no_leaked_password_verify_slots():
|
def _no_leaked_password_verify_slots():
|
||||||
"""Тест не оставляет за собой занятых слотов проверки пароля (#2665, #2714).
|
"""Тест не оставляет за собой занятых слотов проверки пароля (#2665, #2714).
|
||||||
|
|
@ -150,3 +181,20 @@ def pytest_sessionfinish(session, exitstatus) -> None:
|
||||||
)
|
)
|
||||||
if exitstatus == 0:
|
if exitstatus == 0:
|
||||||
session.exitstatus = 1
|
session.exitstatus = 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Никакого НАСТОЯЩЕГО сна в тестах ─────────────────────────────────────────
|
||||||
|
# Фоновый дослальщик алертов GlitchTip (#3471) спит между попытками по
|
||||||
|
# настоящим часам: три попытки по 30 секунд. `TestClient` из Starlette ждёт
|
||||||
|
# завершения background-задачи, привязанной к ответу, поэтому один-единственный
|
||||||
|
# тест на отказ доставки держал весь прогон около минуты, а в CI прогон просто
|
||||||
|
# умирал по таймауту без единой строки об ошибке.
|
||||||
|
#
|
||||||
|
# Ставим паузу в ноль для ВСЕХ тестов: проверять надо, что дослальщик вызван и
|
||||||
|
# сколько раз, а не то, что интерпретатор умеет спать. Тест, которому нужна
|
||||||
|
# настоящая пауза, переопределяет значение сам.
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_real_retry_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from app.tasks import glitchtip_alert_retry
|
||||||
|
|
||||||
|
monkeypatch.setattr(glitchtip_alert_retry, "_RETRY_DELAY_S", 0.0)
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,10 @@ from scripts.geocode_deals_from_houses import (
|
||||||
Centroid,
|
Centroid,
|
||||||
DealRow,
|
DealRow,
|
||||||
Stats,
|
Stats,
|
||||||
|
_address_key,
|
||||||
_build_centroid_map,
|
_build_centroid_map,
|
||||||
_run_backfill,
|
_run_backfill,
|
||||||
|
_select_deals_without_coords,
|
||||||
_street_key,
|
_street_key,
|
||||||
_update_deal_coords,
|
_update_deal_coords,
|
||||||
main,
|
main,
|
||||||
|
|
@ -178,16 +180,16 @@ def test_build_centroid_map_averages_houses_on_one_street():
|
||||||
"""Two houses on Малышева → centroid is the mean of their coords."""
|
"""Two houses on Малышева → centroid is the mean of their coords."""
|
||||||
house_rows = [
|
house_rows = [
|
||||||
{"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
|
{"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
|
||||||
{"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.90, "lon": 60.70},
|
{"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.82, "lon": 60.54},
|
||||||
]
|
]
|
||||||
db, _ = _make_db_mock(house_rows=house_rows)
|
db, _ = _make_db_mock(house_rows=house_rows)
|
||||||
|
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(db)
|
||||||
|
|
||||||
assert set(centroids) == {"малышева"}
|
assert set(centroids) == {(66, "екатеринбург", "малышева")}
|
||||||
c = centroids["малышева"]
|
c = centroids[(66, "екатеринбург", "малышева")]
|
||||||
assert c.lat == pytest.approx(56.85)
|
assert c.lat == pytest.approx(56.81)
|
||||||
assert c.lon == pytest.approx(60.60)
|
assert c.lon == pytest.approx(60.52)
|
||||||
assert c.house_count == 2
|
assert c.house_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -201,12 +203,15 @@ def test_build_centroid_map_groups_distinct_streets():
|
||||||
|
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(db)
|
||||||
|
|
||||||
assert set(centroids) == {"малышева", "ленина"}
|
assert set(centroids) == {
|
||||||
assert centroids["малышева"].house_count == 2
|
(66, "екатеринбург", "малышева"),
|
||||||
assert centroids["ленина"].house_count == 1
|
(66, "екатеринбург", "ленина"),
|
||||||
|
}
|
||||||
|
assert centroids[(66, "екатеринбург", "малышева")].house_count == 2
|
||||||
|
assert centroids[(66, "екатеринбург", "ленина")].house_count == 1
|
||||||
# Малышева centroid = mean of the two Малышева rows.
|
# Малышева centroid = mean of the two Малышева rows.
|
||||||
assert centroids["малышева"].lat == pytest.approx(56.81)
|
assert centroids[(66, "екатеринбург", "малышева")].lat == pytest.approx(56.81)
|
||||||
assert centroids["ленина"].lat == pytest.approx(56.84)
|
assert centroids[(66, "екатеринбург", "ленина")].lat == pytest.approx(56.84)
|
||||||
|
|
||||||
|
|
||||||
def test_build_centroid_map_skips_unparseable_address():
|
def test_build_centroid_map_skips_unparseable_address():
|
||||||
|
|
@ -218,7 +223,7 @@ def test_build_centroid_map_skips_unparseable_address():
|
||||||
db, _ = _make_db_mock(house_rows=house_rows)
|
db, _ = _make_db_mock(house_rows=house_rows)
|
||||||
|
|
||||||
centroids = _build_centroid_map(db)
|
centroids = _build_centroid_map(db)
|
||||||
assert set(centroids) == {"малышева"}
|
assert set(centroids) == {(66, "екатеринбург", "малышева")}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -228,7 +233,7 @@ def test_build_centroid_map_skips_unparseable_address():
|
||||||
|
|
||||||
def test_run_backfill_matched_street_issues_update_with_centroid():
|
def test_run_backfill_matched_street_issues_update_with_centroid():
|
||||||
deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125")
|
deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125")
|
||||||
centroids = {"малышева": Centroid(lat=56.838, lon=60.586, house_count=3)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.838, lon=60.586, house_count=3)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False)
|
||||||
|
|
@ -247,7 +252,7 @@ def test_run_backfill_matched_street_issues_update_with_centroid():
|
||||||
def test_run_backfill_deal_with_only_street_name_matches():
|
def test_run_backfill_deal_with_only_street_name_matches():
|
||||||
"""Deal address with no house number / no type word still matches."""
|
"""Deal address with no house number / no type word still matches."""
|
||||||
deal = DealRow(id=7, address="Екатеринбург, Малышева")
|
deal = DealRow(id=7, address="Екатеринбург, Малышева")
|
||||||
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
||||||
|
|
@ -264,7 +269,7 @@ def test_run_backfill_deal_with_only_street_name_matches():
|
||||||
|
|
||||||
def test_run_backfill_unmatched_street_no_update():
|
def test_run_backfill_unmatched_street_no_update():
|
||||||
deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1")
|
deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1")
|
||||||
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
||||||
|
|
@ -275,13 +280,13 @@ def test_run_backfill_unmatched_street_no_update():
|
||||||
assert updated == []
|
assert updated == []
|
||||||
assert db.commit.call_count == 0
|
assert db.commit.call_count == 0
|
||||||
# Unmatched street tracked for the dry-run report.
|
# Unmatched street tracked for the dry-run report.
|
||||||
assert stats.unmatched_streets["несуществующая"] == 1
|
assert stats.unmatched_streets["екатеринбург/несуществующая"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_run_backfill_unparseable_deal_tracked_as_sentinel():
|
def test_run_backfill_unparseable_deal_tracked_as_sentinel():
|
||||||
"""A deal whose address yields an empty key is a no-match under a sentinel."""
|
"""A deal whose address yields an empty key is a no-match under a sentinel."""
|
||||||
deal = DealRow(id=5, address="Екатеринбург")
|
deal = DealRow(id=5, address="Екатеринбург")
|
||||||
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
|
||||||
|
|
@ -298,7 +303,7 @@ def test_run_backfill_unparseable_deal_tracked_as_sentinel():
|
||||||
|
|
||||||
def test_run_backfill_dry_run_issues_no_update():
|
def test_run_backfill_dry_run_issues_no_update():
|
||||||
deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1")
|
deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1")
|
||||||
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True)
|
stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True)
|
||||||
|
|
@ -319,7 +324,7 @@ def test_run_backfill_db_write_failure_isolated_to_row():
|
||||||
DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"),
|
DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"),
|
||||||
DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"),
|
DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"),
|
||||||
]
|
]
|
||||||
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
|
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
|
||||||
db, updated = _make_db_mock()
|
db, updated = _make_db_mock()
|
||||||
|
|
||||||
# Make the FIRST UPDATE raise, the rest succeed.
|
# Make the FIRST UPDATE raise, the rest succeed.
|
||||||
|
|
@ -359,7 +364,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
|
||||||
assert "geocode_tried_at = NOW()" in sql_str
|
assert "geocode_tried_at = NOW()" in sql_str
|
||||||
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
|
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
|
||||||
assert "geom" not in sql_str
|
assert "geom" not in sql_str
|
||||||
assert binds == {"id": 3, "lat": 56.1, "lon": 60.2}
|
assert binds == {"id": 3, "lat": 56.1, "lon": 60.2, "rc": 66}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -370,7 +375,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
|
||||||
def test_main_respects_limit_and_returns_geocoded():
|
def test_main_respects_limit_and_returns_geocoded():
|
||||||
house_rows = [
|
house_rows = [
|
||||||
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
|
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
|
||||||
{"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.90, "lon": 60.70},
|
{"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.82, "lon": 60.54},
|
||||||
]
|
]
|
||||||
deal_rows = [
|
deal_rows = [
|
||||||
{"id": 1, "address": "Екатеринбург, Малышева"},
|
{"id": 1, "address": "Екатеринбург, Малышева"},
|
||||||
|
|
@ -386,8 +391,8 @@ def test_main_respects_limit_and_returns_geocoded():
|
||||||
assert len(updated) == 1
|
assert len(updated) == 1
|
||||||
assert updated[0]["id"] == 1
|
assert updated[0]["id"] == 1
|
||||||
# Centroid used = mean of the two house rows.
|
# Centroid used = mean of the two house rows.
|
||||||
assert updated[0]["lat"] == pytest.approx(56.85)
|
assert updated[0]["lat"] == pytest.approx(56.81)
|
||||||
assert updated[0]["lon"] == pytest.approx(60.60)
|
assert updated[0]["lon"] == pytest.approx(60.52)
|
||||||
|
|
||||||
|
|
||||||
def test_main_dry_run_writes_nothing():
|
def test_main_dry_run_writes_nothing():
|
||||||
|
|
@ -432,3 +437,114 @@ def test_stats_unmatched_streets_counter_defaults_empty():
|
||||||
assert s.no_street_match == 0
|
assert s.no_street_match == 0
|
||||||
assert s.failed == 0
|
assert s.failed == 0
|
||||||
assert s.unmatched_streets.most_common(3) == []
|
assert s.unmatched_streets.most_common(3) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Одноимённые улицы разных НП + однорегиональность прогона (fix/oblast-deal-geocoding)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_address_key_distinguishes_same_street_in_different_localities():
|
||||||
|
"""«Луговая» в Сабурово и «Луговая» в Красногорске — РАЗНЫЕ ключи.
|
||||||
|
|
||||||
|
Ровно та ловушка, ради которой ключ стал составным: до этого обе улицы
|
||||||
|
сливались в одно ведро 'луговая', и центроид садился между городами.
|
||||||
|
"""
|
||||||
|
saburovo = _address_key("Сабурово, Луговая", 50)
|
||||||
|
krasnogorsk = _address_key("Красногорск, Луговая", 50)
|
||||||
|
assert saburovo == (50, "сабурово", "луговая")
|
||||||
|
assert krasnogorsk == (50, "красногорск", "луговая")
|
||||||
|
assert saburovo != krasnogorsk
|
||||||
|
|
||||||
|
|
||||||
|
def test_address_key_distinguishes_same_street_in_different_regions():
|
||||||
|
"""Регион — тоже часть ключа: «Ленина» в Химках ≠ «Ленина» в Екатеринбурге."""
|
||||||
|
assert _address_key("г Химки, ул Ленина, 5", 50) == (50, "химки", "ленина")
|
||||||
|
assert _address_key("Екатеринбург, ул. Ленина, 5", 66) == (66, "екатеринбург", "ленина")
|
||||||
|
|
||||||
|
|
||||||
|
def test_address_key_region_without_city_core_refuses_nameless_locality():
|
||||||
|
"""У области НП обязателен: без него ключа нет (а не «пусть будет Красногорск»)."""
|
||||||
|
assert _address_key("Луговая, 5", 50) is None
|
||||||
|
# У 66 город-ядро есть — историческое поведение сохранено.
|
||||||
|
assert _address_key("Малышева, 125", 66) == (66, "екатеринбург", "малышева")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_centroid_map_separates_same_street_of_two_localities():
|
||||||
|
"""Дома с одноимённых улиц двух НП не усредняются в одну точку."""
|
||||||
|
house_rows = [
|
||||||
|
{"address": "Московская обл., д Сабурово, ул Луговая, 1", "lat": 55.40, "lon": 37.60},
|
||||||
|
{"address": "Московская обл., д Сабурово, ул Луговая, 3", "lat": 55.41, "lon": 37.61},
|
||||||
|
{"address": "Московская обл., г Химки, ул Луговая, 2", "lat": 55.89, "lon": 37.43},
|
||||||
|
]
|
||||||
|
db, _ = _make_db_mock(house_rows=house_rows)
|
||||||
|
|
||||||
|
centroids = _build_centroid_map(db, region_code=50)
|
||||||
|
|
||||||
|
assert set(centroids) == {(50, "сабурово", "луговая"), (50, "химки", "луговая")}
|
||||||
|
assert centroids[(50, "сабурово", "луговая")].house_count == 2
|
||||||
|
assert centroids[(50, "сабурово", "луговая")].lat == pytest.approx(55.405)
|
||||||
|
assert centroids[(50, "химки", "луговая")].lat == pytest.approx(55.89)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_centroid_map_drops_bucket_with_huge_spread():
|
||||||
|
"""Ведро с разбросом в десятки км — склейка НП, выбрасывается целиком."""
|
||||||
|
house_rows = [
|
||||||
|
{"address": "Московская обл., г Химки, ул Ленина, 1", "lat": 55.89, "lon": 37.43},
|
||||||
|
# Тот же ключ, но точка в 60+ км — так выглядит склейка двух НП.
|
||||||
|
{"address": "Московская обл., г Химки, ул Ленина, 2", "lat": 55.30, "lon": 38.20},
|
||||||
|
]
|
||||||
|
db, _ = _make_db_mock(house_rows=house_rows)
|
||||||
|
|
||||||
|
assert _build_centroid_map(db, region_code=50) == {}
|
||||||
|
# Порог поднят выше разброса → ведро остаётся.
|
||||||
|
kept = _build_centroid_map(db, region_code=50, max_spread_km=100.0)
|
||||||
|
assert set(kept) == {(50, "химки", "ленина")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_centroid_query_filters_by_region():
|
||||||
|
"""Карта центроидов строится по домам ОДНОГО региона, а не по всем."""
|
||||||
|
db, _ = _make_db_mock(house_rows=[])
|
||||||
|
|
||||||
|
_build_centroid_map(db, region_code=50)
|
||||||
|
|
||||||
|
args, _kw = db.execute.call_args
|
||||||
|
sql_str = str(args[0])
|
||||||
|
assert "FROM houses" in sql_str
|
||||||
|
assert "region_code = CAST(:rc AS smallint)" in sql_str
|
||||||
|
assert args[1]["rc"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_select_and_update_are_scoped_to_one_region():
|
||||||
|
"""Прогон по региону 50 не выбирает и не обновляет строки другого региона."""
|
||||||
|
db, _unused = _make_db_mock(
|
||||||
|
deal_rows=[{"id": 7, "address": "Сабурово, Луговая", "city": "Сабурово"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
deals = _select_deals_without_coords(db, 10, region_code=50)
|
||||||
|
args, _kw = db.execute.call_args
|
||||||
|
select_sql = str(args[0])
|
||||||
|
assert "FROM deals" in select_sql
|
||||||
|
assert "region_code = CAST(:rc AS int)" in select_sql
|
||||||
|
assert args[1]["rc"] == 50
|
||||||
|
assert deals[0].city == "Сабурово"
|
||||||
|
|
||||||
|
_update_deal_coords(db, deal_id=7, lat=55.4, lon=37.6, region_code=50)
|
||||||
|
args, _kw = db.execute.call_args
|
||||||
|
update_sql = str(args[0])
|
||||||
|
assert "UPDATE deals" in update_sql
|
||||||
|
assert "region_code = CAST(:rc AS int)" in update_sql
|
||||||
|
assert args[1]["rc"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_backfill_uses_deal_city_column_for_the_locality():
|
||||||
|
"""deals.city (заполнена на 100% по области) переопределяет НП из адреса."""
|
||||||
|
centroids = {(50, "сабурово", "луговая"): Centroid(lat=55.4, lon=37.6, house_count=2)}
|
||||||
|
db, updated = _make_db_mock()
|
||||||
|
deals = [DealRow(id=11, address="Луговая", city="Сабурово")]
|
||||||
|
|
||||||
|
stats = _run_backfill(db, deals, centroids, batch="t", dry_run=False, region_code=50)
|
||||||
|
|
||||||
|
assert stats.geocoded == 1
|
||||||
|
assert updated[0]["lat"] == pytest.approx(55.4)
|
||||||
|
assert updated[0]["rc"] == 50
|
||||||
|
|
|
||||||
|
|
@ -829,6 +829,46 @@ async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog)
|
||||||
assert dadata._clean_disabled_warned is True
|
assert dadata._clean_disabled_warned is True
|
||||||
|
|
||||||
|
|
||||||
|
# Вымышленный токен ТОЙ ЖЕ формы, что настоящий DaData API-ключ (40 hex-символов) — НЕ
|
||||||
|
# реальный секрет. Прод-факт: DaData на 403 кладёт действующий токен в тело ответа
|
||||||
|
# открытым текстом, и body_preview копирует его в WARNING/ERROR лог как есть (#3471).
|
||||||
|
FAKE_LEAKED_TOKEN = "deadbeef1234deadbeef1234deadbeef12345678"
|
||||||
|
|
||||||
|
CLEAN_TOKEN_LEAK_BODY = {
|
||||||
|
"timestamp": "2026-09-13T10:00:00.000+00:00",
|
||||||
|
"status": 403,
|
||||||
|
"error": "Forbidden",
|
||||||
|
"message": (
|
||||||
|
f"Feature 'CLEAN' disabled for token '{FAKE_LEAKED_TOKEN}'. "
|
||||||
|
"See https://dadata.userecho.com/topics/7784 for help."
|
||||||
|
),
|
||||||
|
"path": "/api/v1/clean/address",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clean_address_masks_token_in_feature_disabled_log(caplog) -> None:
|
||||||
|
"""403 body в реалистичной прод-форме (40-символьный токен) — токен НЕ должен светиться в логе.
|
||||||
|
|
||||||
|
Отличие от test_clean_address_logs_feature_disabled_distinctly: тот тест использует
|
||||||
|
укороченный токен 'xxx' и не проверяет утечку. Здесь — токен длины настоящего DaData-ключа,
|
||||||
|
именно такая форма ушла в docker logs на проде (#3471).
|
||||||
|
"""
|
||||||
|
from app.services import dadata
|
||||||
|
|
||||||
|
dadata._clean_disabled_warned = False # изоляция от порядка тестов (module-level throttle)
|
||||||
|
|
||||||
|
transport = _mock_transport_returning(403, CLEAN_TOKEN_LEAK_BODY)
|
||||||
|
with _patch_settings(), _patch_async_client(transport):
|
||||||
|
with caplog.at_level(_logging.WARNING, logger="app.services.dadata"):
|
||||||
|
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
text = caplog.text
|
||||||
|
assert FAKE_LEAKED_TOKEN not in text, "токен утёк в лог-запись открытым текстом"
|
||||||
|
# Диагностика (какая услуга выключена) обязана сохраниться несмотря на маскирование.
|
||||||
|
assert "Стандартизация" in text or "выключена" in text
|
||||||
|
|
||||||
|
|
||||||
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
||||||
"""401 (или 403 без 'disabled') → сообщение про креды."""
|
"""401 (или 403 без 'disabled') → сообщение про креды."""
|
||||||
from app.services import dadata
|
from app.services import dadata
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ from app.services import location_index as lc
|
||||||
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
|
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
|
||||||
_LAT_IN_EKB = 56.838
|
_LAT_IN_EKB = 56.838
|
||||||
_LON_IN_EKB = 60.605
|
_LON_IN_EKB = 60.605
|
||||||
|
_REGION_66 = lc.regions_mod.REGIONS[66]
|
||||||
|
|
||||||
|
# A point well inside Moscow's product core (within MKAD) — #3051 region 77.
|
||||||
|
_LAT_IN_MSK = 55.75
|
||||||
|
_LON_IN_MSK = 37.62
|
||||||
|
_REGION_77 = lc.regions_mod.REGIONS[77]
|
||||||
|
|
||||||
# ── Pure functions ──────────────────────────────────────────────────────────
|
# ── Pure functions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -118,6 +124,7 @@ def test_no_psycopg_v3_colon_colon_cast() -> None:
|
||||||
lc._MEDIAN_PPM2_LOCAL_SQL,
|
lc._MEDIAN_PPM2_LOCAL_SQL,
|
||||||
lc._MEDIAN_PPM2_CITYWIDE_SQL,
|
lc._MEDIAN_PPM2_CITYWIDE_SQL,
|
||||||
lc._NEAREST_POI_SQL,
|
lc._NEAREST_POI_SQL,
|
||||||
|
lc._POI_COUNT_IN_REGION_SQL,
|
||||||
):
|
):
|
||||||
assert not re.search(r":\w+::", str(sql.text))
|
assert not re.search(r":\w+::", str(sql.text))
|
||||||
|
|
||||||
|
|
@ -142,6 +149,51 @@ def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
|
||||||
assert "bbox_west" in sql_text and "bbox_east" in sql_text
|
assert "bbox_west" in sql_text and "bbox_east" in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
# ── #msk-defect-1: медианы обязаны фильтроваться по региону, не только по bbox ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_median_queries_filter_by_region_code() -> None:
|
||||||
|
"""Прод-замер 13.09.2026: bbox продукт-ядра Москвы геометрически захватывает соседние
|
||||||
|
region_code=50 (Химки/Реутов/Котельники/Люберцы) — без фильтра по региону городская
|
||||||
|
медиана Москвы считалась с примесью 16 801 подмосковного лота и была занижена на 7.9%
|
||||||
|
(422 222 вместо 458 208 ₽/м²). У областных строк city IS NULL — фильтровать можно
|
||||||
|
только по region_code, не по имени города."""
|
||||||
|
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
||||||
|
sql_text = str(sql.text)
|
||||||
|
assert "region_code = CAST(:region_code AS integer)" in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_median_queries_do_not_admit_null_region_code() -> None:
|
||||||
|
"""Строгое равенство, БЕЗ `OR region_code IS NULL` — строка без региона не считается
|
||||||
|
ни за один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля, bbox
|
||||||
|
ядра 66): 15 858 строк проходят фильтры, 3 из них с NULL region_code; медиана с ними
|
||||||
|
151 101 ₽/м², без них 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), для 66
|
||||||
|
статистически ничто. Мягкий `OR NULL` был бы лазейкой того же класса, что и сам
|
||||||
|
дефект: в `listings` 16 930 NULL-region строк Avito, 24 из них уже лежат в bbox
|
||||||
|
Москвы (сегодня не проходят по свежести, но могут завтра) — `OR NULL` тихо открыл бы
|
||||||
|
им дорогу обратно в московскую медиану."""
|
||||||
|
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
|
||||||
|
sql_text = str(sql.text)
|
||||||
|
assert "region_code IS NULL" not in sql_text
|
||||||
|
assert "OR region_code" not in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_and_citywide_median_bind_region_code_of_the_given_region() -> None:
|
||||||
|
"""region_code реально долетает bind-параметром и берётся ИЗ переданного региона, а не
|
||||||
|
захардкожен — иначе фикс работал бы только для 66 и молча ломался на следующем регионе."""
|
||||||
|
db66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000})])
|
||||||
|
lc._citywide_median_ppm2(db66, _REGION_66)
|
||||||
|
assert db66.executed[0][1]["region_code"] == 66
|
||||||
|
|
||||||
|
db77 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 458_208.0, "n": 82_719})])
|
||||||
|
lc._citywide_median_ppm2(db77, _REGION_77)
|
||||||
|
assert db77.executed[0][1]["region_code"] == 77
|
||||||
|
|
||||||
|
local66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25})])
|
||||||
|
lc._local_median_ppm2(local66, _LAT_IN_EKB, _LON_IN_EKB, 800, _REGION_66)
|
||||||
|
assert local66.executed[0][1]["region_code"] == 66
|
||||||
|
|
||||||
|
|
||||||
# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ─────────────
|
# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -246,7 +298,9 @@ class _FakeDB:
|
||||||
|
|
||||||
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
||||||
db = _FakeDB([_FakeResult(scalar_value=0)])
|
db = _FakeDB([_FakeResult(scalar_value=0)])
|
||||||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
poi, status = lc._fetch_nearby_poi(
|
||||||
|
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
|
||||||
|
)
|
||||||
assert poi == []
|
assert poi == []
|
||||||
assert status == "unavailable"
|
assert status == "unavailable"
|
||||||
assert len(db.executed) == 1 # only the count probe ran
|
assert len(db.executed) == 1 # only the count probe ran
|
||||||
|
|
@ -254,7 +308,9 @@ def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
|
||||||
|
|
||||||
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
|
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
|
||||||
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
|
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
|
||||||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7)
|
poi, status = lc._fetch_nearby_poi(
|
||||||
|
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
|
||||||
|
)
|
||||||
assert poi == []
|
assert poi == []
|
||||||
assert status == "ok"
|
assert status == "ok"
|
||||||
|
|
||||||
|
|
@ -267,7 +323,7 @@ def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None:
|
||||||
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
|
||||||
]
|
]
|
||||||
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
|
||||||
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
|
||||||
assert status == "ok"
|
assert status == "ok"
|
||||||
assert len(poi) == 4
|
assert len(poi) == 4
|
||||||
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
|
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
|
||||||
|
|
@ -281,7 +337,7 @@ def test_fetch_nearby_poi_limits_to_top_n() -> None:
|
||||||
for i in range(20)
|
for i in range(20)
|
||||||
]
|
]
|
||||||
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
|
||||||
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7)
|
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
|
||||||
assert len(poi) == 7
|
assert len(poi) == 7
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -407,3 +463,64 @@ def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
|
||||||
assert result.poi_status == "unavailable"
|
assert result.poi_status == "unavailable"
|
||||||
assert result.nearby_poi == []
|
assert result.nearby_poi == []
|
||||||
assert result.location_index_pct == 15.0
|
assert result.location_index_pct == 15.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── #msk-defect-1/2: Москва (region 77) — не течёт в EKB, честная POI-деградация ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_covered_region_resolves_moscow_center_to_region_77() -> None:
|
||||||
|
"""МКАД-центр Москвы должен попадать в продукт-ядро 77, а не в 50 (см. комментарий у
|
||||||
|
regions._POINT_LOOKUP_ORDER — компактный bbox_region проверяется раньше объемлющего)."""
|
||||||
|
region = lc._covered_region(_LAT_IN_MSK, _LON_IN_MSK)
|
||||||
|
assert region is not None and region.code == 77
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_nearby_poi_gate_uses_regions_own_bbox_not_whole_table() -> None:
|
||||||
|
"""Дефект (прод 13.09.2026): гейт раньше делал `count(*)` по ВСЕЙ osm_poi_ekb_local
|
||||||
|
(4 850 екатеринбургских строк, всегда > 0) — Москва получала бы poi_status="ok" с
|
||||||
|
пустым списком. Теперь count скоупится в bbox_product_core региона точки: для 77
|
||||||
|
внутри его ядра POI нет (0), для 66 — те же 4 850 строк, что и раньше."""
|
||||||
|
db_msk = _FakeDB([_FakeResult(scalar_value=0)])
|
||||||
|
poi, status = lc._fetch_nearby_poi(
|
||||||
|
db_msk, _LAT_IN_MSK, _LON_IN_MSK, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_77
|
||||||
|
)
|
||||||
|
assert poi == []
|
||||||
|
assert status == "unavailable" # честная деградация, ТА ЖЕ терминология, что и раньше
|
||||||
|
# bbox в запросе — Москвы, не Екатеринбурга (это и есть суть фикса).
|
||||||
|
poi_count_params = db_msk.executed[0][1]
|
||||||
|
lat_min, lat_max, lon_min, lon_max = _REGION_77.bbox_product_core
|
||||||
|
assert poi_count_params["bbox_south"] == lat_min
|
||||||
|
assert poi_count_params["bbox_north"] == lat_max
|
||||||
|
assert poi_count_params["bbox_west"] == lon_min
|
||||||
|
assert poi_count_params["bbox_east"] == lon_max
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_nearby_poi_gate_still_scopes_to_ekb_bbox_for_region_66() -> None:
|
||||||
|
"""Regression: для 66 гейт обязан продолжать спрашивать про bbox ЕКБ — byte-for-byte,
|
||||||
|
просто теперь явно скоупенный, а не «случайно совпадающий» с count(*) по всей таблице."""
|
||||||
|
db = _FakeDB([_FakeResult(scalar_value=4850), _FakeResult(mapping_rows=[])])
|
||||||
|
lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66)
|
||||||
|
poi_count_params = db.executed[0][1]
|
||||||
|
lat_min, lat_max, lon_min, lon_max = _REGION_66.bbox_product_core
|
||||||
|
assert poi_count_params["bbox_south"] == lat_min
|
||||||
|
assert poi_count_params["bbox_north"] == lat_max
|
||||||
|
assert poi_count_params["bbox_west"] == lon_min
|
||||||
|
assert poi_count_params["bbox_east"] == lon_max
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_location_index_moscow_point_isolates_region_and_degrades_honestly() -> None:
|
||||||
|
"""End-to-end #3051 Москва: citywide/local запросы обязаны нести region_code=77 (не 66,
|
||||||
|
не отсутствие фильтра), а poi_status обязан быть честным "unavailable" — НЕ "ok" с
|
||||||
|
пустым списком, как было до фикса дефекта 2."""
|
||||||
|
db = _FakeDB(
|
||||||
|
[
|
||||||
|
_FakeResult(scalar_value=0), # poi-count in Moscow core: 0
|
||||||
|
_FakeResult(mapping_one={"median_ppm2": None, "n": 0}), # citywide: no MSK data yet
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = lc.compute_location_index(db, lat=_LAT_IN_MSK, lon=_LON_IN_MSK)
|
||||||
|
assert result.status == "insufficient_data" # честно: листингов Москвы пока нет
|
||||||
|
assert result.poi_status == "unavailable"
|
||||||
|
assert result.nearby_poi == []
|
||||||
|
citywide_params = db.executed[1][1]
|
||||||
|
assert citywide_params["region_code"] == 77
|
||||||
|
|
|
||||||
|
|
@ -420,8 +420,10 @@ def test_all_candidates_banned_raises_pool_exhausted_not_env_fallback(
|
||||||
assert exc_info.value.pool_total == 2
|
assert exc_info.value.pool_total == 2
|
||||||
assert exc_info.value.banned_for_source == 2
|
assert exc_info.value.banned_for_source == 2
|
||||||
assert exc_info.value.unhealthy_or_disabled == 0
|
assert exc_info.value.unhealthy_or_disabled == 0
|
||||||
errors = [rec for rec in caplog.records if rec.levelname == "ERROR"]
|
# fail-closed без здорового узла — штатный исход скрапинга, а не инцидент;
|
||||||
assert any("fail-closed" in rec.message.lower() for rec in errors)
|
# понижено до warning, чтобы не шуметь в GlitchTip (было logger.error).
|
||||||
|
warnings = [rec for rec in caplog.records if rec.levelname == "WARNING"]
|
||||||
|
assert any("fail-closed" in rec.message.lower() for rec in warnings)
|
||||||
# НЕ должно быть "обход пула" / "static-fallback" в логах — env не тронут.
|
# НЕ должно быть "обход пула" / "static-fallback" в логах — env не тронут.
|
||||||
assert not any("static-fallback" in rec.message for rec in caplog.records)
|
assert not any("static-fallback" in rec.message for rec in caplog.records)
|
||||||
|
|
||||||
|
|
@ -429,8 +431,10 @@ def test_all_candidates_banned_raises_pool_exhausted_not_env_fallback(
|
||||||
def test_exhausted_and_empty_pool_log_texts_are_distinct(
|
def test_exhausted_and_empty_pool_log_texts_are_distinct(
|
||||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Регрессия на замечание ревью: "пуст" и "все отсеяны" — РАЗНЫЕ формулировки И
|
"""Регрессия на замечание ревью: "пуст" и "все отсеяны" — РАЗНЫЕ формулировки,
|
||||||
разные уровни (WARNING vs ERROR), иначе их нельзя различить в логах/алертах."""
|
иначе их нельзя различить в логах/алертах. Оба сценария — штатный исход
|
||||||
|
скрапинга, поэтому оба теперь warning (было WARNING vs ERROR), различимость
|
||||||
|
держится на тексте, не на уровне."""
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
with caplog.at_level("WARNING"):
|
with caplog.at_level("WARNING"):
|
||||||
|
|
@ -452,8 +456,8 @@ def test_exhausted_and_empty_pool_log_texts_are_distinct(
|
||||||
exhausted_messages = {rec.levelname: rec.message for rec in caplog.records}
|
exhausted_messages = {rec.levelname: rec.message for rec in caplog.records}
|
||||||
|
|
||||||
assert "ERROR" not in empty_pool_messages
|
assert "ERROR" not in empty_pool_messages
|
||||||
assert "ERROR" in exhausted_messages
|
assert "ERROR" not in exhausted_messages
|
||||||
assert empty_pool_messages.get("WARNING") != exhausted_messages.get("ERROR")
|
assert empty_pool_messages.get("WARNING") != exhausted_messages.get("WARNING")
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_proxy_raises_pool_exhausted() -> None:
|
def test_disabled_proxy_raises_pool_exhausted() -> None:
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services import proxy_pool
|
from app.services import proxy_pool
|
||||||
|
|
@ -1527,3 +1528,74 @@ def test_clear_source_bans_resets_escalation() -> None:
|
||||||
assert ban["ban_count"] == 1
|
assert ban["ban_count"] == 1
|
||||||
expected = datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS)
|
expected = datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS)
|
||||||
assert abs((ban["banned_until"] - expected).total_seconds()) < 60
|
assert abs((ban["banned_until"] - expected).total_seconds()) < 60
|
||||||
|
|
||||||
|
|
||||||
|
# ── health-probe failure logging (#3471 — GlitchTip/log noise) ──────────────
|
||||||
|
# httpx.ProxyError (типично 407 от провайдера) раньше падал в generic
|
||||||
|
# `except Exception: ... exc_info=True` внутри `_probe_proxy` — полный traceback
|
||||||
|
# на КАЖДЫЙ провал, хотя это штатное состояние пула (184 строки/сутки на
|
||||||
|
# проде), а не инцидент приложения. Тесты ниже бьют по РЕАЛЬНОМУ `_probe_proxy`
|
||||||
|
# (не монки-заглушке, как в тестах `run_proxy_healthcheck` выше) — только так
|
||||||
|
# видно, что осталось от логирования при живом httpx-исключении.
|
||||||
|
|
||||||
|
|
||||||
|
async def test_probe_proxy_proxy_error_logs_single_line_without_traceback(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
async def _broken_get(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> httpx.Response:
|
||||||
|
raise httpx.ProxyError("407 Proxy Authentication Required")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "get", _broken_get)
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING", logger="app.services.proxy_pool"):
|
||||||
|
ok, exit_ip, latency_ms, fail_kind = await proxy_pool._probe_proxy(
|
||||||
|
"http://u:p@h1:8080"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert exit_ip is None
|
||||||
|
assert latency_ms is None
|
||||||
|
assert fail_kind == "proxy_error"
|
||||||
|
|
||||||
|
records = [r for r in caplog.records if r.name == "app.services.proxy_pool"]
|
||||||
|
assert len(records) == 1, "провал ipify-пробы обязан лечь ОДНОЙ строкой, не пачкой"
|
||||||
|
record = records[0]
|
||||||
|
assert record.exc_info is None, "проба — штатная операция, полный traceback не нужен"
|
||||||
|
assert "proxy_error" in record.message
|
||||||
|
assert "407" in record.message # причина (текст исключения) видна без трейса
|
||||||
|
|
||||||
|
|
||||||
|
async def test_healthcheck_counts_proxy_error_as_failed_without_traceback(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Итоговая строка `checked=.../ok=.../failed=...` не ломается провалом
|
||||||
|
вида ProxyError, а сам провал не тащит traceback в лог прогона."""
|
||||||
|
db = FakeSession([_proxy(1, fails=0)])
|
||||||
|
|
||||||
|
async def _broken_get(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> httpx.Response:
|
||||||
|
raise httpx.ProxyError("407 Proxy Authentication Required")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "get", _broken_get)
|
||||||
|
|
||||||
|
# INFO (не WARNING): итоговая сводка `healthcheck done` логируется на INFO —
|
||||||
|
# порог ниже WARNING нужен, чтобы её тоже поймать в этом же прогоне.
|
||||||
|
with caplog.at_level("INFO", logger="app.services.proxy_pool"):
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert counters["checked"] == 1
|
||||||
|
assert counters["ok"] == 0
|
||||||
|
assert counters["failed"] == 1
|
||||||
|
assert db._by_id(1)["consecutive_fails"] == 1
|
||||||
|
|
||||||
|
summary = [
|
||||||
|
r
|
||||||
|
for r in caplog.records
|
||||||
|
if r.name == "app.services.proxy_pool" and "healthcheck done" in r.message
|
||||||
|
]
|
||||||
|
assert len(summary) == 1
|
||||||
|
assert "checked=1" in summary[0].message
|
||||||
|
assert "ok=0" in summary[0].message
|
||||||
|
assert "failed=1" in summary[0].message
|
||||||
|
assert not any(r.exc_info for r in caplog.records if r.name == "app.services.proxy_pool")
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue