Compare commits
No commits in common. "main" and "fix/msk-collector-net-retry" have entirely different histories.
main
...
fix/msk-co
135 changed files with 618 additions and 11503 deletions
|
|
@ -219,30 +219,6 @@ jobs:
|
|||
- '.forgejo/workflows/deploy.yml'
|
||||
- '.forgejo/workflows/deploy-tradein.yml'
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
# #3448: тот же класс, ещё раз. Гейт про исключающие `!`-шаблоны
|
||||
# в paths-filter проверяет ВСЕ воркфлоу, а paths-filter живёт и
|
||||
# здесь — без этой строки правка ci-tradein.yml с таким шаблоном
|
||||
# не запустила бы backend-tests, то есть гейт не побежал бы ровно
|
||||
# на той правке, от которой стережёт.
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
# #3467/#3475: гейт backend/tests/ops/test_3467_prometheus_reload.py
|
||||
# читает оба файла ниже. Без них правка, трогающая ТОЛЬКО
|
||||
# deploy-metrics.yml (скажем, дописывающая `|| true` к шагу
|
||||
# перезагрузки Prometheus), даёт backend=false — джоба
|
||||
# backend-tests пропускается, гейт не исполняется, регрессия
|
||||
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
||||
- '.forgejo/workflows/deploy-metrics.yml'
|
||||
- 'docker-compose.metrics.yml'
|
||||
# #3443: тот же класс, третий раз. Гейт
|
||||
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
||||
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
||||
# все содержательные регрессии живут в самом скрипте, а не в
|
||||
# deploy.yml. PR, правящий только ops/**, без этой строки давал бы
|
||||
# backend=false: джоба пропускается, гейт не исполняется, и
|
||||
# «пересоздавать всегда» (окно 67 с на всех доменах) или
|
||||
# «не пересоздавать никогда» (правка конфига беззвучно не доезжает)
|
||||
# уезжает в main зелёным.
|
||||
- 'ops/**'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
|
|
|
|||
|
|
@ -90,13 +90,8 @@ jobs:
|
|||
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
|
||||
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
|
||||
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
||||
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
|
||||
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
|
||||
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
|
||||
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
|
||||
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
|
||||
with:
|
||||
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET
|
||||
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL
|
||||
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
||||
|
|
@ -193,14 +188,6 @@ jobs:
|
|||
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
|
||||
fi
|
||||
|
||||
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
|
||||
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
|
||||
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
|
||||
# должно — деплой обязан сказать, что канал не поднялся.
|
||||
if [ -z "${ALERT_ACK_GLITCHTIP_SECRET:-}" ]; then
|
||||
echo "::warning title=Резервный канал GlitchTip выключен::ALERT_ACK_GLITCHTIP_SECRET пуст — alert-ack отвечает 503 на /glitchtip, и при падении продуктового бэкенда его ошибки доставлять будет нечем."
|
||||
fi
|
||||
|
||||
if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
|
||||
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
|
||||
else
|
||||
|
|
@ -263,19 +250,6 @@ jobs:
|
|||
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
|
||||
fi
|
||||
|
||||
# Ретранслятор Telegram Bot API (#3471, PR #3487 сломал прод: сервис
|
||||
# без profiles уходил в SystemExit на пустом секрете и висел в
|
||||
# Restarting). Профиль relay включаем НЕЗАВИСИМО от alerts — это
|
||||
# разные каналы (один шлёт алерты боту, другой ретранслирует
|
||||
# продуктовый Bot API трафик с Selectel). PROFILES — список через
|
||||
# запятую, как того требует COMPOSE_PROFILES.
|
||||
if [ -n "${TG_RELAY_SECRET:-}" ]; then
|
||||
PROFILES="${PROFILES:+$PROFILES,}relay"
|
||||
echo "Ретранслятор Telegram: секрет задан, профиль relay включён."
|
||||
else
|
||||
echo "::warning title=Резервный ретранслятор Telegram выключен::TG_RELAY_SECRET пуст — tg-relay не поднимается (профиль relay выключен). Продуктовый Telegram-трафик пойдёт напрямую с Selectel, где теряется примерно каждый четвёртый короткий запрос."
|
||||
fi
|
||||
|
||||
# ── Цели file_sd для Prometheus (#3155) ────────────────────────
|
||||
# Включатель профиля и цель для Prometheus обязаны стоять в ОДНОМ
|
||||
# условии. Пока они жили порознь, вышло так: 27.08 профиль alerts
|
||||
|
|
@ -294,27 +268,19 @@ jobs:
|
|||
AM_TARGETS_FILE=ops/metrics/prometheus/alertmanager_targets.gen.yml
|
||||
: > "$AM_TARGETS_FILE"
|
||||
echo "# Файл рендерится деплоем (deploy-metrics.yml), правки руками затрутся." >> "$AM_TARGETS_FILE"
|
||||
# Сравнение через case, а не "=": PROFILES теперь может быть
|
||||
# комбинацией через запятую ("alerts,relay") с тех пор, как #3471
|
||||
# завёл независимый профиль relay — точное равенство строке
|
||||
# "alerts" сломалось бы молча в тот момент, когда оба профиля
|
||||
# включены разом.
|
||||
case ",$PROFILES," in
|
||||
*,alerts,*)
|
||||
if [ "$PROFILES" = "alerts" ]; then
|
||||
echo '- targets: ["alertmanager:9093"]' >> "$AM_TARGETS_FILE"
|
||||
echo " labels:" >> "$AM_TARGETS_FILE"
|
||||
echo " host: infra" >> "$AM_TARGETS_FILE"
|
||||
echo "Prometheus: приёмник alertmanager:9093 прописан в целях."
|
||||
;;
|
||||
*)
|
||||
else
|
||||
# Пустой список, а НЕ отсутствующий файл: одиночный бинд-маунт
|
||||
# несуществующего пути docker подменяет каталогом, и Prometheus
|
||||
# не стартует вовсе.
|
||||
echo "# Профиль alerts выключен — приёмников нет." >> "$AM_TARGETS_FILE"
|
||||
echo "[]" >> "$AM_TARGETS_FILE"
|
||||
echo "Prometheus: профиль alerts выключен — целей нет, это штатно."
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── read-only роль для датасорса GlitchTip ─────────────────────
|
||||
# Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
|
||||
|
|
@ -326,43 +292,6 @@ jobs:
|
|||
COMPOSE_PROFILES="$PROFILES" \
|
||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans
|
||||
|
||||
# ── alert-ack / tg-relay: код монтируется с хоста ────────────────
|
||||
# Тот же класс бага, что у Alertmanager (см. ниже) и Caddyfile:
|
||||
# `up -d` сравнивает ОПИСАНИЕ сервиса, а не содержимое бинд-маунта.
|
||||
# alert-ack и tg-relay получают код именно бинд-маунтом файла
|
||||
# (./ops/metrics/{alert-ack,tg-relay}/app.py:/app/app.py:ro), а не
|
||||
# сборкой образа — правка app.py оставляет уже запущенный
|
||||
# контейнер работать на СТАРОМ коде в памяти интерпретатора сколько
|
||||
# угодно, и `up -d` этого не видит вовсе.
|
||||
#
|
||||
# Пойман на проде 12.09.2026: PR #3490 (фикс alert-ack) слился,
|
||||
# `git reset --hard` обновил файл на диске (grep по новому
|
||||
# комментарию находил его), а gendesign-alert-ack, запущенный за
|
||||
# 25 минут до этого, продолжал отвечать по старой логике —
|
||||
# зелёный деплой, тихо неверное поведение. Починил только ручной
|
||||
# `docker restart gendesign-alert-ack`. force-recreate здесь —
|
||||
# замена этому ручному шагу.
|
||||
#
|
||||
# case ",$PROFILES," — пересоздаём только если профиль сервиса
|
||||
# реально включён в ЭТОМ прогоне, иначе force-recreate ругается на
|
||||
# несуществующий контейнер (сервис не создан вовсе).
|
||||
case ",$PROFILES," in
|
||||
*,alerts,*)
|
||||
COMPOSE_PROFILES="$PROFILES" \
|
||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
|
||||
up -d --force-recreate alert-ack
|
||||
echo "alert-ack: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)."
|
||||
;;
|
||||
esac
|
||||
case ",$PROFILES," in
|
||||
*,relay,*)
|
||||
COMPOSE_PROFILES="$PROFILES" \
|
||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
|
||||
up -d --force-recreate tg-relay
|
||||
echo "tg-relay: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)."
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Alertmanager: пересоздать, если конфиг перерисовали ─────────
|
||||
# `up -d` выше СЧИТАЕТ alertmanager неизменившимся: он сравнивает
|
||||
# описание сервиса, а содержимое бинд-маунта в это сравнение не
|
||||
|
|
@ -415,51 +344,6 @@ jobs:
|
|||
done
|
||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
||||
|
||||
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
|
||||
# перечитывает ────────────────────────────────────────────────
|
||||
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
||||
# docker compose сравнивает описание сервиса, а НЕ содержимое
|
||||
# бинд-маунта, поэтому уже работающий контейнер продолжает жить
|
||||
# со старым конфигом сколько угодно — на проде дошло до 16 суток
|
||||
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
||||
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
||||
#
|
||||
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
|
||||
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
|
||||
# после `git reset --hard` подхватывается без пересоздания
|
||||
# контейнера. --web.enable-lifecycle уже включён в compose ради
|
||||
# этого шага (см. docker-compose.metrics.yml) — просто раньше
|
||||
# никто не звал сам reload.
|
||||
#
|
||||
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
|
||||
# положить работающий Prometheus молчаливым откатом на дефолты.
|
||||
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
|
||||
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml'; then
|
||||
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
||||
|
||||
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
|
||||
|
||||
# lastConfigTime обновляется на КАЖДЫЙ успешный reload, даже
|
||||
# если содержимое конфига не поменялось — значит сравнение
|
||||
# "было/стало" надёжно ловит и несостоявшийся reload, и
|
||||
# изменившиеся правила.
|
||||
LAST_CONFIG_AFTER=""
|
||||
for i in $(seq 1 10); do
|
||||
LAST_CONFIG_AFTER="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
||||
[ -n "$LAST_CONFIG_AFTER" ] && [ "$LAST_CONFIG_AFTER" != "$LAST_CONFIG_BEFORE" ] && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ -z "$LAST_CONFIG_AFTER" ] || [ "$LAST_CONFIG_AFTER" = "$LAST_CONFIG_BEFORE" ]; then
|
||||
echo "ОШИБКА: reload Prometheus не подтверждён — lastConfigTime не изменился ($LAST_CONFIG_BEFORE)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)."
|
||||
else
|
||||
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
||||
agent-apps:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -121,98 +121,37 @@ jobs:
|
|||
infra: ${{ steps.filter.outputs.infra }}
|
||||
# #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он
|
||||
# включает и compose, и сам workflow, где полный деплой обязателен.
|
||||
caddy_only: ${{ steps.filter.outputs.caddy_only }}
|
||||
# `github.event_name == 'push'` первым множителем НАМЕРЕННО: на
|
||||
# workflow_dispatch у paths-filter нет диффа, и любой его ответ не должен
|
||||
# уметь отключить сборку — ручной прогон обязан оставаться полным.
|
||||
caddy_only: ${{ github.event_name == 'push' && steps.filter.outputs.caddy == 'true' && steps.filter.outputs.non_caddy == 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ── #3448: список изменённых файлов считаем САМИ ─────────────────────────
|
||||
#
|
||||
# ЧТО БЫЛО. Быстрый путь «правка только прокси» (#2916) не отработал НИ
|
||||
# РАЗУ. Причина — НЕ пустой `event.before`: эта гипотеза опровергнута
|
||||
# логом задачи 29244 (run 10881, мерж 84920e6c) — `before` там валиден,
|
||||
# 204e2e09…, и `git diff` вернул ровно один файл. Причина в семантике
|
||||
# самого фильтра: dorny/paths-filter склеивает шаблоны ОДНОГО фильтра
|
||||
# через `some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`,
|
||||
# predicate-quantifier по умолчанию `some`). Список
|
||||
# non_caddy: ['**', '!Caddyfile', '!caddy/**']
|
||||
# читается не как «всё, КРОМЕ caddy», а как «подходит под `**` ИЛИ не
|
||||
# Caddyfile ИЛИ не caddy/**». `**` матчит всё, поэтому non_caddy был true
|
||||
# ВСЕГДА и caddy_only — false всегда. В логе это видно дословно:
|
||||
# ##[group]Filter non_caddy = true
|
||||
# Matching files:
|
||||
# caddy/sites/apps.caddy [modified]
|
||||
# Исключённый файл сам себя и «исключил». deploy-caddy при этом
|
||||
# пропускался, а Forgejo рисует пропущенную джобу зелёной — сигнала не
|
||||
# было ни одного.
|
||||
#
|
||||
# ПОЧЕМУ ШЕЛЛ, А НЕ ЗАПЛАТКА К ФИЛЬТРАМ. Разность множеств тут нужна одна
|
||||
# («все изменения лежат под caddy»), и выражать её действием, у которого
|
||||
# ИЛИ по умолчанию, — значит снова повесить решение на незаметное
|
||||
# умолчание: `predicate-quantifier: every` действует на ВЕСЬ блок и
|
||||
# сломал бы backend/frontend/infra. Плюс два требования #3448: решение
|
||||
# обязано быть ВИДНО в логе (иначе «сработало» и «просто не совпало»
|
||||
# неотличимы), и оно не должно молча зависеть от того, что платформа
|
||||
# кладёт в `before`.
|
||||
#
|
||||
# FAIL-SAFE. База не разрешилась (ручной запуск, пустой/нулевой `before`,
|
||||
# коммита нет на сервере) → считаем изменённым ВЕСЬ репозиторий: лишний
|
||||
# полный деплой безопаснее пропущенного. Фолбэка на `HEAD^..HEAD` тут
|
||||
# намеренно нет: у мерж-коммита он дал бы верный ответ, а у push'а из
|
||||
# нескольких коммитов — молча урезанный, и быстрый путь включился бы
|
||||
# там, где приехал бэкенд.
|
||||
- name: Определить изменённые файлы (#3448)
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
env:
|
||||
BEFORE: ${{ github.event.before }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
set -eu
|
||||
NULL_SHA=0000000000000000000000000000000000000000
|
||||
BASE=""
|
||||
if [ "$EVENT" = "push" ] && [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "$NULL_SHA" ]; then
|
||||
git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \
|
||||
|| git fetch --depth=1 --no-tags origin "$BEFORE" >/dev/null 2>&1 \
|
||||
|| true
|
||||
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
|
||||
BASE="$BEFORE"
|
||||
else
|
||||
echo "::warning::коммит $BEFORE недоступен в клоне — деплой будет полным"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$BASE" ]; then
|
||||
FILES=$(git -c core.quotePath=false diff --no-renames --name-only "$BASE" HEAD)
|
||||
N=$(printf '%s\n' "$FILES" | grep -c . || true)
|
||||
echo "База: $BASE → $(git rev-parse HEAD); изменённых файлов: $N"
|
||||
printf '%s\n' "$FILES" | sed 's/^/ /'
|
||||
else
|
||||
FILES=$(git -c core.quotePath=false ls-files)
|
||||
N=$(printf '%s\n' "$FILES" | grep -c . || true)
|
||||
echo "База не определена (event=$EVENT, before='${BEFORE:-}') — считаем изменённым весь репозиторий ($N файлов), деплой полный"
|
||||
fi
|
||||
|
||||
# Те же наборы путей, что были в фильтрах до #3448.
|
||||
CADDY_RE='^(Caddyfile$|caddy/)'
|
||||
has() { printf '%s\n' "$FILES" | grep -qE "$1"; }
|
||||
|
||||
backend=false; frontend=false; infra=false; caddy_only=false
|
||||
has '^(backend/|data/sql/)' && backend=true
|
||||
has '^frontend/' && frontend=true
|
||||
has '^(docker-compose\.prod\.yml$|Caddyfile$|caddy/|\.forgejo/workflows/deploy\.yml$)' && infra=true
|
||||
# Быстрый путь: изменения ЕСТЬ и НИ ОДНО из них не лежит вне caddy.
|
||||
# Проверка `N -gt 0` обязательна: пустой список иначе прошёл бы как
|
||||
# «всё под caddy» и отключил бы сборку на ровном месте.
|
||||
if [ "$N" -gt 0 ] && ! printf '%s\n' "$FILES" | grep -vE "$CADDY_RE" | grep -q .; then
|
||||
caddy_only=true
|
||||
fi
|
||||
|
||||
echo "Флаги: backend=$backend frontend=$frontend infra=$infra caddy_only=$caddy_only"
|
||||
{
|
||||
echo "backend=$backend"
|
||||
echo "frontend=$frontend"
|
||||
echo "infra=$infra"
|
||||
echo "caddy_only=$caddy_only"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'backend/**'
|
||||
- 'data/sql/**'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
infra:
|
||||
- 'docker-compose.prod.yml'
|
||||
- 'Caddyfile'
|
||||
- 'caddy/**'
|
||||
- '.forgejo/workflows/deploy.yml'
|
||||
# Пара фильтров для «правка ТОЛЬКО прокси» (#2916). Одного `caddy`
|
||||
# мало: он true и когда вместе с конфигом приехал бэкенд — тогда
|
||||
# нужен обычный полный деплой. `non_caddy` матчит ВСЁ остальное,
|
||||
# и быстрый путь включается лишь когда он false.
|
||||
caddy:
|
||||
- 'Caddyfile'
|
||||
- 'caddy/**'
|
||||
non_caddy:
|
||||
- '**'
|
||||
- '!Caddyfile'
|
||||
- '!caddy/**'
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -1058,17 +997,12 @@ jobs:
|
|||
docker compose -p gendesign -f docker-compose.prod.yml up -d \
|
||||
--force-recreate --no-deps $WORKER_SERVICES
|
||||
|
||||
# Caddy: пересоздание ТОЛЬКО когда без него правка не доедет (#3443).
|
||||
# Здесь стоял безусловный `up -d --force-recreate --no-deps caddy` —
|
||||
# то есть КАЖДЫЙ полный деплой сносил единственный процесс, слушающий
|
||||
# 80/443, и все домены хоста отдавали `code=000` (замер 05.09: 67 с).
|
||||
# Довод той правки (17.05, 11e78d73 — «иначе новые volume mounts не
|
||||
# появляются») не подтвердился: `up -d` БЕЗ флага пересоздаёт
|
||||
# контейнер сам, как только меняется описание сервиса или образ.
|
||||
# Разбор и проверки — в шапке ops/caddy-apply.sh; там же сверка
|
||||
# пофайловых bind-маунтов (Caddyfile + 4 сниппета держат инод) и
|
||||
# `caddy validate` до применения.
|
||||
sh ops/caddy-apply.sh
|
||||
# Caddy: force-recreate чтобы подхватить изменения в Caddyfile
|
||||
# И в особенности новые volume mounts из docker-compose.prod.yml
|
||||
# (`reload` не пересоздаёт container, поэтому новые binds не появляются —
|
||||
# был случай 2026-05-17 с PR #268 preview/ — потребовался manual SSH fix).
|
||||
docker compose -p gendesign -f docker-compose.prod.yml up -d \
|
||||
--force-recreate --no-deps caddy
|
||||
|
||||
# Forwarder: force-recreate чтобы новый image / новые env подхватывались.
|
||||
# Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild
|
||||
|
|
@ -1295,13 +1229,14 @@ jobs:
|
|||
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
|
||||
# страница = новая строка allowlist'а.
|
||||
#
|
||||
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Опечатка в конфиге на
|
||||
# пересоздании уводит контейнер в crash-loop и роняет ВСЕ домены сразу, а
|
||||
# `caddy reload` её просто не принимает: job краснеет, домены продолжают
|
||||
# обслуживаться прежним конфигом. С #3443 ровно тот же порядок действует и на
|
||||
# полном деплое — оба пути зовут ops/caddy-apply.sh, который сперва проверяет
|
||||
# конфиг одноразовым контейнером и пересоздаёт Caddy, только если правка иначе
|
||||
# не доедет (пофайловый bind-маунт держит инод).
|
||||
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой
|
||||
# осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы
|
||||
# принять битый конфиг и оставил бы работать старый — на общем деплое это
|
||||
# скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ
|
||||
# применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой
|
||||
# код → job краснеет, а домены продолжают обслуживаться старым конфигом.
|
||||
# Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop
|
||||
# и роняет ВСЕ домены сразу.
|
||||
#
|
||||
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
|
||||
# вторая, уже против боевого файла после `git reset`.
|
||||
|
|
@ -1347,73 +1282,14 @@ jobs:
|
|||
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# #3448: ТОТ ЖЕ ЛОК, что берёт полный деплой (см. job `deploy` выше).
|
||||
# Эта джоба делает `git reset --hard` в /opt/gendesign, то есть правит
|
||||
# прод-дерево — ровно то, что полный деплой сериализует локом. Пока
|
||||
# быстрый путь был мёртв, столкнуться было нечему; теперь есть.
|
||||
exec 9>/var/lock/gendesign-docker-deploy.lock
|
||||
if flock -n 9; then
|
||||
echo "→ докер-лок свободен, взят сразу"
|
||||
else
|
||||
echo "→ докер-лок занят соседним деплоем, жду (до 900с)…"
|
||||
lock_wait_started=$(date +%s)
|
||||
if ! flock -w 900 9; then
|
||||
echo "ERROR: не дождался лока докер-деплоя за 900с."
|
||||
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
|
||||
exit 1
|
||||
fi
|
||||
echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания"
|
||||
fi
|
||||
cd /opt/gendesign
|
||||
git fetch origin main
|
||||
|
||||
# ── #3448: быстрый путь законен, только если прод отстаёт РОВНО на
|
||||
# конфиг прокси ────────────────────────────────────────────────────
|
||||
#
|
||||
# Джоба `changes` считает дифф between-push (before→HEAD) и не знает,
|
||||
# что доехало до прода. Пока caddy_only был мёртв, любой push шёл
|
||||
# полным деплоем и гард свежести :latest (#2950, job `deploy`)
|
||||
# прикрывал прод по умолчанию. Оживший быстрый путь этот гард
|
||||
# обходит: при caddy_only=true джоба `deploy` пропускается целиком.
|
||||
#
|
||||
# Сценарий отказа: push A правит бэкенд, билды ~6 мин, `deploy` в
|
||||
# очереди; через 2 мин push B правит только caddy/. Forgejo на
|
||||
# 10.0.3 отменяет ещё не стартовавший `deploy` предыдущего прогона
|
||||
# ДАЖЕ при cancel-in-progress: false (наблюдение 21.08.2026 10:35:13,
|
||||
# см. шапку scripts/check-latest-image-revision.sh). Дифф A..B — один
|
||||
# caddy-файл, быстрый путь включается, `compose pull` + `up -d` не
|
||||
# делает никто: прод крутит старый образ при зелёной голове main.
|
||||
#
|
||||
# Единственный источник правды о том, что реально на проде, — HEAD
|
||||
# прод-дерева (у Trade-In для этого заведён отдельный маркер
|
||||
# /opt/gendesign/.tradein-deployed-sha, см. deploy-tradein.yml:150;
|
||||
# у ПТИЦЫ маркера нет, но git reset ниже делает HEAD эквивалентом).
|
||||
# Проверка стоит ДО reset намеренно: при отказе прод-HEAD остаётся
|
||||
# честным для следующего прогона.
|
||||
#
|
||||
# ЧЕГО ЭТА ПРОВЕРКА НЕ ЛОВИТ: `deploy` прогона A, упавшую ПОСЛЕ
|
||||
# `git reset --hard` (например на миграции). Тогда прод-HEAD уже
|
||||
# равен A, а контейнеры старые, и caddy-only push пройдёт быстрым
|
||||
# путём. Это остаётся за настоящим маркером «что задеплоено».
|
||||
PROD_HEAD=$(git rev-parse HEAD)
|
||||
OUTSIDE=$(git -c core.quotePath=false diff --name-only "$PROD_HEAD" origin/main | grep -vE '^(Caddyfile$|caddy/)' || true)
|
||||
if [ -n "$OUTSIDE" ]; then
|
||||
echo "::error::прод отстаёт не только по конфигу прокси — быстрый путь запрещён:"
|
||||
printf '%s\n' "$OUTSIDE" | sed 's/^/ /'
|
||||
echo "Запусти полный деплой через workflow_dispatch."
|
||||
exit 1
|
||||
fi
|
||||
git reset --hard origin/main
|
||||
# #3443: тот же скрипт, что и в полном деплое. Голый `exec caddy
|
||||
# reload` здесь был ВЕРЕН только для каталогов (caddy/sites/**,
|
||||
# caddy/local/**). Caddyfile и четыре сниппета смонтированы
|
||||
# ПОФАЙЛОВО, а `git reset --hard` выше пишет новый инод — контейнер
|
||||
# остаётся на прежнем, и reload перечитывает СТАРЫЙ текст. Отказ
|
||||
# беззвучный: джоба зелёная, конфиг на диске новый, прокси работает
|
||||
# по старому. Скрипт сверяет, что именно видит контейнер, и
|
||||
# пересоздаёт его только в этом случае.
|
||||
sh ops/caddy-apply.sh
|
||||
echo "✓ быстрый путь завершён: без пересборки образов и без миграций"
|
||||
# Конфиг примонтирован read-only с хоста, пересборка не нужна —
|
||||
# контейнер читает тот же файл, что только что обновил git.
|
||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
|
||||
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
echo "✓ конфиг прокси перезагружен без пересборки и без миграций"
|
||||
|
||||
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
|
||||
#
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.observability.metrics import REPORTS_EXPORTED
|
||||
from app.schemas.parcel import (
|
||||
AnalysisRunDetail,
|
||||
AnalysisRunListResponse,
|
||||
|
|
@ -1613,11 +1612,6 @@ def export_parcel_forecast(
|
|||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
|
||||
|
||||
# #3471: считаем выгрузку здесь, а не в каждой format-ветке ниже — рано
|
||||
# (до самого рендера), зато один раз на весь запрос и без риска разъехаться
|
||||
# с новой веткой формата, если её когда-нибудь добавят.
|
||||
REPORTS_EXPORTED.labels(format=format).inc()
|
||||
|
||||
# tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
|
||||
if format == "tg":
|
||||
return Response(
|
||||
|
|
@ -4917,7 +4911,6 @@ async def get_parcel_best_layouts_pdf(
|
|||
today = _dt.date.today().strftime("%Y-%m-%d")
|
||||
cad_safe = cad_num.replace(":", "-")
|
||||
filename = f"tz-layout-{cad_safe}-{today}.pdf"
|
||||
REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
|
|
|
|||
|
|
@ -100,19 +100,6 @@ BUILD_INFO.labels(
|
|||
release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown",
|
||||
).set(1)
|
||||
|
||||
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
|
||||
#
|
||||
# `format` — фиксированный литерал из сигнатуры эндпоинта (Literal["md", "json",
|
||||
# "tg", "docx", "pptx", "pdf"] в `export_parcel_forecast` + одно статичное
|
||||
# значение "best_layouts_pdf" из ТЗ-на-проектирование), НЕ произвольная строка —
|
||||
# кардинальность ограничена набором форматов экспорта, а не количеством
|
||||
# участков/пользователей.
|
||||
REPORTS_EXPORTED = Counter(
|
||||
"sitefinder_reports_exported_total",
|
||||
"Экспортов отчётов по участку (§22-форсайт, ТЗ на проектирование), по формату",
|
||||
labelnames=("format",),
|
||||
)
|
||||
|
||||
|
||||
def route_label(scope: Scope) -> str:
|
||||
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
|
||||
|
|
|
|||
|
|
@ -17,22 +17,12 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
|
|||
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
|
||||
per-test ``patch`` — он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
|
||||
что их ожидаемые значения резолвера сохраняются.
|
||||
|
||||
Perf-fix (2026-09-12): в конце ``analyze_parcel`` — безусловный best-effort
|
||||
``forecast_site_finder_report.delay(...)`` (§22-форсайт enqueue, см. app/api/v1/parcels.py).
|
||||
В песочнице тестов Celery-брокер (Redis) недоступен → ``.delay()`` синхронно ждёт
|
||||
kombu-реконнект с растущим backoff (~69с) ДО того как try/except его проглотит —
|
||||
эта пауза оказалась внутри КАЖДОГО теста, который дергает ``POST /analyze`` и не
|
||||
мокал форсайт-таску. Авто-фикстура ниже глушит ``.delay`` в no-op-мок для ВСЕХ
|
||||
тестов каталога (как и с резолвером выше) — тесты самого enqueue
|
||||
(``test_parcels_forecast.py``, ``test_run_history_and_response_contract.py``)
|
||||
переопределяют тот же target своим per-test ``patch`` поверх авто-фикстуры.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -47,34 +37,3 @@ def _stub_zone_regulation_resolver() -> Iterator[None]:
|
|||
"""
|
||||
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_forecast_enqueue() -> Iterator[None]:
|
||||
"""No-op форсайт-enqueue по умолчанию (без реального Celery/Redis round-trip).
|
||||
|
||||
``.delay(...)`` в проде — fire-and-forget (best-effort, обёрнут в try/except в
|
||||
``analyze_parcel``), тестам сам форсайт не нужен, а живой брокер в CI/локальной
|
||||
песочнице недоступен и держит запрос ~69с на реконнект-backoff.
|
||||
"""
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_inline_fetch_wait(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Схлопнуть inline-ожидание NSPD-фетча (#93 graceful fallback) до миллисекунд.
|
||||
|
||||
В ``analyze_parcel`` ветка «участка нет в БД» ждёт появления геометрии циклом
|
||||
``sleep(_INLINE_FETCH_POLL_INTERVAL_S)`` до ``_INLINE_FETCH_WAIT_S`` (15с прод-
|
||||
значение). В тестах фетч замокан и геометрия не появится никогда → каждый такой
|
||||
тест честно спал 16с (``test_market_price_invalid_cad_returns_404``,
|
||||
``test_recent_permits_invalid_cad_no_regression``).
|
||||
|
||||
Оставляем цикл РАБОЧИМ (несколько итераций по 10мс), а не выключаем его нулём:
|
||||
тесты, проверяющие сам fast-path «строка появилась на N-м опросе», продолжают
|
||||
видеть опросы. Тесты с собственным ``patch`` того же имени (напр.
|
||||
``test_run_history_and_response_contract.py``) переопределяют это поверх.
|
||||
"""
|
||||
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0.05)
|
||||
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_POLL_INTERVAL_S", 0.01)
|
||||
|
|
|
|||
|
|
@ -1,463 +0,0 @@
|
|||
"""Полный деплой не пересоздаёт Caddy без нужды (#3443).
|
||||
|
||||
ЧТО СЛУЧИЛОСЬ. Каждый полный деплой ПТИЦЫ делал `up -d --force-recreate
|
||||
--no-deps caddy`, то есть сносил единственный процесс, слушающий 80/443.
|
||||
Замер 05.09 (#3274): 67 с `code=000` на ВСЕХ доменах хоста — gendsgn.ru,
|
||||
meraocenka.ru и зеркала. Не 502/503: принимающего процесса нет вовсе, поэтому
|
||||
заглушка окна деплоя бессильна по построению — её отдаёт тот же Caddy.
|
||||
|
||||
ЧТО УСТАНОВЛЕНО. Безусловный флаг появился 17.05 (11e78d73) ради нового
|
||||
bind-маунта `./preview`, который «не появлялся в running container». Довод
|
||||
неверен: `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер
|
||||
сам, как только меняется описание сервиса или образ (проверено на живом демоне
|
||||
docker 28.4). Единственное, чего compose не видит, — СОДЕРЖИМОЕ пофайлового
|
||||
bind-маунта: `git reset --hard` пишет новый инод, контейнер держит прежний, и
|
||||
`caddy reload` перечитывает старый текст. У Caddy так смонтированы Caddyfile и
|
||||
четыре сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают.
|
||||
|
||||
ЗАЧЕМ ЭТОТ ФАЙЛ. У правки нет отрицательного признака: вернуть `--force-recreate`
|
||||
«на всякий случай» — одна строка, все деплои останутся зелёными, а окно в минуту
|
||||
увидит только тот, кто в этот момент держал непрерывную пробу. Проверки ниже
|
||||
ИСПОЛНЯЮТ ops/caddy-apply.sh с подставным `docker` и смотрят на СОВЕРШЁННЫЕ
|
||||
действия (пересоздал / перезагрузил / не тронул), а не на текст скрипта.
|
||||
Отдельно проверяется проводка в deploy.yml — что оба пути деплоя зовут именно
|
||||
его.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
# backend/tests/ops/<этот файл> → корень репозитория
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = REPO_ROOT / "ops" / "caddy-apply.sh"
|
||||
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
|
||||
DEPLOY = WORKFLOWS / "deploy.yml"
|
||||
|
||||
CID = "caddy-cid-0001"
|
||||
|
||||
# Маунты Caddy ровно как на проде (`docker inspect gendesign-caddy-1`, 12.09):
|
||||
# пять ПОФАЙЛОВЫХ bind-маунтов и три каталога. Тома (caddy_data и соседи) в
|
||||
# сверку не входят — их фильтрует `{{if eq .Type "bind"}}`.
|
||||
FILE_MOUNTS = {
|
||||
"Caddyfile": "/etc/caddy/Caddyfile",
|
||||
"caddy/users.caddy.snippet": "/etc/caddy/caddy/users.caddy.snippet",
|
||||
"caddy/metrics-ui.caddy.snippet": "/etc/caddy/caddy/metrics-ui.caddy.snippet",
|
||||
"caddy/metrics-ingest.caddy.snippet": "/etc/caddy/caddy/metrics-ingest.caddy.snippet",
|
||||
"caddy/deploy-window.caddy.snippet": "/etc/caddy/caddy/deploy-window.caddy.snippet",
|
||||
}
|
||||
DIR_MOUNTS = {
|
||||
"caddy/sites": "/etc/caddy/caddy/sites",
|
||||
"caddy/local": "/etc/caddy/caddy/local",
|
||||
"preview": "/srv/preview",
|
||||
}
|
||||
|
||||
# Подставной `docker`. Пишет каждый вызов в $FAKE_LOG и отвечает по сценарию:
|
||||
# run — одноразовый `caddy validate`, код из $FAKE_VALIDATE_RC;
|
||||
# inspect — список маунтов из $FAKE_MOUNTS, код из $FAKE_INSPECT_RC;
|
||||
# exec — sha256sum ФАЙЛА, КОТОРЫЙ ВИДИТ КОНТЕЙНЕР ($FAKE_VIEW/<slug>);
|
||||
# compose … ps — текущий id контейнера из $FAKE_CID_FILE;
|
||||
# compose … up — при $FAKE_UP_RECREATES=1 подменяет id (compose пересоздал сам).
|
||||
FAKE_DOCKER = r"""#!/bin/bash
|
||||
printf '%s\n' "$*" >> "$FAKE_LOG"
|
||||
cmd="$1"; shift
|
||||
case "$cmd" in
|
||||
run) exit "${FAKE_VALIDATE_RC:-0}" ;;
|
||||
inspect)
|
||||
if [ "${FAKE_INSPECT_RC:-0}" != "0" ]; then
|
||||
echo "Error: No such object" >&2
|
||||
exit "$FAKE_INSPECT_RC"
|
||||
fi
|
||||
cat "$FAKE_MOUNTS"
|
||||
;;
|
||||
exec)
|
||||
dst="$3"
|
||||
view="$FAKE_VIEW/$(printf '%s' "$dst" | tr '/' '_')"
|
||||
[ -f "$view" ] || exit 1
|
||||
sha256sum "$view"
|
||||
;;
|
||||
compose)
|
||||
case " $* " in
|
||||
*" ps "*) cat "$FAKE_CID_FILE" ;;
|
||||
*--force-recreate*) echo "recreated caddy (forced)" ;;
|
||||
*" up "*)
|
||||
if [ "${FAKE_UP_RECREATES:-0}" = "1" ]; then
|
||||
printf 'caddy-cid-NEW\n' > "$FAKE_CID_FILE"
|
||||
echo "Container gendesign-caddy-1 Started"
|
||||
else
|
||||
echo "Container gendesign-caddy-1 Running"
|
||||
fi
|
||||
;;
|
||||
*) echo "(compose $*)" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
"""
|
||||
|
||||
# macOS несёт shasum вместо sha256sum; на раннере (ubuntu) и на проде утилита
|
||||
# настоящая. Шим ставится только при её отсутствии — иначе гейт не запускался бы
|
||||
# локально вовсе.
|
||||
SHA_SHIM = '#!/bin/sh\nexec shasum -a 256 "$@"\n'
|
||||
|
||||
|
||||
def _write_exec(path: Path, text: str) -> None:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prod_tree(tmp_path: Path) -> Path:
|
||||
"""Копия боевого дерева: скрипт + конфиги + «взгляд контейнера»."""
|
||||
tree = tmp_path / "opt" / "gendesign"
|
||||
(tree / "ops").mkdir(parents=True)
|
||||
shutil.copy(SCRIPT, tree / "ops" / SCRIPT.name)
|
||||
for rel in [*FILE_MOUNTS, "caddy/sites/apps.caddy", "caddy/local/.gitignore"]:
|
||||
path = tree / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(f"# {rel} версия НОВАЯ\n", encoding="utf-8")
|
||||
(tree / "preview").mkdir()
|
||||
|
||||
view = tmp_path / "view"
|
||||
view.mkdir()
|
||||
# По умолчанию контейнер видит ровно то же, что лежит на диске.
|
||||
for rel, dst in FILE_MOUNTS.items():
|
||||
(view / dst.replace("/", "_")).write_text(
|
||||
(tree / rel).read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
|
||||
mounts = tmp_path / "mounts"
|
||||
mounts.write_text(
|
||||
"".join(f"{tree / rel}|{dst}\n" for rel, dst in {**FILE_MOUNTS, **DIR_MOUNTS}.items()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
_write_exec(bin_dir / "docker", FAKE_DOCKER)
|
||||
if shutil.which("sha256sum") is None:
|
||||
_write_exec(bin_dir / "sha256sum", SHA_SHIM)
|
||||
(tmp_path / "cid").write_text(CID + "\n", encoding="utf-8")
|
||||
(tmp_path / "log").write_text("", encoding="utf-8")
|
||||
return tree
|
||||
|
||||
|
||||
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
|
||||
root = tree.parent.parent
|
||||
env = {
|
||||
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
"FAKE_LOG": str(root / "log"),
|
||||
"FAKE_CID_FILE": str(root / "cid"),
|
||||
"FAKE_MOUNTS": str(root / "mounts"),
|
||||
"FAKE_VIEW": str(root / "view"),
|
||||
**env_extra,
|
||||
}
|
||||
proc = subprocess.run(
|
||||
["sh", str(tree / "ops" / "caddy-apply.sh")],
|
||||
cwd=str(tree),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
|
||||
return proc.returncode, proc.stdout + proc.stderr, calls
|
||||
|
||||
|
||||
def _stale(tree: Path, dst: str) -> None:
|
||||
"""Контейнер остался на старом иноде этого маунта."""
|
||||
view = tree.parent.parent / "view" / dst.replace("/", "_")
|
||||
view.write_text("# версия СТАРАЯ\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _recreated(calls: list[str]) -> bool:
|
||||
return any("--force-recreate" in c for c in calls)
|
||||
|
||||
|
||||
def _reloaded(calls: list[str]) -> bool:
|
||||
return any("caddy reload" in c for c in calls)
|
||||
|
||||
|
||||
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_nothing_changed_reloads_without_recreate(prod_tree: Path) -> None:
|
||||
"""Обычный полный деплой (конфиг прокси не трогали): reload, без окна."""
|
||||
rc, out, calls = _run(prod_tree)
|
||||
assert rc == 0, out
|
||||
assert not _recreated(calls), (
|
||||
f"Caddy пересоздан, хотя ничего не изменилось: {calls}. "
|
||||
"Это и есть #3443: 67 с code=000 на всех доменах при каждом деплое."
|
||||
)
|
||||
assert _reloaded(calls), f"конфиг не применён вовсе: {calls}"
|
||||
|
||||
|
||||
def test_changed_file_mount_forces_recreate(prod_tree: Path) -> None:
|
||||
"""Caddyfile правлен: reload перечитал бы старый инод — нужен recreate."""
|
||||
_stale(prod_tree, "/etc/caddy/Caddyfile")
|
||||
rc, out, calls = _run(prod_tree)
|
||||
assert rc == 0, out
|
||||
assert _recreated(calls), (
|
||||
f"пересоздания нет: {calls}. Пофайловый bind-маунт держит инод — правка "
|
||||
"Caddyfile не доехала бы до контейнера, а деплой ушёл бы зелёным."
|
||||
)
|
||||
assert "/etc/caddy/Caddyfile" in out, f"решение не названо в логе:\n{out}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dst", sorted(set(FILE_MOUNTS.values()) - {"/etc/caddy/Caddyfile"}))
|
||||
def test_changed_snippet_forces_recreate(prod_tree: Path, dst: str) -> None:
|
||||
"""Каждый из четырёх сниппетов — тот же класс, не только Caddyfile."""
|
||||
_stale(prod_tree, dst)
|
||||
_, _out, calls = _run(prod_tree)
|
||||
assert _recreated(calls), f"{dst}: правка сниппета не доехала бы: {calls}"
|
||||
|
||||
|
||||
def test_directory_mount_change_does_not_recreate(prod_tree: Path) -> None:
|
||||
"""caddy/sites/apps.caddy — самый частый случай; каталог инод не держит.
|
||||
|
||||
Если сюда приползёт пересоздание «за компанию», окно недоступности вернётся
|
||||
ровно на тех правках, ради которых заведён быстрый путь #2916.
|
||||
"""
|
||||
(prod_tree / "caddy" / "sites" / "apps.caddy").write_text("# новый блок\n", encoding="utf-8")
|
||||
rc, out, calls = _run(prod_tree)
|
||||
assert rc == 0, out
|
||||
assert not _recreated(calls), f"правка в КАТАЛОГЕ вызвала пересоздание: {calls}"
|
||||
assert _reloaded(calls), f"правка в каталоге не применена: {calls}"
|
||||
|
||||
|
||||
def test_broken_config_touches_nothing(prod_tree: Path) -> None:
|
||||
"""Битый конфиг: ни up, ни reload, ни пересоздания — прокси не тронут.
|
||||
|
||||
Иначе опечатка в Caddyfile уводит контейнер в crash-loop и роняет все
|
||||
домены сразу (ровно то, чем опасен `--force-recreate` вслепую).
|
||||
"""
|
||||
rc, out, calls = _run(prod_tree, FAKE_VALIDATE_RC="1")
|
||||
assert rc != 0, f"скрипт не упал на битом конфиге:\n{out}"
|
||||
assert not _recreated(calls), f"битый конфиг поехал в пересоздание: {calls}"
|
||||
assert not _reloaded(calls), f"битый конфиг поехал в reload: {calls}"
|
||||
assert not any(" up " in f" {c} " for c in calls), f"был `up` при битом конфиге: {calls}"
|
||||
|
||||
|
||||
def test_compose_recreate_is_not_doubled(prod_tree: Path) -> None:
|
||||
"""compose пересоздал сам (сменилось описание сервиса/образ) — хватит.
|
||||
|
||||
Второй `--force-recreate` поверх — ещё одно окно недоступности на ровном
|
||||
месте, а новый контейнер и так читает свежие файлы.
|
||||
"""
|
||||
rc, out, calls = _run(prod_tree, FAKE_UP_RECREATES="1")
|
||||
assert rc == 0, out
|
||||
assert not _recreated(calls), f"пересоздание сделано дважды: {calls}"
|
||||
assert not _reloaded(calls), f"reload поверх свежего контейнера: {calls}"
|
||||
|
||||
|
||||
def test_unreadable_container_view_falls_back_to_recreate(prod_tree: Path) -> None:
|
||||
"""Сверка не отработала (контейнер не отвечает) → прежнее поведение.
|
||||
|
||||
Fail-safe направлен в сторону пересоздания: лучше окно в секунды, чем
|
||||
беззвучно не применённая правка конфига прокси.
|
||||
"""
|
||||
(prod_tree.parent.parent / "view" / "_etc_caddy_Caddyfile").unlink()
|
||||
_, out, calls = _run(prod_tree)
|
||||
assert _recreated(calls), f"непрочитанный маунт сочли доехавшим: {calls}\n{out}"
|
||||
|
||||
|
||||
def test_unreadable_mount_list_falls_back_to_recreate(prod_tree: Path) -> None:
|
||||
"""`docker inspect` не ответил → пересоздать, а не «расхождений нет».
|
||||
|
||||
Статус `$(docker inspect … | while …)` — это статус `while`, то есть всегда
|
||||
0, а `pipefail` в POSIX-sh не существует. Провал команды давал бы пустой
|
||||
список маунтов, ветку «всё доехало» и зелёную строку «окна недоступности
|
||||
нет» при прокси, работающем по СТАРОМУ конфигу — тот самый беззвучный отказ,
|
||||
ради которого написан скрипт.
|
||||
"""
|
||||
rc, out, calls = _run(prod_tree, FAKE_INSPECT_RC="1")
|
||||
assert rc == 0, out
|
||||
assert _recreated(calls), f"непрочитанный список маунтов сочли «всё доехало»: {calls}\n{out}"
|
||||
assert not _reloaded(calls), f"reload вместо пересоздания: {calls}"
|
||||
|
||||
|
||||
def test_no_file_mounts_is_not_silence(prod_tree: Path) -> None:
|
||||
"""Ноль пофайловых маунтов — не «сверка прошла», а «сверять было нечем».
|
||||
|
||||
Так выглядит, например, перевод Caddyfile на именованный том: фильтр
|
||||
`{{if eq .Type "bind"}}` перестаёт что-либо отбирать, и сверка становится
|
||||
тавтологически успешной.
|
||||
"""
|
||||
root = prod_tree.parent.parent
|
||||
(root / "mounts").write_text(
|
||||
"".join(f"{prod_tree / rel}|{dst}\n" for rel, dst in DIR_MOUNTS.items()), encoding="utf-8"
|
||||
)
|
||||
rc, out, calls = _run(prod_tree)
|
||||
assert rc == 0, out
|
||||
assert _recreated(calls), f"пустая сверка сочтена успешной: {calls}\n{out}"
|
||||
|
||||
|
||||
def test_missing_host_file_is_not_skipped_as_a_directory(prod_tree: Path) -> None:
|
||||
"""Файла на хосте нет — это расхождение, а не «нечего сверять».
|
||||
|
||||
Пропуск по `[ -f "$src" ] || continue` склеивает два разных случая: каталог
|
||||
(пропустить верно — инод он не держит) и исчезнувший/нечитаемый файл, для
|
||||
которого в контейнере как раз живёт старый инод со старым текстом. Файл
|
||||
удаляется после проверки конфига (в тесте она подставная) — проверяется
|
||||
именно ветка сверки.
|
||||
"""
|
||||
(prod_tree / "Caddyfile").unlink()
|
||||
_, out, calls = _run(prod_tree)
|
||||
assert _recreated(calls), f"исчезнувший файл сочли доехавшим: {calls}\n{out}"
|
||||
|
||||
|
||||
def test_log_says_how_many_mounts_were_compared(prod_tree: Path) -> None:
|
||||
"""В логе должно быть ЧИСЛО сверенных файлов, а не только вердикт.
|
||||
|
||||
«Сверили пять» и «сверили ноль» обязаны различаться: иначе строка
|
||||
«перезагружен без пересоздания» одинаково означает и проверку, и её
|
||||
отсутствие.
|
||||
"""
|
||||
_, out, _ = _run(prod_tree)
|
||||
assert re.search(r"сверено пофайловых маунтов[^\n]*: 5", out), (
|
||||
f"скрипт не печатает число сверенных маунтов (их пять):\n{out}"
|
||||
)
|
||||
|
||||
|
||||
def test_validation_precedes_any_action(prod_tree: Path) -> None:
|
||||
"""Проверка конфига идёт ПЕРВЫМ вызовом, до любого изменения состояния."""
|
||||
_, out, calls = _run(prod_tree)
|
||||
assert calls, f"скрипт не сделал ни одного вызова docker:\n{out}"
|
||||
assert calls[0].startswith("run "), f"первым идёт не проверка конфига: {calls}"
|
||||
assert "caddy validate" in calls[0], f"первый вызов — не validate: {calls[0]}"
|
||||
|
||||
|
||||
# ── Проводка: оба пути деплоя зовут именно этот скрипт ───────────────────────
|
||||
|
||||
|
||||
def _ssh_script(job: str) -> str:
|
||||
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
|
||||
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
|
||||
assert len(steps) == 1, f"в job `{job}` нет ровно одного ssh-шага — гейт #3443 ослеп"
|
||||
script = steps[0]["with"]["script"]
|
||||
assert script.strip(), f"ssh-скрипт job `{job}` пуст"
|
||||
return script
|
||||
|
||||
|
||||
def _commands(script: str) -> str:
|
||||
"""Только команды: комментарии выкинуты, продолжения строк склеены.
|
||||
|
||||
Комментарии — потому что разбор дефекта живёт в тех же файлах и содержит
|
||||
его формулировку дословно: гейт по голому тексту краснел бы от объяснения,
|
||||
а не от кода. Склейка `\\` — потому что `--force-recreate` и имя сервиса
|
||||
легко оказываются на РАЗНЫХ физических строках, и построчный поиск такую
|
||||
запись не увидел бы (зелено по построению).
|
||||
"""
|
||||
kept = [ln for ln in script.splitlines() if not ln.lstrip().startswith("#")]
|
||||
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
|
||||
|
||||
|
||||
def _forced_caddy_recreates(commands: str) -> list[str]:
|
||||
"""Строки, которые пересоздают именно сервис caddy."""
|
||||
return [
|
||||
ln
|
||||
for ln in commands.splitlines()
|
||||
if "--force-recreate" in ln and re.search(r"\bcaddy\b", ln)
|
||||
]
|
||||
|
||||
|
||||
def test_script_exists_and_is_the_one_under_test() -> None:
|
||||
"""Признак непустоты: без скрипта проверки выше проходили бы вхолостую."""
|
||||
assert SCRIPT.is_file(), f"нет {SCRIPT} — проводка ниже проверяла бы пустоту"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", ["deploy", "deploy-caddy"])
|
||||
def test_deploy_applies_caddy_config_through_the_script(job: str) -> None:
|
||||
assert "ops/caddy-apply.sh" in _commands(_ssh_script(job)), (
|
||||
f"job `{job}` не зовёт ops/caddy-apply.sh — конфиг прокси применяется "
|
||||
"мимо разбора #3443 (или безусловным пересозданием, или reload'ом, "
|
||||
"который на пофайловом маунте читает старый инод)"
|
||||
)
|
||||
|
||||
|
||||
def test_full_deploy_has_no_unconditional_caddy_recreate() -> None:
|
||||
"""Главный инвариант: в полном деплое нет безусловного пересоздания Caddy.
|
||||
|
||||
Возврат одной строки `up -d --force-recreate --no-deps caddy` в job `deploy`
|
||||
возвращает 67-секундное окно `code=000` на всех доменах — и не краснит
|
||||
ничего: деплой остаётся зелёным, а увидеть отказ может только непрерывная
|
||||
проба, запущенная ровно в эту минуту.
|
||||
"""
|
||||
bad = _forced_caddy_recreates(_commands(_ssh_script("deploy")))
|
||||
assert not bad, (
|
||||
"в полный деплой вернулось безусловное пересоздание Caddy:\n "
|
||||
+ "\n ".join(bad)
|
||||
+ "\nПересоздание обязано быть УСЛОВНЫМ — см. ops/caddy-apply.sh: compose "
|
||||
"сам пересоздаёт контейнер при смене описания сервиса или образа, а "
|
||||
"вручную это нужно только когда до контейнера не доехал пофайловый "
|
||||
"bind-маунт (#3443)."
|
||||
)
|
||||
|
||||
|
||||
def test_failures_are_not_swallowed() -> None:
|
||||
"""Ни применение конфига, ни сам reload не гасятся `|| true`.
|
||||
|
||||
Строка `caddy reload … || true` в репозитории уже живёт
|
||||
(deploy-tradein.yml), то есть это не гипотеза: с ней отказ применения
|
||||
перестаёт краснеть, и «конфиг доехал» становится неотличимо от «команда
|
||||
упала, а мы продолжили». Проверяется и вызов скрипта из обеих джоб, и
|
||||
строка reload внутри самого скрипта.
|
||||
"""
|
||||
swallow = re.compile(r"\|\|\s*(true|:)\s*$")
|
||||
offenders = []
|
||||
for where, text in [
|
||||
("deploy", _commands(_ssh_script("deploy"))),
|
||||
("deploy-caddy", _commands(_ssh_script("deploy-caddy"))),
|
||||
(SCRIPT.name, SCRIPT.read_text(encoding="utf-8")),
|
||||
]:
|
||||
for line in text.splitlines():
|
||||
code = line.split("#", 1)[0] if not line.lstrip().startswith("#") else ""
|
||||
if ("caddy-apply.sh" in code or "caddy reload" in code) and swallow.search(code):
|
||||
offenders.append(f"{where}: {line.strip()}")
|
||||
assert not offenders, "отказ применения конфига проглочен:\n " + "\n ".join(offenders)
|
||||
|
||||
|
||||
def test_gate_runs_on_changes_to_the_script_itself() -> None:
|
||||
"""CI-фильтр обязан пускать backend-тесты на правку ops/**.
|
||||
|
||||
Все содержательные регрессии живут в ops/caddy-apply.sh: проверки выше его
|
||||
ИСПОЛНЯЮТ. Без `ops/**` в фильтре PR, правящий только скрипт, даёт
|
||||
backend=false — джоба пропускается, гейт не исполняется, и «пересоздавать
|
||||
всегда» уезжает в main зелёным. Тот же класс, что #2950/#3448/#3467.
|
||||
"""
|
||||
spec = yaml.safe_load((WORKFLOWS / "ci.yml").read_text(encoding="utf-8"))
|
||||
steps = [
|
||||
s
|
||||
for job in spec["jobs"].values()
|
||||
for s in job.get("steps") or []
|
||||
if str(s.get("uses", "")).startswith("dorny/paths-filter")
|
||||
]
|
||||
assert steps, "в ci.yml не найден paths-filter — проверка прошла бы вхолостую"
|
||||
patterns = [p for s in steps for p in yaml.safe_load(s["with"]["filters"]).get("backend") or []]
|
||||
assert "ops/**" in patterns, (
|
||||
f"фильтр backend не покрывает ops/** (сейчас: {patterns}) — гейт #3443 не "
|
||||
"побежит на правке ops/caddy-apply.sh, то есть ровно на той правке, от "
|
||||
"которой стережёт"
|
||||
)
|
||||
|
||||
|
||||
def test_gate_would_notice_the_regression() -> None:
|
||||
"""Сам гейт обязан краснеть на возвращённом дефекте — проверка на себя.
|
||||
|
||||
Без этого «не нашли force-recreate» неотличимо от «искали не там»: маска
|
||||
поиска, промахнувшаяся мимо строки, выглядит зелёной ровно так же.
|
||||
"""
|
||||
regressed = _commands(
|
||||
" # безусловное пересоздание caddy вернулось сюда\n"
|
||||
" docker compose -p gendesign -f docker-compose.prod.yml up -d \\\n"
|
||||
" --force-recreate --no-deps caddy\n"
|
||||
)
|
||||
assert _forced_caddy_recreates(regressed), (
|
||||
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
|
||||
)
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
"""Гейт: быстрый путь «правка только прокси» действительно включается (#3448).
|
||||
|
||||
ЧТО СЛУЧИЛОСЬ. Быстрый путь #2916 (`caddy_only` → джоба `deploy-caddy` с
|
||||
`caddy reload` вместо пересоздания контейнеров) не отработал ни разу за всё
|
||||
время жизни. Проверено на мерже 84920e6c, где в диффе ровно один файл
|
||||
`caddy/sites/apps.caddy`: контейнеры пересозданы, в логе Caddy
|
||||
`serving initial configuration` — холодный старт, а не reload.
|
||||
|
||||
ПРИЧИНА — НЕ пустой `github.event.before` (рабочая гипотеза #3448 опровергнута
|
||||
логом задачи 29244: `before` = 204e2e09…, `git diff` вернул ровно один файл).
|
||||
Причина в том, что dorny/paths-filter склеивает шаблоны одного фильтра через
|
||||
`some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`; параметр
|
||||
`predicate-quantifier` по умолчанию `some`). Список
|
||||
|
||||
non_caddy: ['**', '!Caddyfile', '!caddy/**']
|
||||
|
||||
значит «подходит под `**` ИЛИ не Caddyfile ИЛИ не caddy/**», а `**` матчит всё —
|
||||
non_caddy был true ВСЕГДА. В логе это видно дословно:
|
||||
|
||||
##[group]Filter non_caddy = true
|
||||
Matching files:
|
||||
caddy/sites/apps.caddy [modified]
|
||||
|
||||
исключённый файл сам себя и «исключил».
|
||||
|
||||
ЗАЧЕМ ЭТОТ ФАЙЛ. У самой правки нет отрицательного признака: пропущенную джобу
|
||||
Forgejo рисует зелёной, поэтому «зелёный deploy-caddy» одинаково выглядит и
|
||||
когда быстрый путь сработал, и когда его вообще не было. Проверки ниже
|
||||
ИСПОЛНЯЮТ шаг определения файлов из deploy.yml на настоящем временном
|
||||
репозитории (включая мерж-коммит — ровно случай #3448) и смотрят на значения
|
||||
флагов, а не на текст воркфлоу. Так же исполняется и прод-сторож из джобы
|
||||
`deploy-caddy`: быстрый путь пропускает джобу `deploy` целиком, а вместе с ней
|
||||
и гард свежести :latest (#2950), поэтому перезагружать прокси можно, только
|
||||
если прод отстаёт РОВНО на конфиг прокси. Регресс к исключающим шаблонам
|
||||
paths-filter ловит отдельная проверка в конце.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
# backend/tests/ops/<этот файл> → корень репозитория
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
|
||||
DEPLOY = WORKFLOWS / "deploy.yml"
|
||||
|
||||
NULL_SHA = "0" * 40
|
||||
|
||||
# Файлы, которые лежат в тестовом репозитории до правки. Набор подобран так,
|
||||
# чтобы фолбэк «база не определена» мог отличить полный деплой от пустого.
|
||||
BASE_FILES = (
|
||||
"caddy/sites/apps.caddy",
|
||||
"Caddyfile",
|
||||
"backend/app/main.py",
|
||||
"frontend/src/page.tsx",
|
||||
"data/sql/001.sql",
|
||||
"docker-compose.prod.yml",
|
||||
"README.md",
|
||||
)
|
||||
|
||||
GIT_ENV = {
|
||||
"GIT_AUTHOR_NAME": "t",
|
||||
"GIT_AUTHOR_EMAIL": "t@example.com",
|
||||
"GIT_COMMITTER_NAME": "t",
|
||||
"GIT_COMMITTER_EMAIL": "t@example.com",
|
||||
"GIT_CONFIG_GLOBAL": "/dev/null",
|
||||
"GIT_CONFIG_SYSTEM": "/dev/null",
|
||||
}
|
||||
|
||||
|
||||
def _detect_script() -> str:
|
||||
"""Тело шага, который считает изменённые файлы в job `changes`."""
|
||||
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
|
||||
job = spec["jobs"]["changes"]
|
||||
steps = [s for s in job["steps"] if s.get("id") == "filter"]
|
||||
assert len(steps) == 1, (
|
||||
"в job `changes` нет ровно одного шага с `id: filter` — определение "
|
||||
"изменённых файлов переехало, гейт #3448 ослеп"
|
||||
)
|
||||
step = steps[0]
|
||||
assert "run" in step, (
|
||||
f"шаг `filter` не считает файлы сам, а делегирует их {step.get('uses')!r}. "
|
||||
"Именно так и возник #3448: у dorny/paths-filter шаблоны одного фильтра "
|
||||
"склеиваются через ИЛИ, поэтому `non_caddy: ['**', '!caddy/**']` был true "
|
||||
"ВСЕГДА и быстрый путь не включался ни разу."
|
||||
)
|
||||
assert step["run"].strip(), "шаг `filter` пуст"
|
||||
return step["run"]
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> None:
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), *args], check=True, env=dict(GIT_ENV), capture_output=True
|
||||
)
|
||||
|
||||
|
||||
def _sha(repo: Path) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=dict(GIT_ENV),
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _commit(repo: Path, files: tuple[str, ...], msg: str = "c") -> None:
|
||||
for name in files:
|
||||
path = repo / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("changed\n", encoding="utf-8")
|
||||
_git(repo, "add", "-A")
|
||||
_git(repo, "commit", "-qm", msg, *([] if files else ["--allow-empty"]))
|
||||
|
||||
|
||||
def _base_repo(tmp_path: Path) -> tuple[Path, str]:
|
||||
"""Репозиторий с одним базовым коммитом; возвращает его sha — это `before`."""
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir(parents=True)
|
||||
_git(repo, "init", "-q", "-b", "main")
|
||||
for name in BASE_FILES:
|
||||
path = repo / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("base\n", encoding="utf-8")
|
||||
_git(repo, "add", "-A")
|
||||
_git(repo, "commit", "-qm", "base")
|
||||
return repo, _sha(repo)
|
||||
|
||||
|
||||
def _merge_commit(repo: Path, changed: tuple[str, ...]) -> None:
|
||||
"""Ветка с правкой и мерж `--no-ff` обратно в main.
|
||||
|
||||
Мерж, а не обычный коммит, — потому что #3448 наблюдался именно на мерже
|
||||
PR'а: у мерж-коммита две родительские линии, и любой разбор диффа обязан
|
||||
работать на этой форме. Что `before` нельзя заменить на `HEAD^`, стережёт
|
||||
отдельная проверка — test_multi_commit_push_is_not_truncated: на ОДНОМ
|
||||
мерж-коммите `HEAD^..HEAD` даёт верный ответ и такую подмену не ловит.
|
||||
"""
|
||||
_git(repo, "checkout", "-q", "-b", "feature")
|
||||
_commit(repo, changed, "feature")
|
||||
_git(repo, "checkout", "-q", "main")
|
||||
_git(repo, "merge", "-q", "--no-ff", "-m", "merge feature", "feature")
|
||||
|
||||
|
||||
def _exec(repo: Path, before: str, event: str = "push") -> tuple[dict[str, str], str]:
|
||||
out_file = repo.parent / "outputs"
|
||||
out_file.touch()
|
||||
env = {
|
||||
"PATH": "/usr/bin:/bin:/usr/local/bin",
|
||||
"BEFORE": before,
|
||||
"EVENT": event,
|
||||
"GITHUB_OUTPUT": str(out_file),
|
||||
**GIT_ENV,
|
||||
}
|
||||
proc = subprocess.run(
|
||||
["bash", "-c", _detect_script()],
|
||||
cwd=repo,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode == 0, f"шаг упал:\n{proc.stdout}\n{proc.stderr}"
|
||||
outputs = dict(
|
||||
line.split("=", 1)
|
||||
for line in out_file.read_text(encoding="utf-8").splitlines()
|
||||
if "=" in line
|
||||
)
|
||||
return outputs, proc.stdout
|
||||
|
||||
|
||||
def _run(
|
||||
tmp_path: Path, changed: tuple[str, ...], *, before: str | None = None, event: str = "push"
|
||||
) -> tuple[dict[str, str], str]:
|
||||
repo, base_sha = _base_repo(tmp_path)
|
||||
_merge_commit(repo, changed)
|
||||
return _exec(repo, base_sha if before is None else before, event)
|
||||
|
||||
|
||||
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
|
||||
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
|
||||
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
|
||||
assert outputs["caddy_only"] == "true", (
|
||||
f"быстрый путь не включился на правке ТОЛЬКО прокси: {outputs}. "
|
||||
"Ровно это и есть #3448: deploy-caddy пропускается, идёт полный деплой "
|
||||
"с пересозданием контейнеров, а Forgejo рисует пропуск зелёным."
|
||||
)
|
||||
assert outputs["backend"] == "false"
|
||||
assert outputs["frontend"] == "false"
|
||||
|
||||
|
||||
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
|
||||
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
|
||||
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
|
||||
assert outputs["caddy_only"] == "false", (
|
||||
f"быстрый путь включился, хотя вместе с конфигом приехал бэкенд: {outputs}. "
|
||||
"Так прод остался бы на старом образе при зелёном деплое."
|
||||
)
|
||||
assert outputs["backend"] == "true"
|
||||
|
||||
|
||||
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
|
||||
"""База не разрешилась → полный деплой, а не пустой список.
|
||||
|
||||
Пустой список изменений — это `caddy_only` без единого caddy-файла и
|
||||
отключённая сборка: отказ, который выглядит как успешный быстрый путь.
|
||||
"""
|
||||
for before, event in ((NULL_SHA, "push"), ("", "push"), (None, "workflow_dispatch")):
|
||||
outputs, log = _run(
|
||||
tmp_path / f"case-{event}-{before!r}",
|
||||
("caddy/sites/apps.caddy",),
|
||||
before=before,
|
||||
event=event,
|
||||
)
|
||||
assert outputs["caddy_only"] == "false", f"before={before!r} event={event}: {outputs}"
|
||||
assert outputs["backend"] == "true", f"before={before!r} event={event}: {outputs}"
|
||||
assert outputs["frontend"] == "true", f"before={before!r} event={event}: {outputs}"
|
||||
assert outputs["infra"] == "true", f"before={before!r} event={event}: {outputs}"
|
||||
assert "деплой полный" in log
|
||||
|
||||
|
||||
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
|
||||
"""Решение печатается: и список файлов, и итоговые флаги.
|
||||
|
||||
Без этого «сработало» и «просто не совпало» неотличимы — единственным
|
||||
свидетелем остаётся метка Created у контейнера на проде.
|
||||
"""
|
||||
_, log = _run(tmp_path, ("caddy/sites/apps.caddy",))
|
||||
assert "caddy/sites/apps.caddy" in log, f"шаг не печатает список файлов:\n{log}"
|
||||
assert "caddy_only=true" in log and "backend=false" in log, (
|
||||
f"шаг не печатает итоговые флаги:\n{log}"
|
||||
)
|
||||
|
||||
|
||||
# ── Быстрый путь на самом проде: джоба deploy-caddy ──────────────────────────
|
||||
#
|
||||
# Пока caddy_only был мёртв, каждый push шёл полным деплоем, и гард свежести
|
||||
# :latest (#2950, job `deploy`) прикрывал прод по умолчанию. Оживший быстрый
|
||||
# путь его обходит: при caddy_only=true джоба `deploy` пропускается целиком.
|
||||
# Дифф between-push (before→HEAD) не знает, что реально доехало до прода:
|
||||
# отменённая очередью `deploy` предыдущего прогона оставляет прод на старом
|
||||
# образе, а следующий caddy-only push честно видит «изменился один caddy-файл».
|
||||
|
||||
|
||||
def _caddy_deploy_script() -> str:
|
||||
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
|
||||
steps = [s for s in spec["jobs"]["deploy-caddy"]["steps"] if "ssh-action" in str(s.get("uses"))]
|
||||
assert len(steps) == 1, "в deploy-caddy нет ровно одного ssh-шага — гейт #3448 ослеп"
|
||||
return steps[0]["with"]["script"]
|
||||
|
||||
|
||||
def _prod_lag_guard() -> str:
|
||||
"""Кусок ssh-скрипта от вычисления PROD_HEAD до `git reset --hard`."""
|
||||
script = _caddy_deploy_script()
|
||||
assert "PROD_HEAD=" in script, (
|
||||
"джоба deploy-caddy не сверяет отставание прода: быстрый путь перезагрузит "
|
||||
"прокси и уйдёт зелёным, оставив прод на старом образе (#3448)"
|
||||
)
|
||||
# Ищем КОМАНДУ, а не подстроку: `git reset --hard` упоминается выше в
|
||||
# комментариях, и поиск по тексту нашёл бы объяснение вместо кода.
|
||||
reset_cmd = re.search(r"(?m)^\s*git reset --hard", script)
|
||||
assert reset_cmd, "в deploy-caddy пропал `git reset --hard` — гейт опирается на него"
|
||||
start, reset = script.index("PROD_HEAD="), reset_cmd.start()
|
||||
assert start < reset, (
|
||||
"проверка отставания прода стоит ПОСЛЕ `git reset --hard` — при отказе "
|
||||
"прод-HEAD уже переписан, и следующий прогон снова уйдёт быстрым путём"
|
||||
)
|
||||
return "set -euo pipefail\n" + script[start:reset]
|
||||
|
||||
|
||||
def _prod_repo(tmp_path: Path, ahead: tuple[str, ...]) -> Path:
|
||||
"""Прод-дерево на базовом коммите, origin/main — на `ahead` впереди."""
|
||||
repo, base_sha = _base_repo(tmp_path)
|
||||
_commit(repo, ahead, "ahead")
|
||||
_git(repo, "update-ref", "refs/remotes/origin/main", "HEAD")
|
||||
_git(repo, "reset", "--hard", "-q", base_sha)
|
||||
return repo
|
||||
|
||||
|
||||
def _run_guard(repo: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["bash", "-c", _prod_lag_guard()],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={"PATH": "/usr/bin:/bin:/usr/local/bin", **GIT_ENV},
|
||||
)
|
||||
|
||||
|
||||
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
|
||||
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
|
||||
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
|
||||
|
||||
|
||||
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
|
||||
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
|
||||
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
|
||||
assert proc.returncode != 0, (
|
||||
"быстрый путь разрешён, хотя прод отстаёт по коду бэкенда: перезагрузка "
|
||||
f"прокси подменила бы выкатку, деплой ушёл бы зелёным.\n{proc.stdout}"
|
||||
)
|
||||
assert "backend/app/main.py" in proc.stdout, (
|
||||
f"отказ не называет файлы, из-за которых он произошёл:\n{proc.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_fast_path_takes_the_same_host_lock() -> None:
|
||||
"""deploy-caddy правит прод-дерево — значит берёт тот же лок, что `deploy`.
|
||||
|
||||
Проверка текстовая, как в test_2950: исполнить flock-секцию в тесте нельзя,
|
||||
а её пропажа не даёт ни одного сигнала до совпадения окон двух деплоев.
|
||||
"""
|
||||
script = _caddy_deploy_script()
|
||||
assert "exec 9>/var/lock/gendesign-docker-deploy.lock" in script, (
|
||||
"deploy-caddy делает `git reset --hard` в /opt/gendesign в обход лока, "
|
||||
"которым полный деплой сериализует работу с прод-деревом (#2950)"
|
||||
)
|
||||
assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
|
||||
)
|
||||
def test_paths_that_merely_start_with_caddy_are_not_the_fast_path(
|
||||
tmp_path: Path, changed: tuple[str, ...]
|
||||
) -> None:
|
||||
"""`caddy-extra/…` и `Caddyfile.bak` — НЕ конфиг прокси.
|
||||
|
||||
Граница шаблона — единственное, что отделяет быстрый путь от тихого
|
||||
пропуска полного деплоя: `^(Caddyfile|caddy)` вместо `^(Caddyfile$|caddy/)`
|
||||
отправил бы эти правки перезагружать прокси вместо выкатки.
|
||||
"""
|
||||
outputs, _ = _run(tmp_path, changed)
|
||||
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
|
||||
|
||||
|
||||
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
|
||||
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
|
||||
|
||||
Без проверки «файлов больше нуля» пустой список формально удовлетворяет
|
||||
«ни один файл не лежит вне caddy»: сборка отключается, деплой подменяется
|
||||
перезагрузкой прокси — отказ, выглядящий как успешный быстрый путь.
|
||||
"""
|
||||
outputs, log = _run(tmp_path, ())
|
||||
assert outputs["caddy_only"] == "false", f"пустой дифф ушёл в быстрый путь: {outputs}"
|
||||
assert "изменённых файлов: 0" in log
|
||||
|
||||
|
||||
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
|
||||
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
|
||||
outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
|
||||
assert outputs["backend"] == "true", outputs
|
||||
assert outputs["caddy_only"] == "false", outputs
|
||||
|
||||
|
||||
def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
|
||||
"""Кириллица в пути не должна прятать файл от классификации.
|
||||
|
||||
`git diff --name-only` при `core.quotePath=true` (умолчание) отдаёт
|
||||
не-ASCII пути закавыченными и с \\NNN-экранированием — `^backend/`
|
||||
такую строку не матчит. Старый paths-filter брал `--name-status -z`, где
|
||||
квотирования нет; при переходе на свой diff это единственное место, где
|
||||
поведение могло разойтись. В дереве такие пути уже живут (docs/).
|
||||
"""
|
||||
outputs, log = _run(tmp_path, ("backend/модуль.py",))
|
||||
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
|
||||
|
||||
|
||||
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
|
||||
"""Push из нескольких коммитов разбирается целиком, а не по последнему.
|
||||
|
||||
Ровно та подмена, которую соблазнительно сделать «чтобы не зависеть от
|
||||
before»: `HEAD^..HEAD`. На одном мерж-коммите она даёт верный ответ и
|
||||
выглядит рабочей, а здесь — молча теряет бэкенд из первого коммита и
|
||||
включает быстрый путь, то есть пропускает выкатку кода.
|
||||
"""
|
||||
repo, base_sha = _base_repo(tmp_path)
|
||||
_commit(repo, ("backend/app/main.py",), "backend")
|
||||
_commit(repo, ("caddy/sites/apps.caddy",), "caddy")
|
||||
outputs, log = _exec(repo, base_sha)
|
||||
assert outputs["backend"] == "true", f"первый коммит push'а потерян: {outputs}\n{log}"
|
||||
assert outputs["caddy_only"] == "false", outputs
|
||||
|
||||
|
||||
def _paths_filter_steps() -> list[tuple[Path, str, dict]]:
|
||||
"""Все шаги dorny/paths-filter во всех воркфлоу (включая .yaml)."""
|
||||
found = []
|
||||
for path in sorted(WORKFLOWS.glob("*.y*ml")):
|
||||
spec = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
for job_name, job in (spec.get("jobs") or {}).items():
|
||||
for step in job.get("steps") or []:
|
||||
if str(step.get("uses", "")).startswith("dorny/paths-filter"):
|
||||
found.append((path, job_name, step))
|
||||
return found
|
||||
|
||||
|
||||
def test_exclusion_gate_has_something_to_check() -> None:
|
||||
"""Признак непустоты: проверка ниже обязана что-то находить.
|
||||
|
||||
Переименуют действие, разнесут воркфлоу по .yaml, уедут шаги — и гейт
|
||||
пройдёт при нулевом охвате, молча (ровно то, от чего страхуется ci.yml:190).
|
||||
"""
|
||||
steps = _paths_filter_steps()
|
||||
assert steps, (
|
||||
"не найдено ни одного шага dorny/paths-filter — проверка исключающих "
|
||||
"шаблонов прошла бы впустую, перепроверь маску поиска"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,job_name,step",
|
||||
_paths_filter_steps(),
|
||||
ids=[f"{p.name}:{j}" for p, j, _ in _paths_filter_steps()],
|
||||
)
|
||||
def test_no_paths_filter_relies_on_exclusion_patterns(
|
||||
path: Path, job_name: str, step: dict
|
||||
) -> None:
|
||||
"""Ни один paths-filter в репозитории не вычитает пути через `!`.
|
||||
|
||||
Класс бага, а не единственный его случай: при `predicate-quantifier: some`
|
||||
(умолчание) шаблоны фильтра склеиваются через ИЛИ, и `!` ничего не вычитает.
|
||||
"""
|
||||
with_ = step.get("with") or {}
|
||||
if with_.get("predicate-quantifier") == "every":
|
||||
pytest.skip("predicate-quantifier: every — шаблоны склеиваются через И")
|
||||
filters = yaml.safe_load(with_.get("filters") or "") or {}
|
||||
assert filters, f"{path.name}: job {job_name} — у paths-filter пустой блок filters"
|
||||
for filter_name, patterns in filters.items():
|
||||
bad = [p for p in (patterns or []) if isinstance(p, str) and p.startswith("!")]
|
||||
assert not bad, (
|
||||
f"{path.name}: job {job_name}, фильтр {filter_name!r} вычитает пути "
|
||||
f"шаблонами {bad} — при `some` (умолчание) они склеиваются через ИЛИ "
|
||||
"и фильтр становится true ВСЕГДА. Так #2916 не сработал ни разу (#3448)."
|
||||
)
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
"""Правки Prometheus-конфига/правил обязаны доезжать до работающего процесса.
|
||||
|
||||
ЧТО СЛУЧИЛОСЬ НА ПРОДЕ. `GET /api/v1/status/runtimeinfo` внутри
|
||||
`gendesign-prometheus` 12.09 отдавал `lastConfigTime`, совпадающий со
|
||||
`startTime` контейнера, — конфиг и правила не перечитывались 16 суток.
|
||||
Деплой при этом был зелёный: `docker compose up -d` не пересоздаёт
|
||||
контейнер из-за изменения содержимого бинд-маунта (он сравнивает только
|
||||
описание сервиса), а `--web.enable-lifecycle` был включён в
|
||||
docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не вызывал.
|
||||
|
||||
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
||||
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
||||
в этом же workflow — только для Prometheus починка не пересоздание
|
||||
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
|
||||
пути заново, так что новый инод после `git reset --hard` подхватывается
|
||||
без даунтайма.
|
||||
|
||||
Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается
|
||||
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
|
||||
подтверждён сменой lastConfigTime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||
|
||||
|
||||
def _text() -> str:
|
||||
return WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_promtool_checks_config_and_rules() -> None:
|
||||
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
||||
text = _text()
|
||||
assert "promtool check config /etc/prometheus/prometheus.yml" in text, (
|
||||
"нет проверки конфига promtool'ом — битый prometheus.yml долетит до reload"
|
||||
)
|
||||
assert "promtool check rules" in text, (
|
||||
"нет проверки правил promtool'ом — битое правило долетит до reload"
|
||||
)
|
||||
|
||||
|
||||
def test_reload_endpoint_is_called() -> None:
|
||||
"""Сам reload обязан вызываться — иначе валидация ничего не решает."""
|
||||
text = _text()
|
||||
assert "localhost:9090/-/reload" in text, (
|
||||
"нет вызова POST /-/reload — конфиг/правила проверяются, но в силу не вступают "
|
||||
"(#3467: lastConfigTime не менялся 16 суток при зелёном деплое)"
|
||||
)
|
||||
|
||||
|
||||
def test_reload_happens_after_validation_not_before() -> None:
|
||||
"""Reload обязан идти ПОСЛЕ promtool, а не до/вместо него."""
|
||||
text = _text()
|
||||
check_pos = text.index("promtool check config /etc/prometheus/prometheus.yml")
|
||||
reload_pos = text.index("localhost:9090/-/reload")
|
||||
assert check_pos < reload_pos, (
|
||||
"reload стоит раньше проверки конфига — битый конфиг мог бы применяться вслепую"
|
||||
)
|
||||
|
||||
|
||||
def test_reload_is_guarded_by_the_promtool_check() -> None:
|
||||
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
||||
text = _text()
|
||||
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
|
||||
else_pos = text.index("else", guard_start)
|
||||
reload_pos = text.index("localhost:9090/-/reload")
|
||||
assert guard_start < reload_pos < else_pos, (
|
||||
"вызов reload лежит вне ветки успешной проверки promtool — "
|
||||
"битый конфиг всё равно приведёт к reload, либо reload вообще не защищён проверкой"
|
||||
)
|
||||
|
||||
|
||||
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
|
||||
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
||||
text = _text()
|
||||
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
|
||||
else_pos = text.index("else", guard_start)
|
||||
fi_pos = text.index("fi", else_pos)
|
||||
else_branch = text[else_pos:fi_pos]
|
||||
assert "localhost:9090/-/reload" not in else_branch, (
|
||||
"reload вызывается даже в ветке провалившейся проверки"
|
||||
)
|
||||
assert "exit 1" in else_branch, (
|
||||
"провал promtool не роняет шаг — деплой останется зелёным при битом конфиге"
|
||||
)
|
||||
|
||||
|
||||
def test_acceptance_checks_last_config_time_actually_changed() -> None:
|
||||
"""Приёмка обязана сверять `lastConfigTime` до/после, а не доверять коду ответа reload.
|
||||
|
||||
`wget` на POST /-/reload может отрапортовать успех, даже если Prometheus
|
||||
молча остался на старом конфиге (например, если бинарь внутри образа не
|
||||
поддерживает --post-data так, как ожидалось) — единственное надёжное
|
||||
подтверждение реального перечитывания конфига это смена таймстемпа.
|
||||
"""
|
||||
text = _text()
|
||||
assert text.count("lastConfigTime") >= 2, (
|
||||
"нет сравнения lastConfigTime до/после — reload не проверяется по факту"
|
||||
)
|
||||
assert "LAST_CONFIG_BEFORE" in text and "LAST_CONFIG_AFTER" in text, (
|
||||
"нет явного до/после сравнения таймстемпа последней перезагрузки конфига"
|
||||
)
|
||||
verify_start = text.index("LAST_CONFIG_AFTER")
|
||||
verify_block_end = text.index("Prometheus: конфиг и правила проверены", verify_start)
|
||||
verify_block = text[verify_start:verify_block_end]
|
||||
assert "exit 1" in verify_block, (
|
||||
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
|
||||
)
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
"""Гейт: `$value` в тексте алерта — это та величина, которую текст называет.
|
||||
|
||||
Найдено 12.09.2026 по боевым сообщениям в Telegram:
|
||||
|
||||
«apps / tradein-browser: 2.684e+11% от mem_limit. Дальше OOM-kill.»
|
||||
«apps / listings: доля HOT 75.21%.» (при пороге срабатывания «доля < 20%»)
|
||||
|
||||
Причина у обоих одна и она про ФОРМУ выражения, а не про условие. В PromQL
|
||||
`A and B` возвращает ЗНАЧЕНИЯ ЛЕВОЙ части, отфильтрованные правой. Значит в
|
||||
`$value` попадает A, а не то отношение, ради которого правило написано:
|
||||
|
||||
- ContainerNearMemoryLimit: слева стоял `container_spec_memory_limit_bytes`,
|
||||
и в сообщение уходил ЛИМИТ В БАЙТАХ, отрендеренный `humanizePercentage`
|
||||
(2 684 354 560 → «2.684e+11%»). Условие при этом срабатывало верно.
|
||||
- PostgresLowHotUpdateRatio: слева стоял `rate(tup_upd[6h])` — АПДЕЙТОВ В
|
||||
СЕКУНДУ. Это опаснее: 0.7521 превращалось в «75.21%», число попадало в
|
||||
правдоподобный диапазон и противоречило собственному порогу («доля < 20%»),
|
||||
но выглядело настоящим.
|
||||
|
||||
Отсюда инвариант, который здесь и проверяется: **если описание рендерит
|
||||
`$value` как долю (`humanizePercentage`), выражение обязано возвращать долю** —
|
||||
то есть его ЛЕВАЯ часть (до первого `and`/`unless`) обязана содержать деление.
|
||||
Отсев побочных условий переносится внутрь знаменателя (`X / (Y > 0)`), а не в
|
||||
`and` слева.
|
||||
|
||||
Гейт намеренно не пытается «понять» PromQL целиком: он ловит ровно ту форму,
|
||||
которая уже дважды уехала в прод, и не мешает правилам, печатающим абсолютные
|
||||
величины без humanize (PostgresDeadTuplesHigh, PostgresIdleInTransaction).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
_RULES_DIR = Path(__file__).resolve().parents[3] / "ops" / "metrics" / "prometheus" / "rules"
|
||||
|
||||
|
||||
def _alerts() -> list[tuple[str, str, str, dict]]:
|
||||
"""(файл, имя алерта, expr, annotations) по всем файлам правил."""
|
||||
out: list[tuple[str, str, str, dict]] = []
|
||||
for path in sorted(_RULES_DIR.glob("*.yml")):
|
||||
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
for group in doc.get("groups", []):
|
||||
for rule in group.get("rules", []):
|
||||
if "alert" in rule:
|
||||
out.append(
|
||||
(
|
||||
path.name,
|
||||
rule["alert"],
|
||||
rule.get("expr", ""),
|
||||
rule.get("annotations") or {},
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _left_of_and(expr: str) -> str:
|
||||
"""Часть выражения ДО первого бинарного `and`/`unless` — её значения и видит $value."""
|
||||
parts = re.split(r"\band\b|\bunless\b", expr, maxsplit=1)
|
||||
return parts[0]
|
||||
|
||||
|
||||
def test_rules_dir_is_found() -> None:
|
||||
assert _RULES_DIR.is_dir(), f"нет каталога правил: {_RULES_DIR}"
|
||||
assert _alerts(), "правила не распарсились — гейт был бы зелёным впустую"
|
||||
|
||||
|
||||
def test_percentage_annotations_come_from_a_ratio() -> None:
|
||||
"""Текст обещает долю → выражение обязано её и возвращать."""
|
||||
broken: list[str] = []
|
||||
for fname, name, expr, ann in _alerts():
|
||||
text = " ".join(str(v) for v in ann.values())
|
||||
if "humanizePercentage" not in text:
|
||||
continue
|
||||
left = _left_of_and(expr)
|
||||
if "/" not in left:
|
||||
broken.append(
|
||||
f"{fname}::{name}: описание печатает $value как долю, но левая часть "
|
||||
f"выражения (её и видит $value) деления не содержит: {' '.join(left.split())!r}"
|
||||
)
|
||||
assert not broken, "\n".join(broken)
|
||||
|
||||
|
||||
def test_known_two_rules_are_fixed() -> None:
|
||||
"""Именные проверки для двух правил, которые уже соврали в проде."""
|
||||
by_name = {name: (expr, ann) for _, name, expr, ann in _alerts()}
|
||||
|
||||
expr, ann = by_name["ContainerNearMemoryLimit"]
|
||||
flat = " ".join(expr.split())
|
||||
assert flat.startswith("container_memory_working_set_bytes"), (
|
||||
"слева должно стоять потребление, иначе в Telegram уедет лимит в байтах: " + flat
|
||||
)
|
||||
assert "(container_spec_memory_limit_bytes" in flat and "> 0)" in flat, (
|
||||
"отсев нулевого лимита должен стоять В ЗНАМЕНАТЕЛЕ, а не в `and` слева: " + flat
|
||||
)
|
||||
assert "humanizePercentage" in " ".join(ann.values())
|
||||
|
||||
expr, ann = by_name["PostgresLowHotUpdateRatio"]
|
||||
flat = " ".join(expr.split())
|
||||
assert flat.startswith("rate(pg_table_write_amplification_tup_hot_upd"), (
|
||||
"слева должен стоять числитель доли HOT, иначе печатается rate(tup_upd): " + flat
|
||||
)
|
||||
assert "/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5)" in flat, (
|
||||
"гейт по объёму апдейтов должен жить в знаменателе — он же защищает от 0/0: " + flat
|
||||
)
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
"""Продуктовый счётчик экспорта отчётов (#3471): выгрузок §22-форсайта / ТЗ.
|
||||
|
||||
Мера кардинальности та же, что в `test_metrics.py`: единственный лейбл —
|
||||
`format`, фиксированный литерал из `Literal[...]` сигнатуры эндпоинта
|
||||
(`export_parcel_forecast`) плюс одно статичное значение `best_layouts_pdf`
|
||||
(ТЗ на проектирование) — не кадастровый номер и не идентификатор пользователя.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from prometheus_client import REGISTRY, generate_latest
|
||||
|
||||
from app.observability import metrics as m
|
||||
|
||||
|
||||
def test_reports_exported_counter_has_bounded_format_label() -> None:
|
||||
assert tuple(m.REPORTS_EXPORTED._labelnames) == ("format",) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_reports_exported_counter_increments_per_format() -> None:
|
||||
def _value(fmt: str) -> float:
|
||||
return (
|
||||
REGISTRY.get_sample_value("sitefinder_reports_exported_total", {"format": fmt}) or 0.0
|
||||
)
|
||||
|
||||
before_pdf = _value("pdf")
|
||||
before_layouts = _value("best_layouts_pdf")
|
||||
|
||||
m.REPORTS_EXPORTED.labels(format="pdf").inc()
|
||||
m.REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
|
||||
|
||||
assert _value("pdf") - before_pdf == 1.0
|
||||
assert _value("best_layouts_pdf") - before_layouts == 1.0
|
||||
|
||||
body = generate_latest(REGISTRY).decode()
|
||||
assert "sitefinder_reports_exported_total" in body
|
||||
|
|
@ -133,35 +133,6 @@ gendsgn.ru {
|
|||
format json
|
||||
}
|
||||
|
||||
# #3471: копия access-лога на stdout. За 3 часа наблюдения в
|
||||
# gendesign-caddy-1 не было НИ ОДНОЙ строки http.log.access — только ACME/
|
||||
# TLS/warn от reverse_proxy, статусы/латентность/RPS прокси не видны в
|
||||
# Loki вообще. Alloy на этом хосте уже собирает stdout контейнеров через
|
||||
# journald (`loki.source.journal "host"`,
|
||||
# ops/metrics/alloy/alloy-apps.alloy) — второй bind-монт не нужен.
|
||||
#
|
||||
# Секреты в query (`?secret=`, `?token=` и т.п., см. инцидент #3154) режет
|
||||
# УЖЕ РАБОТАЮЩИЙ `loki.process.scrub_credentials` (#3354, тот же список
|
||||
# имён параметров, что в app/core/log_scrub.py) — он стоит на пути ЛЮБОГО
|
||||
# journal-лога, включая этот, поэтому второй слой скраба здесь не заводим.
|
||||
#
|
||||
# `log_skip` — общий для ВСЕХ логгеров сайта флаг на запрос (Caddy не даёт
|
||||
# скипать выборочно по конкретному логгеру), поэтому /health и статика
|
||||
# Next пропадают заодно и из файлового gendsgn.ru.log выше, не только из
|
||||
# копии на stdout. Это осознанный побочный эффект, не только экономия:
|
||||
# /health — 32% строк gendsgn.ru.log в измеренном сегменте (12.09.2026,
|
||||
# 240 из 742 строк за ~4ч, аптайм-монитор раз в минуту) без диагностической
|
||||
# ценности, и именно он гонит файловый лог через 50MiB roll_size так часто.
|
||||
log access_stdout {
|
||||
output stdout
|
||||
format json
|
||||
}
|
||||
|
||||
@noisy_access_log {
|
||||
path /health /_next/static/*
|
||||
}
|
||||
log_skip @noisy_access_log
|
||||
|
||||
route {
|
||||
# `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078).
|
||||
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
|
||||
|
|
@ -404,24 +375,6 @@ meraocenka.ru {
|
|||
output file /var/log/caddy/meraocenka.ru.log
|
||||
}
|
||||
|
||||
# #3471: та же копия access-лога на stdout, что у gendsgn.ru — см.
|
||||
# развёрнутый комментарий там (Alloy/journald, scrub_credentials #3354,
|
||||
# log_skip общий на все логгеры сайта). `/trade-in/_next/static/*` — 5.1%
|
||||
# строк meraocenka.ru.log в измерении 12.09.2026 (3782 из 73810 за
|
||||
# ~17.6 суток без ротации, roll_size здесь вообще не настроен) — статика
|
||||
# Next не может быть источником 5xx бэкенда. `/health` на этом домене не
|
||||
# проксируется (allowlist ниже отдаёт по нему 404), но матчер добавлен для
|
||||
# единообразия с gendsgn.ru — вреда от него ноль.
|
||||
log access_stdout {
|
||||
output stdout
|
||||
format json
|
||||
}
|
||||
|
||||
@noisy_access_log {
|
||||
path /health /trade-in/_next/static/*
|
||||
}
|
||||
log_skip @noisy_access_log
|
||||
|
||||
# `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и
|
||||
# финальный `handle { respond 404 }`, так что путь и без этой строки не
|
||||
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
|
||||
|
|
|
|||
|
|
@ -105,44 +105,11 @@ metrics.gendsgn.ru {
|
|||
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
|
||||
# поставил не человек. Прав в системе токен не даёт никаких.
|
||||
#
|
||||
# Сервис отвечает только на /ack/*, /glitchtip и /healthz; всё прочее — 404.
|
||||
# Сервис отвечает только на /ack/* и /healthz; всё прочее — 404.
|
||||
handle /ack/* {
|
||||
reverse_proxy alert-ack:8080
|
||||
}
|
||||
|
||||
# Резервный приёмник алертов GlitchTip (#3471). Основной получатель —
|
||||
# продуктовый бэкенд на Selectel, то есть тот самый сервис, за которым эти
|
||||
# алерты и следят: пока он лежит, его собственные ошибки доставлять некому.
|
||||
# Этот путь живёт у другого провайдера и с чистой сетью до Telegram, поэтому
|
||||
# переживает падение Selectel целиком.
|
||||
#
|
||||
# Секрет — в значении query-параметра, а не в пути: путь сам по себе не
|
||||
# секрет, и его попадание в access-лог безопасно. Само значение вырезает
|
||||
# scrub_credentials в Alloy до записи в Loki (#3154).
|
||||
handle /glitchtip* {
|
||||
reverse_proxy alert-ack:8080
|
||||
}
|
||||
|
||||
# Ретранслятор Bot API продукта (#3471). Путь от Selectel до
|
||||
# api.telegram.org теряет примерно каждый четвёртый короткий запрос, тот же
|
||||
# замер с Beget в те же минуты — чистый. Поэтому продуктовый бот ходит в
|
||||
# Telegram отсюда, а не напрямую.
|
||||
#
|
||||
# Путь содержит токен бота (/tg-relay/bot<TOKEN>/<method>) — он не должен
|
||||
# осесть ни в файловом логе сайта, ни в stdout-копии (#3154), поэтому
|
||||
# запрос помечен log_skip.
|
||||
@tg_relay path /tg-relay/*
|
||||
log_skip @tg_relay
|
||||
|
||||
handle_path /tg-relay/* {
|
||||
reverse_proxy tg-relay:8080 {
|
||||
# getUpdates — long-poll до ~40с, дефолтный таймаут ответа короче.
|
||||
transport http {
|
||||
response_header_timeout 80s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по
|
||||
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
|
||||
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.
|
||||
|
|
|
|||
|
|
@ -231,71 +231,6 @@ services:
|
|||
mem_limit: 128m
|
||||
logging: *default-logging
|
||||
|
||||
# ── redis-exporter: здоровье общего Redis (только на Poincare) ───────────────
|
||||
# #3471. Redis — один инстанс на три потребителя: db0 celery-брокер Site
|
||||
# Finder, db1 SearchCache trade-in, db2 glitchtip (см. комментарий у сервиса
|
||||
# `redis` в docker-compose.prod.yml). Один `redis_up` покрывает риск для всех
|
||||
# трёх разом — до этой правки Redis не измерялся вообще, переполнение
|
||||
# брокера и обычная недоступность снаружи выглядели одинаково — тишиной.
|
||||
#
|
||||
# Адрес — через alias `gendesign-redis`, который `redis` регистрирует на
|
||||
# сети `shared` (см. #2709 в docker-compose.prod.yml) — джойнить ещё и
|
||||
# `product` не нужно, тем же путём уже идёт postgres-exporter-tradein.
|
||||
#
|
||||
# Пароль — из окружения, не хардкод: сегодня на Redis нет requirepass (нет
|
||||
# переменной ни в docker-compose.prod.yml, ни здесь), но если он появится,
|
||||
# значение подставляется через METRICS_REDIS_PASSWORD в /opt/gendesign/.env
|
||||
# на хосте, а не в этот файл.
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:v1.65.0
|
||||
container_name: gendesign-redis-exporter
|
||||
restart: unless-stopped
|
||||
profiles: ["apps"]
|
||||
environment:
|
||||
REDIS_ADDR: ${METRICS_REDIS_ADDR:-redis://gendesign-redis:6379}
|
||||
REDIS_PASSWORD: ${METRICS_REDIS_PASSWORD:-}
|
||||
expose:
|
||||
- "9121"
|
||||
networks:
|
||||
- shared
|
||||
mem_limit: 64m
|
||||
logging: *default-logging
|
||||
|
||||
# ── celery-exporter: очередь Site Finder (только на Poincare) ────────────────
|
||||
# #3471. Слепая зона: глубина очереди, число живых воркеров и счётчик
|
||||
# неуспешных задач нигде не измерялись — залипший воркер и переполненная
|
||||
# очередь снаружи неотличимы от тишины.
|
||||
#
|
||||
# ПОЧЕМУ ОТДЕЛЬНЫЙ ОБРАЗ, А НЕ redis-exporter --check-keys. check-keys дал
|
||||
# бы LLEN дефолтной очереди "celery" (в backend/app/workers/celery_app.py
|
||||
# НЕТ task_routes — все таски идут в один дефолтный queue, имя буквально
|
||||
# "celery") без нового образа вообще. Но он НЕ умеет считать живых
|
||||
# воркеров и неуспешные таски — то есть закрыл бы только треть минимума
|
||||
# из задачи. celery-exporter слушает событийную шину Celery через тот же
|
||||
# брокер и даёт все три метрики разом, поэтому выбран он, а не комбинация
|
||||
# check-keys + что-то ещё для остальных двух чисел.
|
||||
#
|
||||
# ⚠️ ИМЕНА МЕТРИК НИЖЕ (celery_queue_length, celery_worker_up,
|
||||
# celery_task_failed_total) — по документации проекта на момент правки, БЕЗ
|
||||
# прогона на реальном брокере (агент писал этот файл без доступа к проду).
|
||||
# Сверить с `curl http://gendesign-celery-exporter:9808/metrics` на хосте
|
||||
# после первого деплоя и поправить `ops/metrics/prometheus/rules/infra.yml`
|
||||
# при расхождении — иначе алерты будут молча ничего не ловить.
|
||||
celery-exporter:
|
||||
image: danihodovic/celery-exporter:0.13.0
|
||||
container_name: gendesign-celery-exporter
|
||||
restart: unless-stopped
|
||||
profiles: ["apps"]
|
||||
command:
|
||||
- "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}"
|
||||
- "--queue=celery"
|
||||
expose:
|
||||
- "9808"
|
||||
networks:
|
||||
- shared
|
||||
mem_limit: 128m
|
||||
logging: *default-logging
|
||||
|
||||
# ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
|
||||
# forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
|
||||
# ничем не ограничен — политики ретенции у GlitchTip нет вообще.
|
||||
|
|
|
|||
|
|
@ -196,12 +196,6 @@ services:
|
|||
# Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки,
|
||||
# но уйдёт: алерт важнее подтверждения.
|
||||
ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru}
|
||||
# Резервный получатель GlitchTip-алертов (#3471, POST /glitchtip) — второй
|
||||
# получатель наряду с основным вебхуком в продуктовый бэкенд на Selectel.
|
||||
# Секрет СВОЙ, не общий с продуктовым TRADEIN_INTERNAL_AUTH_SECRET: разные
|
||||
# хосты/домены безопасности. Пусто — эндпоинт отвечает 503, остальной
|
||||
# функционал сервиса не затронут.
|
||||
ALERT_ACK_GLITCHTIP_SECRET: ${ALERT_ACK_GLITCHTIP_SECRET:-}
|
||||
volumes:
|
||||
- ./ops/metrics/alert-ack/app.py:/app/app.py:ro
|
||||
expose:
|
||||
|
|
@ -216,74 +210,7 @@ services:
|
|||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# ── Ретранслятор Bot API продукта через Beget (#3471) ───────────────────────
|
||||
#
|
||||
# Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера
|
||||
# `tradein-tgbot` на Selectel — 9 успешных из 12, три ConnectTimeout. TCP-443
|
||||
# до адреса, резолвящегося на Selectel — 5/6. Тот же TCP-443 до адреса,
|
||||
# резолвящегося на Beget — 8/8. За сутки 508 строк `network error` в логе
|
||||
# бота, за 30 дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel
|
||||
# лоссовый, с Beget чистый — Alertmanager (тот же чат, живёт рядом) шлёт без
|
||||
# проблем. Продуктовые sendMessage/copyMessage/getUpdates идут сюда вместо
|
||||
# прямого пути; выключается пустым TELEGRAM_RELAY_BASE_URL на стороне
|
||||
# продукта — это и есть откат.
|
||||
#
|
||||
# НЕ рядом с продуктом: смысл ретранслятора именно в том, что он живёт там,
|
||||
# откуда путь до Telegram чистый, а не там, откуда он лоссовый.
|
||||
#
|
||||
# Образ без сборки и без зависимостей (только stdlib) — тот же принцип, что у
|
||||
# alert-ack: сервис обязан подниматься даже когда сломано всё остальное.
|
||||
tg-relay:
|
||||
image: python:3.12-slim
|
||||
container_name: gendesign-tg-relay
|
||||
# #3471 (PR #3487 инцидент): без profiles сервис поднимался ВСЕГДА, а при
|
||||
# пустом TG_RELAY_SECRET делает SystemExit — то есть уходит в бесконечный
|
||||
# Restarting сразу после деплоя. Профиль включает deploy-metrics.yml, и
|
||||
# только когда секрет реально задан (см. PROFILES там).
|
||||
profiles: ["relay"]
|
||||
restart: unless-stopped
|
||||
user: "65534:65534"
|
||||
command: ["python", "-u", "/app/app.py"]
|
||||
env_file:
|
||||
- path: ./backend/.env.runtime
|
||||
required: false
|
||||
- path: ./backend/.env
|
||||
required: false
|
||||
environment:
|
||||
# Общий секрет с продуктовым клиентом (TELEGRAM_RELAY_SECRET на стороне
|
||||
# tradein-backend/tradein-tgbot) — домен публичный, без секрета отказ.
|
||||
TG_RELAY_SECRET: ${TG_RELAY_SECRET:-}
|
||||
volumes:
|
||||
- ./ops/metrics/tg-relay/app.py:/app/app.py:ro
|
||||
expose:
|
||||
- "8080"
|
||||
networks:
|
||||
- shared
|
||||
mem_limit: 128m
|
||||
logging: *default-logging
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8080/healthz',timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# ── Grafana: витрина ─────────────────────────────────────────────────────────
|
||||
# Grafana здесь ТОЛЬКО рисует — не решает, что считать инцидентом и куда его
|
||||
# слать. Тревоги живут в Prometheus (правила) и Alertmanager (маршрутизация,
|
||||
# Telegram); это единственный путь доставки (#3158).
|
||||
#
|
||||
# Встроенный Alerting выключен ЯВНО, а не просто «не настроен». Проверка на
|
||||
# живом API 12.09.2026 нашла: 0 правил, единственный контакт-поинт —
|
||||
# стоковый grafana-default-email на example@email.com, GF_SMTP_* не заданы.
|
||||
# То есть кнопка «New alert rule» в интерфейсе есть и работает, а результат
|
||||
# молча уходит в никуда — ровно та ситуация, из-за которой никто не проверяет
|
||||
# второй, настоящий путь. Дублирующий движок на том же датасорсе Prometheus
|
||||
# надёжности всё равно не прибавляет (общая точка отказа), только даёт второе
|
||||
# место, где правило может быть заведено и забыто.
|
||||
#
|
||||
# Если это когда-нибудь понадобится включить обратно — сначала подключить
|
||||
# реальный SMTP или другой contact point и завести хотя бы одно тестовое
|
||||
# правило руками, иначе вернётся тот же капкан.
|
||||
grafana:
|
||||
image: grafana/grafana:11.5.1
|
||||
container_name: gendesign-grafana
|
||||
|
|
@ -303,12 +230,6 @@ services:
|
|||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
|
||||
GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
|
||||
GF_SERVER_SERVE_FROM_SUB_PATH: "false"
|
||||
# Единственный официальный переключатель Grafana Alerting в 11.x — секция
|
||||
# [unified_alerting], легаси-[alerting] удалён из Grafana ещё в 9.0 и в
|
||||
# 11.5 в конфиге отсутствует (сверено с grafana.com/docs/grafana/v11.5/
|
||||
# setup-grafana/configure-grafana/#unified_alerting). false здесь убирает
|
||||
# раздел Alerting из UI и глушит движок правил целиком — см. #3158 выше.
|
||||
GF_UNIFIED_ALERTING_ENABLED: "false"
|
||||
# Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не
|
||||
# хочется, чтобы наблюдатель сам ходил в интернет без нужды.
|
||||
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
#!/bin/sh
|
||||
# Применить текущий конфиг прокси к работающему Caddy (#3443).
|
||||
#
|
||||
# ЗАЧЕМ. Полный деплой ПТИЦЫ пересоздавал сам Caddy БЕЗУСЛОВНО
|
||||
# (`up -d --force-recreate --no-deps caddy`), а вместе с контейнером исчезал
|
||||
# единственный процесс, слушающий 80/443. Замер 05.09 (#3274): 67 с `code=000`
|
||||
# на ВСЕХ доменах хоста — gendsgn.ru, meraocenka.ru и зеркала, включая
|
||||
# публичный лендинг МЕРЫ. Это не 502/503: принимающего процесса нет вовсе,
|
||||
# поэтому заглушка окна деплоя (caddy/sites/deploy-window.caddy.snippet) здесь
|
||||
# бессильна по построению — её отдаёт тот же Caddy.
|
||||
#
|
||||
# ЧТО НА САМОМ ДЕЛЕ ТРЕБУЕТ ПЕРЕСОЗДАНИЯ. Безусловный флаг появился 17.05
|
||||
# (11e78d73) ради нового bind-маунта `./preview` из docker-compose.prod.yml,
|
||||
# который «не появлялся в running container». Довод неверен: `docker compose
|
||||
# up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам, как только меняется
|
||||
# описание сервиса или образ. Проверено на живом демоне (docker 28.4):
|
||||
# добавлен volume → `Container … Starting/Started`, id контейнера новый;
|
||||
# тег указан на др. образ → id новый;
|
||||
# не менялось ничего → `Container … Running`, id тот же.
|
||||
#
|
||||
# Остаётся ровно один класс изменений, которого compose не видит: СОДЕРЖИМОЕ
|
||||
# пофайлового bind-маунта. `git reset --hard` не правит файл на месте, а пишет
|
||||
# новый инод; контейнер держит примонтированным прежний и продолжает читать
|
||||
# его — `caddy reload` перечитает ровно тот же старый инод. Тот же механизм уже
|
||||
# ловили на Alertmanager (27.08, deploy-metrics.yml) и на Alloy (#3380). У Caddy
|
||||
# так смонтированы пять путей: Caddyfile и четыре сниппета. Каталоги
|
||||
# (caddy/sites, caddy/local, preview) этим не страдают — правка внутри каталога
|
||||
# видна контейнеру сразу, поэтому самый частый случай (caddy/sites/apps.caddy)
|
||||
# пересоздания НЕ требует.
|
||||
#
|
||||
# ОТСЮДА ПОРЯДОК: проверить конфиг → `up -d` без `--force-recreate` → если
|
||||
# контейнер остался тем же, сверить, видит ли он текущее содержимое пофайловых
|
||||
# маунтов → пересоздать ТОЛЬКО при расхождении, иначе `caddy reload`, который
|
||||
# не рвёт соединения.
|
||||
#
|
||||
# ГРАНИЦА. Сверка по СОДЕРЖИМОМУ, а не по иноду: файл, переписанный тем же
|
||||
# текстом, пересоздания не требует. Не прочиталось (контейнер не запущен, в
|
||||
# образе нет sha256sum) — считаем расхождением: fail-safe в сторону прежнего
|
||||
# поведения, то есть пересоздания.
|
||||
set -eu
|
||||
|
||||
# Оба вызывающих (job `deploy` и job `deploy-caddy` в .forgejo/workflows/deploy.yml)
|
||||
# работают в /opt/gendesign, но не зависеть от cwd дешевле, чем это помнить.
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
COMPOSE="docker compose -p gendesign -f docker-compose.prod.yml"
|
||||
|
||||
caddy_cid() { $COMPOSE ps -aq caddy 2>/dev/null | tail -n1; }
|
||||
|
||||
# ── 1. Проверка ДО применения ────────────────────────────────────────────────
|
||||
# Одноразовый контейнер читает файлы С ХОСТА — то есть ровно то, что поедет в
|
||||
# работающий Caddy. `exec caddy validate` здесь не годится: он проверил бы
|
||||
# старый инод, то есть предыдущую версию конфига. Образ и парсер те же, что на
|
||||
# PR-гейте (ci.yml «Guard: Caddyfile синтаксически валиден»).
|
||||
#
|
||||
# ГРАНИЦА ЭТОЙ ПРОВЕРКИ. Она обрывает применение до того, как конфиг попадёт в
|
||||
# работающий Caddy, — но только на быстром пути. В полном деплое ВЫШЕ по
|
||||
# скрипту (deploy.yml, `up -d $UP_SERVICES`) уже прошёл общий подъём всех
|
||||
# сервисов, и если правка одновременно ломает Caddyfile И меняет блок caddy в
|
||||
# docker-compose.prod.yml, контейнер пересоздастся там — с непроверенным
|
||||
# конфигом и раньше этой строки. Первая линия против этого — гейт на PR (#2913).
|
||||
echo "→ проверяю конфиг прокси одноразовым контейнером…"
|
||||
if ! docker run --rm -v "$PWD:/work:ro" -w /work caddy:2 \
|
||||
caddy validate --config /work/Caddyfile --adapter caddyfile; then
|
||||
echo "ОШИБКА: конфиг прокси не применён — проверка не пройдена ЛИБО не удалось"
|
||||
echo " запустить проверочный контейнер (нет образа caddy:2, занят демон,"
|
||||
echo " недоступен реестр). Причина — в выводе выше, не гадать по этой строке."
|
||||
echo " Работающий Caddy не тронут, домены живы."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 2. Описание сервиса и образ ──────────────────────────────────────────────
|
||||
before=$(caddy_cid)
|
||||
$COMPOSE up -d --no-deps caddy
|
||||
after=$(caddy_cid)
|
||||
if [ -z "$after" ]; then
|
||||
echo 'ОШИБКА: после `up -d` контейнера caddy нет — смотри вывод compose выше.'
|
||||
exit 1
|
||||
fi
|
||||
if [ "$before" != "$after" ]; then
|
||||
echo "✓ Caddy пересоздан compose'ом: изменилось описание сервиса или образ (${before:-нет}→${after})."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 3. Доехало ли содержимое пофайловых маунтов ──────────────────────────────
|
||||
# Список маунтов читается ОТДЕЛЬНОЙ командой, а не в конвейере с циклом: в
|
||||
# `$(docker inspect … | while …)` статус подстановки — это статус `while`, то
|
||||
# есть всегда 0 (`pipefail` в POSIX-sh нет вовсе). Провал `docker inspect`
|
||||
# давал бы пустой список → «расхождений нет» → `caddy reload` → зелёная джоба с
|
||||
# надписью «окна недоступности нет», а прокси работал бы по СТАРОМУ конфигу.
|
||||
# Это ровно тот беззвучный отказ, ради которого написан весь скрипт.
|
||||
mounts=$(docker inspect "$after" \
|
||||
--format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}|{{.Destination}}{{println}}{{end}}{{end}}') \
|
||||
|| mounts=''
|
||||
|
||||
if [ -z "$mounts" ]; then
|
||||
echo "WARNING: список маунтов Caddy не прочитан (docker inspect молчит или упал) —"
|
||||
echo " сверить нечем, считаю расхождением: fail-safe в прежнее поведение."
|
||||
verdicts=""
|
||||
stale="(маунты не прочитаны)"
|
||||
else
|
||||
verdicts=$(printf '%s\n' "$mounts" | while IFS='|' read -r src dst; do
|
||||
[ -n "${dst:-}" ] || continue
|
||||
# Каталог инод не держит — пропускаем. Именно `-d`, а не `-f`:
|
||||
# отсутствующий/нечитаемый ФАЙЛ — не повод молча пропустить, в
|
||||
# контейнере в этот момент живёт старый инод со старым текстом.
|
||||
if [ -d "$src" ]; then continue; fi
|
||||
host_sum=$(sha256sum "$src" 2>/dev/null | cut -d' ' -f1)
|
||||
seen_sum=$(docker exec "$after" sha256sum "$dst" 2>/dev/null | cut -d' ' -f1)
|
||||
if [ -n "$host_sum" ] && [ "$host_sum" = "${seen_sum:-НЕ_ПРОЧИТАНО}" ]; then
|
||||
echo "OK $dst"
|
||||
else
|
||||
echo "STALE $dst"
|
||||
fi
|
||||
done)
|
||||
# «Сверили пять файлов» и «сверили ноль» обязаны различаться в логе — иначе
|
||||
# зелёная строка ниже одинаково означает и проверку, и её отсутствие.
|
||||
checked=$(printf '%s\n' "$verdicts" | grep -c . || true)
|
||||
echo "→ сверено пофайловых маунтов с тем, что видит контейнер: $checked"
|
||||
stale=$(printf '%s\n' "$verdicts" | sed -n 's/^STALE //p' | tr '\n' ' ')
|
||||
if [ "$checked" -eq 0 ]; then
|
||||
echo "WARNING: ни одного пофайлового bind-маунта не найдено — у Caddy их пять"
|
||||
echo " (Caddyfile + 4 сниппета). Считаю расхождением: fail-safe."
|
||||
stale="(пофайловых маунтов не найдено)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$stale" ]; then
|
||||
echo "→ до контейнера НЕ доехали пофайловые маунты: $stale"
|
||||
echo " (bind-маунт файла держит инод: reload перечитал бы старую версию — нужен recreate)"
|
||||
$COMPOSE up -d --force-recreate --no-deps caddy
|
||||
echo "✓ Caddy пересоздан — иначе правка осталась бы неприменённой."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 4. Всё доехало — перезагрузка без разрыва соединений ─────────────────────
|
||||
$COMPOSE exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
echo "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."
|
||||
|
|
@ -27,28 +27,6 @@
|
|||
METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна)
|
||||
ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку
|
||||
ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440)
|
||||
ALERT_ACK_GLITCHTIP_SECRET секрет резервного вебхука GlitchTip (#3471,
|
||||
см. POST /glitchtip ниже); пусто — 503
|
||||
|
||||
РЕЗЕРВНЫЙ КАНАЛ GLITCHTIP (#3471). Все три alert-правила GlitchTip (backend,
|
||||
frontend, Trade-In) шлют основной вебхук в продуктовый бэкенд на Selectel —
|
||||
тот самый хост, за которым они следят. Если там упал сам бэкенд или Caddy,
|
||||
алерт об этом теряется именно тогда, когда нужнее всего. `POST /glitchtip`
|
||||
— второй получатель того же алерта, зарегистрированный в GlitchTip отдельной
|
||||
строкой; живёт на ЭТОМ (инфраструктурном, Beget) хосте и не зависит от
|
||||
здоровья продукта. Формат тела — тот же Slack-совместимый payload, что и у
|
||||
продуктового приёмника (`tradein-mvp/backend/app/api/v1/glitchtip.py`):
|
||||
``{"text": str, "attachments": [{"title","title_link","text","color",
|
||||
"fields":[{"title","value"}]}]}``, GlitchTip заголовков не шлёт вовсе —
|
||||
аутентификация только через секрет в query (``?secret=``) или в заголовке
|
||||
``X-GlitchTip-Secret`` (тот же выбор, что там же и по той же причине: заголовок
|
||||
не течёт в access-log, query остаётся, т.к. сам GlitchTip 6.1.6 заголовков не
|
||||
добавляет). Секрет намеренно СВОЙ (``ALERT_ACK_GLITCHTIP_SECRET``), а не общий
|
||||
с продуктовым ``TRADEIN_INTERNAL_AUTH_SECRET`` — секреты разных хостов/доменов
|
||||
безопасности компрометировать вместе незачем. Сообщение уходит в ту же тему
|
||||
клиентских инцидентов (``METRICS_TELEGRAM_CHAT_ID``/``_TOPIC_ID``), что и
|
||||
Alertmanager-алерты через alert-ack, с явной пометкой «резервный канал», чтобы
|
||||
не спутать с основным путём.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -73,7 +51,6 @@ TOPIC_ID = os.environ.get("METRICS_TELEGRAM_TOPIC_ID", "")
|
|||
ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "")
|
||||
PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/")
|
||||
TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
|
||||
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
|
||||
API = "https://api.telegram.org/bot{}/{}"
|
||||
|
||||
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
|
||||
|
|
@ -176,79 +153,6 @@ def _send_alert(payload: dict) -> None:
|
|||
_tg("sendMessage", msg)
|
||||
|
||||
|
||||
_GLITCHTIP_TEXT_LIMIT = 3500 # запас под баннер+имя проекта до лимита Telegram 4096
|
||||
|
||||
|
||||
def _verify_glitchtip_secret(provided: str) -> bool:
|
||||
"""Constant-time сравнение — длина/префикс секрета не утекают через время
|
||||
ответа (тот же приём, что у продуктового приёмника, см. docstring модуля)."""
|
||||
return bool(GLITCHTIP_SECRET) and secrets.compare_digest(provided or "", GLITCHTIP_SECRET)
|
||||
|
||||
|
||||
def _glitchtip_field(attachment: dict, label: str) -> str | None:
|
||||
for field in attachment.get("fields") or []:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
if str(field.get("title", "")).strip().lower() == label.lower():
|
||||
value = field.get("value")
|
||||
return str(value) if value is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def _render_glitchtip(payload: dict) -> str:
|
||||
"""Собрать текст сообщения из Slack-совместимого payload GlitchTip.
|
||||
|
||||
Максимально терпимо к форме тела: GlitchTip шлёт ОДИНАКОВУЮ структуру для
|
||||
issue- и uptime-алертов, но поля внутри attachments опциональны, а тестовое
|
||||
сообщение из UI GlitchTip может не иметь attachments вовсе. Ничего в теле
|
||||
не считаем обязательным — падать сервису на резервном канале нельзя.
|
||||
"""
|
||||
lines = ["⚠️ РЕЗЕРВНЫЙ КАНАЛ (GlitchTip → alert-ack)"]
|
||||
lines.append("Основной путь через продуктовый бэкенд мог быть недоступен.")
|
||||
lines.append("")
|
||||
text = payload.get("text")
|
||||
lines.append(html.escape(str(text)) if text else "GlitchTip alert")
|
||||
|
||||
attachments = payload.get("attachments")
|
||||
for attachment in attachments if isinstance(attachments, list) else []:
|
||||
if not isinstance(attachment, dict):
|
||||
continue
|
||||
block: list[str] = []
|
||||
project = _glitchtip_field(attachment, "Project")
|
||||
if project:
|
||||
block.append(f"Проект: {html.escape(project)}")
|
||||
if attachment.get("title"):
|
||||
block.append(html.escape(str(attachment["title"])))
|
||||
if attachment.get("text"):
|
||||
block.append(html.escape(str(attachment["text"])))
|
||||
if attachment.get("title_link"):
|
||||
block.append(f"Ссылка: {html.escape(str(attachment['title_link']))}")
|
||||
if block:
|
||||
lines.append("")
|
||||
lines.extend(block)
|
||||
|
||||
out = "\n".join(lines)
|
||||
if len(out) > _GLITCHTIP_TEXT_LIMIT:
|
||||
out = out[:_GLITCHTIP_TEXT_LIMIT] + "\n… (обрезано)"
|
||||
return out
|
||||
|
||||
|
||||
def _send_glitchtip_alert(payload: dict) -> None:
|
||||
"""Переслать вебхук GlitchTip в ту же тему клиентских инцидентов, что и
|
||||
Alertmanager через этот сервис. Без кнопки подтверждения — это не
|
||||
firing/resolved инцидент с состоянием, а разовое уведомление резервного
|
||||
канала."""
|
||||
msg = {
|
||||
"chat_id": CHAT_ID,
|
||||
"text": _render_glitchtip(payload),
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": "true",
|
||||
}
|
||||
if TOPIC_ID:
|
||||
msg["message_thread_id"] = TOPIC_ID
|
||||
_tg("sendMessage", msg)
|
||||
|
||||
|
||||
_PAGE = (
|
||||
"<!doctype html><meta charset=utf-8>"
|
||||
"<title>{t}</title>"
|
||||
|
|
@ -339,24 +243,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self._reply(code, page.encode())
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
||||
# Тело читается ДО любой развилки и ветки отказа. protocol_version =
|
||||
# HTTP/1.1, то есть соединение переиспользуется, а Caddy перед нами
|
||||
# держит пул к апстриму. Ответить 401/404/503, не вычитав тело, значит
|
||||
# оставить его в сокете — и следующий запрос по тому же соединению
|
||||
# начнётся с чужих байт. Проверено на проде 12.09.2026: неавторизованный
|
||||
# зонд на /glitchtip, а следом законный алерт получил
|
||||
# 501 Unsupported method ('{"text":"probe"}POST'). То есть один
|
||||
# отказ ронял следующий НАСТОЯЩИЙ алерт — ровно то, ради чего этот
|
||||
# резервный канал и заводился.
|
||||
parsed = urllib.parse.urlsplit(self.path)
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
if parsed.path == "/glitchtip":
|
||||
self._handle_glitchtip(parsed, raw)
|
||||
return
|
||||
if parsed.path != "/alertmanager":
|
||||
if self.path != "/alertmanager":
|
||||
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
try:
|
||||
payload = json.loads(raw.decode() or "{}")
|
||||
except Exception: # noqa: BLE001
|
||||
|
|
@ -370,36 +261,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
||||
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
|
||||
|
||||
def _handle_glitchtip(self, parsed: urllib.parse.SplitResult, raw: bytes) -> None:
|
||||
"""POST /glitchtip — резервный получатель GlitchTip-алертов (#3471).
|
||||
|
||||
Секрет — из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт
|
||||
в access-log) либо из query ``?secret=`` (fallback: GlitchTip 6.1.6
|
||||
заголовков не шлёт вовсе). Несконфигурированный секрет → 503, а не
|
||||
тихий приём без проверки. Неразобранное/нестандартное тело НЕ роняет
|
||||
запрос — это резервный канал, теряться на кривом JSON ему нельзя.
|
||||
"""
|
||||
if not GLITCHTIP_SECRET:
|
||||
self._reply(503, b"glitchtip webhook not configured", "text/plain; charset=utf-8")
|
||||
return
|
||||
|
||||
header_secret = self.headers.get("X-GlitchTip-Secret", "")
|
||||
query_secret = urllib.parse.parse_qs(parsed.query).get("secret", [""])[0]
|
||||
if not _verify_glitchtip_secret(header_secret or query_secret):
|
||||
log.warning("glitchtip webhook: invalid or missing secret")
|
||||
self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8")
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.loads(raw.decode() or "{}")
|
||||
if not isinstance(payload, dict):
|
||||
payload = {"text": raw.decode(errors="replace")}
|
||||
except Exception: # noqa: BLE001 — резервный канал не роняем на кривом теле
|
||||
payload = {"text": raw.decode(errors="replace")}
|
||||
|
||||
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
||||
threading.Thread(target=_send_glitchtip_alert, args=(payload,), daemon=True).start()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v]
|
||||
|
|
@ -407,11 +268,6 @@ def main() -> None:
|
|||
raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}")
|
||||
if not PUBLIC_URL:
|
||||
log.warning("ALERT_ACK_PUBLIC_URL пуст — сообщения уйдут БЕЗ кнопки подтверждения")
|
||||
if not GLITCHTIP_SECRET:
|
||||
log.warning(
|
||||
"ALERT_ACK_GLITCHTIP_SECRET пуст — резервный канал GlitchTip (#3471) "
|
||||
"отключён, POST /glitchtip будет отвечать 503"
|
||||
)
|
||||
port = int(os.environ.get("ALERT_ACK_PORT", "8080"))
|
||||
log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "—", ONCALL or "—")
|
||||
ThreadingHTTPServer(("", port), Handler).serve_forever()
|
||||
|
|
|
|||
|
|
@ -1,256 +0,0 @@
|
|||
"""Тесты для резервного канала GlitchTip → alert-ack (#3471).
|
||||
|
||||
Продукт (tradein-backend на Selectel) — САМ объект наблюдения GlitchTip. Если
|
||||
он лежит, основной вебхук-получатель лежит вместе с ним, и алерт об этом не
|
||||
доходит именно тогда, когда нужнее всего. `POST /glitchtip` — второй
|
||||
получатель на ДРУГОМ хосте (Beget, рядом с этим сервисом), не зависящий от
|
||||
здоровья продукта.
|
||||
|
||||
Проверяем на уровне функций, а не полного HTTP-транспорта (тот же приём, что
|
||||
`ops/glitchtip-auth-forwarder/test_forwarder.py`): `BaseHTTPRequestHandler`
|
||||
неудобно поднимать без реального сокета, а бизнес-логика — секрет, рендер,
|
||||
отправка — целиком вынесена в чистые функции модуля.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
# Env — ДО импорта app.py: BOT_TOKEN/CHAT_ID читаются на уровне модуля.
|
||||
os.environ.setdefault("METRICS_TELEGRAM_BOT_TOKEN", "test-bot-token")
|
||||
os.environ.setdefault("METRICS_TELEGRAM_CHAT_ID", "-1001234567890")
|
||||
os.environ.setdefault("METRICS_TELEGRAM_TOPIC_ID", "158")
|
||||
os.environ["ALERT_ACK_GLITCHTIP_SECRET"] = "correct-secret"
|
||||
|
||||
import app as alert_ack # noqa: E402
|
||||
|
||||
|
||||
def test_verify_glitchtip_secret_accepts_correct_value() -> None:
|
||||
assert alert_ack._verify_glitchtip_secret("correct-secret") is True
|
||||
|
||||
|
||||
def test_verify_glitchtip_secret_rejects_wrong_value() -> None:
|
||||
"""Неверный секрет — отказ."""
|
||||
assert alert_ack._verify_glitchtip_secret("wrong-secret") is False
|
||||
|
||||
|
||||
def test_verify_glitchtip_secret_rejects_empty_value() -> None:
|
||||
assert alert_ack._verify_glitchtip_secret("") is False
|
||||
|
||||
|
||||
def test_verify_glitchtip_secret_fails_closed_when_unconfigured(monkeypatch) -> None:
|
||||
"""Пустой ALERT_ACK_GLITCHTIP_SECRET — отказ всем, а не тихий fail-open."""
|
||||
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
|
||||
assert alert_ack._verify_glitchtip_secret("correct-secret") is False
|
||||
assert alert_ack._verify_glitchtip_secret("") is False
|
||||
|
||||
|
||||
def test_render_glitchtip_full_payload_marks_fallback_channel() -> None:
|
||||
payload = {
|
||||
"text": "GlitchTip Alert: Something broke",
|
||||
"attachments": [
|
||||
{
|
||||
"title": "TypeError: boom",
|
||||
"title_link": "https://errors.gendsgn.ru/issue/1",
|
||||
"text": "подробности ошибки",
|
||||
"color": "#ff0000",
|
||||
"fields": [{"title": "Project", "value": "tradein-backend"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
text = alert_ack._render_glitchtip(payload)
|
||||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||||
assert "Проект: tradein-backend" in text
|
||||
assert "TypeError: boom" in text
|
||||
assert "https://errors.gendsgn.ru/issue/1" in text
|
||||
|
||||
|
||||
def test_render_glitchtip_survives_payload_without_attachments() -> None:
|
||||
"""Тело без attachments не роняет сервис — только текст."""
|
||||
text = alert_ack._render_glitchtip({"text": "просто текст без вложений"})
|
||||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||||
assert "просто текст без вложений" in text
|
||||
|
||||
|
||||
def test_render_glitchtip_survives_empty_payload() -> None:
|
||||
"""Пустой словарь (нет ни text, ни attachments) — тоже не должен падать."""
|
||||
text = alert_ack._render_glitchtip({})
|
||||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||||
assert "GlitchTip alert" in text
|
||||
|
||||
|
||||
def test_render_glitchtip_survives_malformed_attachments() -> None:
|
||||
"""attachments/fields неожиданной формы (не список, не словарь, битые
|
||||
типы) — резервный канал не должен падать на кривом теле."""
|
||||
payload = {
|
||||
"text": "test",
|
||||
"attachments": [
|
||||
"not-a-dict",
|
||||
{"fields": "not-a-list"},
|
||||
{"fields": [{"title": "Project"}]}, # value отсутствует
|
||||
None,
|
||||
],
|
||||
}
|
||||
text = alert_ack._render_glitchtip(payload)
|
||||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||||
|
||||
|
||||
def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None:
|
||||
"""Верный секрет уже проверен вызывающей стороной (do_POST) — здесь
|
||||
проверяем, что отрендеренное сообщение реально уходит в тот же chat/topic,
|
||||
что и Alertmanager-алерты этого сервиса, без кнопки подтверждения."""
|
||||
calls = []
|
||||
monkeypatch.setattr(alert_ack, "_tg", lambda method, payload: calls.append((method, payload)))
|
||||
|
||||
alert_ack._send_glitchtip_alert({"text": "boom", "attachments": []})
|
||||
|
||||
assert len(calls) == 1
|
||||
method, sent = calls[0]
|
||||
assert method == "sendMessage"
|
||||
assert sent["chat_id"] == alert_ack.CHAT_ID
|
||||
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
|
||||
assert "reply_markup" not in sent
|
||||
assert "boom" in sent["text"]
|
||||
|
||||
|
||||
# ── Keep-alive: отказ не должен ронять СЛЕДУЮЩИЙ запрос ──────────────────────
|
||||
# Эти четыре теста — единственные, что поднимают настоящий сокет. Дефект,
|
||||
# который они стерегут, живёт именно в транспорте и на уровне функций невидим:
|
||||
# ветка отказа отвечала, не вычитав тело запроса, а `protocol_version` здесь
|
||||
# HTTP/1.1, то есть соединение переиспользуется (и Caddy перед сервисом держит
|
||||
# пул к апстриму). Непрочитанное тело оставалось в сокете, и следующий запрос
|
||||
# по тому же соединению начинался с чужих байт.
|
||||
#
|
||||
# Поймано на проде 12.09.2026: зонд без секрета получил 401, а следующий —
|
||||
# уже с верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST').
|
||||
# То есть один отказ съедал следующий НАСТОЯЩИЙ алерт.
|
||||
|
||||
|
||||
def _serve_in_background(monkeypatch):
|
||||
"""Поднимает Handler на эфемерном порту, глушит отправку в Telegram."""
|
||||
import threading as _threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
sent: list[dict] = []
|
||||
monkeypatch.setattr(alert_ack, "_send_glitchtip_alert", sent.append)
|
||||
monkeypatch.setattr(alert_ack, "_send_alert", sent.append)
|
||||
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", 0), alert_ack.Handler)
|
||||
thread = _threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return srv, sent
|
||||
|
||||
|
||||
def _raw_post(sock, path: str, body: bytes, headers: str = "") -> str:
|
||||
"""Шлёт POST по уже открытому сокету и возвращает статусную строку."""
|
||||
req = (
|
||||
f"POST {path} HTTP/1.1\r\n"
|
||||
f"Host: localhost\r\n"
|
||||
f"Content-Type: application/json\r\n"
|
||||
f"Content-Length: {len(body)}\r\n"
|
||||
f"{headers}"
|
||||
f"\r\n"
|
||||
).encode() + body
|
||||
sock.sendall(req)
|
||||
# Читаем ровно заголовки: тела короткие, Content-Length всегда проставлен.
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
head, _, rest = buf.partition(b"\r\n\r\n")
|
||||
length = 0
|
||||
for line in head.split(b"\r\n")[1:]:
|
||||
if line.lower().startswith(b"content-length:"):
|
||||
length = int(line.split(b":")[1])
|
||||
while len(rest) < length:
|
||||
rest += sock.recv(4096)
|
||||
return head.split(b"\r\n")[0].decode()
|
||||
|
||||
|
||||
def _pair_on_one_connection(monkeypatch, first_headers: str, first_path: str = "/glitchtip"):
|
||||
import socket
|
||||
|
||||
srv, sent = _serve_in_background(monkeypatch)
|
||||
try:
|
||||
sock = socket.create_connection(srv.server_address, timeout=5)
|
||||
try:
|
||||
first = _raw_post(sock, first_path, b'{"text":"probe"}', first_headers)
|
||||
second = _raw_post(
|
||||
sock,
|
||||
"/glitchtip",
|
||||
b'{"text":"real alert"}',
|
||||
"X-GlitchTip-Secret: correct-secret\r\n",
|
||||
)
|
||||
finally:
|
||||
sock.close()
|
||||
finally:
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
return first, second, sent
|
||||
|
||||
|
||||
def test_rejected_request_does_not_break_next_one_on_same_connection(monkeypatch) -> None:
|
||||
"""401 без секрета, следом законный алерт по ТОМУ ЖЕ соединению — 200."""
|
||||
first, second, sent = _pair_on_one_connection(monkeypatch, "")
|
||||
assert "401" in first
|
||||
assert "200" in second, f"второй запрос испорчен первым: {second}"
|
||||
assert sent == [{"text": "real alert"}]
|
||||
|
||||
|
||||
def test_unconfigured_secret_does_not_break_next_request(monkeypatch) -> None:
|
||||
"""503 при пустом секрете тоже обязан вычитать тело."""
|
||||
import socket
|
||||
|
||||
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
|
||||
srv, _sent = _serve_in_background(monkeypatch)
|
||||
try:
|
||||
sock = socket.create_connection(srv.server_address, timeout=5)
|
||||
try:
|
||||
first = _raw_post(sock, "/glitchtip", b'{"text":"probe"}')
|
||||
second = _raw_post(sock, "/glitchtip", b'{"text":"again"}')
|
||||
finally:
|
||||
sock.close()
|
||||
finally:
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
assert "503" in first
|
||||
assert "503" in second, f"второй запрос испорчен первым: {second}"
|
||||
|
||||
|
||||
def test_unknown_path_does_not_break_next_request(monkeypatch) -> None:
|
||||
"""404 на чужом пути — та же ветка раннего ответа, то же требование."""
|
||||
first, second, sent = _pair_on_one_connection(monkeypatch, "", first_path="/nope")
|
||||
assert "404" in first
|
||||
assert "200" in second, f"второй запрос испорчен первым: {second}"
|
||||
assert sent == [{"text": "real alert"}]
|
||||
|
||||
|
||||
def test_bad_json_on_alertmanager_does_not_break_next_request(monkeypatch) -> None:
|
||||
"""400 на неразобранном теле /alertmanager — тело уже вычитано, связь цела."""
|
||||
import socket
|
||||
|
||||
srv, sent = _serve_in_background(monkeypatch)
|
||||
try:
|
||||
sock = socket.create_connection(srv.server_address, timeout=5)
|
||||
try:
|
||||
first = _raw_post(sock, "/alertmanager", b"{not json")
|
||||
second = _raw_post(
|
||||
sock,
|
||||
"/glitchtip",
|
||||
b'{"text":"real alert"}',
|
||||
"X-GlitchTip-Secret: correct-secret\r\n",
|
||||
)
|
||||
finally:
|
||||
sock.close()
|
||||
finally:
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
assert "400" in first
|
||||
assert "200" in second, f"второй запрос испорчен первым: {second}"
|
||||
assert sent == [{"text": "real alert"}]
|
||||
|
|
@ -71,16 +71,9 @@ route:
|
|||
repeat_interval: 3h
|
||||
|
||||
inhibit_rules:
|
||||
# Если хост целиком недоступен, не сыпать отдельно про каждый его
|
||||
# warning-сервис. `target_matchers` НАМЕРЕННО ограничен одним warning
|
||||
# (#3471): раньше сюда попадал и critical, и падение node-exporter молча
|
||||
# гасило заодно PostgresLongTransactionCritical и все critical cAdvisor-
|
||||
# алерты того же хоста — самое важное сообщение исчезало вместе с шумом,
|
||||
# который оно должно было подавить. Warning того же хоста подавлять по-
|
||||
# прежнему стоит (диск/память/своп неотличимы от «нет данных»), а critical
|
||||
# обязан пережить это подавление и дойти до дежурного отдельно.
|
||||
# Если хост целиком недоступен, не сыпать отдельно про каждый его сервис.
|
||||
- source_matchers: [alertname = "HostAgentDown"]
|
||||
target_matchers: [severity = "warning"]
|
||||
target_matchers: [severity =~ "warning|critical"]
|
||||
equal: ["host"]
|
||||
|
||||
receivers:
|
||||
|
|
|
|||
|
|
@ -119,27 +119,6 @@ prometheus.scrape "postgres" {
|
|||
scrape_interval = "60s"
|
||||
}
|
||||
|
||||
// ═══ REDIS И ОЧЕРЕДЬ CELERY (#3471) ═════════════════════════════════════════════
|
||||
// До этой правки ни одной серии redis_* / celery_* в Prometheus не было: глубина
|
||||
// очереди, число живых воркеров и потеря соединения с брокером были невидимы —
|
||||
// переполнение очереди и залипший воркер снаружи выглядели одинаково, тишиной.
|
||||
|
||||
prometheus.scrape "redis" {
|
||||
targets = [
|
||||
{ __address__ = "gendesign-redis-exporter:9121", job = "redis" },
|
||||
]
|
||||
forward_to = [prometheus.remote_write.central.receiver]
|
||||
scrape_interval = "30s"
|
||||
}
|
||||
|
||||
prometheus.scrape "celery" {
|
||||
targets = [
|
||||
{ __address__ = "gendesign-celery-exporter:9808", job = "celery" },
|
||||
]
|
||||
forward_to = [prometheus.remote_write.central.receiver]
|
||||
scrape_interval = "30s"
|
||||
}
|
||||
|
||||
// ═══ МЕТРИКИ ПРИЛОЖЕНИЙ ════════════════════════════════════════════════════════
|
||||
// Эндпоинты появляются в части 3. До этого скрейп просто отдаёт `up 0` — и это
|
||||
// правильно: цель видна как недоступная, а не отсутствует молча.
|
||||
|
|
|
|||
|
|
@ -57,13 +57,9 @@ prometheus.scrape "cadvisor" {
|
|||
prometheus.relabel "cadvisor_trim" {
|
||||
forward_to = [prometheus.remote_write.central.receiver]
|
||||
|
||||
// `up`/`scrape_samples_scraped` — служебные ряды самого скрейпа, не
|
||||
// container_*-метрики. Без явного допуска этот keep-фильтр резал их вместе
|
||||
// с прочим шумом, и у cAdvisor как job'а не было своей серии `up` вообще —
|
||||
// его смерть выглядела так же, как «ничего не изменилось» (#3471).
|
||||
rule {
|
||||
source_labels = ["__name__"]
|
||||
regex = "up|scrape_samples_scraped|container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
|
||||
regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
|
||||
action = "keep"
|
||||
}
|
||||
|
||||
|
|
@ -74,15 +70,9 @@ prometheus.relabel "cadvisor_trim" {
|
|||
//
|
||||
// NB: на пустые панели это правило НЕ влияло. Причина была в cAdvisor 0.52 на
|
||||
// Docker 29 — до сюда доезжал ровно один ряд, корневой. Лечится версией 0.55.1.
|
||||
//
|
||||
// `up`/`scrape_samples_scraped` лейбла `name` не несут вовсе (они не про
|
||||
// конкретный контейнер, а про сам скрейп) — фильтр по нему вырезал бы и их.
|
||||
// Поэтому здесь смотрим на пару (__name__, name): для служебных рядов
|
||||
// достаточно самого __name__, для контейнерных метрик по-прежнему обязателен
|
||||
// непустой name.
|
||||
rule {
|
||||
source_labels = ["__name__", "name"]
|
||||
regex = "up;.*|scrape_samples_scraped;.*|container_[^;]*;.+"
|
||||
source_labels = ["name"]
|
||||
regex = ".+"
|
||||
action = "keep"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@
|
|||
{
|
||||
"type": "timeseries",
|
||||
"title": "Запросы по классам ответов",
|
||||
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка. Линии НЕ стекируются: высота красной линии — это и есть число пятисоток, а не сумма со всем, что под ней.",
|
||||
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
|
||||
"targets": [
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
{ "refId": "D", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m]))", "legendFormat": "{{app}} · 5xx" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } },
|
||||
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 25, "stacking": { "mode": "normal" }, "showPoints": "never", "lineWidth": 1 } },
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": ".*2xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] },
|
||||
{ "matcher": { "id": "byRegexp", "options": ".*3xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] },
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
{
|
||||
"uid": "gendesign-product",
|
||||
"title": "Продуктовые метрики",
|
||||
"description": "Числа бизнеса, а не процесса: сколько людей реально что-то сделали в «Мере» и «Птице» (#3471). Источник — те же счётчики Prometheus, что и на дашборде «Приложения», только считают не HTTP-статусы, а продуктовые события (оценка, лид, отчёт, вход, обращение в поддержку). Часовое окно нарочно грубое: при текущем трафике (сотни событий в сутки) минутные всплески — шум, а не сигнал.",
|
||||
"tags": ["gendesign", "product"],
|
||||
"timezone": "browser",
|
||||
"editable": false,
|
||||
"schemaVersion": 39,
|
||||
"refresh": "5m",
|
||||
"time": { "from": "now-24h", "to": "now" },
|
||||
"panels": [
|
||||
{ "type": "row", "title": "Мера — воронка продукта", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Оценок в час, по исходу",
|
||||
"description": "insufficient_data — не ошибка: аналогов рядом с адресом не нашлось, расчёт прошёл штатно. Тревожиться стоит, если эта линия начинает расти быстрее ok — значит покрытие рынка проседает, а не то, что сломался расчёт.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (outcome) (increase(mera_estimates_total[1h]))",
|
||||
"legendFormat": "{{outcome}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"min": 0,
|
||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byName", "options": "insufficient_data" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
|
||||
{ "matcher": { "id": "byName", "options": "ok" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Подсказки адреса в час, нашёлся ли результат",
|
||||
"description": "found=no — человек напечатал адрес, а автокомплит ничего не предложил: либо адреса нет в базе, либо он вне зоны покрытия. Устойчивый рост этой линии — повод расширять покрытие, а не баг одного запроса.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (found) (increase(mera_address_suggestions_total[1h]))",
|
||||
"legendFormat": "найден: {{found}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"min": 0,
|
||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": ".*no.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Лиды и скачанные отчёты в час",
|
||||
"description": "Лид — заявка с телефоном после оценки, отчёт — скачанный PDF по оценке. Оба редкие: сверяйте с недельным окном (кнопка времени вверху), часовой провал сам по себе ни о чём не говорит при таком трафике.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "increase(mera_leads_total[1h])", "legendFormat": "лиды" },
|
||||
{ "refId": "B", "expr": "increase(mera_reports_exported_total[1h])", "legendFormat": "отчёты" }
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"min": 0,
|
||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Входы в час, по исходу",
|
||||
"description": "Устойчивый рост failed при ровном success — либо перебор паролей, либо сломался клиент (истёкшая сессия, старый билд фронта). При подозрении на перебор смотрите заодно панель «Отказы авторизации и лимитера» на дашборде «Приложения» — там 401/429 по HTTP.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (result) (increase(mera_logins_total[1h]))",
|
||||
"legendFormat": "{{result}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"min": 0,
|
||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] },
|
||||
{ "matcher": { "id": "byName", "options": "success" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Обращения в поддержку в час, по каналу",
|
||||
"description": "anon — обращения с экрана входа, без логина (типично «не могу войти»), web — уже залогиненные пользователи. Всплеск anon без роста web — обычно означает баг именно в форме/процессе входа, а не общий рост нагрузки на поддержку.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (channel) (increase(mera_support_messages_total[1h]))",
|
||||
"legendFormat": "{{channel}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"min": 0,
|
||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byName", "options": "anon" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Экспортов отчётов по участку в час, по формату",
|
||||
"description": "Формат — то, что реально скачали: md/json/docx/pptx/pdf/tg-сводка §22-форсайта плюс best_layouts_pdf (ТЗ на проектирование). Провал всех форматов разом при живом трафике на дашборде «Приложения» значит, что сломан сам экспорт, а не рендер одного конкретного формата.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 26 },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (format) (increase(sitefinder_reports_exported_total[1h]))",
|
||||
"legendFormat": "{{format}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"min": 0,
|
||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# Эта папка сознательно пустая
|
||||
|
||||
Grafana умеет провижинить contact points, notification policies и alert rules
|
||||
файлами отсюда (`/etc/grafana/provisioning/alerting`). Не клади их сюда.
|
||||
|
||||
Решение (#3158, 12.09.2026): единственный путь доставки тревог — Prometheus
|
||||
(правила) + Alertmanager (маршрутизация, Telegram). Grafana только рисует.
|
||||
Встроенный Alerting выключен явно (`GF_UNIFIED_ALERTING_ENABLED: "false"` в
|
||||
`docker-compose.metrics.yml`, секция `grafana`) — при живом API 12.09.2026
|
||||
единственным контакт-поинтом был стоковый `grafana-default-email` на
|
||||
`example@email.com`, `GF_SMTP_*` не задан, правил ноль. Файл сюда работать не
|
||||
заставит: движок alerting выключен на уровне сервиса, провижининг в эту папку
|
||||
Grafana просто не читает.
|
||||
|
||||
Если понадобится включить обратно — сначала пересмотреть само решение в
|
||||
`docker-compose.metrics.yml`, а не просто добавить файл в эту папку.
|
||||
|
|
@ -44,21 +44,6 @@ groups:
|
|||
summary: "С продуктового хоста 15 минут не приходят метрики"
|
||||
description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write."
|
||||
|
||||
# cAdvisor как job не публикует `up` естественным образом — до фикса
|
||||
# keep-фильтра в alloy-infra.alloy/alloy-apps.alloy (#3471) эта серия
|
||||
# вырезалась тем же правилом, что чистит container_* от мусорных
|
||||
# лейблов. Итог — смерть cAdvisor молча гасила ContainerRestartLoop и
|
||||
# ContainerNearMemoryLimit: обе метрики просто переставали поступать, а
|
||||
# выглядело это как «событий не было».
|
||||
- alert: CadvisorDown
|
||||
expr: up{job="cadvisor"} == 0 or absent(up{job="cadvisor"})
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "cAdvisor не отвечает"
|
||||
description: "{{ if $labels.host }}{{ $labels.host }}: {{ end }}job=\"cadvisor\" вернул up=0 либо серия пропала целиком. Без неё контейнерные алерты этого хоста молчат вне зависимости от реального состояния контейнеров."
|
||||
|
||||
# ── Хост ────────────────────────────────────────────────────────────────────
|
||||
- name: host
|
||||
interval: 60s
|
||||
|
|
@ -140,19 +125,11 @@ groups:
|
|||
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
|
||||
|
||||
# Подошёл к своему mem_limit — следующий шаг OOM-kill.
|
||||
#
|
||||
# ФОРМА ВЫРАЖЕНИЯ ВАЖНА, а не только условие. `A and B` возвращает ЗНАЧЕНИЯ
|
||||
# ЛЕВОЙ части, отфильтрованные правой, — то есть в `$value` попадает именно
|
||||
# A. Прежняя запись (`limit > 0 and working_set/limit > 0.90`) слала в
|
||||
# Telegram лимит В БАЙТАХ, отрендеренный как процент: боевое сообщение
|
||||
# 12.09 — «2.684e+11% от mem_limit» при limit = 2 684 354 560 Б. Условие
|
||||
# при этом срабатывало верно, врал только текст. Поэтому отношение стоит
|
||||
# СЛЕВА, а отсев нулевого лимита убран внутрь знаменателя: `(X > 0)`
|
||||
# выбрасывает серии без лимита ДО деления.
|
||||
- alert: ContainerNearMemoryLimit
|
||||
expr: |
|
||||
container_memory_working_set_bytes{name!=""}
|
||||
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
|
||||
container_spec_memory_limit_bytes{name!=""} > 0
|
||||
and container_memory_working_set_bytes{name!=""}
|
||||
/ container_spec_memory_limit_bytes{name!=""} > 0.90
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
|
|
@ -160,135 +137,6 @@ groups:
|
|||
summary: "Контейнер у своего потолка памяти"
|
||||
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
|
||||
|
||||
# tradein-tgbot и tradein-scraper не HTTP-сервисы — у них нет `up{}`
|
||||
# вообще, поэтому крэш-без-рестарта или удаление контейнера иначе не
|
||||
# поймать. `absent()` на каждое имя отдельно (не одним regex-селектором):
|
||||
# regex-селектор с несколькими сериями считается «пустым» только когда
|
||||
# ПРОПАЛИ ОБЕ — если жив хотя бы один из двух контейнеров, absent() по
|
||||
# общему selector'у молчит и не заметит пропажу второго.
|
||||
#
|
||||
# ВАЖНО, чего это правило НЕ ловит: container_last_seen обновляется, пока
|
||||
# Docker видит контейнер живым, — зависший, но не упавший процесс
|
||||
# (внутренний цикл встал, контейнер по-прежнему числится running) эту
|
||||
# метрику не тронет. Слепая зона «живой процесс с застрявшим циклом»
|
||||
# остаётся открытой: подходящей метрики для неё сейчас нет.
|
||||
- alert: TradeInBackgroundContainerMissing
|
||||
expr: |
|
||||
absent(container_last_seen{name="tradein-tgbot"})
|
||||
or absent(container_last_seen{name="tradein-scraper"})
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Фоновый контейнер Меры пропал из cAdvisor"
|
||||
description: "{{ $labels.name }}: серия container_last_seen исчезла — контейнер, судя по всему, не работает и не перезапускается."
|
||||
|
||||
# ── Приложение ──────────────────────────────────────────────────────────────
|
||||
# `job="app"` — job из alloy-apps.alloy, лейбл `app` различает продукты
|
||||
# (sitefinder / mera). Метрики отдаёт `MetricsMiddleware`
|
||||
# (`backend/app/observability/metrics.py` у «Птицы»,
|
||||
# `tradein-mvp/backend/app/observability/metrics.py` у «Меры») — счётчик
|
||||
# `http_requests_total{method,route,status}` и гистограмма
|
||||
# `http_request_duration_seconds{method,route}`. До этой группы доля 5xx и
|
||||
# задержка были видны только постфактум в GlitchTip, без порога срабатывания
|
||||
# (#3471).
|
||||
#
|
||||
# severity: critical + host: apps здесь ОБЯЗАТЕЛЬНЫ содержательно, не для
|
||||
# красоты: именно эта пара матчится маршрутом telegram-clients в
|
||||
# alertmanager.yml.tmpl — тот зовёт дежурного и напоминает каждые 30 минут.
|
||||
# `host` выставлен статически: `sum by (app)` вырезает его из результата
|
||||
# запроса, а job="app" в принципе существует только на продуктовом хосте.
|
||||
- name: app
|
||||
interval: 60s
|
||||
rules:
|
||||
# Гейт по RPS внутри знаменателя — тот же приём, что у
|
||||
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
|
||||
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
|
||||
# дежурного зря.
|
||||
- alert: AppHighErrorRate
|
||||
expr: |
|
||||
sum by (app) (rate(http_requests_total{job="app", status=~"5.."}[5m]))
|
||||
/ (sum by (app) (rate(http_requests_total{job="app"}[5m])) > 0.1) > 0.05
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
host: apps
|
||||
annotations:
|
||||
summary: "Доля 5xx выше 5%"
|
||||
description: "{{ $labels.app }}: {{ $value | humanizePercentage }} ответов 5xx за последние 5 минут при RPS выше 0.1."
|
||||
|
||||
# Порог 5s — заведомо выше рабочего профиля обоих продуктов (у «Меры»
|
||||
# типичный расчёт 90мс, у «Птицы» верхняя граница гистограммы — 60с под
|
||||
# тяжёлую геометрию, но это единичные хвостовые запросы, не p95).
|
||||
# Калибровка по реальному трафику — отдельная задача, не эта.
|
||||
- alert: AppHighLatencyP95
|
||||
expr: |
|
||||
histogram_quantile(0.95, sum by (le, app) (rate(http_request_duration_seconds_bucket{job="app"}[10m]))) > 5
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critical
|
||||
host: apps
|
||||
annotations:
|
||||
summary: "p95 задержки ответа выше 5 секунд"
|
||||
description: "{{ $labels.app }}: p95 за 10 минут — {{ $value | humanizeDuration }}."
|
||||
|
||||
# ── Redis и очередь Celery (#3471) ───────────────────────────────────────────
|
||||
# Слепая зона: до этих правил ни redis_*, ни celery_* не собирались вовсе.
|
||||
# Redis — общий инстанс на три потребителя (celery-брокер Site Finder, кэш
|
||||
# trade-in, glitchtip — см. docker-compose.prod.yml), поэтому его смерть
|
||||
# клиентская, отсюда severity: critical без явного host: apps — серия
|
||||
# приходит только с продуктового alloy (alloy-apps.alloy), host в неё
|
||||
# проставляется через external_labels уже на месте.
|
||||
#
|
||||
# ⚠️ Имена метрик celery_queue_length / celery_worker_up /
|
||||
# celery_task_failed_total — по документации celery-exporter на момент
|
||||
# написания правил, без проверки на реальном брокере (см. комментарий у
|
||||
# сервиса celery-exporter в docker-compose.metrics-agent.yml). Сверить после
|
||||
# первого деплоя.
|
||||
- name: redis-celery
|
||||
interval: 60s
|
||||
rules:
|
||||
- alert: RedisDown
|
||||
expr: up{job="redis"} == 0 or redis_up == 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Redis недоступен"
|
||||
description: "redis_exporter не может достучаться до Redis (или сам процесс лёг). Разом теряют связь celery-брокер Site Finder, SearchCache trade-in и glitchtip."
|
||||
|
||||
# `absent()` — как у CadvisorDown: если сам celery-exporter не поднялся,
|
||||
# серии celery_worker_up не будет вообще, а не будет со значением 0.
|
||||
- alert: NoActiveCeleryWorkers
|
||||
expr: count(celery_worker_up == 1) == 0 or absent(celery_worker_up)
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Ни одного живого воркера Celery"
|
||||
description: "celery-exporter не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
||||
|
||||
# Порог 150 ПРЕДВАРИТЕЛЬНЫЙ: реальных данных по глубине очереди нет (до
|
||||
# этой правки метрика не собиралась). beat_schedule.py на момент правки
|
||||
# содержит 44 periodic-задачи с разным временем срабатывания — даже
|
||||
# маловероятный залп всех разом даёт кратно меньше 150. Порог взят с
|
||||
# запасом сознательно и требует пересмотра через неделю наблюдений по
|
||||
# факту `celery_queue_length`.
|
||||
#
|
||||
# `delta(...) >= 0` — очередь не УМЕНЬШАЕТСЯ за 15 минут (тот же приём,
|
||||
# что и "растёт и не разгребается" в тексте задачи): просто высокое
|
||||
# значение без этого условия поймало бы и здоровый кратковременный всплеск.
|
||||
- alert: CeleryQueueGrowing
|
||||
expr: |
|
||||
celery_queue_length{queue_name="celery"} > 150
|
||||
and delta(celery_queue_length{queue_name="celery"}[15m]) >= 0
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Очередь Celery растёт и не разгребается"
|
||||
description: "В очереди {{ $value }} задач, за 15 минут меньше не стало. Похоже на залипший воркер или устойчивый рост нагрузки."
|
||||
|
||||
# ── Postgres ────────────────────────────────────────────────────────────────
|
||||
- name: postgres
|
||||
interval: 60s
|
||||
|
|
@ -324,20 +172,12 @@ groups:
|
|||
|
||||
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
|
||||
# позволило 91 день не замечать 198 апдейтов на строку.
|
||||
#
|
||||
# Та же ловушка `A and B`, что и у ContainerNearMemoryLimit, и здесь она
|
||||
# опаснее: в `$value` попадал `rate(tup_upd[6h])` — АПДЕЙТОВ В СЕКУНДУ, а
|
||||
# текст называл это долей HOT. Боевое сообщение 12.09 — «доля HOT 75.21%»
|
||||
# при пороге срабатывания «доля < 20%»: число само себе противоречило и
|
||||
# выглядело правдоподобно, поэтому никто не заметил (замер 12.09 по той же
|
||||
# таблице listings: rate(tup_upd[6h]) = 0.0411 → сообщение сказало бы
|
||||
# «4.11%», настоящая доля HOT = 0.00%). Гейт по объёму апдейтов
|
||||
# (> 0.5/с — «трафик есть, значит вопрос осмыслен») перенесён внутрь
|
||||
# знаменателя: там он и фильтрует серии, и защищает от деления на ноль.
|
||||
- alert: PostgresLowHotUpdateRatio
|
||||
expr: |
|
||||
rate(pg_table_write_amplification_tup_upd[6h]) > 0.5
|
||||
and
|
||||
rate(pg_table_write_amplification_tup_hot_upd[6h])
|
||||
/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5) < 0.2
|
||||
/ rate(pg_table_write_amplification_tup_upd[6h]) < 0.2
|
||||
for: 6h
|
||||
labels:
|
||||
severity: warning
|
||||
|
|
@ -345,11 +185,6 @@ groups:
|
|||
summary: "Обновления идут мимо HOT"
|
||||
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
|
||||
|
||||
# Третье правило того же семейства `A and B` — и единственное, где текст
|
||||
# верен: `$value` тут печатается без humanize, а слева стоит ровно то, что
|
||||
# описание и называет («N мёртвых»). Совпадение, а не заслуга формы: если
|
||||
# когда-нибудь захочется печатать здесь ДОЛЮ, отношение придётся вынести
|
||||
# влево, как в двух правилах выше.
|
||||
- alert: PostgresDeadTuplesHigh
|
||||
expr: |
|
||||
pg_table_write_amplification_dead_tup > 1000000
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ретранслятор Bot API продукта через инфраструктурный хост Beget (#3471).
|
||||
|
||||
ЗАЧЕМ. Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера
|
||||
`tradein-tgbot` на Selectel — 9 успешных из 12, три `ConnectTimeout`. TCP на 443
|
||||
до адреса, резолвящегося на Selectel (149.154.167.220) — 5 из 6. Тот же TCP до
|
||||
адреса, резолвящегося на Beget (149.154.166.110) — 8 из 8. За сутки в логе бота
|
||||
508 строк `network error`, за 30 дней 92 обрыва итерации poll loop. Путь до
|
||||
Telegram с Selectel лоссовый, с Beget чистый: Alertmanager (живёт на Beget)
|
||||
пишет в тот же чат без проблем, а бот поддержки на Selectel часть отправок
|
||||
теряет. Решение — не чинить сеть Selectel (вне контроля), а дать продуктовым
|
||||
сервисам обходной путь через хост, с которого Telegram отвечает надёжно.
|
||||
|
||||
ЧТО ПРОКСИРУЕТСЯ. Метод Bot API целиком — путь `/bot<TOKEN>/<method>`, тело,
|
||||
query. Не только отправка: `getUpdates` (long-poll) страдает от потерь ровно
|
||||
так же, как `sendMessage`, и это тот же HTTP-путь через тот же испорченный
|
||||
транзит.
|
||||
|
||||
ТОКЕН НЕ ЛОГИРУЕТСЯ. Он приходит в пути запроса. `log_request` переопределён
|
||||
ПОЛНОСТЬЮ (не вызывает `super()`): дефолт stdlib кладёт в лог `requestline`
|
||||
целиком, включая токен. Здесь путь редактируется до записи в лог.
|
||||
|
||||
АУТЕНТИФИКАЦИЯ. Общий секрет в заголовке `X-Relay-Secret`, по образцу общего
|
||||
секрета `X-Internal-Auth-Secret` в этом же стеке (`app/core/config.py`,
|
||||
`tradein_internal_auth_secret`) — сравнение строкой (не сравнение таймингов:
|
||||
секрет не является паролем пользователя, ценность атаки по времени здесь
|
||||
исчезающе мала при секрете длиной от 32 байт, а stdlib `hmac` — лишняя
|
||||
зависимость ради stdlib-only сервиса). Домен публичный, без секрета — отказ,
|
||||
а не тихий приём.
|
||||
|
||||
БЕЗ ЗАВИСИМОСТЕЙ. Только стандартная библиотека — тот же принцип, что у
|
||||
`ops/metrics/alert-ack/app.py`: сервис обязан подниматься, даже когда всё
|
||||
остальное сломано, и не тащить установку пакетов.
|
||||
|
||||
ОТКАЗ РЕТРАНСЛЯТОРА НЕ ДОЛЖЕН РОНЯТЬ БОТА. Это реализовано НЕ здесь, а на
|
||||
стороне клиента (`tradein-mvp/backend/app/services/tgbot/client.py`): при
|
||||
транспортном отказе похода на ретранслятор клиент делает одну попытку
|
||||
напрямую к `api.telegram.org`. Здесь достаточно не быть точкой отказа хуже
|
||||
прямого пути: таймаут до апстрима подобран так, чтобы не обрубать long-poll
|
||||
`getUpdates` раньше, чем это сделал бы сам Telegram.
|
||||
|
||||
Переменные окружения:
|
||||
TG_RELAY_SECRET обязательна — общий секрет, сверяется с
|
||||
заголовком X-Relay-Secret
|
||||
TG_RELAY_PORT порт (по умолчанию 8080)
|
||||
TG_RELAY_UPSTREAM_TIMEOUT_S таймаут запроса к api.telegram.org в секундах
|
||||
(по умолчанию 75 — с запасом над самым долгим
|
||||
long-poll getUpdates, который шлёт клиент:
|
||||
timeout=30 + 10с запаса на стороне httpx = 40с)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("tg-relay")
|
||||
|
||||
RELAY_SECRET = os.environ.get("TG_RELAY_SECRET", "")
|
||||
UPSTREAM = "https://api.telegram.org"
|
||||
UPSTREAM_TIMEOUT_S = float(os.environ.get("TG_RELAY_UPSTREAM_TIMEOUT_S", "75"))
|
||||
SECRET_HEADER = "X-Relay-Secret"
|
||||
|
||||
|
||||
def redact_path(path: str) -> str:
|
||||
"""Прячет токен из `/bot<TOKEN>/method[?query]` для логов и ошибок.
|
||||
|
||||
Вынесена в модульную функцию (не метод), чтобы быть проверяемой напрямую
|
||||
без поднятия HTTP-сервера.
|
||||
"""
|
||||
if not path.startswith("/bot"):
|
||||
return path
|
||||
rest = path[len("/bot") :]
|
||||
_token_part, sep, tail = rest.partition("/")
|
||||
if not sep:
|
||||
# Ни `/method`, ни query — токен без хвоста (или токен+query без slash).
|
||||
return "/bot<REDACTED>"
|
||||
tail = tail.split("?", 1)[0]
|
||||
return f"/bot<REDACTED>/{tail}" if tail else "/bot<REDACTED>"
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — сигнатура из stdlib
|
||||
log.info("%s", fmt % args)
|
||||
|
||||
def log_request(self, code="-", size="-") -> None: # noqa: A003 — сигнатура из stdlib
|
||||
# ПОЛНОСТЬЮ заменяет реализацию BaseHTTPRequestHandler (не вызывает
|
||||
# super()): та кладёт в лог self.requestline целиком, а он для Bot API
|
||||
# содержит токен в пути.
|
||||
log.info('%s "%s %s" %s', self.address_string(), self.command, redact_path(self.path), code)
|
||||
|
||||
def _reply(self, code: int, body: bytes, ctype: str = "application/json") -> None:
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
return bool(RELAY_SECRET) and self.headers.get(SECRET_HEADER) == RELAY_SECRET
|
||||
|
||||
def _proxy(self) -> None:
|
||||
if self.path == "/healthz":
|
||||
self._reply(200, b"ok", "text/plain; charset=utf-8")
|
||||
return
|
||||
if not self._authorized():
|
||||
self._reply(401, b'{"ok":false,"description":"unauthorized"}')
|
||||
return
|
||||
if not self.path.startswith("/bot"):
|
||||
self._reply(404, b'{"ok":false,"description":"not found"}')
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else None
|
||||
req = urllib.request.Request(
|
||||
UPSTREAM + self.path,
|
||||
data=body,
|
||||
method=self.command,
|
||||
headers={"Content-Type": self.headers.get("Content-Type") or "application/json"},
|
||||
)
|
||||
try:
|
||||
# Таймаут ЯВНО шире любого long-poll getUpdates клиента — иначе
|
||||
# ретранслятор обрубит соединение раньше площадки и превратит
|
||||
# штатный long-poll в вечный network error, то есть станет хуже
|
||||
# прямого пути, а не лучше.
|
||||
with urllib.request.urlopen(req, timeout=UPSTREAM_TIMEOUT_S) as resp:
|
||||
out = resp.read()
|
||||
self._reply(resp.status, out, resp.headers.get("Content-Type") or "application/json")
|
||||
except urllib.error.HTTPError as exc:
|
||||
# Telegram ответил ошибкой (4xx/5xx) — это НЕ отказ ретранслятора,
|
||||
# передаём как есть, клиент сам решает, ретраить или нет.
|
||||
out = exc.read()
|
||||
ctype = exc.headers.get("Content-Type") if exc.headers else None
|
||||
self._reply(exc.code, out, ctype or "application/json")
|
||||
except Exception as exc: # noqa: BLE001 — любой отказ апстрима не должен уронить сервис
|
||||
log.warning("upstream недоступен (%s): %s", redact_path(self.path), type(exc).__name__)
|
||||
self._reply(502, b'{"ok":false,"description":"relay upstream unreachable"}')
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 — имя из stdlib
|
||||
self._proxy()
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
||||
self._proxy()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not RELAY_SECRET:
|
||||
raise SystemExit("не задан TG_RELAY_SECRET — домен публичный, отказ на старте")
|
||||
port = int(os.environ.get("TG_RELAY_PORT", "8080"))
|
||||
log.info("tg-relay слушает :%d, upstream_timeout=%.0fs", port, UPSTREAM_TIMEOUT_S)
|
||||
ThreadingHTTPServer(("", port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
"""Тесты для tg-relay — ретранслятора Bot API продукта через Beget (#3471).
|
||||
|
||||
Гоняют реальный `ThreadingHTTPServer` на localhost (эфемерный порт), апстрим
|
||||
`urllib.request.urlopen` подменяется моком — реальный api.telegram.org НЕ
|
||||
дёргаем никогда.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import app as relay
|
||||
|
||||
|
||||
def _fake_upstream_response(status: int = 200, body: bytes = b'{"ok": true, "result": []}'):
|
||||
resp = mock.MagicMock()
|
||||
resp.status = status
|
||||
resp.read.return_value = body
|
||||
resp.headers.get.return_value = "application/json"
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def secret(monkeypatch):
|
||||
monkeypatch.setattr(relay, "RELAY_SECRET", "test-secret-value")
|
||||
return "test-secret-value"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(secret):
|
||||
httpd = relay.ThreadingHTTPServer(("127.0.0.1", 0), relay.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield httpd
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def _request(server, path, headers=None, method="GET", body=None):
|
||||
conn = http.client.HTTPConnection(*server.server_address, timeout=5)
|
||||
try:
|
||||
conn.request(method, path, body=body, headers=headers or {})
|
||||
resp = conn.getresponse()
|
||||
return resp.status, resp.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_redact_path_hides_token_from_method_path():
|
||||
assert relay.redact_path("/bot123456:ABC-DEF/sendMessage") == "/bot<REDACTED>/sendMessage"
|
||||
|
||||
|
||||
def test_redact_path_hides_bare_token():
|
||||
assert relay.redact_path("/bot123456:ABC-DEF/") == "/bot<REDACTED>"
|
||||
assert relay.redact_path("/bot123456:ABC-DEF") == "/bot<REDACTED>"
|
||||
|
||||
|
||||
def test_redact_path_leaves_non_bot_paths_untouched():
|
||||
assert relay.redact_path("/healthz") == "/healthz"
|
||||
|
||||
|
||||
def test_missing_secret_rejected_without_touching_upstream(server):
|
||||
with mock.patch.object(relay.urllib.request, "urlopen") as mocked:
|
||||
status, _body = _request(server, "/bot123:TOK/getMe")
|
||||
assert status == 401
|
||||
mocked.assert_not_called()
|
||||
|
||||
|
||||
def test_wrong_secret_rejected_without_touching_upstream(server):
|
||||
with mock.patch.object(relay.urllib.request, "urlopen") as mocked:
|
||||
status, _body = _request(server, "/bot123:TOK/getMe", headers={"X-Relay-Secret": "wrong"})
|
||||
assert status == 401
|
||||
mocked.assert_not_called()
|
||||
|
||||
|
||||
def test_valid_secret_passes_method_path_and_body_unmodified(server, secret):
|
||||
with mock.patch.object(
|
||||
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
|
||||
) as mocked:
|
||||
status, body = _request(
|
||||
server,
|
||||
"/bot123:TOK/sendMessage",
|
||||
headers={"X-Relay-Secret": secret, "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
body=b'{"chat_id": 1, "text": "hi"}',
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert json.loads(body) == {"ok": True, "result": []}
|
||||
|
||||
sent_request = mocked.call_args[0][0]
|
||||
assert sent_request.full_url == "https://api.telegram.org/bot123:TOK/sendMessage"
|
||||
assert sent_request.data == b'{"chat_id": 1, "text": "hi"}'
|
||||
assert sent_request.get_method() == "POST"
|
||||
|
||||
|
||||
def test_get_updates_uses_upstream_timeout_wider_than_longest_client_poll(server, secret):
|
||||
"""Клиент шлёт getUpdates(timeout=30) → httpx ждёт ответ 40с (30 + запас
|
||||
10с). Апстрим-таймаут ретранслятора обязан быть шире, иначе он обрубит
|
||||
long-poll раньше площадки и превратит штатный цикл в вечный network error."""
|
||||
with mock.patch.object(
|
||||
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
|
||||
) as mocked:
|
||||
_request(
|
||||
server,
|
||||
"/bot123:TOK/getUpdates",
|
||||
headers={"X-Relay-Secret": secret},
|
||||
method="POST",
|
||||
body=b'{"offset": 1, "timeout": 30}',
|
||||
)
|
||||
|
||||
_req, kwargs = mocked.call_args
|
||||
assert kwargs["timeout"] >= 40
|
||||
|
||||
|
||||
def test_token_never_appears_in_logs(server, secret, caplog):
|
||||
token = "999888777:VerySecretTokenValue"
|
||||
with caplog.at_level(logging.INFO, logger="tg-relay"):
|
||||
with mock.patch.object(
|
||||
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
|
||||
):
|
||||
_request(server, f"/bot{token}/getMe", headers={"X-Relay-Secret": secret})
|
||||
|
||||
for record in caplog.records:
|
||||
assert token not in record.getMessage()
|
||||
|
||||
|
||||
def test_unauthorized_attempt_does_not_leak_token_either(server, caplog):
|
||||
token = "999888777:VerySecretTokenValue"
|
||||
with caplog.at_level(logging.INFO, logger="tg-relay"):
|
||||
_request(server, f"/bot{token}/getMe")
|
||||
|
||||
for record in caplog.records:
|
||||
assert token not in record.getMessage()
|
||||
|
|
@ -79,7 +79,7 @@ from pydantic import BaseModel, Field
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.v1.geocode import SuggestResponse, effective_region_code, suggest_addresses
|
||||
from app.api.v1.geocode import SuggestResponse, suggest_addresses
|
||||
from app.api.v1.trade_in import coverage_probe, estimate
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
|
|
@ -222,9 +222,6 @@ class PublicSuggestInput(BaseModel):
|
|||
q: str = Field(min_length=2, max_length=200)
|
||||
limit: int = Field(default=8, ge=1, le=10)
|
||||
city_hint: str | None = Field(default=None, max_length=100)
|
||||
# #3051: явный регион с фронта (если он его когда-нибудь пришлёт) —
|
||||
# приоритетнее вывода из city_hint, см. effective_region_code.
|
||||
region_code: int | None = Field(default=None)
|
||||
|
||||
|
||||
def _fold(text: str) -> str:
|
||||
|
|
@ -329,7 +326,6 @@ async def public_suggest(
|
|||
limit=payload.limit,
|
||||
db=db,
|
||||
city_hint=payload.city_hint,
|
||||
region_code=effective_region_code(payload.region_code, payload.city_hint),
|
||||
)
|
||||
finally:
|
||||
_suggest_slots.release()
|
||||
|
|
@ -483,15 +479,9 @@ class ShowcaseStats(BaseModel):
|
|||
|
||||
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
|
||||
посетитель не может отличить выборку из работы оценщика от её лучшего
|
||||
хвоста. `eligible` — сколько строк прогон СОБРАЛ (данных хватило),
|
||||
`written` — сколько из них показано; `rejection_rule` — по какому правилу
|
||||
отобраны показанные, записанное ТЕМ прогоном, который их посчитал.
|
||||
|
||||
`eligible` минус `written` — НЕ «столько не поместилось»: с 2026-09-12
|
||||
витрина показывает полосу расхождения −5 %..+20 %, и в разницу входят
|
||||
строки, отсеянные полосой. Что это именно отбор, а не вся сверка, говорит
|
||||
`rejection_rule` — поэтому счётчики и правило показываются вместе, одной
|
||||
подписью, а не порознь.
|
||||
хвоста. `eligible` минус `written` — сколько годных строк не поместилось
|
||||
в витрину; `rejection_rule` — по какому правилу отсеяно остальное,
|
||||
записанное ТЕМ прогоном, который эти строки посчитал.
|
||||
"""
|
||||
|
||||
considered: int
|
||||
|
|
@ -560,7 +550,7 @@ def public_showcase(
|
|||
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
|
||||
|
||||
Вместе со строками едет `stats` — сколько сделок рассмотрено, сколько
|
||||
строк прогон собрал и по какому правилу из них отобраны показанные. Числа
|
||||
годных строк не поместилось и по какому правилу отсеяно остальное. Числа
|
||||
считает пересчёт; без них витрина не имеет права подписаться честно.
|
||||
"""
|
||||
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ from app.core.password import (
|
|||
verify_slots_saturated,
|
||||
)
|
||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||
from app.observability.metrics import LOGINS
|
||||
from app.services.auth_session import create_session, get_user_by_username, revoke_session
|
||||
from app.services.identity_store import AccessState, get_identity_db
|
||||
from app.services.user_events import schedule_event
|
||||
|
|
@ -325,7 +324,6 @@ async def _reject_invalid_credentials(
|
|||
fails = _USERNAME_FAIL_LIMITER.record(username)
|
||||
delay_s = _throttle_delay_s(fails)
|
||||
|
||||
LOGINS.labels(result="failed").inc()
|
||||
schedule_event(
|
||||
event_type="login_failed",
|
||||
username=username,
|
||||
|
|
@ -454,7 +452,6 @@ async def login(
|
|||
path="/",
|
||||
)
|
||||
|
||||
LOGINS.labels(result="success").inc()
|
||||
schedule_event(
|
||||
event_type="login_success",
|
||||
username=user["username"],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
|
|
@ -10,34 +11,15 @@ from pydantic import BaseModel, Field
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db, run_db_thread
|
||||
from app.observability.metrics import ADDRESS_SUGGESTIONS
|
||||
from app.core.db import get_db
|
||||
from app.services.estimator import _lookup_house_facts
|
||||
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
|
||||
from app.services.regions import DEFAULT_REGION_CODE, region_by_city
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def effective_region_code(region_code: int | None, city_hint: str | None) -> int:
|
||||
"""Регион для геокодера (#3051): явный `region_code` > вывод из `city_hint` > 66.
|
||||
|
||||
До этого хелпера `/suggest` всегда уходил в геокодер с регионом 66
|
||||
по умолчанию — московский `city_hint` («Москва») молча получал
|
||||
свердловский bbox-констрейнт и терял подсказки. Реестр `app.services.regions`
|
||||
уже знает, каким городам какой регион соответствует (REGIONS[77].cities
|
||||
содержит «москва») — используем его вместо повторного захардкоженного списка.
|
||||
"""
|
||||
if region_code is not None:
|
||||
return region_code
|
||||
region = region_by_city(city_hint)
|
||||
if region is not None:
|
||||
return region.code
|
||||
return DEFAULT_REGION_CODE
|
||||
|
||||
|
||||
@router.get("/lookup", response_model=GeocodeResult)
|
||||
async def lookup(
|
||||
address: Annotated[str, Query(min_length=3, max_length=500)],
|
||||
|
|
@ -100,17 +82,16 @@ async def suggest_addresses(
|
|||
),
|
||||
] = None,
|
||||
region_code: Annotated[
|
||||
int | None,
|
||||
int,
|
||||
Query(
|
||||
description=(
|
||||
"Регион покрытия (#3051). None (дефолт) — выводится из `city_hint` "
|
||||
"через реестр регионов, иначе 66 (Свердловская область, прежнее "
|
||||
"поведение). 77 — Москва: без него DaData и Nominatim получают "
|
||||
"свердловский hard-констрейнт и молча возвращают ПУСТО на "
|
||||
"московском адресе."
|
||||
"Регион покрытия (#3051). Дефолт 66 — Свердловская область, прежнее "
|
||||
"поведение для существующих клиентов. 77 — Москва: без него DaData "
|
||||
"и Nominatim получают свердловский hard-констрейнт и молча "
|
||||
"возвращают ПУСТО на московском адресе."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
] = 66,
|
||||
) -> SuggestResponse:
|
||||
"""Автокомплит адресов в регионе `region_code` (дефолт 66 — Свердловская область;
|
||||
ЕКБ — основной трафик, остаётся быстрым fast-path).
|
||||
|
|
@ -125,15 +106,11 @@ async def suggest_addresses(
|
|||
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
|
||||
/api/v1/geocode/suggest?q=Тверская+6®ion_code=77 # Москва
|
||||
"""
|
||||
resolved_region_code = effective_region_code(region_code, city_hint)
|
||||
try:
|
||||
items = await suggest(
|
||||
q, db=db, limit=limit, city_hint=city_hint, region_code=resolved_region_code
|
||||
)
|
||||
items = await suggest(q, db=db, limit=limit, city_hint=city_hint, region_code=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(
|
||||
items=[
|
||||
SuggestItem(
|
||||
|
|
@ -277,9 +254,9 @@ async def house_facts(
|
|||
"""
|
||||
target_house_id: int | None = None
|
||||
if fias_id is not None:
|
||||
target_house_id = await run_db_thread(_resolve_house_id_by_fias, db, fias_id)
|
||||
target_house_id = await asyncio.to_thread(_resolve_house_id_by_fias, db, fias_id)
|
||||
|
||||
facts = await run_db_thread(
|
||||
facts = await asyncio.to_thread(
|
||||
_lookup_house_facts,
|
||||
db,
|
||||
target_house_id=target_house_id,
|
||||
|
|
|
|||
|
|
@ -50,14 +50,10 @@ from datetime import UTC, datetime
|
|||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.tgbot.client import TelegramError
|
||||
from app.services.tgbot.shared import get_telegram_client
|
||||
from app.tasks.glitchtip_alert_retry import retry_forward_alert
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -187,28 +183,16 @@ def _verify_secret(provided: str) -> None:
|
|||
raise HTTPException(status_code=401, detail="invalid or missing secret")
|
||||
|
||||
|
||||
@router.post("/ops/glitchtip-webhook", response_model=None)
|
||||
@router.post("/ops/glitchtip-webhook")
|
||||
async def glitchtip_webhook(
|
||||
request: Request,
|
||||
secret: Annotated[str, Query()] = "",
|
||||
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
|
||||
) -> dict[str, str] | JSONResponse:
|
||||
) -> dict[str, str]:
|
||||
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
||||
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
||||
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
|
||||
|
||||
Отказ синхронной попытки (#3471) отвечает 502 как и раньше (#3456 — честный
|
||||
сигнал отправителю), но ставит доставку в фон
|
||||
(``app.tasks.glitchtip_alert_retry.retry_forward_alert`` через
|
||||
``starlette.background.BackgroundTask`` на самом ответе) — GlitchTip вебхуки
|
||||
не ретраит (#3157), без этого текст алерта терялся бы безвозвратно.
|
||||
``BackgroundTask`` привязан НАПРЯМУЮ к возвращаемому ``JSONResponse``, а не
|
||||
к ``BackgroundTasks``-зависимости: FastAPI прикрепляет задачи из
|
||||
``BackgroundTasks`` только к ответу, который вернул сам хендлер, а `raise
|
||||
HTTPException` строит ОТДЕЛЬНЫЙ ответ в exception-мидлваре — задачи,
|
||||
поставленные до `raise`, в реальности молча терялись бы вместе с ним (это
|
||||
воспроизведено тестом, не гипотеза).
|
||||
|
||||
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
|
||||
хендлер сам делает единственную проверку секрета.
|
||||
|
||||
|
|
@ -229,10 +213,7 @@ async def glitchtip_webhook(
|
|||
received_at = datetime.now(UTC)
|
||||
text = _build_message(raw_body, received_at)
|
||||
|
||||
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
|
||||
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
||||
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
||||
client = get_telegram_client()
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
try:
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_alerts_chat_id,
|
||||
|
|
@ -243,30 +224,8 @@ async def glitchtip_webhook(
|
|||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
)
|
||||
except TelegramError:
|
||||
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
|
||||
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
|
||||
# 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный
|
||||
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
|
||||
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
|
||||
# (#3471, см. app.tasks.glitchtip_alert_retry).
|
||||
#
|
||||
# `raise HTTPException` здесь НЕ подходит: FastAPI прикрепляет
|
||||
# background-задачи только к ответу, который вернул сам хендлер, а
|
||||
# исключение строит СВОЙ отдельный JSONResponse в exception-мидлваре —
|
||||
# задача, поставленная до `raise`, никогда бы не выполнилась. Поэтому
|
||||
# 502 собран и возвращён вручную, с задачей на этом же объекте ответа.
|
||||
except TelegramApiError:
|
||||
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"detail": "failed to forward alert to telegram"},
|
||||
background=BackgroundTask(
|
||||
retry_forward_alert,
|
||||
client,
|
||||
chat_id=settings.telegram_alerts_chat_id,
|
||||
text=text,
|
||||
message_thread_id=settings.telegram_alerts_topic_id or None,
|
||||
),
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
|
||||
|
||||
return {"status": "ok"}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ from sqlalchemy.orm import Session
|
|||
from app.api.v1.trade_in import _assert_estimate_access
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.observability.metrics import LEADS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -174,7 +173,6 @@ async def create_trade_in_lead(
|
|||
)
|
||||
|
||||
db.commit()
|
||||
LEADS.inc()
|
||||
|
||||
logger.info(
|
||||
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ module docstring (честно про то, что не всегда разре
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
|
@ -22,7 +23,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db, run_db_thread
|
||||
from app.core.db import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -66,7 +67,7 @@ async def erase_person_data_endpoint(
|
|||
|
||||
from app.services.data_erasure import erase_person_data
|
||||
|
||||
counters = await run_db_thread(
|
||||
counters = await asyncio.to_thread(
|
||||
erase_person_data,
|
||||
db,
|
||||
username=payload.username,
|
||||
|
|
|
|||
|
|
@ -62,23 +62,18 @@ import hashlib
|
|||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||
from app.observability.metrics import SUPPORT_MESSAGES
|
||||
from app.services.tgbot import web_support_storage as storage
|
||||
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
||||
from app.services.tgbot.client import TelegramError
|
||||
from app.services.tgbot.shared import get_telegram_client
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -127,58 +122,10 @@ _INTERACTIVE_SEND_TIMEOUT_S = 5.0
|
|||
_INTERACTIVE_SEND_MAX_RETRIES = 3
|
||||
_INTERACTIVE_SEND_MAX_BACKOFF_S = 1.0
|
||||
|
||||
# Счётчик ОТКАЗОВ отправки — отдельный от бюджетов выше (#tgsupport-fail-cooldown).
|
||||
#
|
||||
# Зачем вообще второй счётчик. `_send_limiter`/`_anon_ip_limiter` расходуются
|
||||
# ТОЛЬКО на успехе (review L3, non-destructive peek выше) — это верно для
|
||||
# «не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram
|
||||
# недоступен, лимита нет ВООБЩЕ. Каждый повтор пользователя при этом стоит до
|
||||
# `1 + _INTERACTIVE_SEND_MAX_RETRIES` = 4 попыток к api.telegram.org и не
|
||||
# расходует ни один бюджет. Двух-трёх вкладок с авто-ретраем хватает, чтобы
|
||||
# выесть лимиты группы ровно в тот момент, когда канал и так еле жив.
|
||||
#
|
||||
# Отсюда — дешёвый gate ПЕРЕД походом в Telegram, на своих ключах (тех же, что
|
||||
# у основных лимитеров: username / anon-thread-key и client IP).
|
||||
#
|
||||
# N=5. Отказ одной отправки — не событие: по замеру #tgsupport-retry доля отказов
|
||||
# на попытку 15-38%, но после 4 попыток до пользователя доходит ~0.8-2% отказов.
|
||||
# Пять подряд на живом канале — вероятность порядка 1e-10, то есть cooldown
|
||||
# физически не может сработать на «просто не повезло»; он срабатывает только на
|
||||
# настоящей недоступности. Плюс `reset()` на успехе: считаем именно ПОДРЯД, одна
|
||||
# успешная отправка стирает историю.
|
||||
#
|
||||
# 30с окна (оно же длительность cooldown: блок держится, пока самый старый из
|
||||
# N отказов не выпадет из окна). Верхняя граница осмысленности — реальная
|
||||
# недоступность Telegram по тому же замеру длится минутами, так что 30с заведомо
|
||||
# короче и пользователя после восстановления канала не наказывают. Нижняя —
|
||||
# один отказавший интерактивный запрос сам по себе занимает до 23с
|
||||
# (4 попытки × 5с + 3 паузы × 1с), cooldown короче этого просто не имел бы смысла.
|
||||
_SEND_FAILURE_LIMIT = 5
|
||||
_SEND_FAILURE_WINDOW_S = 30.0
|
||||
_send_failure_limiter = SlidingWindowLimiter(
|
||||
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
|
||||
)
|
||||
# Отдельный экземпляр для IP-ключей — ровно как `_anon_ip_limiter` отдельный от
|
||||
# `_send_limiter`: ключи разных пространств (IP vs thread-key) в одном словаре
|
||||
# смешивать нельзя.
|
||||
_anon_ip_failure_limiter = SlidingWindowLimiter(
|
||||
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
|
||||
)
|
||||
|
||||
_SEND_UNAVAILABLE_DETAIL = "Telegram сейчас недоступен. Попробуйте через полминуты."
|
||||
|
||||
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
|
||||
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
|
||||
_LIST_MESSAGES_LIMIT = 200
|
||||
|
||||
# Идемпотентность отправки (#3471 retry-storm) — см. `_resolve_idempotency_key`.
|
||||
_IDEMPOTENCY_HEADER = "idempotency-key"
|
||||
# Форма клиентского ключа — как у anon-токена (`_ANON_TOKEN_RE`): произвольная
|
||||
# opaque-строка клиента, без пробелов/спецсимволов, которые попали бы в SQL-параметр
|
||||
# как есть. Не матчится — считаем заголовок отсутствующим и уходим на fallback,
|
||||
# а не пытаемся его "починить" (тот же принцип, что у `_read_anon_token`).
|
||||
_CLIENT_IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{8,128}\Z")
|
||||
|
||||
|
||||
def _require_username(request: Request) -> str:
|
||||
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
|
||||
|
|
@ -218,18 +165,6 @@ class SupportMessageOut(BaseModel):
|
|||
text_body: str
|
||||
operator_tg_id: int | None = None
|
||||
created_at: str
|
||||
# Дошло ли сообщение до БД. `True` по умолчанию — все существующие пути
|
||||
# (`GET /support/messages`, успешный POST) строят модель из storage-строки и
|
||||
# ничего про флаг не знают, контракт для них не меняется.
|
||||
#
|
||||
# `False` ставит ТОЛЬКО `_unpersisted_message_out`: доставлено оператору, но
|
||||
# не записано. Флаг нужен потому, что без него деградация неотличима от
|
||||
# тишины: фронт выбрасывает тело POST и рендерит транскрипт исключительно из
|
||||
# `GET /support/messages` (`useSupportChat.ts`), где этого сообщения нет —
|
||||
# поле ввода очищается, сообщение не появляется, ошибки нет. Пользователь
|
||||
# решает, что не отправилось, и шлёт снова — ровно тот дубль в топике,
|
||||
# против которого вся эта ветка и сделана.
|
||||
persisted: bool = True
|
||||
|
||||
@field_validator("created_at", mode="before")
|
||||
@classmethod
|
||||
|
|
@ -255,150 +190,8 @@ def _format_mirror_text(username: str, message_text: str) -> str:
|
|||
return f"[С САЙТА] {username}:\n{message_text}"
|
||||
|
||||
|
||||
def _too_many_failures_error(retry_after: float) -> HTTPException:
|
||||
"""429 вместо похода в Telegram — канал только что отказал N раз подряд."""
|
||||
return HTTPException(
|
||||
status_code=429,
|
||||
detail=_SEND_UNAVAILABLE_DETAIL,
|
||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
|
||||
|
||||
def _unpersisted_message_out(text_body: str) -> SupportMessageOut:
|
||||
"""Синтетический ответ для случая «в топик доставлено, а в БД не записано».
|
||||
|
||||
Почему ответ вообще УСПЕШНЫЙ. Сообщение оператору реально доставлено —
|
||||
отдать на это ошибку значит соврать: пользователь повторит, и в топике
|
||||
окажется дубль (плюс второе предупреждение оператору). Успех здесь честнее
|
||||
отказа, но он неполный, и об этом клиенту надо сказать явно.
|
||||
|
||||
Что сообщает `persisted=False`: «доставлено, но ответить тебе через чат не
|
||||
смогут; повторять не надо». Именно флаг — контракт для клиента, а НЕ `id=0`:
|
||||
по идентификатору клиент отличить деградацию не обязан и не будет.
|
||||
|
||||
`id` при этом всё равно нужен модели, и 0 — сознательный сентинел, а не
|
||||
выдумка: реальные id в `web_support_messages` начинаются с 1 (serial),
|
||||
поэтому 0 ни с чем не столкнётся, а `GET /support/messages` принимает
|
||||
`since>=0`. Даже если фронт когда-нибудь начнёт курсорить по возвращённому
|
||||
id (сейчас он перечитывает тред целиком с `since=0`, `useSupportChat.ts`),
|
||||
нулевой курсор не перепрыгнет ни одного реального сообщения: занижение
|
||||
курсора безопасно, завышение — нет.
|
||||
"""
|
||||
return SupportMessageOut(
|
||||
id=0,
|
||||
direction="in",
|
||||
text_body=text_body,
|
||||
operator_tg_id=None,
|
||||
created_at=datetime.now(UTC),
|
||||
persisted=False,
|
||||
)
|
||||
|
||||
|
||||
async def _warn_operator_message_not_recorded(*, label: str, topic_message_id: int | None) -> None:
|
||||
"""Реплаем к только что доставленному зеркалу предупреждает оператора, что
|
||||
ответ на ЭТО сообщение не смаршрутизируется: треда в БД нет, на реплай к
|
||||
осиротевшему зеркалу `bridge._handle_group_reply` напишет только WARNING, а
|
||||
клиент не увидит ничего. Без предупреждения оператор отвечает в пустоту и
|
||||
считает, что помог.
|
||||
|
||||
Текст обращения сюда НЕ дублируется (ПДн) — оператор видит его в сообщении,
|
||||
к которому это реплай.
|
||||
|
||||
Формулировка учитывает, что клиенту ответили успехом (`persisted=False`,
|
||||
«принято, повторять не надо»): рассчитывать на «клиент напишет снова»
|
||||
оператору нельзя, поднимать тред придётся иначе.
|
||||
|
||||
Любой отказ гасится логом: основное сообщение УЖЕ доставлено, превращать
|
||||
неудачу служебного уведомления в 500 поверх успеха нельзя.
|
||||
"""
|
||||
text = (
|
||||
f"ВНИМАНИЕ · {label}: сообщение доставлено в топик, но НЕ записано в базу "
|
||||
"(сбой БД). Треда у этого обращения нет — ответить клиенту через бота "
|
||||
"НЕЛЬЗЯ: реплай на это сообщение никуда не уйдёт. Повтора тоже не ждите, "
|
||||
"клиенту показано, что сообщение принято и отправлять его снова не нужно. "
|
||||
"Если в обращении есть контакт — свяжитесь напрямую; иначе передайте "
|
||||
"дежурному и сообщите об отказе БД."
|
||||
)
|
||||
try:
|
||||
client = get_telegram_client()
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=text,
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
# reply_to может отсутствовать (sendMessage не вернул message_id) —
|
||||
# тогда уведомление уходит отдельным сообщением в топик: хуже, чем
|
||||
# реплай, но несравнимо лучше тишины.
|
||||
reply_to_message_id=topic_message_id,
|
||||
# Тот же узкий интерактивный бюджет (review H1): клиент ждёт ответа
|
||||
# ручки, а не доставки служебного уведомления.
|
||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
||||
)
|
||||
except Exception:
|
||||
# Шире `TelegramError` намеренно: это best-effort хвост уже успешного
|
||||
# запроса, любой отказ здесь обязан остаться в логе, а не у клиента.
|
||||
logger.exception(
|
||||
"web support: не удалось предупредить оператора о несохранённом сообщении (%s)",
|
||||
label,
|
||||
)
|
||||
|
||||
|
||||
def _rollback_quietly(db: Session) -> None:
|
||||
"""Откат после `SQLAlchemyError`. Сам rollback на мёртвом соединении тоже
|
||||
может бросить, а мы уже решили отдать клиенту успех — `get_db` в любом
|
||||
случае закроет сессию в `finally`."""
|
||||
try:
|
||||
db.rollback()
|
||||
except SQLAlchemyError:
|
||||
logger.warning("web support: rollback после сбоя БД тоже не удался", exc_info=True)
|
||||
|
||||
|
||||
def _resolve_idempotency_key(request: Request, *, identity_key: str, text: str) -> str:
|
||||
"""Ключ идемпотентности inbound-отправки (#3471, миграция 301).
|
||||
|
||||
Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key` —
|
||||
ЕДИНСТВЕННЫЙ надёжный путь: клиент генерирует ключ ОДИН раз на "намерение
|
||||
отправить" и переиспользует его на любом ретрае (fetch retry / переотправка
|
||||
после таймаута) независимо от того, что именно менялось в UI между
|
||||
попытками (см. `useSendSupportMessage` во фронтенде — реальный трафик
|
||||
обязан идти этим путём). Fallback без заголовка — детерминированный
|
||||
отпечаток sha256(identity, текст, минутное окно) — существует ТОЛЬКО для
|
||||
клиентов, которые заголовок не прислали (п.5 требования — старое поведение
|
||||
не должно сломаться), и у него два честных изъяна, оба снимаются самим
|
||||
фактом использования заголовка:
|
||||
1. два РАЗНЫХ по смыслу сообщения с одинаковым текстом от одного и того
|
||||
же человека в течение одной минуты ("да" и ещё раз "да", "+", "ок")
|
||||
СХЛОПНУТСЯ в одно — второе будет молча проглочено, клиент получит id
|
||||
первого, оператор не увидит второе сообщение вообще;
|
||||
2. окно — не скользящие 60 секунд, а округление `time.time() // 60` вниз:
|
||||
фактический срок дедупликации случаен от 0 до 60с в зависимости от
|
||||
момента внутри минуты, и часть настоящих повторов (ретрай ровно на
|
||||
границе окна) эту защиту не получит.
|
||||
Оба пункта перестают быть важны, когда клиент реально шлёт заголовок
|
||||
(см. `useSendSupportMessage`).
|
||||
|
||||
Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ —
|
||||
он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда
|
||||
попадает ТОЛЬКО как вход в sha256, в открытом виде не сохраняется и не
|
||||
возвращается — жёсткое правило проекта "текст не в логах" (ПДн) при этом
|
||||
не нарушается, даже если бы этот ключ где-то залогировали.
|
||||
|
||||
Префиксы `client:`/`auto:` разводят два namespace'а ключей (защита от
|
||||
случайного совпадения клиентского токена с fallback-хэшем) — дёшево и не
|
||||
требует отдельной колонки.
|
||||
"""
|
||||
header = (request.headers.get(_IDEMPOTENCY_HEADER) or "").strip()
|
||||
if header and _CLIENT_IDEMPOTENCY_KEY_RE.match(header):
|
||||
return f"client:{header}"
|
||||
window = int(time.time() // 60)
|
||||
fingerprint = f"{identity_key}|{text}|{window}"
|
||||
return "auto:" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@router.post("/support/messages", response_model=SupportMessageOut)
|
||||
async def send_support_message(
|
||||
request: Request,
|
||||
payload: SupportMessageInput,
|
||||
username: Annotated[str, Depends(_require_username)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
|
|
@ -426,47 +219,7 @@ async def send_support_message(
|
|||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
|
||||
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`): канал только что отказал
|
||||
# N раз подряд — не тратим на этот запрос ещё четыре попытки к Telegram.
|
||||
cooldown = _send_failure_limiter.retry_after(username)
|
||||
if cooldown is not None:
|
||||
raise _too_many_failures_error(cooldown)
|
||||
|
||||
# Идемпотентность (#3471). Что именно закрывает этот pre-check — важно не
|
||||
# переоценить: строка в БД (и её idempotency_key) появляется ТОЛЬКО ПОСЛЕ
|
||||
# успешной доставки в Telegram (H1 ниже), поэтому потерю самого запроса на
|
||||
# плече Selectel -> api.telegram.org этот механизм НЕ дедуплицирует — если
|
||||
# `send_message` не удался, ключ нигде не записан, и повтор клиента после
|
||||
# неудачи это законная первая попытка. Реально закрывается другой, тоже
|
||||
# частый случай: доставка УЖЕ состоялась (Telegram принял, строка
|
||||
# закоммичена), но ответ до клиента не дошёл (обрыв на обратном пути,
|
||||
# клиентский таймаут) или пользователь кликнул "отправить" второй раз по
|
||||
# той же ещё не отрисовавшейся отправке — тогда pre-check находит уже
|
||||
# записанное сообщение по ключу и отдаёт его же, не уходя в Telegram снова.
|
||||
# Тред может ещё не существовать (это первая попытка этого ключа) — тогда
|
||||
# сравнивать не с чем, идём в Telegram как обычно; сам факт "нет треда"
|
||||
# здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке (см. H1) —
|
||||
# если бы сообщение под этим ключом уже было записано, тред уже был бы.
|
||||
idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text)
|
||||
existing_thread_id = storage.find_thread_id(db, username)
|
||||
if existing_thread_id is not None:
|
||||
existing = storage.find_inbound_by_idempotency_key(
|
||||
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"web support: repeat send (idempotency key already recorded) "
|
||||
"username=%s thread_id=%d message_id=%s",
|
||||
username,
|
||||
existing_thread_id,
|
||||
existing["id"],
|
||||
)
|
||||
return SupportMessageOut(**existing)
|
||||
|
||||
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
|
||||
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
||||
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
||||
client = get_telegram_client()
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
try:
|
||||
mirrored = await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
|
|
@ -477,22 +230,16 @@ async def send_support_message(
|
|||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
||||
)
|
||||
except TelegramError:
|
||||
except TelegramApiError:
|
||||
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
|
||||
# TelegramApiError и не бывает — см. client.py docstring про redaction).
|
||||
# Предок, а не `TelegramApiError`: при таймауте до Telegram пользователь
|
||||
# должен увидеть тот же «сервис недоступен», а не 500 (#3456).
|
||||
logger.exception(
|
||||
"web support: не удалось отправить зеркало в топик (username=%s)", username
|
||||
)
|
||||
_send_failure_limiter.record(username)
|
||||
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||
|
||||
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
|
||||
_send_limiter.record(username)
|
||||
SUPPORT_MESSAGES.labels(channel="web").inc()
|
||||
# Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы.
|
||||
_send_failure_limiter.reset(username)
|
||||
|
||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||
if topic_message_id is None:
|
||||
|
|
@ -506,35 +253,15 @@ async def send_support_message(
|
|||
)
|
||||
|
||||
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
|
||||
#
|
||||
# Оборотная сторона этого порядка: отказ БД здесь означает, что сообщение
|
||||
# оператору УЖЕ доставлено. Отдавать на это 500 (как было) — худший из
|
||||
# вариантов: клиент видит ошибку, шлёт повторно, в топике дубль, а на
|
||||
# осиротевшее зеркало оператор отвечает в пустоту. Поэтому отвечаем успехом
|
||||
# (доставка правда состоялась) и отдельным сообщением предупреждаем
|
||||
# оператора, что отвечать на это зеркало бесполезно.
|
||||
try:
|
||||
thread_id = storage.get_or_create_thread(db, username)
|
||||
row = storage.record_inbound(
|
||||
db,
|
||||
thread_id=thread_id,
|
||||
text_body=payload.text,
|
||||
topic_message_id=topic_message_id,
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
# Ни текста обращения (ПДн), ни токена в логе — только кто и что сломалось.
|
||||
logger.exception(
|
||||
"web support: сообщение доставлено в топик, но не записано в БД (username=%s)",
|
||||
username,
|
||||
)
|
||||
_rollback_quietly(db)
|
||||
await _warn_operator_message_not_recorded(
|
||||
label=f"[С САЙТА] {username}", topic_message_id=topic_message_id
|
||||
)
|
||||
return _unpersisted_message_out(payload.text)
|
||||
thread_id = storage.get_or_create_thread(db, username)
|
||||
row = storage.record_inbound(
|
||||
db,
|
||||
thread_id=thread_id,
|
||||
text_body=payload.text,
|
||||
topic_message_id=topic_message_id,
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
|
||||
return SupportMessageOut(**row)
|
||||
|
|
@ -681,46 +408,8 @@ async def send_anon_support_message(
|
|||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
|
||||
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`) — оба ключа, как и у
|
||||
# бюджетов выше: per-token ловит одну вкладку с авто-ретраем, per-IP — ту же
|
||||
# петлю после сброса куки.
|
||||
for cooldown in (
|
||||
_send_failure_limiter.retry_after(thread_key),
|
||||
_anon_ip_failure_limiter.retry_after(ip),
|
||||
):
|
||||
if cooldown is not None:
|
||||
raise _too_many_failures_error(cooldown)
|
||||
|
||||
display_id = _anon_display_id(token)
|
||||
|
||||
# Идемпотентность (#3471) — см. развёрнутый комментарий в авторизованной
|
||||
# ветке. Ограничение специфично для анонимной ветки: если предыдущая
|
||||
# попытка сама провалилась ДО выдачи куки (`is_new_token=True` тогда и
|
||||
# сейчас), identity_key каждый раз новый (случайный токен) и pre-check
|
||||
# структурно не может найти прошлую попытку — это тот же класс проблемы,
|
||||
# что и потеря куки в сети, вне scope этой правки.
|
||||
idempotency_key = _resolve_idempotency_key(request, identity_key=thread_key, text=payload.text)
|
||||
existing_thread_id = storage.find_thread_id(db, thread_key)
|
||||
if existing_thread_id is not None:
|
||||
existing = storage.find_inbound_by_idempotency_key(
|
||||
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"web support (anon): repeat send (idempotency key already recorded) "
|
||||
"%s thread_id=%d message_id=%s",
|
||||
display_id,
|
||||
existing_thread_id,
|
||||
existing["id"],
|
||||
)
|
||||
if is_new_token:
|
||||
_set_anon_cookie(response, token)
|
||||
return SupportMessageOut(**existing)
|
||||
|
||||
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
|
||||
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
||||
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
||||
client = get_telegram_client()
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
try:
|
||||
mirrored = await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
|
|
@ -730,22 +419,15 @@ async def send_anon_support_message(
|
|||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
||||
)
|
||||
except TelegramError:
|
||||
except TelegramApiError:
|
||||
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
|
||||
# Про предок вместо `TelegramApiError` — см. комментарий в парной ручке.
|
||||
logger.exception(
|
||||
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
|
||||
)
|
||||
_send_failure_limiter.record(thread_key)
|
||||
_anon_ip_failure_limiter.record(ip)
|
||||
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||
|
||||
_send_limiter.record(thread_key)
|
||||
_anon_ip_limiter.record(ip)
|
||||
SUPPORT_MESSAGES.labels(channel="anon").inc()
|
||||
# Канал жив — счётчики отказов считают именно ПОДРЯД идущие отказы.
|
||||
_send_failure_limiter.reset(thread_key)
|
||||
_anon_ip_failure_limiter.reset(ip)
|
||||
|
||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||
if topic_message_id is None:
|
||||
|
|
@ -755,37 +437,15 @@ async def send_anon_support_message(
|
|||
display_id,
|
||||
)
|
||||
|
||||
# Отказ БД после успешной отправки — см. развёрнутый комментарий в парной
|
||||
# (авторизованной) ручке: сообщение оператору уже доставлено, 500 тут создаёт
|
||||
# дубли в топике и «осиротевшее» зеркало, на которое оператор отвечает зря.
|
||||
try:
|
||||
thread_id = storage.get_or_create_thread(db, thread_key)
|
||||
row = storage.record_inbound(
|
||||
db,
|
||||
thread_id=thread_id,
|
||||
text_body=payload.text,
|
||||
topic_message_id=topic_message_id,
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
# Ни текста обращения (ПДн), ни токена (bearer треда) в логе — только
|
||||
# несекретный ярлык треда.
|
||||
logger.exception(
|
||||
"web support (anon): сообщение доставлено в топик, но не записано в БД (%s)",
|
||||
display_id,
|
||||
)
|
||||
_rollback_quietly(db)
|
||||
await _warn_operator_message_not_recorded(
|
||||
label=f"[С САЙТА · БЕЗ ВХОДА] {display_id}", topic_message_id=topic_message_id
|
||||
)
|
||||
# Куку ставим ВСЁ РАВНО: тред в БД не создан, но идентичность посетителя
|
||||
# обязана пережить этот сбой — иначе следующее сообщение (когда база
|
||||
# поднимется) заведёт ВТОРОЙ тред, и переписка разъедется на два.
|
||||
if is_new_token:
|
||||
_set_anon_cookie(response, token)
|
||||
return _unpersisted_message_out(payload.text)
|
||||
thread_id = storage.get_or_create_thread(db, thread_key)
|
||||
row = storage.record_inbound(
|
||||
db,
|
||||
thread_id=thread_id,
|
||||
text_body=payload.text,
|
||||
topic_message_id=topic_message_id,
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if is_new_token:
|
||||
_set_anon_cookie(response, token)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ from app.core.anon_session import get_or_create_anon_session_id
|
|||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||
from app.observability.metrics import ESTIMATES, REPORTS_EXPORTED
|
||||
from app.schemas.trade_in import (
|
||||
AggregatedEstimate,
|
||||
AnalogLot,
|
||||
|
|
@ -561,13 +560,6 @@ async def estimate(
|
|||
# #3082: слот возвращаем сразу после дорогой части — инкремент квоты и
|
||||
# сериализация ответа ниже дёшевы и слот держать не должны.
|
||||
_estimate_slots.release()
|
||||
|
||||
# #3471: считаем оценку "успешно посчитанной" здесь — до квоты и до 429
|
||||
# ниже, потому что расчёт (дорогая часть) уже прошёл. insufficient_data —
|
||||
# тоже успех расчёта: медиана не нашлась не потому что что-то сломалось, а
|
||||
# потому что аналогов не было, это отдельный, а не ошибочный исход.
|
||||
ESTIMATES.labels(outcome="insufficient_data" if result.insufficient_data else "ok").inc()
|
||||
|
||||
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
|
||||
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
|
||||
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
|
||||
|
|
@ -1021,7 +1013,6 @@ def estimate_pdf(
|
|||
brand_obj = _resolve_brand(owner_brand_slug, db)
|
||||
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
|
||||
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
|
||||
REPORTS_EXPORTED.inc()
|
||||
logger.info(
|
||||
"PDF generated estimate_id=%s brand=%s size=%d",
|
||||
estimate_id,
|
||||
|
|
@ -2210,7 +2201,6 @@ def get_street_deals(
|
|||
_percentile,
|
||||
_resolve_target_city,
|
||||
extract_street_name,
|
||||
region_code_for_address,
|
||||
)
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
|
|
@ -2254,17 +2244,6 @@ def get_street_deals(
|
|||
target_city = _resolve_target_city(address)
|
||||
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
|
||||
|
||||
# #dkp-corridor-scope (2026-09-12): region_code — обязательный фильтр, в отличие
|
||||
# от city_filter выше (который применяется, только если _resolve_target_city
|
||||
# узнал город обл.66). extract_street_name после фикса московского порядка
|
||||
# ("Название улица") стал возвращать имя улицы и для Москвы, а
|
||||
# _resolve_target_city знает ТОЛЬКО города обл.66 → для Москвы target_city=None,
|
||||
# city_filter пуст, и без region_code одноимённая улица другого региона
|
||||
# подмешалась бы в выборку (прод-замер: "Ясная" — 168 сделок в 66 и 80 в 77,
|
||||
# "Советская" — 1202 и 17). region_code заполнен у всех deals (66→108 623,
|
||||
# 77→212 937, NULL нет) — фильтр не отрезает ни одной существующей строки.
|
||||
region_code = region_code_for_address(address)
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -2276,7 +2255,6 @@ def get_street_deals(
|
|||
AND address ILIKE :street_pattern
|
||||
AND address ~* :street_regex
|
||||
{city_filter}
|
||||
AND region_code = CAST(:region_code AS integer)
|
||||
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
|
||||
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
|
||||
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто —
|
||||
|
|
@ -2291,7 +2269,6 @@ def get_street_deals(
|
|||
"street_pattern": "%" + street_name + "%",
|
||||
"street_regex": r"\m" + street_name + r"\M",
|
||||
"target_city": target_city.lower() if target_city else None,
|
||||
"region_code": region_code,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"period_months": period_months,
|
||||
|
|
@ -2511,12 +2488,8 @@ def get_sales_vs_listings(
|
|||
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
|
||||
|
||||
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
|
||||
matching listing на той же улице с близкой area_m2 / listing_date в окне
|
||||
[deal_date - window_days, deal_date + 30d grace]. Комнатность в ключе стоит
|
||||
ТОЛЬКО на стороне объявлений (`l.rooms = p_rooms`, комнатность клиента): у
|
||||
сделок Росреестра `rooms` — синтетика из площади, предикат по ней снят
|
||||
миграцией 300 (#3451/#3256). Поэтому `deal_rooms` в паре может не совпадать
|
||||
с запрошенным `rooms`.
|
||||
matching listing на той же улице с такими же rooms / близкой area_m2 /
|
||||
listing_date в окне [deal_date - window_days, deal_date + 30d grace].
|
||||
|
||||
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
|
||||
чтобы вычислить linkage_rate.
|
||||
|
|
@ -2526,12 +2499,7 @@ def get_sales_vs_listings(
|
|||
|
||||
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
|
||||
"""
|
||||
from app.services.estimator import (
|
||||
_percentile,
|
||||
_resolve_target_city,
|
||||
extract_street_name,
|
||||
region_code_for_address,
|
||||
)
|
||||
from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
|
||||
|
||||
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
|
||||
return SalesVsListingsResponse(
|
||||
|
|
@ -2561,36 +2529,16 @@ def get_sales_vs_listings(
|
|||
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
|
||||
target_city = _resolve_target_city(address)
|
||||
|
||||
# #dkp-corridor-scope (2026-09-12): region_code — фильтр ДОПОЛНИТЕЛЬНО к
|
||||
# target_city выше, нужен по той же причине, что и в /street-deals: для
|
||||
# Москвы _resolve_target_city (словарь городов ТОЛЬКО обл.66) возвращает
|
||||
# None → target_city-фильтр внутри TVF (миграция 205) не срабатывает ни
|
||||
# для deals, ни для listings, и одноимённая улица другого региона
|
||||
# подмешивается в пары (см. estimator.region_code_for_address).
|
||||
#
|
||||
# TVF street_sales_vs_listings() (миграция 205) параметра региона не
|
||||
# знает — сама TVF не трогается (это отдельная миграция, вне текущего
|
||||
# фикса), фильтр применён СНАРУЖИ: оборачиваем вызов в JOIN на deals по
|
||||
# deal_id и фильтруем region_code ТОЛЬКО на стороне сделок. Сторона
|
||||
# listings внутри TVF остаётся НЕ отфильтрованной по региону — это
|
||||
# известный узкий компромисс, не побочный эффект: у listings нет
|
||||
# общего для всех источников поля региона (city заполнен частично, см.
|
||||
# комментарий 205 выше), а сам JOIN внутри TVF уже требует совпадения
|
||||
# street_pattern + rooms + area + date proximity, что резко сужает шанс
|
||||
# чужого региона на listing-стороне. Полный фикс (передать region_code
|
||||
# внутрь TVF седьмым/восьмым параметром) — отдельная миграция.
|
||||
region_code = region_code_for_address(address)
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
sv.deal_id, sv.deal_date, sv.deal_price_rub, sv.deal_price_per_m2,
|
||||
sv.deal_area_m2, sv.deal_rooms, sv.deal_floor, sv.deal_address,
|
||||
sv.listing_id, sv.listing_source, sv.listing_source_url,
|
||||
sv.listing_date, sv.listing_price_rub, sv.listing_price_per_m2,
|
||||
sv.listing_area_m2, sv.days_listing_to_deal, sv.discount_pct
|
||||
deal_id, deal_date, deal_price_rub, deal_price_per_m2,
|
||||
deal_area_m2, deal_rooms, deal_floor, deal_address,
|
||||
listing_id, listing_source, listing_source_url,
|
||||
listing_date, listing_price_rub, listing_price_per_m2,
|
||||
listing_area_m2, days_listing_to_deal, discount_pct
|
||||
FROM street_sales_vs_listings(
|
||||
CAST(:street_pattern AS text),
|
||||
CAST(:area_m2 AS numeric),
|
||||
|
|
@ -2599,9 +2547,7 @@ def get_sales_vs_listings(
|
|||
CAST(:area_tolerance AS numeric),
|
||||
CAST(:period_months AS integer),
|
||||
CAST(:target_city AS text)
|
||||
) sv
|
||||
JOIN deals d ON d.id = sv.deal_id
|
||||
WHERE d.region_code = CAST(:region_code AS integer)
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
|
|
@ -2612,7 +2558,6 @@ def get_sales_vs_listings(
|
|||
"area_tolerance": area_tolerance,
|
||||
"period_months": period_months,
|
||||
"target_city": target_city,
|
||||
"region_code": region_code,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ from sqlalchemy.exc import ArgumentError
|
|||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import DB_CONNECT_ARGS
|
||||
|
||||
|
||||
class AuthDatabaseNotConfiguredError(RuntimeError):
|
||||
|
|
@ -102,18 +101,6 @@ def _build() -> tuple[Engine, sessionmaker[Session]]:
|
|||
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
||||
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
||||
hide_parameters=True,
|
||||
# #3463. Те же потолки, что у продуктового движка, — ОДНОЙ константой на оба:
|
||||
# потолок на одном движке и мина на втором это не починка, а половина.
|
||||
# Этот движок живёт на ГОРЯЧЕМ пути: `core/rbac.py` резолвит session-cookie
|
||||
# в middleware, синхронно на event loop'е, на КАЖДОМ запросе с cookie
|
||||
# (на проде IDENTITY_STORE=auth во всех трёх сервисах образа — сверено 12.09,
|
||||
# `printenv` в контейнерах). Без потолка `ACCESS EXCLUSIVE` на `auth.sessions`
|
||||
# вешает не четыре слота `/estimate`, а весь uvicorn-воркер (он один, без
|
||||
# --workers) — включая `/health`.
|
||||
# Срабатывание потолка безопасно: вызов в rbac.py уже под `except Exception`
|
||||
# с фолбэком на заголовочную аутентификацию, то есть отмена запроса даёт тот
|
||||
# же путь, что и любой другой сбой реестра, а не 500.
|
||||
connect_args=DB_CONNECT_ARGS,
|
||||
)
|
||||
except (ArgumentError, ValueError):
|
||||
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
|
||||
|
|
|
|||
|
|
@ -1287,69 +1287,6 @@ class Settings(BaseSettings):
|
|||
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
|
||||
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
|
||||
|
||||
# Общий (не per-тему) лимит частоты отправки в ОДНУ группу — Telegram
|
||||
# считает ~20 сообщений/минуту на группу суммарно по всем её темам (#3471:
|
||||
# всплеск GlitchTip-алертов + поток поддержки в ту же группу давали 429 и
|
||||
# потерю сообщений). `TELEGRAM_SUPPORT_CHAT_ID`/`TELEGRAM_ALERTS_CHAT_ID` на
|
||||
# проде равны (одна группа, темы разные) — обе половины делят один
|
||||
# площадочный бюджет.
|
||||
#
|
||||
# РАЗДЕЛЕНО ПО РОЛЯМ (review H2, #3471), а не одна общая константа: лимитер
|
||||
# живёт in-memory В ЭКЗЕМПЛЯРЕ `TelegramClient`, а в эту группу пишут ДВА
|
||||
# независимых процесса — API под uvicorn (`app/services/tgbot/shared.py`,
|
||||
# интерактивные ручки + GlitchTip-вебхук) и контейнер бота (`app/tgbot_main.py`,
|
||||
# long-polling воркер). У них НЕТ общего счётчика (это отдельная задача —
|
||||
# Redis-based распределённый лимитер), поэтому если каждому дать по 18,
|
||||
# сумма (2×18=36) УДВОИТ площадочный лимit и 429 вернётся ровно там же.
|
||||
# Бюджет делится статически так, чтобы СУММА была заметно НИЖЕ ~20: у API
|
||||
# больше — там же интерактивные ответы клиентам, у бота меньше — там же
|
||||
# обычно только зеркалирование/уведомления, которые могут подождать дольше
|
||||
# (см. `TelegramGroupRateLimiter.acquire` про `max_wait=None` для фона).
|
||||
# 0/отрицательное значение выключает лимитер для соответствующего процесса.
|
||||
#
|
||||
# ЧЕСТНО ПРО ОГРАНИЧЕНИЕ ЭТОГО ДИЗАЙНА (review, #3471):
|
||||
# `telegram_group_rate_limit_api_per_minute` — ОДИН общий бюджет на ВСЕ
|
||||
# отправки процесса API в эту группу, а туда
|
||||
# пишут И зеркала веб-чата поддержки (`app/api/v1/support.py`), И
|
||||
# GlitchTip-алерты (`app/api/v1/glitchtip.py`) — обе ручки идут через один
|
||||
# и тот же `get_telegram_client()` (см. `app/services/tgbot/shared.py`).
|
||||
# Приоритета между ними НЕТ: кто первый встал в очередь `TelegramGroupRateLimiter`,
|
||||
# тот и получил слот. Оба пути передают узкий `timeout` (5с у support, 8с у
|
||||
# glitchtip) — он же становится потолком ожидания слота (см.
|
||||
# `TelegramClient._request`, review H1). Значит при всплеске алертов (пачка
|
||||
# ошибок прода бьёт в вебхук залпом) реально возможен сценарий: бюджет
|
||||
# 12/мин исчерпан алертами → следующая отправка живого клиента в веб-чате
|
||||
# ждёт до 5с и получает `TelegramRateLimitedError` → 502 клиенту поддержки.
|
||||
# То есть при достаточно большом всплеске алертов веб-чат ДЕЙСТВИТЕЛЬНО
|
||||
# может временно вставать. Разделить бюджет по ИСТОЧНИКУ (не по процессу) —
|
||||
# отдельная задача: нужен свой `TelegramGroupRateLimiter` на алерты с явно
|
||||
# малой квотой и/или приоритет для support-трафика; здесь НЕ сделано
|
||||
# (вне бюджета этой правки).
|
||||
telegram_group_rate_limit_api_per_minute: int = Field(
|
||||
default=12, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_API_PER_MINUTE"
|
||||
)
|
||||
telegram_group_rate_limit_bot_per_minute: int = Field(
|
||||
default=6, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_BOT_PER_MINUTE"
|
||||
)
|
||||
|
||||
# ── Ретранслятор Bot API через Beget (#3471) ─────────────────────────────
|
||||
# Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` с Selectel — 9
|
||||
# успешных из 12 (три ConnectTimeout), TCP-443 до адреса Selectel — 5/6, TCP-443
|
||||
# до адреса Beget — 8/8; за сутки 508 строк `network error` в логе бота, за 30
|
||||
# дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel лоссовый, с
|
||||
# Beget чистый (Alertmanager там же шлёт без проблем) — поэтому продуктовый
|
||||
# трафик Bot API идёт через маленький HTTP-ретранслятор на Beget
|
||||
# (`ops/metrics/tg-relay`), а не напрямую.
|
||||
#
|
||||
# Пусто (дефолт) = прежнее поведение, прямой путь к api.telegram.org — это и
|
||||
# есть механизм отката, если ретранслятор сам подведёт. При заданном адресе
|
||||
# клиент (`app/services/tgbot/client.py`) всё равно делает одну попытку
|
||||
# напрямую при транспортном отказе похода на ретранслятор — хуже прямого
|
||||
# пути быть не должно ни при каких условиях.
|
||||
# ENV: TELEGRAM_RELAY_BASE_URL, TELEGRAM_RELAY_SECRET.
|
||||
telegram_relay_base_url: str = Field(default="", validation_alias="TELEGRAM_RELAY_BASE_URL")
|
||||
telegram_relay_secret: str = Field(default="", validation_alias="TELEGRAM_RELAY_SECRET")
|
||||
|
||||
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
|
||||
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
|
||||
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
|
||||
|
|
|
|||
|
|
@ -1,68 +1,14 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable, Generator
|
||||
from typing import Any
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# #3463. Потолок ОДНОГО statement'а, секунды·1000. Ставится на КОННЕКТЕ (libpq
|
||||
# `options`), а не в питоновской обёртке: обёртка (`run_db_thread` ниже) при
|
||||
# отмене обязана ДОЖДАТЬСЯ потока, иначе поток остаётся сиротой в общей
|
||||
# `Session` — ровно то, ради чего писался #3449. Значит верхняя граница ожидания
|
||||
# = длительность самого запроса, и задать её может только сервер.
|
||||
#
|
||||
# 30 с выбраны так, чтобы потолок НИКОГДА не стал биндящим ограничением для
|
||||
# честной работы, но остался конечным:
|
||||
# * самый длинный ОБЪЯВЛЕННЫЙ бюджет на `/estimate` — 20 с (`estimate_avito_imv_timeout_s`,
|
||||
# config.py:852); дальше 12 с геокод, 8 с Yandex/Cian/house_meta. 30 с = 1.5× от максимума;
|
||||
# * ОСНОВНАЯ опора по планировщику — `scrape_runs` (длительности целых прогонов, они
|
||||
# не вытесняются): самая долгая ЧИСТО-БД задача за 14 суток — listing_source_snapshot,
|
||||
# 9.7 с ЦЕЛИКОМ (и у неё сверх того свой `SET LOCAL statement_timeout = 900000`,
|
||||
# который перекрывает это значение — гейт tests/test_3463_db_timeouts.py);
|
||||
# * самый длинный set-based statement ЧЕРЕЗ движок из замеренных — матч ГАР→houses
|
||||
# (`services/gar_flats_loader._MATCH_SQL`): 2.07 с с городским фильтром и 6.46 с без
|
||||
# него (`city_filter=None`, флаг CLI). Запас ~3×, и это СЧИТАЮЩИЙ запрос, а не ждущий.
|
||||
#
|
||||
# `pg_stat_statements` опорой по планировщику НЕ является: при `max = 5000` он вытесняет
|
||||
# редкие записи (проверено 12.09 — `dealloc` вырос на единицу за десять минут, и из топа
|
||||
# пропали ВСЕ записи с `calls = 1`, включая `REFRESH MATERIALIZED VIEW` 30.85 с и KNN
|
||||
# `cadastral_geo_match` 2.45 с). Суточная задача до следующих суток там не доживает, так
|
||||
# что «самый долгий запрос 4.27 с» верно только для ВЫСОКОЧАСТОТНЫХ запросов.
|
||||
_STATEMENT_TIMEOUT_MS = 30_000
|
||||
|
||||
# Ожидание БЛОКИРОВКИ — заведомо меньше: ждать лок дольше секунд смысла нет, лучше
|
||||
# деградировать. 5 с — та же величина, что у миграций проекта
|
||||
# (`SET LOCAL lock_timeout = '5s'` в data/sql/250,251,260,272,277…), снизу ограничена
|
||||
# deadlock_timeout (на проде 1 с — сверено 12.09). Именно этот потолок закрывает
|
||||
# сценарий #3463: под `ACCESS EXCLUSIVE` на `geocode_cache` запрос ЖДЁТ лок, а не
|
||||
# считает, — statement_timeout тут только страховка от «считает вечно».
|
||||
_LOCK_TIMEOUT_MS = 5_000
|
||||
|
||||
# idle_in_transaction_session_timeout НАМЕРЕННО не трогаем: тем же движком живёт tgbot,
|
||||
# и `services/tgbot/bridge.py` держит транзакцию открытой ПОВЕРХ long-poll Telegram
|
||||
# (замер на проде 12.09, 3 пробы с шагом 7 с: одна и та же сессия, запрос
|
||||
# `SELECT value FROM tg_support_state …`, возраст транзакции циклически растёт до ~29 с).
|
||||
# Сессионный потолок на простой в транзакции ронял бы long-poll КАЖДЫЙ цикл —
|
||||
# гарантированно, а не в редком случае.
|
||||
DB_CONNECT_ARGS = {
|
||||
"options": f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
|
||||
}
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
# #3463. Накрывает ВСЕ три сервиса образа (backend / scraper / tgbot — один и тот
|
||||
# же `app.core.db`, см. docker-compose.prod.yml) и обе стороны: продуктовый путь
|
||||
# `/estimate` и задачи планировщика. Миграции идут мимо (psql из
|
||||
# .forgejo/workflows/deploy-tradein.yml, не этот движок) — их DDL под своим
|
||||
# `SET LOCAL lock_timeout` и потолком не ограничен.
|
||||
connect_args=DB_CONNECT_ARGS,
|
||||
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
|
||||
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
|
||||
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
|
||||
|
|
@ -119,39 +65,3 @@ def get_db() -> Generator[Session, None, None]:
|
|||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def run_db_thread[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
"""`asyncio.to_thread(fn, ...)`, который при отмене ДОЖИДАЕТСЯ своего потока (#3449).
|
||||
|
||||
Для синхронной работы по сессии, которая шагу НЕ принадлежит — она общая со
|
||||
всем остальным запросом (`Depends(get_db)`). Поток отменить нельзя: у
|
||||
`asyncio.to_thread` отменяется только ожидание со стороны loop'а. Корутина
|
||||
умирает по бюджету источника (`estimator._with_budget` = `asyncio.wait_for`,
|
||||
геокодер — 12 с), а поток продолжает работать с ТОЙ ЖЕ `Session`, пока
|
||||
вызывающий уже идёт дальше по коду — следующий источник,
|
||||
`_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной
|
||||
`Session` дают «another operation is in progress» / `InvalidRequestError` на
|
||||
СЛЕДУЮЩЕМ шаге: у источников такую ошибку глушит `except` вокруг вызова, у
|
||||
персиста оценки не глушит ничего — 500 и потерянная оценка клиента.
|
||||
|
||||
Поэтому отмена пробрасывается ПОСЛЕ того, как поток отпустил сессию. Цена —
|
||||
бюджет источника переезжает на длину ОДНОГО шага БД (чекаут ≤ `pool_timeout`
|
||||
плюс сам запрос), а не на длину фетча, ради которой бюджет и заведён.
|
||||
|
||||
Только защита от сироты: транзакцию шаг НЕ завершает (в середине геокодинга
|
||||
commit зафиксировал бы частичное состояние оценки). Кому нужен ещё и возврат
|
||||
коннекта в пул перед внешним HTTP — `estimator._db_step`, он поверх этого.
|
||||
|
||||
Гейт — tests/test_3449_geocoder_cancel_orphan.py.
|
||||
"""
|
||||
step = asyncio.ensure_future(asyncio.to_thread(fn, *args, **kwargs))
|
||||
try:
|
||||
return await asyncio.shield(step)
|
||||
except asyncio.CancelledError:
|
||||
await asyncio.wait([step])
|
||||
if not step.cancelled() and step.exception() is not None:
|
||||
# Результата уже никто не ждёт: без явного чтения asyncio напечатает
|
||||
# «Task exception was never retrieved» вообще без контекста.
|
||||
logger.warning("шаг БД упал уже после отмены: %s", step.exception())
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -161,18 +161,6 @@ class SlidingWindowLimiter:
|
|||
del self._hits[k]
|
||||
return len(bucket)
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
"""Обнуляет окно под *key*.
|
||||
|
||||
Нужен вызывающим, которые считают не «сколько сделано», а «сколько ПОДРЯД»
|
||||
— например счётчик отказов отправки (`support.py:_send_failure_limiter`):
|
||||
успешная отправка означает, что канал жив, и предыдущие отказы больше не
|
||||
должны приближать cooldown. Для лимитеров-бюджетов (`_send_limiter`,
|
||||
`_anon_ip_limiter`, `_notify_limiter`) не используется — там обнуление
|
||||
по запросу было бы дырой в самом лимите.
|
||||
"""
|
||||
self._hits.pop(key, None)
|
||||
|
||||
def check(self, key: str) -> float | None:
|
||||
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
||||
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ from sentry_sdk.integrations.httpx import HttpxIntegration
|
|||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||
from sentry_sdk.types import Event, Hint
|
||||
|
||||
from app.api.public import mera as public_mera
|
||||
from app.api.v1 import (
|
||||
|
|
@ -51,7 +50,6 @@ from app.core.rbac import rbac_guard
|
|||
from app.core.request_audit import RequestAuditMiddleware
|
||||
from app.observability import metrics as app_metrics
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
from app.services.tgbot.shared import close_telegram_client, init_telegram_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -80,43 +78,31 @@ install_query_secret_filter()
|
|||
# frontend), отдельного broker нет → мониторить нечего.
|
||||
if settings.glitchtip_dsn:
|
||||
from app.observability.sentry_scrub import (
|
||||
drop_payments_disabled_event,
|
||||
redact_telegram_bot_token,
|
||||
scrub_payment_request_body,
|
||||
scrub_public_address,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
def _before_send(event: Event, hint: Hint) -> Event | None:
|
||||
"""Композиция payments-disabled drop + платёжный body-wipe + PII-scrub +
|
||||
Telegram bot-токен redaction + RetryError fingerprint-стабилизация
|
||||
(#tgsupport-web, PR-D2, glitchtip-noise, #3471) — см.
|
||||
app/tgbot_main.py._before_send (идентичная композиция без последнего
|
||||
шага, тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже
|
||||
держит TelegramClient в стек-фреймах при ошибке sendMessage, а
|
||||
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
|
||||
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
|
||||
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
|
||||
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
|
||||
TelegramClient в стек-фреймах при ошибке sendMessage, а
|
||||
include_local_variables=False ниже — первый рубеж защиты.
|
||||
|
||||
#3471: payments-disabled drop идёт ПЕРВЫМ шагом — это единственный
|
||||
процесс из трёх entrypoint'ов, который реально держит ASGI-роут
|
||||
`/api/v1/payments/*`, поэтому именно здесь события возникают; ранний
|
||||
return None экономит остальную композицию на заведомо отбрасываемом
|
||||
событии.
|
||||
|
||||
PR-D2: платёжный body-wipe идёт следующим шагом, а не заменяет
|
||||
остальные — режет `request.data` целиком только для `/payments/*`,
|
||||
остальные пути (extra/contexts/traceback) по-прежнему проходят
|
||||
ключ-based scrub и token-redaction. Тот же обработчик передан ОБОИМ
|
||||
каналам ниже (before_send и before_send_transaction) — вчерашний баг в
|
||||
Птице закрыл только error-канал, transaction-канал остался вообще без
|
||||
обработчика.
|
||||
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные —
|
||||
режет `request.data` целиком только для `/payments/*`, остальные пути
|
||||
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
|
||||
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
|
||||
(before_send и before_send_transaction) — вчерашний баг в Птице закрыл
|
||||
только error-канал, transaction-канал остался вообще без обработчика.
|
||||
|
||||
RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/*
|
||||
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
|
||||
sentry_scrub.stabilize_retry_error_fingerprint."""
|
||||
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
|
||||
if dropped is None:
|
||||
return None
|
||||
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
|
||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
|
||||
|
|
@ -230,19 +216,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
|
||||
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
|
||||
# this API process never actually launched scheduler_loop() in production.
|
||||
|
||||
# Общий на приложение Telegram-клиент (#tg-connection-resilience): ручки
|
||||
# support/glitchtip раньше создавали его на КАЖДЫЙ запрос, то есть каждое
|
||||
# зеркалирование сообщения начиналось с полного TCP+TLS-хендшейка до
|
||||
# api.telegram.org. Один пул keep-alive на процесс, закрываем на shutdown.
|
||||
# Без токена не создаём: ручки в этом случае и так отвечают 503.
|
||||
if settings.telegram_bot_token:
|
||||
init_telegram_client()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await close_telegram_client()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
|
|
|||
|
|
@ -99,47 +99,6 @@ BUILD_INFO = Gauge(
|
|||
# смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно.
|
||||
BUILD_INFO.labels(app="mera", release=f"{APP_VERSION}+{BUILD_SHA}").set(1)
|
||||
|
||||
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
|
||||
#
|
||||
# Источник списка — реальные `event_type` из `user_events` (миграция 184) плюс
|
||||
# ручки, которые сами в этот аудит-лог не пишут (suggest, PDF-экспорт). Метки
|
||||
# везде — фиксированный литерал из кода вызова (outcome/found/channel/result),
|
||||
# НЕ значение из запроса: username, адрес, estimate_id в метку не идут —
|
||||
# это ровно то, что взрывает кардинальность ряда у Prometheus.
|
||||
ESTIMATES = Counter(
|
||||
"mera_estimates_total",
|
||||
"Запрошенных оценок trade-in, по исходу расчёта",
|
||||
labelnames=("outcome",), # ok | insufficient_data
|
||||
)
|
||||
|
||||
ADDRESS_SUGGESTIONS = Counter(
|
||||
"mera_address_suggestions_total",
|
||||
"Запросов автокомплита адреса (/geocode/suggest), нашёлся ли результат",
|
||||
labelnames=("found",), # yes | no
|
||||
)
|
||||
|
||||
REPORTS_EXPORTED = Counter(
|
||||
"mera_reports_exported_total",
|
||||
"Скачанных PDF-отчётов по оценке trade-in",
|
||||
)
|
||||
|
||||
LEADS = Counter(
|
||||
"mera_leads_total",
|
||||
"Сохранённых контактных заявок (телефон + согласие) с результата оценки",
|
||||
)
|
||||
|
||||
SUPPORT_MESSAGES = Counter(
|
||||
"mera_support_messages_total",
|
||||
"Сообщений в поддержку, дошедших до Telegram-топика, по каналу",
|
||||
labelnames=("channel",), # web | anon
|
||||
)
|
||||
|
||||
LOGINS = Counter(
|
||||
"mera_logins_total",
|
||||
"Попыток входа в личный кабинет, по исходу",
|
||||
labelnames=("result",), # success | failed
|
||||
)
|
||||
|
||||
|
||||
def route_label(scope: Scope) -> str:
|
||||
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import re
|
|||
from typing import Any
|
||||
|
||||
from sentry_sdk.types import Event
|
||||
from starlette.exceptions import HTTPException as _StarletteHTTPException
|
||||
from tenacity import RetryError
|
||||
|
||||
_REDACTED = "[REDACTED]"
|
||||
|
|
@ -257,41 +256,6 @@ def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | N
|
|||
return event
|
||||
|
||||
|
||||
_PAYMENTS_DISABLED_DETAIL = "payments are disabled"
|
||||
|
||||
|
||||
def drop_payments_disabled_event(event: Event, hint: dict[str, Any]) -> Event | None:
|
||||
"""before_send-хук: роняет 503 "payments are disabled" из
|
||||
`payments.py._require_enabled` (issue #3471, GlitchTip-группа TRADE-IN-3GG).
|
||||
|
||||
Источник — внутренний IP смоук-проверки: кнопки оплаты во фронте нет,
|
||||
клиентского трафика на эти пути нет вообще, а выключенный платёжный контур
|
||||
(`settings.payments_enabled=False`) штатно отвечает 503 на каждый такой
|
||||
запрос — 167 событий за 29.08-12.09 размывали ленту, на этом фоне терялась
|
||||
настоящая ошибка. Само поведение ручки НЕ меняется (503 остаётся) —
|
||||
фильтруется только репортинг в трекер: sentry_sdk `StarletteIntegration`
|
||||
репортит любой `HTTPException` с кодом из `failed_request_status_codes`
|
||||
(по умолчанию весь диапазон 5xx) как error-событие, даже когда исключение
|
||||
штатно обработано FastAPI и превращено в корректный HTTP-ответ.
|
||||
|
||||
Матчим `isinstance` реального объекта исключения из `hint["exc_info"]` (тот
|
||||
же контракт, что `stabilize_retry_error_fingerprint` ниже) + точный текст
|
||||
`detail` — НЕ код 503 сам по себе, чтобы не проглотить другие 503 (напр.
|
||||
будущий maintenance-режим другого роутера).
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
|
||||
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
|
||||
if (
|
||||
isinstance(exc_value, _StarletteHTTPException)
|
||||
and exc_value.status_code == 503
|
||||
and exc_value.detail == _PAYMENTS_DISABLED_DETAIL
|
||||
):
|
||||
return None
|
||||
return event
|
||||
|
||||
|
||||
_PUBLIC_API_URL_SEGMENT = "/api/public/"
|
||||
|
||||
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.
|
||||
|
|
|
|||
|
|
@ -43,16 +43,14 @@ if settings.glitchtip_dsn:
|
|||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
from sentry_sdk.types import Event, Hint
|
||||
|
||||
from app.observability.sentry_scrub import (
|
||||
drop_payments_disabled_event,
|
||||
scrub_payment_request_body,
|
||||
scrub_pii_event,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
def _before_send(event: Event, hint: Hint) -> Event | None:
|
||||
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
|
||||
"""PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event
|
||||
сегодня), но payments_confirm/payments_reconcile (PR-E, тот же
|
||||
`tradein-scraper` контейнер) будут звать Т-Банк API отсюда — belt-and-
|
||||
|
|
@ -60,13 +58,6 @@ if settings.glitchtip_dsn:
|
|||
`request`/`extra`. Тот же обработчик на оба канала ниже — см.
|
||||
app/main.py._before_send (идентичный мотив, не дублировать без причины).
|
||||
|
||||
#3471: payments-disabled drop — тот же belt-and-suspenders мотив, что и
|
||||
payment body-wipe выше по докстрингу: этот процесс сегодня не отвечает
|
||||
503 из `_require_enabled` (нет ASGI/роутов), реальный источник шума —
|
||||
`app/main.py`, но фильтр держим одинаковым во всех трёх entrypoint'ах,
|
||||
чтобы поведение не разошлось, если payments-код когда-нибудь переедет
|
||||
сюда же.
|
||||
|
||||
PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут
|
||||
следом за платёжным body-wipe: этот процесс гоняет
|
||||
`geocode_missing_listings` (ночной batch, сотни адресов за прогон) —
|
||||
|
|
@ -75,16 +66,13 @@ if settings.glitchtip_dsn:
|
|||
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
|
||||
См. sentry_scrub docstring.
|
||||
"""
|
||||
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
|
||||
if dropped is None:
|
||||
return None
|
||||
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
|
||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return stabilize_retry_error_fingerprint(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||
return stabilize_retry_error_fingerprint(scrubbed, hint)
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
|
|
|
|||
|
|
@ -158,14 +158,10 @@ class DkpCorridor(BaseModel):
|
|||
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
|
||||
|
||||
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
|
||||
rooms + площади ±15% за период. Показывается как тонкая референсная линия
|
||||
«коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит за
|
||||
[low,high]×slack — добавляется текстовая пометка.
|
||||
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
|
||||
линия «коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит
|
||||
за [low,high]×slack — добавляется текстовая пометка. НЕ хард-клампит оценку.
|
||||
None / count=0 если по улице нет сопоставимых сделок.
|
||||
|
||||
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
||||
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
||||
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
||||
"""
|
||||
|
||||
count: int # число ДКП-сделок в выборке
|
||||
|
|
@ -187,30 +183,6 @@ class DkpCorridor(BaseModel):
|
|||
# None = сделки без даты (в проде не встречается) — потребитель молчит.
|
||||
latest_deal_date: date | None = None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def advisory_only(self) -> bool:
|
||||
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
|
||||
|
||||
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
|
||||
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
||||
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
||||
цену — зона n=3..9 на экране была неотличима от работающей.
|
||||
|
||||
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
|
||||
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
|
||||
deals-headline-fallback берёт медиану коридора начиная с трёх сделок.
|
||||
Потребителю (витрине) поэтому корректно говорить про РАЗМЕР ВЫБОРКИ, а
|
||||
не про то, что цену коридор не трогал.
|
||||
|
||||
Производное от count, поэтому верно во ВСЕХ конструкторах DkpCorridor
|
||||
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
|
||||
вторым числом.
|
||||
"""
|
||||
from app.core.config import settings # локально: schemas остаётся import-light
|
||||
|
||||
return self.count < settings.estimate_corridor_clamp_min_n
|
||||
|
||||
|
||||
class PriceTrendPoint(BaseModel):
|
||||
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ async def backfill_cian_price_history(
|
|||
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
|
||||
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
|
||||
# логом, который читается как «Циан нас блокирует».
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
|
||||
"на listing_id=%s (обработано %d из %d)",
|
||||
exc,
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
|||
# ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый
|
||||
# статичный узел мог быть источником бана, см. proxy_egress module docstring).
|
||||
# Явный отказ вместо слепого прохода через заведомо подозрительный egress.
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
|
||||
"cookies НЕ помечены протухшими, retry на следующем такте",
|
||||
exc,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings
|
||||
from app.core.db import SessionLocal, run_db_thread
|
||||
from app.core.db import SessionLocal
|
||||
from app.schemas.trade_in import (
|
||||
AggregatedEstimate,
|
||||
AnalogLot,
|
||||
|
|
@ -328,13 +328,6 @@ SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд р
|
|||
_SBER_REGION_SERIES: dict[int, str] = {
|
||||
66: SBER_TIME_ADJUST_REGION,
|
||||
77: "Москва",
|
||||
# Московская область — свой ряд, а не фолбэк на «Россию». Загрузчик научился
|
||||
# тянуть REF_AREA=50 (services/sber_index), и 12.09.2026 ряд приехал на прод:
|
||||
# real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 — та же глубина,
|
||||
# что у Москвы и обл.66. До этой строки область считалась по общероссийскому
|
||||
# ряду, то есть коридор ошибался на расхождение области со страной; теперь
|
||||
# ошибка только внутрирегиональная.
|
||||
50: "Московская область",
|
||||
}
|
||||
|
||||
# Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0):
|
||||
|
|
@ -810,9 +803,8 @@ async def _db_step[T](db: Session, work: Callable[[], T]) -> T:
|
|||
взятый ради одного SELECT'а кэша, живёт до конца функции — включая
|
||||
ожидание чужого HTTP (замер 11.09: 8.5 с на догрузку Яндекса), и пиковый
|
||||
спрос считается не в коротких SELECT'ах, а в целых фетчах.
|
||||
3. Отмена шага ДОЖИДАЕТСЯ потока — это `run_db_thread` (app/core/db.py,
|
||||
#3449), общий для эстиматора и геокодера. Поток отменить нельзя, а
|
||||
сессия у шага не своя — общая со всем остальным запросом.
|
||||
3. Отмена шага ДОЖИДАЕТСЯ потока (`asyncio.shield` ниже). Поток отменить
|
||||
нельзя, а сессия у шага не своя — общая со всем остальным запросом.
|
||||
|
||||
ЧТО ЭТО МЕНЯЕТ ДЛЯ ВЫЗЫВАЮЩЕГО: незакоммиченная работа его транзакции
|
||||
(например fill-null backfill ФИАСа в `estimate_quality`) фиксируется здесь,
|
||||
|
|
@ -832,7 +824,30 @@ async def _db_step[T](db: Session, work: Callable[[], T]) -> T:
|
|||
db.commit()
|
||||
return result
|
||||
|
||||
return await run_db_thread(_run)
|
||||
step = asyncio.ensure_future(asyncio.to_thread(_run))
|
||||
try:
|
||||
return await asyncio.shield(step)
|
||||
except asyncio.CancelledError:
|
||||
# Отмена по бюджету источника (`_with_budget` = `asyncio.wait_for`) поток НЕ
|
||||
# останавливает: у `to_thread` отменяется только ожидание со стороны loop'а.
|
||||
# Корутина умирает, поток продолжает работать с ТОЙ ЖЕ `Session`, а вызывающий
|
||||
# тем временем идёт дальше — следующий источник, `_fetch_anchor_comps`,
|
||||
# `_persist_estimate_and_commit` — ПО ТОЙ ЖЕ сессии. Два потока в одной сессии
|
||||
# дают «another operation is in progress» / InvalidRequestError на следующем
|
||||
# шаге БД: у источников такую ошибку глушит `except` вокруг вызова, у персиста
|
||||
# оценки не глушит ничего — это 500 и потерянная оценка клиента, причём ровно
|
||||
# под нагрузкой, ради которой правка и делается.
|
||||
#
|
||||
# Поэтому отмену пробрасываем ПОСЛЕ того, как поток отпустил сессию. Цена —
|
||||
# бюджет источника переезжает на длину ОДНОГО шага БД (чекаут ≤ `pool_timeout`
|
||||
# плюс сам запрос), а не на длину фетча, ради которой бюджет и заведён.
|
||||
# Гейт — tests/test_3408_db_step_cancel_orphan.py.
|
||||
await asyncio.wait([step])
|
||||
if not step.cancelled() and step.exception() is not None:
|
||||
# Результата уже никто не ждёт: без явного чтения asyncio напечатает
|
||||
# «Task exception was never retrieved» вообще без контекста.
|
||||
logger.warning("db-шаг упал уже после отмены по бюджету: %s", step.exception())
|
||||
raise
|
||||
|
||||
|
||||
async def _get_or_fetch_imv_cached(
|
||||
|
|
@ -1998,38 +2013,6 @@ def _resolve_target_city(address_text: str | None) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def region_code_for_address(address: str | None) -> int:
|
||||
"""Код региона покрытия (regions.REGIONS) по городу в адресе.
|
||||
|
||||
Режет адрес по запятым, каждый сегмент — strip+lower, ищет ТОЧНОЕ
|
||||
совпадение с одним из городов региона (regions.region_by_city). Точное
|
||||
равенство сегмента, а НЕ подстрока: подстрочный поиск "москва" в адресе
|
||||
поймал бы екатеринбургскую "Московская улица" и увёл её в регион 77 —
|
||||
сегмент "московская улица" целиком ≠ "москва".
|
||||
|
||||
#dkp-corridor-scope (2026-09-12): нужен для WHERE region_code = ... в
|
||||
get_street_deals/get_sales_vs_listings — extract_street_name после фикса
|
||||
московского порядка ("Название улица") стал возвращать имя улицы и для
|
||||
Москвы, и без явного region-скоупа одноимённая улица из другого региона
|
||||
(66 vs 77) подмешивалась бы в медиану (напр. "Ясная": 168 сделок в 66 и
|
||||
80 в 77, "Советская": 1202 и 17).
|
||||
|
||||
Returns regions.DEFAULT_REGION_CODE (66), если ни один сегмент не
|
||||
распознан как город ни одного региона — байт-в-байт прежнее поведение
|
||||
(все ЕКБ/безымянные адреса и раньше трактовались как регион 66).
|
||||
"""
|
||||
if not address:
|
||||
return regions_mod.DEFAULT_REGION_CODE
|
||||
for raw_segment in address.split(","):
|
||||
segment = raw_segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
region = regions_mod.region_by_city(segment)
|
||||
if region:
|
||||
return region.code
|
||||
return regions_mod.DEFAULT_REGION_CODE
|
||||
|
||||
|
||||
def _fetch_dkp_corridor(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -2188,11 +2171,6 @@ def _fetch_dkp_corridor(
|
|||
if dd is not None and (latest_deal is None or dd > latest_deal):
|
||||
latest_deal = dd
|
||||
ppm2_values = sorted(adjusted)
|
||||
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
|
||||
# Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и
|
||||
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
|
||||
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
|
||||
scope = "street"
|
||||
|
||||
# #oblast-D widen: a single street in a small non-EKB town can easily have
|
||||
# <3 (or 0) ДКП deals in the last 12 months even though the CITY overall
|
||||
|
|
@ -2268,7 +2246,6 @@ def _fetch_dkp_corridor(
|
|||
city,
|
||||
)
|
||||
ppm2_values = sorted(city_adjusted)
|
||||
scope = "city_wide"
|
||||
# #2846: коридор теперь описывает city-выборку — и возраст обязан
|
||||
# переехать вместе с числами, иначе подпись осталась бы от street-
|
||||
# выборки, которую на витрине уже никто не видит.
|
||||
|
|
@ -2296,7 +2273,6 @@ def _fetch_dkp_corridor(
|
|||
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
||||
"period_months": period_months,
|
||||
"latest_deal_date": latest_deal,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -3138,12 +3114,6 @@ async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any:
|
|||
blowing the gateway read timeout (#654: opaque Caddy 502/504).
|
||||
|
||||
budget_s <= 0 disables the guard (await directly) — escape hatch via config.
|
||||
|
||||
НЕ ВКЛАДЫВАТЬ бюджеты друг в друга: защита `run_db_thread` (#3449) одноразовая
|
||||
— она ловит ОДНУ отмену, а вторая, прилетевшая пока шаг БД дожидается своего
|
||||
потока, вылетает из самого ожидания, и поток остаётся сиротой в чужой `Session`
|
||||
(ровно то, ради чего защита и заведена). Сейчас ни один вызов `_with_budget` не
|
||||
обёрнут другим — это инвариант, а не совпадение.
|
||||
"""
|
||||
if budget_s is None or budget_s <= 0:
|
||||
return await coro
|
||||
|
|
@ -4451,7 +4421,7 @@ async def estimate_quality(
|
|||
if geo is None:
|
||||
# Без координат не можем искать через PostGIS. Возвращаем low confidence.
|
||||
logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address)
|
||||
return await run_db_thread(
|
||||
return await asyncio.to_thread(
|
||||
_empty_estimate,
|
||||
payload,
|
||||
db,
|
||||
|
|
@ -4482,7 +4452,7 @@ async def estimate_quality(
|
|||
dadata_fias = dadata.house_fias_id if dadata else None
|
||||
match_fias = payload.target_fias_id or dadata_fias
|
||||
try:
|
||||
match = await run_db_thread(
|
||||
match = await asyncio.to_thread(
|
||||
match_house_readonly,
|
||||
db,
|
||||
address=(dadata.canonical_address if dadata else None) or geo.full_address,
|
||||
|
|
@ -4523,7 +4493,7 @@ async def estimate_quality(
|
|||
)
|
||||
|
||||
try:
|
||||
await run_db_thread(_backfill_house_fias)
|
||||
await asyncio.to_thread(_backfill_house_fias)
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.warning("estimate fias back-fill failed (graceful): %s", exc)
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
|
|
@ -4543,7 +4513,7 @@ async def estimate_quality(
|
|||
# house_metadata-кэше). payload остаётся главнее в любом случае — houses
|
||||
# заполняет только то, что пользователь не указал.
|
||||
if target_year is None or target_house_type is None or target_total_floors is None:
|
||||
house_facts = await run_db_thread(
|
||||
house_facts = await asyncio.to_thread(
|
||||
_lookup_house_facts,
|
||||
db,
|
||||
target_house_id=target_house_id,
|
||||
|
|
@ -4615,7 +4585,7 @@ async def estimate_quality(
|
|||
|
||||
if cohort_range is not None:
|
||||
cy_min, cy_max = cohort_range
|
||||
listings_tier0, _, analog_tier = await run_db_thread(
|
||||
listings_tier0, _, analog_tier = await asyncio.to_thread(
|
||||
_fetch_analogs,
|
||||
db,
|
||||
lat=geo.lat,
|
||||
|
|
@ -4640,7 +4610,7 @@ async def estimate_quality(
|
|||
fallback_used = False
|
||||
else:
|
||||
# Tier 0 пуст/мал — graceful fallback на Tier A без cohort
|
||||
listings, fallback_used, analog_tier = await run_db_thread(
|
||||
listings, fallback_used, analog_tier = await asyncio.to_thread(
|
||||
_fetch_analogs,
|
||||
db,
|
||||
lat=geo.lat,
|
||||
|
|
@ -4660,7 +4630,7 @@ async def estimate_quality(
|
|||
area_widened = False
|
||||
|
||||
if len(listings) < 5:
|
||||
listings_wide, _, analog_tier_wide = await run_db_thread(
|
||||
listings_wide, _, analog_tier_wide = await asyncio.to_thread(
|
||||
_fetch_analogs,
|
||||
db,
|
||||
lat=geo.lat,
|
||||
|
|
@ -4683,7 +4653,7 @@ async def estimate_quality(
|
|||
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
|
||||
# (актуально для отдалённых районов / новостроек с нестандартной планировкой)
|
||||
if len(listings) < 3:
|
||||
listings_widearea, _, analog_tier_wa = await run_db_thread(
|
||||
listings_widearea, _, analog_tier_wa = await asyncio.to_thread(
|
||||
_fetch_analogs,
|
||||
db,
|
||||
lat=geo.lat,
|
||||
|
|
@ -4738,7 +4708,7 @@ async def estimate_quality(
|
|||
"""Один шаг каскада #oblast-F. Возвращает (listings, tier) только если
|
||||
кандидат СТРОГО больше текущей выборки — иначе релаксация не засчитана
|
||||
(ничего реально не выиграла) и вызывающий её не применяет."""
|
||||
candidate, _, tier = await run_db_thread(
|
||||
candidate, _, tier = await asyncio.to_thread(
|
||||
_fetch_analogs,
|
||||
db,
|
||||
lat=geo.lat,
|
||||
|
|
@ -4868,7 +4838,7 @@ async def estimate_quality(
|
|||
# ничего), т.е. держим гарантию «не резолвится → 66», не пусто.
|
||||
target_region = regions_mod.region_for_point(geo.lat, geo.lon)
|
||||
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
|
||||
dkp_raw = await run_db_thread(
|
||||
dkp_raw = await asyncio.to_thread(
|
||||
_fetch_dkp_corridor,
|
||||
db,
|
||||
address=payload.address,
|
||||
|
|
@ -4939,7 +4909,7 @@ async def estimate_quality(
|
|||
),
|
||||
)
|
||||
if yandex_val is not None:
|
||||
saved_hist = await run_db_thread(_save_yandex_history_items, db, yandex_val)
|
||||
saved_hist = await asyncio.to_thread(_save_yandex_history_items, db, yandex_val)
|
||||
logger.info(
|
||||
"yandex_valuation: history items processed=%d saved=%d (ext_val house_id=%s)",
|
||||
len(yandex_val.history_items),
|
||||
|
|
@ -5022,7 +4992,7 @@ async def estimate_quality(
|
|||
_anchor_comps_pre: list[dict]
|
||||
_anchor_tier_pre: str | None
|
||||
if payload.area_m2 and (payload.address or (geo is not None and geo.full_address)):
|
||||
_anchor_comps_pre, _anchor_tier_pre = await run_db_thread(
|
||||
_anchor_comps_pre, _anchor_tier_pre = await asyncio.to_thread(
|
||||
_fetch_anchor_comps,
|
||||
db,
|
||||
address=payload.address or geo.full_address,
|
||||
|
|
@ -5036,7 +5006,7 @@ async def estimate_quality(
|
|||
_anchor_comps_pre, _anchor_tier_pre = [], None
|
||||
|
||||
# ── Pre-fetch: house IMV anchor ONCE (used for both blend and display) ───
|
||||
imv_anchor_data = await run_db_thread(
|
||||
imv_anchor_data = await asyncio.to_thread(
|
||||
_fetch_house_imv_anchor,
|
||||
db,
|
||||
target_house_id=target_house_id,
|
||||
|
|
@ -5059,7 +5029,7 @@ async def estimate_quality(
|
|||
and not target_quarter_cadnum
|
||||
and geo is not None
|
||||
):
|
||||
target_quarter_cadnum = await run_db_thread(
|
||||
target_quarter_cadnum = await asyncio.to_thread(
|
||||
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
|
||||
)
|
||||
|
||||
|
|
@ -5085,7 +5055,7 @@ async def estimate_quality(
|
|||
)
|
||||
|
||||
# ── Deterministic pricing orchestration ──────────────────────────────────
|
||||
pr = await run_db_thread(
|
||||
pr = await asyncio.to_thread(
|
||||
_price_from_inputs,
|
||||
listings=listings,
|
||||
area_m2=payload.area_m2,
|
||||
|
|
@ -5205,7 +5175,7 @@ async def estimate_quality(
|
|||
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
|
||||
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
|
||||
# исключены — больше не скёюят median вторички ~110-120 К/м².
|
||||
deals = await run_db_thread(
|
||||
deals = await asyncio.to_thread(
|
||||
_fetch_deals,
|
||||
db,
|
||||
lat=geo.lat,
|
||||
|
|
@ -5216,24 +5186,6 @@ async def estimate_quality(
|
|||
|
||||
# 6. Сохраняем в trade_in_estimates
|
||||
estimate_id = uuid4()
|
||||
|
||||
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
|
||||
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
|
||||
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
|
||||
# Зона n=3..9 на экране неотличима от работающего коридора, поэтому
|
||||
# попадание в неё пишется явной строкой: число оценок за сутки —
|
||||
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
|
||||
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
|
||||
if dkp_corridor is not None and dkp_corridor.advisory_only:
|
||||
logger.info(
|
||||
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
|
||||
estimate_id,
|
||||
dkp_corridor.count,
|
||||
settings.estimate_corridor_clamp_min_n,
|
||||
(dkp_raw or {}).get("scope", "unknown"),
|
||||
target_city or "unknown",
|
||||
)
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours)
|
||||
|
||||
|
|
@ -5429,7 +5381,7 @@ async def estimate_quality(
|
|||
|
||||
db.commit()
|
||||
|
||||
await run_db_thread(_persist_estimate_and_commit)
|
||||
await asyncio.to_thread(_persist_estimate_and_commit)
|
||||
|
||||
logger.info(
|
||||
"estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)%s%s",
|
||||
|
|
@ -5451,7 +5403,7 @@ async def estimate_quality(
|
|||
last_scraped_at = _compute_last_scraped_at(metadata_lots)
|
||||
# Месячный ₽/м² тренд целевого дома (web TREND chart) — best-effort, None если нет данных.
|
||||
# #audit-3: передаём freshness_months из настроек — исключаем устаревшие items.
|
||||
price_trend_raw = await run_db_thread(
|
||||
price_trend_raw = await asyncio.to_thread(
|
||||
_fetch_price_trend,
|
||||
db,
|
||||
target_house_id=target_house_id,
|
||||
|
|
@ -5473,7 +5425,7 @@ async def estimate_quality(
|
|||
premium_building,
|
||||
premium_building_median_ppm2,
|
||||
premium_building_class,
|
||||
) = await run_db_thread(_is_premium_building, db, target_house_id)
|
||||
) = await asyncio.to_thread(_is_premium_building, db, target_house_id)
|
||||
|
||||
# #audit-2: структурный analog_tier — стабильный enum для фронта.
|
||||
# anchor-путь: anchor_tier "A" → "same_building", "C" → "micro_radius".
|
||||
|
|
@ -6026,34 +5978,12 @@ def _extract_short_addr(full_address: str | None) -> str | None:
|
|||
# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная
|
||||
# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже
|
||||
# покрывает оба варианта (с точкой и без).
|
||||
# Общий список street-keyword альтернатив — единственное место, где он
|
||||
# перечислен. И _STREET_KW_RE (forward, требует \s+ после), и
|
||||
# _STREET_KW_SUFFIX_RE (moscow-reverse, без \s+) собираются из этой же
|
||||
# константы, чтобы при добавлении нового типа улицы (аллея/линия/...) их
|
||||
# списки не разъехались независимой правкой одного из двух regex.
|
||||
_STREET_KW_ALTERNATION = (
|
||||
_STREET_KW_RE = re.compile(
|
||||
r"(?<![А-Яа-яёЁa-zA-Z])"
|
||||
r"(?:ул\.?|улица|пр\.?|пр-т|проспект|пер\.?|переулок|"
|
||||
r"б-р|бульвар|ш\.?|шоссе|наб\.?|набережная|проезд|тракт|"
|
||||
r"пл\.?|площадь|мкр\.?|микрорайон)"
|
||||
)
|
||||
|
||||
_STREET_KW_RE = re.compile(
|
||||
r"(?<![А-Яа-яёЁa-zA-Z])" + _STREET_KW_ALTERNATION + r"\s+",
|
||||
flags=re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
||||
# Тот же список keyword'ов (_STREET_KW_ALTERNATION), но БЕЗ обязательного
|
||||
# \s+ после них — вместо этого lookahead на запятую/пробел/конец строки.
|
||||
# Нужен для московского формата "Название улица, 6" (Тверская улица,
|
||||
# Ленинский проспект, Брюсов переулок): там сразу после keyword идёт запятая,
|
||||
# и _STREET_KW_RE (требующий \s+) вообще не матчит эту позицию — ни keyword,
|
||||
# ни имя после него найти нельзя. Используется ТОЛЬКО как fallback ниже,
|
||||
# после того как forward-путь через _STREET_KW_RE не дал результата — может
|
||||
# дать результат там, где раньше был None (напр. keyword найден, но имя
|
||||
# после него не распарсилось: "Тверская ул. 6"); непустые результаты
|
||||
# форматов, которые уже что-то возвращали, не меняются.
|
||||
_STREET_KW_SUFFIX_RE = re.compile(
|
||||
r"(?<![А-Яа-яёЁa-zA-Z])" + _STREET_KW_ALTERNATION + r"(?=[,\s]|$)",
|
||||
r"\s+",
|
||||
flags=re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
||||
|
|
@ -6078,29 +6008,10 @@ def extract_street_name(full_address: str | None) -> str | None:
|
|||
"ул. Большая Конюшенная, 25" → "Большая Конюшенная"
|
||||
"" → None
|
||||
|
||||
Московский формат — keyword стоит ПОСЛЕ имени улицы ("Название улица, N"),
|
||||
а не перед ним:
|
||||
"Москва, Тверская улица, 6" → "Тверская"
|
||||
"Москва, 1-я Тверская-Ямская улица, 12" → "1-я Тверская-Ямская"
|
||||
"Москва, Брюсов переулок, 8" → "Брюсов"
|
||||
"Москва, Ленинский проспект, 30" → "Ленинский"
|
||||
"Театр имени М. Н. Ермоловой, 5/6, Тверская улица, 58,
|
||||
Тверской район, Москва, Центральный федеральный округ,
|
||||
125009, Россия" (reverse-формат Nominatim) → "Тверская"
|
||||
"Москва, Проектируемый проезд № 4062, 5" → None (безымянный
|
||||
нумерованный проезд)
|
||||
|
||||
Алгоритм:
|
||||
1. Ищем street-keyword (ул/улица/пр/проспект/...) — case-insensitive,
|
||||
берём 1-3 слова ПОСЛЕ него до запятой или номера дома (forward-формат).
|
||||
2. Московский формат: сразу после keyword — запятая/конец строки (имени
|
||||
после него нет, т.к. keyword в этом формате идёт ПОСЛЕ названия).
|
||||
Ищем keyword ещё раз без требования пробела после него и берём 1-3
|
||||
слова ДО keyword внутри той же запятой-секции ("Тверская улица, 6" →
|
||||
"Тверская"). Не срабатывает, если сразу после keyword — "№"
|
||||
(нумерованный проезд без имени, напр. "Проектируемый проезд №
|
||||
4062" — извлекать нечего, "Проектируемый" НЕ street name).
|
||||
3. Если keyword не нашёлся вовсе — пытаемся первый capitalized токен с
|
||||
1. Ищем street-keyword (ул/улица/пр/проспект/...) — case-insensitive.
|
||||
2. После keyword берём 1-3 слова до запятой или номера дома.
|
||||
3. Если keyword не нашёлся — пытаемся первый capitalized токен с
|
||||
поиском до запятой или номера (fallback для адресов без keyword'а).
|
||||
|
||||
Returns None если ничего не извлеклось.
|
||||
|
|
@ -6118,27 +6029,7 @@ def extract_street_name(full_address: str | None) -> str | None:
|
|||
if nm:
|
||||
return nm.group(1).strip()
|
||||
|
||||
# 2. Московский формат "Название улица, N": keyword идёт ПОСЛЕ имени, и
|
||||
# сразу за ним запятая/конец строки — _STREET_KW_RE (шаг 1) такую позицию
|
||||
# вообще не матчит, т.к. требует \s+ сразу после keyword. Берём keyword
|
||||
# отдельно (без требования пробела) и смотрим на слова ДО него в пределах
|
||||
# той же запятой-секции.
|
||||
km = _STREET_KW_SUFFIX_RE.search(s)
|
||||
if km:
|
||||
# Исключение: "Проектируемый проезд № 4062" — keyword с числовым
|
||||
# индексом БЕЗ имени (forward-паттерн "keyword № N", не moscow-
|
||||
# reverse). Слово перед keyword ("Проектируемый") здесь — прилагательное
|
||||
# к самому "проезду", а не имя улицы; сотни таких проездов имеют один
|
||||
# и тот же "Проектируемый" — коридор по нему смешал бы все их сделки.
|
||||
after = s[km.end() :].lstrip()
|
||||
if not after.startswith("№"):
|
||||
segment_start = s.rfind(",", 0, km.start()) + 1
|
||||
before = s[segment_start : km.start()].strip()
|
||||
words = before.split()
|
||||
if words:
|
||||
return " ".join(words[-3:])
|
||||
|
||||
# 3. Fallback: нет keyword — пробуем первый capitalized токен
|
||||
# 2. Fallback: нет keyword — пробуем первый capitalized токен
|
||||
# Используется для "Большая Конюшенная, 25" без "ул."
|
||||
nm = _STREET_NAME_RE.match(s)
|
||||
if nm:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ from sqlalchemy.orm import Session
|
|||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import run_db_thread
|
||||
from app.services import dadata
|
||||
from app.services.regions import REGIONS as _ALL_REGIONS
|
||||
from app.services.regions import Region, is_within_bbox
|
||||
|
|
@ -1885,11 +1884,11 @@ async def suggest(
|
|||
parsed = _parse_street_house(query.strip())
|
||||
if parsed is not None:
|
||||
street, house = parsed
|
||||
hit = await run_db_thread(_cadastral_house_match, db, street, house)
|
||||
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
|
||||
if hit is not None:
|
||||
return [hit]
|
||||
# 1b. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
|
||||
cad_results = await run_db_thread(_cadastral_forward_sync, db, query.strip(), limit)
|
||||
cad_results = await asyncio.to_thread(_cadastral_forward_sync, db, query.strip(), limit)
|
||||
if cad_results:
|
||||
return cad_results
|
||||
|
||||
|
|
@ -1996,7 +1995,7 @@ async def _geocode_resolve(
|
|||
addr_norm = _cache_key(normalize_address(address), city_hint)
|
||||
|
||||
# 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop)
|
||||
cached = await run_db_thread(_cache_get, db, addr_norm)
|
||||
cached = await asyncio.to_thread(_cache_get, db, addr_norm)
|
||||
if cached is not None:
|
||||
logger.info("geocode cache hit: %s", addr_norm)
|
||||
return replace(cached, city_ambiguous=city_ambiguous)
|
||||
|
|
@ -2029,7 +2028,7 @@ async def _geocode_resolve(
|
|||
if use_local_ekb and parsed is not None:
|
||||
street, house = parsed
|
||||
try:
|
||||
hit = await run_db_thread(_geoportal_house_match, db, street, house)
|
||||
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
|
||||
except Exception:
|
||||
logger.warning("geoportal house-match raised — fall through", exc_info=True)
|
||||
hit = None
|
||||
|
|
@ -2042,7 +2041,7 @@ async def _geocode_resolve(
|
|||
confidence="exact",
|
||||
city_ambiguous=city_ambiguous,
|
||||
)
|
||||
await run_db_thread(_cache_put, db, addr_norm, result)
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info(
|
||||
"geocode geoportal house-match: %s → (%.5f, %.5f)",
|
||||
addr_norm,
|
||||
|
|
@ -2057,7 +2056,7 @@ async def _geocode_resolve(
|
|||
# (литеральная подстрока не совпадает).
|
||||
if use_local_ekb and parsed is not None:
|
||||
street, house = parsed
|
||||
hit = await run_db_thread(_cadastral_house_match, db, street, house)
|
||||
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
|
||||
if hit is not None:
|
||||
result = GeocodeResult(
|
||||
lat=hit.lat,
|
||||
|
|
@ -2067,7 +2066,7 @@ async def _geocode_resolve(
|
|||
confidence="exact",
|
||||
city_ambiguous=city_ambiguous,
|
||||
)
|
||||
await run_db_thread(_cache_put, db, addr_norm, result)
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info(
|
||||
"geocode cadastral house-match: %s → (%.5f, %.5f)",
|
||||
addr_norm,
|
||||
|
|
@ -2078,7 +2077,9 @@ async def _geocode_resolve(
|
|||
|
||||
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
|
||||
if use_local_ekb:
|
||||
cad_suggestions = await run_db_thread(_cadastral_forward_sync, db, address.strip(), limit=1)
|
||||
cad_suggestions = await asyncio.to_thread(
|
||||
_cadastral_forward_sync, db, address.strip(), limit=1
|
||||
)
|
||||
if cad_suggestions:
|
||||
s = cad_suggestions[0]
|
||||
result = GeocodeResult(
|
||||
|
|
@ -2089,7 +2090,7 @@ async def _geocode_resolve(
|
|||
confidence="exact",
|
||||
city_ambiguous=city_ambiguous,
|
||||
)
|
||||
await run_db_thread(_cache_put, db, addr_norm, result)
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info(
|
||||
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
|
||||
)
|
||||
|
|
@ -2100,7 +2101,7 @@ async def _geocode_resolve(
|
|||
result = await _nominatim_lookup(address, city_hint, region_code)
|
||||
if result is not None:
|
||||
result = replace(result, city_ambiguous=city_ambiguous)
|
||||
await run_db_thread(_cache_put, db, addr_norm, result)
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
|
||||
return result
|
||||
except Exception:
|
||||
|
|
@ -2119,7 +2120,7 @@ async def _geocode_resolve(
|
|||
if use_local_ekb and parsed is not None:
|
||||
local_street, _parsed_house = parsed
|
||||
local_house = _extract_local_house_token(address) or _parsed_house
|
||||
hit = await run_db_thread(_local_houses_match, db, local_street, local_house)
|
||||
hit = await asyncio.to_thread(_local_houses_match, db, local_street, local_house)
|
||||
if hit is not None:
|
||||
result = GeocodeResult(
|
||||
lat=hit.lat,
|
||||
|
|
@ -2328,7 +2329,7 @@ async def reverse_geocode(
|
|||
"""
|
||||
# 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI)
|
||||
if db is not None:
|
||||
cad = await run_db_thread(_cadastral_reverse_sync_full, db, lat, lon)
|
||||
cad = await asyncio.to_thread(_cadastral_reverse_sync_full, db, lat, lon)
|
||||
if cad is not None:
|
||||
address, snap_lat, snap_lon = cad
|
||||
return ReverseGeocodeResult(
|
||||
|
|
|
|||
|
|
@ -325,34 +325,6 @@ async def _job_landing_stats(
|
|||
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
|
||||
|
||||
|
||||
# ── landing_showcase_deals — sync пересчёт витрины сделок в executor ─────────
|
||||
async def _job_landing_showcase_deals(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
"""Пересчёт витрины сделок публичного лэндинга (#3469).
|
||||
|
||||
ЛАЙФСАЙКЛ ПРОГОНА ВЕДЁТ HANDLER, а не задача. `refresh_landing_showcase_deals`
|
||||
писалась под ручной запуск (`python -m app.tasks.landing_showcase_deals`) и про
|
||||
`run_id` ничего не знает — тот же случай, что у `_job_refresh_search_matview`,
|
||||
и решается так же: done/failed ставим здесь.
|
||||
|
||||
Параметры берём ИЗ РАСПИСАНИЯ только те, что в нём есть: дефолты живут в
|
||||
сигнатуре задачи, и повтор их здесь дал бы два места, которые разъедутся.
|
||||
"""
|
||||
from app.tasks.landing_showcase_deals import refresh_landing_showcase_deals
|
||||
|
||||
kwargs = {k: params[k] for k in ("sample", "since", "limit", "city") if k in params}
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
counters = await loop.run_in_executor(
|
||||
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
|
||||
)
|
||||
ctx.runs.mark_done(db, run_id, counters)
|
||||
except Exception:
|
||||
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
|
||||
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
|
||||
|
||||
|
||||
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
||||
async def _job_sber_freshness_monitor(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
|
|
@ -939,7 +911,6 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
||||
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
||||
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
|
||||
"landing_showcase_deals": Handler(_job_landing_showcase_deals, "landing_showcase_deals"),
|
||||
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
||||
"yandex_newbuilding_sweep": Handler(
|
||||
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ def resolve_proxy_url(db: Session, source: str) -> str | None:
|
|||
|
||||
# Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного
|
||||
# здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env.
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН "
|
||||
"не прошёл фильтр для этого источника (banned_for_source=%d, "
|
||||
"unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через "
|
||||
|
|
|
|||
|
|
@ -1309,8 +1309,6 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
|
|||
транзиентный сбой узла ≠ перманентный бан, используется пока только для логов):
|
||||
- "timeout" — сеть недоступна/медленная (httpx.TimeoutException)
|
||||
- "connect_error" — прокси не поднят/не слушает/DNS (httpx.ConnectError)
|
||||
- "proxy_error" — сам прокси отверг соединение (httpx.ProxyError, напр. 407 от
|
||||
провайдера — это состояние пула, а не инцидент; #3471)
|
||||
- "http_error" — ipify ответил ошибкой через прокси (auth/upstream)
|
||||
- "other" — прочее
|
||||
|
||||
|
|
@ -1330,14 +1328,6 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
|
|||
except httpx.ConnectError:
|
||||
logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url))
|
||||
return False, None, None, "connect_error"
|
||||
except httpx.ProxyError as exc:
|
||||
# #3471: сам прокси-провайдер отверг соединение (чаще всего 407 —
|
||||
# исчерпан лимит/просрочен пакет) — штатный исход health-пробы, не
|
||||
# инцидент приложения. Одна строка без трейса: узел + причина текстом
|
||||
# исключения, полный traceback здесь не несёт новой информации и только
|
||||
# засорял логи (184 строки/сутки, #3471).
|
||||
logger.warning("proxy_pool: health probe proxy_error proxy=%s reason=%s", _mask(url), exc)
|
||||
return False, None, None, "proxy_error"
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(
|
||||
"proxy_pool: health probe http_error proxy=%s status=%s",
|
||||
|
|
|
|||
|
|
@ -28,18 +28,9 @@ class Region:
|
|||
"""Один регион покрытия продукта.
|
||||
|
||||
bbox_tight — ядро города: geocoder-фильтрация фуззи-матчей провайдеров
|
||||
(не принять соседний город за совпадение по опечатке). У
|
||||
региона БЕЗ одного центрального города (50 — область, много
|
||||
сопоставимых по объёму городов, ни один не «ядро») — равен
|
||||
bbox_product_core: эмпирический пояс, где данные РЕАЛЬНО
|
||||
наблюдались (перцентили 0.5..99.5 координат сырья), а не
|
||||
административная граница — см. REGIONS[50] и обоснование там.
|
||||
(не принять соседний город за совпадение по опечатке).
|
||||
bbox_wide — город + легитимное приграничье: ingest-guard координат,
|
||||
ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит tight.
|
||||
У 50 — полный наблюдённый диапазон координат (min..max, без
|
||||
перцентильной обрезки) вместо «город + отступ»: без своего
|
||||
города отступать не от чего, поэтому граница «легитимности»
|
||||
здесь тоже эмпирическая, просто менее обрезанная, чем tight.
|
||||
bbox_region — генеральный bbox региона: fallback-accept для провайдеров без
|
||||
структурного region-поля. Содержит wide.
|
||||
bbox_product_core — гео-охват ПРОДУКТА в этом регионе: location_index
|
||||
|
|
@ -47,14 +38,9 @@ class Region:
|
|||
out_of_coverage. У 66 УЖЕ (не равен) tight: исторический bbox
|
||||
location_index (56.70..56.95/60.50..60.75), синхронизирован с
|
||||
EKB_BBOX Overpass-загрузчика POI основного gendesign-бэкенда
|
||||
(комментарий в обе стороны, см. site_finder/poi_loader.py). У
|
||||
50 — намеренно НЕ административный bbox (обещать охват там,
|
||||
где нет ни одного объявления, нельзя) — эмпирический пояс
|
||||
фактических данных, см. REGIONS[50].
|
||||
(комментарий в обе стороны, см. site_finder/poi_loader.py).
|
||||
city_token — нормализованный токен главного города (нижний регистр, е==ё
|
||||
нормализует потребитель — matching.normalize). У региона без
|
||||
единого центра (50) — самый объёмный по данным город, который
|
||||
ОДНОВРЕМЕННО де-факто административный: см. REGIONS[50].
|
||||
нормализует потребитель — matching.normalize).
|
||||
cities — узнаваемые города региона (для city_hint / prefix-логики
|
||||
геокодера). НЕ исчерпывающий список — основные центры.
|
||||
enrichment_tiers — какие тиры обогащения РЕАЛЬНО доступны региону.
|
||||
|
|
@ -173,122 +159,19 @@ REGIONS: dict[int, Region] = {
|
|||
# import_rosreestr_dkp подставляет каноничное имя вместо city источника.
|
||||
canonical_city="Москва",
|
||||
),
|
||||
50: Region(
|
||||
code=50,
|
||||
name="Московская область",
|
||||
# У области НЕТ города-ядра (в отличие от 66/77) — 20 сопоставимых по
|
||||
# объёму городов-спутников. Поэтому tight/wide/product_core здесь не
|
||||
# «город + отступ», а ЭМПИРИЧЕСКИЙ пояс данных: разброс координат
|
||||
# подмосковного сырья Циан (45 294 строки, отбор по городскому
|
||||
# поддомену ссылки ≠ www, замер на дату добавления региона):
|
||||
# полный диапазон: lat 54.673..56.762, lon 35.920..39.888
|
||||
# перцентили 0.5..99.5: lat 54.834..56.728, lon 36.193..39.545
|
||||
# tight = product_core = перцентильный пояс (без выбросов из хвоста
|
||||
# распределения — то немногое, что уверенно наблюдали). wide = полный
|
||||
# диапазон (min..max) — легитимное приграничье для ingest-guard шире
|
||||
# tight, но всё ещё эмпирическое, не административное.
|
||||
bbox_tight=(54.834, 56.728, 36.193, 39.545),
|
||||
bbox_wide=(54.673, 56.762, 35.920, 39.888),
|
||||
# region — административный bbox МО целиком (fallback-accept должен
|
||||
# покрывать всю область, а не только пояс, где уже есть данные):
|
||||
# lat 54.20..56.96, lon 35.14..40.21.
|
||||
bbox_region=(54.20, 56.96, 35.14, 40.21),
|
||||
# product_core НЕ равен bbox_region: location_index не должен обещать
|
||||
# медианы там, где по факту нет ни одного объявления (deals=0,
|
||||
# listings=0 на дату добавления — импорт 411 056 сделок Росреестра из
|
||||
# FDW идёт отдельным PR). Равен tight — см. выше.
|
||||
bbox_product_core=(54.834, 56.728, 36.193, 39.545),
|
||||
# Красногорск: и самый объёмный город по факту сырья (см. cities ниже,
|
||||
# по убыванию объёма), и де-факто административный центр региона —
|
||||
# Правительство Московской области физически размещается в Красногорске
|
||||
# с 2013 г. (Москва как формальный административный центр — экстра-
|
||||
# территориальна и уже занята регионом 77). Единственный кандидат,
|
||||
# обоснованный ОБОИМИ критериями сразу.
|
||||
city_token="красногорск",
|
||||
cities=frozenset(
|
||||
{
|
||||
"красногорск",
|
||||
"балашиха",
|
||||
"видное",
|
||||
"люберцы",
|
||||
"звенигород",
|
||||
"химки",
|
||||
"мытищи",
|
||||
"подольск",
|
||||
"одинцово",
|
||||
"солнечногорск",
|
||||
"домодедово",
|
||||
"королёв",
|
||||
"королев",
|
||||
"котельники",
|
||||
"дмитров",
|
||||
"электросталь",
|
||||
"реутов",
|
||||
"щёлково",
|
||||
"щелково",
|
||||
"серпухов",
|
||||
"ногинск",
|
||||
"железнодорожный",
|
||||
}
|
||||
),
|
||||
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
|
||||
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст
|
||||
# намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в
|
||||
# карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не
|
||||
# становится: у Москвы набор тиров тоже пуст, а свой ряд она читает —
|
||||
# поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде
|
||||
# за пределами этого реестра не спрашивают).
|
||||
enrichment_tiers=frozenset(),
|
||||
# Источники по области несут настоящий city (Химки, Балашиха — не
|
||||
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
||||
# нечего и незачем, в отличие от 77.
|
||||
canonical_city=None,
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_REGION_CODE = 66
|
||||
|
||||
|
||||
def _bbox_area(bbox: BBox) -> float:
|
||||
"""Грубая «площадь» bbox в кв. градусах (lat_range * lon_range).
|
||||
|
||||
Не учитывает сжатие долготы на широте (cos(lat)) — не нужно: значение
|
||||
используется ТОЛЬКО чтобы сравнить специфичность bbox'ов разного порядка
|
||||
(город vs область), не как настоящая площадь в км²."""
|
||||
lat_min, lat_max, lon_min, lon_max = bbox
|
||||
return (lat_max - lat_min) * (lon_max - lon_min)
|
||||
|
||||
|
||||
# Порядок обхода для region_for_point: от САМОГО специфичного (маленький
|
||||
# bbox_region) к самому общему — НЕ sorted(REGIONS) по числовому коду.
|
||||
#
|
||||
# Почему код региона как ключ порядка сломался: bbox_region(50) (Московская
|
||||
# область целиком, lat 54.20..56.96/lon 35.14..40.21) геометрически СОДЕРЖИТ
|
||||
# bbox_region(77) (Москва, 55.10..56.10/36.80..38.10) как прямоугольники — а
|
||||
# 50 < 66 < 77 по числу. При обходе `sorted(REGIONS)` регион 50 проверялся бы
|
||||
# ПЕРВЫМ (50 < 77) и забирал бы себе ВСЕ точки Москвы, включая центр
|
||||
# (55.75, 37.62) — она лежит в bbox_region обоих регионов одновременно. Старый
|
||||
# докстринг называл это «на случай, если когда-нибудь пересекутся» — случай
|
||||
# наступил прямо при добавлении региона 50, не гипотетически.
|
||||
#
|
||||
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает это БЕЗ
|
||||
# ручного списка: чем компактнее регион, тем раньше его проверяют, поэтому
|
||||
# вложенный регион (77 внутри 50) всегда выигрывает у объемлющего, а будущий
|
||||
# новый регион сам встанет в верную позицию по своей площади — правку этого
|
||||
# места повторять не придётся.
|
||||
_POINT_LOOKUP_ORDER: tuple[int, ...] = tuple(
|
||||
sorted(REGIONS, key=lambda code: (_bbox_area(REGIONS[code].bbox_region), code))
|
||||
)
|
||||
|
||||
|
||||
def region_for_point(lat: float, lon: float) -> Region | None:
|
||||
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
|
||||
|
||||
Обход — `_POINT_LOOKUP_ORDER` (компактный bbox_region раньше обширного), а
|
||||
не числовой код региона: код как ключ порядка ломается ровно на паре
|
||||
50/77, см. комментарий у `_POINT_LOOKUP_ORDER`.
|
||||
Регионы географически не пересекаются; порядок обхода детерминирован кодом
|
||||
региона на случай, если когда-нибудь пересекутся (первый по коду выигрывает
|
||||
— и это станет видно в тестах реестра, а не в проде).
|
||||
"""
|
||||
for code in _POINT_LOOKUP_ORDER:
|
||||
for code in sorted(REGIONS):
|
||||
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
|
||||
return REGIONS[code]
|
||||
return None
|
||||
|
|
@ -296,16 +179,7 @@ def region_for_point(lat: float, lon: float) -> Region | None:
|
|||
|
||||
def region_by_city(city: str | None) -> Region | None:
|
||||
"""Регион, в чьём списке городов есть `city` (нормализованный нижний
|
||||
регистр, е/ё не различаются). None — город не узнан ни одним регионом.
|
||||
|
||||
В отличие от `region_for_point`, здесь нет геометрической вложенности —
|
||||
сравнение точное (токен строки), не bbox-containment, поэтому порядок по
|
||||
коду региона не создаёт баг ordering'а САМ ПО СЕБЕ. Он МОГ бы сломаться,
|
||||
если бы одно имя города оказалось в `cities` двух регионов (тогда побеждал
|
||||
бы меньший код) — список городов 50 сверен вручную с `cities` регионов 66
|
||||
и 77, пересечений нет (закреплено test_no_city_name_duplicated_across_regions
|
||||
в tests/test_3051_region_registry_moscow_oblast.py).
|
||||
"""
|
||||
регистр, е/ё не различаются). None — город не узнан ни одним регионом."""
|
||||
if not city:
|
||||
return None
|
||||
token = " ".join(city.lower().replace("ё", "е").split())
|
||||
|
|
|
|||
|
|
@ -40,18 +40,6 @@ REF_AREA codes (sberindex internal region IDs, not ОКАТО/ISO):
|
|||
77 = Москва — verified 2026-05-31 brute-force against /api/sowa
|
||||
(region name from ref_area field of response);
|
||||
real_estate_deals: 2017-01 148 471 → 2026-04 309 510 руб/м².
|
||||
50 = Московская область — verified live 2026-09-12 (все три дашборда, ref_area
|
||||
в ответе = «Московская область»); real_estate_deals 116 месячных точек
|
||||
2017-01..2026-08, residential_real_estate_prices 47 (2022-10..2026-08),
|
||||
dinamika-tsen-obyavlenii 56 (2021-12..2026-07) — та же глубина и свежесть,
|
||||
что у Москвы.
|
||||
|
||||
Область заведена ЗАРАНЕЕ, до появления региона 50 в реестре regions.REGIONS. Ряд
|
||||
источника наполняется месяцами и задним числом не восстанавливается, поэтому
|
||||
загружать его надо начинать раньше, чем он понадобится оценщику. На саму оценку
|
||||
это не влияет: estimator берёт ряды через _SBER_REGION_SERIES, кода 50 там нет, и
|
||||
до его появления область в расчёт не попадает. Мониторинг свежести тоже не
|
||||
затронут — SBER_REQUIRED_REGIONS считается от карты ЭСТИМАТОРА, а не отсюда.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -83,7 +71,6 @@ SBER_REF_AREAS: dict[str, str] = {
|
|||
"643": "Россия",
|
||||
"66": "Свердловская область",
|
||||
"77": "Москва",
|
||||
"50": "Московская область",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,13 +29,9 @@
|
|||
Telegram 403 (клиент заблокировал бота) → is_blocked=true + уведомление в
|
||||
топике (только для Telegram-ветки — у веб-клиента нет "заблокировал бота").
|
||||
C) Дедуп: update_id <= сохранённого offset — skip. Offset сохраняется И
|
||||
коммитится в той же транзакции, что и запись сообщения (см. `process_update`),
|
||||
после КАЖДОГО апдейта — рестарт воркера не переигрывает уже обработанные
|
||||
апдейты и не подвисает вечно на «ядовитом» апдейте. Исключение —
|
||||
ТРАНЗИЕНТНЫЙ сетевой отказ (`TelegramNetworkError`, т.е. исчерпанный бюджет
|
||||
ретраев клиента): такой апдейт СОЗНАТЕЛЬНО остаётся неподтверждённым, чтобы
|
||||
Telegram отдал его снова, иначе ответ оператора пропадал бы навсегда
|
||||
(#tg-connection-resilience). Потолок переигрываний — `_MAX_NETWORK_REPLAYS`.
|
||||
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
|
||||
`finally`), после КАЖДОГО апдейта — рестарт воркера не переигрывает уже
|
||||
обработанные апдейты и не подвисает вечно на «ядовитом» апдейте.
|
||||
D) TELEGRAM_BOT_TOKEN пуст → бот выключен — проверяется в `app.tgbot_main`
|
||||
(entrypoint), не здесь.
|
||||
E) /start клиенту → короткое приветствие МЕРЫ, без зеркалирования в топик
|
||||
|
|
@ -80,7 +76,7 @@ from app.core.config import settings
|
|||
from app.core.ratelimit import SlidingWindowLimiter
|
||||
from app.core.shutdown import shutdown_requested
|
||||
from app.services.tgbot import web_support_storage
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient, TelegramNetworkError
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -135,59 +131,6 @@ FLOOD_LIMITED_TEXT = (
|
|||
# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил).
|
||||
_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено."
|
||||
|
||||
# Уведомления оператору в топике отправляются ИЗНУТРИ poll loop, который
|
||||
# однопоточный: пока висит одна отправка, не обрабатывается НИ ОДИН следующий
|
||||
# апдейт. Поэтому им нужен интерактивный бюджет, а не воркерный дефолт клиента
|
||||
# (5 ретраев, backoff до 30с, полный retry_after на 429 — минуты стопа на
|
||||
# ВТОРИЧНОМ действии). Числа — те же, что у интерактивных отправок веб-чата
|
||||
# поддержки (`_INTERACTIVE_SEND_*` в app/api/v1/support.py, подобраны замером
|
||||
# прода #tgsupport-retry); сознательно ДУБЛИРУЕМ, а не импортируем из слоя API —
|
||||
# воркер не должен зависеть от роутера.
|
||||
_NOTIFY_SEND_TIMEOUT_S = 5.0
|
||||
_NOTIFY_SEND_MAX_RETRIES = 3
|
||||
_NOTIFY_SEND_MAX_BACKOFF_S = 1.0
|
||||
|
||||
# Потолок ожидания в TelegramGroupRateLimiter для отправок ИЗ ЭТОГО модуля,
|
||||
# которые не проходят через _notify_topic (review M1, #3471). Без него
|
||||
# `client.send_message`/`copy_message` без явного `timeout` наследуют
|
||||
# лимитер-политику "без потолка" (см. TelegramClient._request) — приемлемую
|
||||
# ДЛЯ ФОНОВОЙ отправки как таковой, но НЕ здесь: эти вызовы идут внутри
|
||||
# `run_poll_loop`, который на каждый апдейт держит ОТКРЫТУЮ сессию БД
|
||||
# (SessionLocal, см. вызывающих) и однопоточно блокирует опрос СЛЕДУЮЩИХ
|
||||
# апдейтов — минутный сон здесь стопорит и БД-соединение, и весь мост, а не
|
||||
# только одно сообщение. Второй повод — SIGTERM drain: `tgbot_main._DRAIN_TIMEOUT_S`
|
||||
# даёт 100с на завершение текущей итерации; ожидание слота дольше этого
|
||||
# бюджета уже не успевает подчиниться cooperative drain. 20с — заметно больше,
|
||||
# чем разумная очередь при исчерпанном лимите (окно 60с, обычно секунды), но
|
||||
# заметно МЕНЬШЕ минуты и вписывается в drain-бюджет с запасом.
|
||||
_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S = 20.0
|
||||
|
||||
# Потолок переигрываний ОДНОГО update_id на транзиентных сетевых отказах
|
||||
# (#tg-connection-resilience).
|
||||
#
|
||||
# Зачем потолок: без него «вечно недоставляемый» апдейт заклинил бы очередь
|
||||
# НАВСЕГДА — ровно то, от чего защищал прежний безусловный `finally: save_offset`.
|
||||
# Потерять одно сообщение плохо, потерять весь поток — хуже, поэтому на потолке
|
||||
# offset всё-таки сдвигается, но ГРОМКО (`logger.error`), а не молча.
|
||||
#
|
||||
# Про дубли: `TelegramNetworkError` означает исчерпанный бюджет ретраев клиента,
|
||||
# при этом запрос МОГ дойти до Telegram (потерялся ответ) — переигрывание тогда
|
||||
# доставит клиенту то же сообщение второй раз. Это осознанный at-least-once
|
||||
# компромисс: дубль и клиент, и оператор ВИДЯТ и могут поправить, а тихая потеря
|
||||
# ответа не оставляет следа нигде, кроме строчки в логе. Полноценная
|
||||
# идемпотентность по (update_id, target_chat_id) потребовала бы нового
|
||||
# персистентного состояния (колонка/таблица + миграция) ради редкого случая;
|
||||
# вместо этого число возможных дублей жёстко ограничено сверху — не больше
|
||||
# (_MAX_NETWORK_REPLAYS - 1) повторов на апдейт.
|
||||
_MAX_NETWORK_REPLAYS = 3
|
||||
|
||||
# update_id -> сколько раз мы уже отказались подтверждать этот апдейт.
|
||||
# In-memory осознанно: воркер long-polling однопоточный, запись живёт ровно до
|
||||
# подтверждения апдейта (`pop` в `process_update`), так что словарь не растёт.
|
||||
# Рестарт воркера обнуляет счётчик — это допустимо (новый процесс = новая сеть),
|
||||
# потолок всё равно действует в пределах каждой жизни процесса.
|
||||
_network_replay_attempts: dict[int, int] = {}
|
||||
|
||||
|
||||
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
|
||||
class BridgeStorage(Protocol):
|
||||
|
|
@ -238,13 +181,7 @@ class BridgeStorage(Protocol):
|
|||
) -> int | None: ...
|
||||
|
||||
def record_web_out_message(
|
||||
self,
|
||||
*,
|
||||
thread_id: int,
|
||||
text_body: str,
|
||||
operator_tg_id: int | None,
|
||||
topic_message_id: int | None = None,
|
||||
support_chat_id: int | None = None,
|
||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||
) -> None: ...
|
||||
|
||||
|
||||
|
|
@ -397,20 +334,14 @@ class SqlBridgeStorage:
|
|||
со ЧУЖИМ (не NULL, не текущим) support_chat_id — исторический артефакт
|
||||
ротации support-группы, не валидный маршрут сегодня. NULL (строки до
|
||||
миграции 188, если есть) — лениентный wildcard-матч (единственный
|
||||
действовавший чат на тот момент).
|
||||
|
||||
БЕЗ фильтра по direction (#3471 P0, было `AND direction = 'in'`): с тех
|
||||
пор как `record_message` на исходящем ответе тоже сохраняет
|
||||
`topic_message_id` (id сообщения оператора В ТОПИКЕ), реплай оператора
|
||||
на СВОЙ предыдущий ответ обязан резолвиться так же, как реплай на
|
||||
зеркало клиента — иначе продолжение диалога без повторного цитирования
|
||||
клиента тихо проваливалось в orphan-check."""
|
||||
действовавший чат на тот момент)."""
|
||||
row = self._db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT chat_id
|
||||
FROM tg_support_messages
|
||||
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
||||
AND direction = 'in'
|
||||
AND (support_chat_id = CAST(:support_chat_id AS bigint)
|
||||
OR support_chat_id IS NULL)
|
||||
ORDER BY created_at DESC
|
||||
|
|
@ -440,21 +371,13 @@ class SqlBridgeStorage:
|
|||
)
|
||||
|
||||
def record_web_out_message(
|
||||
self,
|
||||
*,
|
||||
thread_id: int,
|
||||
text_body: str,
|
||||
operator_tg_id: int | None,
|
||||
topic_message_id: int | None = None,
|
||||
support_chat_id: int | None = None,
|
||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||
) -> None:
|
||||
web_support_storage.record_outbound(
|
||||
self._db,
|
||||
thread_id=thread_id,
|
||||
text_body=text_body,
|
||||
operator_tg_id=operator_tg_id,
|
||||
topic_message_id=topic_message_id,
|
||||
support_chat_id=support_chat_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -477,51 +400,6 @@ def _format_topic_header(
|
|||
return f"Новое обращение от {display_name}{username_part} (chat_id={chat_id})"
|
||||
|
||||
|
||||
async def _notify_topic(
|
||||
client: TelegramClient,
|
||||
*,
|
||||
text: str,
|
||||
reply_to_message_id: int | None,
|
||||
context: str,
|
||||
) -> bool:
|
||||
"""Служебное уведомление оператору в support-топик (вторичное действие).
|
||||
|
||||
Два свойства, которых не было у прямых `client.send_message` вызовов:
|
||||
1) узкий интерактивный бюджет (`_NOTIFY_SEND_*`) — иначе одна такая отправка
|
||||
стопорит весь однопоточный poll loop на минуты;
|
||||
2) собственный `except` — провал УВЕДОМЛЕНИЯ не отменяет основную ветку
|
||||
обработки (клиент уже помечен заблокированным / медиа-реплай уже отклонён)
|
||||
и не решает судьбу апдейта.
|
||||
`context` — только технические идентификаторы (chat_id/thread_id), НЕ текст
|
||||
переписки: логи моста принципиально не содержат ПДн.
|
||||
|
||||
Возвращает True, если уведомление реально ушло, False — если само
|
||||
уведомление тоже упало (напр. Telegram недоступен). Вызывающий, для
|
||||
которого проваленное уведомление означает ПОЛНУЮ тишину (ни клиенту, ни
|
||||
оператору), обязан на False залогировать `logger.error` с идентификаторами
|
||||
(#3471 P0) — иначе единственный след остаётся только в этом WARNING.
|
||||
"""
|
||||
try:
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=text,
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
timeout=_NOTIFY_SEND_TIMEOUT_S,
|
||||
max_retries=_NOTIFY_SEND_MAX_RETRIES,
|
||||
max_backoff=_NOTIFY_SEND_MAX_BACKOFF_S,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"tgbot bridge: не удалось отправить уведомление оператору в топик (%s) — "
|
||||
"основная ветка обработки не отменяется",
|
||||
context,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# ── Update routing ────────────────────────────────────────────────────────────
|
||||
async def _handle_private_message(
|
||||
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||
|
|
@ -550,11 +428,7 @@ async def _handle_private_message(
|
|||
text_body = message.get("text")
|
||||
if text_body == "/start":
|
||||
# E) команда — не содержательное обращение, топик не засоряем.
|
||||
await client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=GREETING_TEXT,
|
||||
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
|
||||
)
|
||||
await client.send_message(chat_id=chat_id, text=GREETING_TEXT)
|
||||
return
|
||||
|
||||
if not settings.telegram_support_chat_id:
|
||||
|
|
@ -564,11 +438,7 @@ async def _handle_private_message(
|
|||
chat_id,
|
||||
)
|
||||
# Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет.
|
||||
await client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=SERVICE_UNAVAILABLE_TEXT,
|
||||
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
|
||||
)
|
||||
await client.send_message(chat_id=chat_id, text=SERVICE_UNAVAILABLE_TEXT)
|
||||
return
|
||||
|
||||
message_id = message.get("message_id")
|
||||
|
|
@ -596,11 +466,7 @@ async def _handle_private_message(
|
|||
# за окно — `_flood_notify_limiter.check()` возвращает None (и сам
|
||||
# фиксирует попытку) ровно один раз за окно.
|
||||
if _flood_notify_limiter.check(flood_key) is None:
|
||||
await client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=FLOOD_LIMITED_TEXT,
|
||||
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
|
||||
)
|
||||
await client.send_message(chat_id=chat_id, text=FLOOD_LIMITED_TEXT)
|
||||
return
|
||||
_flood_limiter.record(flood_key)
|
||||
|
||||
|
|
@ -611,7 +477,6 @@ async def _handle_private_message(
|
|||
chat_id=settings.telegram_support_chat_id,
|
||||
text=header,
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
|
||||
)
|
||||
|
||||
mirrored = await client.copy_message(
|
||||
|
|
@ -619,7 +484,6 @@ async def _handle_private_message(
|
|||
from_chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
|
||||
)
|
||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||
|
||||
|
|
@ -684,20 +548,19 @@ async def _handle_group_reply(
|
|||
chat_id=target_chat_id,
|
||||
from_chat_id=settings.telegram_support_chat_id,
|
||||
message_id=message_id,
|
||||
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
|
||||
)
|
||||
except TelegramApiError as exc:
|
||||
if exc.error_code == 403:
|
||||
# Клиент заблокировал бота — фиксируем и уведомляем оператора в топике.
|
||||
storage.mark_blocked(target_chat_id)
|
||||
await _notify_topic(
|
||||
client,
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=(
|
||||
f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — "
|
||||
"бот заблокирован."
|
||||
),
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
reply_to_message_id=message_id,
|
||||
context=f"403 на доставке клиенту chat_id={target_chat_id}",
|
||||
)
|
||||
return
|
||||
raise
|
||||
|
|
@ -707,22 +570,10 @@ async def _handle_group_reply(
|
|||
chat_id=target_chat_id,
|
||||
direction="out",
|
||||
tg_message_id=tg_message_id,
|
||||
# #3471 P0: id ЭТОГО сообщения оператора В ТОПИКЕ (было безусловно
|
||||
# None) — без него реплай оператора на СВОЙ предыдущий ответ не
|
||||
# резолвился (искать было нечего), маршрут держался только на
|
||||
# зеркале клиента. Совпадение с `in`-записью структурно исключено:
|
||||
# `message_id` — id реплая оператора, а зеркало клиента уже занимает
|
||||
# другой message_id в том же чате.
|
||||
topic_message_id=message_id,
|
||||
topic_message_id=None,
|
||||
kind=_infer_kind(message),
|
||||
text_body=message.get("text") or message.get("caption"),
|
||||
operator_tg_id=operator_id,
|
||||
# Deep review PR #3479: без этого out-строка была бы вечным
|
||||
# wildcard для `find_chat_by_topic_message` (матчит support_chat_id
|
||||
# IS NULL под ЛЮБЫМ текущим чатом) — при ротации support-группы
|
||||
# (188) новый message_id мог бы совпасть со старой out-строкой и
|
||||
# увести ответ ЧУЖОМУ клиенту. Симметрично in-ветке выше (строка ~601).
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -741,11 +592,11 @@ async def _handle_group_reply(
|
|||
web_thread_id,
|
||||
kind,
|
||||
)
|
||||
await _notify_topic(
|
||||
client,
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT,
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
reply_to_message_id=message_id if isinstance(message_id, int) else None,
|
||||
context=f"медиа-реплай на веб-зеркало thread_id={web_thread_id}",
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -756,64 +607,11 @@ async def _handle_group_reply(
|
|||
|
||||
operator = message.get("from") or {}
|
||||
operator_id = operator.get("id")
|
||||
try:
|
||||
storage.record_web_out_message(
|
||||
thread_id=web_thread_id,
|
||||
text_body=text_body,
|
||||
operator_tg_id=operator_id,
|
||||
# #3471 P0: id ЭТОГО сообщения оператора в топике — без него
|
||||
# реплай оператора на СВОЙ предыдущий веб-ответ не резолвится
|
||||
# (см. `find_thread_by_topic_message`, direction-фильтр снят).
|
||||
topic_message_id=message_id if isinstance(message_id, int) else None,
|
||||
# Deep review PR #3479: БЕЗ этого out-строка писалась бы с
|
||||
# support_chat_id=NULL — `find_thread_by_topic_message` матчит
|
||||
# NULL под ЛЮБЫМ текущим чатом (лениентный wildcard для легаси
|
||||
# строк до 187/188), т.е. каждая out-строка стала бы вечным
|
||||
# wildcard. При ротации support-группы новый message_id мог бы
|
||||
# совпасть со старой out-строкой и увести ответ в ЧУЖОЙ тред —
|
||||
# ровно то, от чего защищала скоупинг-миграция 187/188.
|
||||
support_chat_id=settings.telegram_support_chat_id,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
# #3471 P0: для веб-треда ЭТА запись — и есть доставка клиенту (веб-
|
||||
# фронт вычитывает ответ обычным polling'ом web_support_messages).
|
||||
# Откат без уведомления означал бы: оператор уверен, что ответил,
|
||||
# клиент ждёт молча. Offset ниже всё равно сдвигается — НЕ потому,
|
||||
# что апдейт "частично применён в Telegram" (в этой ветке до сбоя в
|
||||
# Telegram ничего не уходило вообще: сам реплай оператора Telegram
|
||||
# уже полностью доставил ДО того, как мы начали его разбирать,
|
||||
# ретраить на стороне площадки нечего), а потому что действует общая
|
||||
# политика `process_update` для `SQLAlchemyError` — сбой БД не
|
||||
# переигрывается (в отличие от `TelegramNetworkError`), а
|
||||
# сигнализируется громко; здесь это explicit-просьба оператору
|
||||
# прислать ответ заново — human-in-the-loop retry вместо
|
||||
# технического. rollback() ОБЯЗАН отработать ДО уведомления —
|
||||
# сессия в failed-transaction state, а `_notify_topic` шлёт через
|
||||
# `client`, не через `storage`, поэтому сам rollback тут не нужен для
|
||||
# отправки, но нужен, чтобы process_update дальше не упал на
|
||||
# save_offset/commit тем же PendingRollbackError (см. #3 review).
|
||||
storage.rollback()
|
||||
notified = await _notify_topic(
|
||||
client,
|
||||
text=(
|
||||
"Не удалось сохранить ваш ответ из-за сбоя базы данных — клиенту "
|
||||
"он НЕ доставлен. Пожалуйста, отправьте ответ ещё раз."
|
||||
),
|
||||
reply_to_message_id=message_id if isinstance(message_id, int) else None,
|
||||
context=f"сбой БД на доставке веб-ответа thread_id={web_thread_id}",
|
||||
)
|
||||
if not notified:
|
||||
# Оба канала молчат (БД и уведомление) — единственный след,
|
||||
# который останется, это эта строка. Идентификаторы, НЕ текст
|
||||
# (ПДн в лог не идёт) — по ним человек найдёт ответ оператора в
|
||||
# топике и перешлёт его руками (#3471 P0).
|
||||
logger.error(
|
||||
"tgbot bridge: сбой БД на веб-ответе И не удалось уведомить "
|
||||
"оператора (thread_id=%d, message_id=%s) — ответ клиенту "
|
||||
"потерян молча, требуется ручной разбор support-топика",
|
||||
web_thread_id,
|
||||
message_id,
|
||||
)
|
||||
storage.record_web_out_message(
|
||||
thread_id=web_thread_id,
|
||||
text_body=text_body,
|
||||
operator_tg_id=operator_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
|
||||
|
|
@ -838,46 +636,25 @@ async def _handle_group_reply(
|
|||
|
||||
async def process_update(
|
||||
update: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||
) -> bool:
|
||||
) -> None:
|
||||
"""Маршрутизирует один Telegram update. Дедуп (C) + атомарный offset-commit.
|
||||
|
||||
Возвращает True, если offset сдвинут (апдейт подтверждён, Telegram его больше
|
||||
не отдаст), и False, если апдейт СОЗНАТЕЛЬНО оставлен неподтверждённым ради
|
||||
переигрывания. На False вызывающий (`run_poll_loop`) ОБЯЗАН прервать разбор
|
||||
пачки: offset у Telegram — единая «высшая отметка», подтверждение любого
|
||||
СЛЕДУЮЩЕГО апдейта неявно подтвердило бы и этот, и переигрывания не было бы.
|
||||
Дедуп: update_id <= сохранённого offset — skip без side-effects. Offset
|
||||
сохраняется и коммитится ПОСЛЕ обработки (в т.ч. если обработка упала —
|
||||
иначе «ядовитый» апдейт блокировал бы весь поток навсегда).
|
||||
|
||||
Дедуп: update_id <= сохранённого offset — skip без side-effects.
|
||||
|
||||
Судьба offset'а по классам отказа:
|
||||
- `TelegramNetworkError` (транзиентный: Telegram не ответил, бюджет ретраев
|
||||
клиента исчерпан) — offset НЕ двигаем, `rollback()` частичных записей,
|
||||
апдейт переигрывается на следующей итерации. Иначе ответ оператора
|
||||
терялся НАВСЕГДА: copyMessage не дошёл, `record_message` не выполнился,
|
||||
Telegram апдейт больше не отдаст, а оператор уверен, что ответил
|
||||
(#tg-connection-resilience). Ограничено `_MAX_NETWORK_REPLAYS` — на
|
||||
потолке offset всё-таки сдвигается с `logger.error`, иначе «вечно
|
||||
недоставляемый» апдейт заклинил бы поток навсегда.
|
||||
- `SQLAlchemyError` (сбой БД) — offset двигаем, но `rollback()` ОБЯЗАН
|
||||
отработать ПЕРЕД `save_offset`: сбой БД оставляет сессию в
|
||||
failed-transaction state, иначе `save_offset` сам кинет
|
||||
`PendingRollbackError`, `process_update` вылетит без сохранения offset'а,
|
||||
следующая итерация получит СТАРЫЙ offset от `get_offset()` и переиграет
|
||||
тот же апдейт — copyMessage задублирует зеркало клиента в топике на
|
||||
каждый повтор поллинга (#3 review, воспроизведено). Для веб-ветки
|
||||
(`_handle_group_reply` → `record_web_out_message`) этот `SQLAlchemyError`
|
||||
перехватывается ЛОКАЛЬНО, до этого места: там запись в БД И ЕСТЬ
|
||||
доставка клиенту, поэтому rollback сопровождается уведомлением оператору
|
||||
в топике, что ответ НЕ доставлен (#3471 P0) — сюда, на верхний уровень,
|
||||
это исключение уже не долетает.
|
||||
- любое прочее исключение (в т.ч. `TelegramApiError` — площадка ОТВЕТИЛА
|
||||
отказом, повтор ничего не изменит) — offset двигаем, «ядовитый» апдейт
|
||||
не блокирует поток.
|
||||
Различаем сбой БД (`SQLAlchemyError`) от прочих (Telegram API и т.п.):
|
||||
сбой БД оставляет сессию в failed-transaction state — `rollback()` ОБЯЗАН
|
||||
отработать ПЕРЕД `save_offset`, иначе тот сам кинет `PendingRollbackError`,
|
||||
`process_update` вылетит без сохранения offset'а, следующая итерация
|
||||
`run_poll_loop` получит СТАРЫЙ offset от `get_offset()` и переиграет тот же
|
||||
апдейт заново — copyMessage задублирует зеркало клиента в топике на
|
||||
каждый повтор поллинга (#3 review, воспроизведено).
|
||||
"""
|
||||
update_id = update.get("update_id")
|
||||
if not isinstance(update_id, int):
|
||||
logger.warning("tgbot bridge: update без валидного update_id — игнор")
|
||||
return True
|
||||
return
|
||||
|
||||
current_offset = storage.get_offset()
|
||||
if update_id <= current_offset:
|
||||
|
|
@ -886,7 +663,7 @@ async def process_update(
|
|||
update_id,
|
||||
current_offset,
|
||||
)
|
||||
return True
|
||||
return
|
||||
|
||||
message = update.get("message")
|
||||
try:
|
||||
|
|
@ -900,35 +677,6 @@ async def process_update(
|
|||
await _handle_group_reply(message, client, storage)
|
||||
# иначе — необрабатываемый тип чата/апдейта (edited_message, канал и
|
||||
# т.п.) — тихий игнор, но offset всё равно сдвигаем ниже.
|
||||
except TelegramNetworkError:
|
||||
attempts = _network_replay_attempts.get(update_id, 0) + 1
|
||||
# Частичные записи этого апдейта не должны уехать в БД чужим commit'ом
|
||||
# (сессия одна на всю пачку) — переигрывание начинается с чистого листа.
|
||||
storage.rollback()
|
||||
if attempts < _MAX_NETWORK_REPLAYS:
|
||||
_network_replay_attempts[update_id] = attempts
|
||||
logger.warning(
|
||||
"tgbot bridge: Telegram недоступен на update_id=%d (отказ %d из %d) — "
|
||||
"offset НЕ сдвигаем, апдейт переиграется на следующей итерации",
|
||||
update_id,
|
||||
attempts,
|
||||
_MAX_NETWORK_REPLAYS,
|
||||
)
|
||||
return False
|
||||
logger.error(
|
||||
"tgbot bridge: update_id=%d исчерпал потолок переигрываний (%d сетевых "
|
||||
"отказов подряд) — сдвигаем offset, содержимое апдейта ПОТЕРЯНО; поток не "
|
||||
"блокируем, требуется ручной разбор support-топика "
|
||||
"(chat_id=%s, message_id=%s)",
|
||||
update_id,
|
||||
_MAX_NETWORK_REPLAYS,
|
||||
# Идентификаторы, а НЕ текст: это единственная строка, по которой
|
||||
# человек найдёт потерянный ответ оператора в топике и перешлёт его
|
||||
# руками. Без них в логе остаётся только update_id, которого в
|
||||
# интерфейсе Telegram не видно. Текст сообщения — ПДн, в лог не идёт.
|
||||
(message or {}).get("chat", {}).get("id") if isinstance(message, dict) else None,
|
||||
(message or {}).get("message_id") if isinstance(message, dict) else None,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"tgbot bridge: DB-ошибка на update_id=%d — rollback перед сохранением "
|
||||
|
|
@ -942,12 +690,9 @@ async def process_update(
|
|||
"(не блокируем поток на 'ядовитом' апдейте)",
|
||||
update_id,
|
||||
)
|
||||
|
||||
# Апдейт подтверждён — счётчик переигрываний больше не нужен (словарь не растёт).
|
||||
_network_replay_attempts.pop(update_id, None)
|
||||
storage.save_offset(update_id)
|
||||
storage.commit()
|
||||
return True
|
||||
finally:
|
||||
storage.save_offset(update_id)
|
||||
storage.commit()
|
||||
|
||||
|
||||
# ── Long-polling loop ─────────────────────────────────────────────────────────
|
||||
|
|
@ -972,20 +717,8 @@ async def run_poll_loop(
|
|||
updates = await client.get_updates(
|
||||
offset=offset + 1, timeout=poll_timeout_s, allowed_updates=["message"]
|
||||
)
|
||||
for idx, update in enumerate(updates):
|
||||
if not await process_update(update, client, storage):
|
||||
# Апдейт намеренно не подтверждён (транзиентный сетевой
|
||||
# отказ). Обрабатывать остаток пачки НЕЛЬЗЯ: offset —
|
||||
# единая «высшая отметка», подтверждение следующего
|
||||
# апдейта неявно подтвердило бы и этот. Остаток Telegram
|
||||
# отдаст заново на следующей итерации.
|
||||
logger.warning(
|
||||
"tgbot bridge: update_id=%s не подтверждён — остаток пачки "
|
||||
"(%d апдейтов) разберём на следующей итерации",
|
||||
update.get("update_id"),
|
||||
len(updates) - idx - 1,
|
||||
)
|
||||
break
|
||||
for update in updates:
|
||||
await process_update(update, client, storage)
|
||||
consecutive_errors = 0
|
||||
except Exception:
|
||||
consecutive_errors += 1
|
||||
|
|
|
|||
|
|
@ -12,22 +12,11 @@ Docs: https://core.telegram.org/bots/api
|
|||
Ретраи:
|
||||
- HTTP 429 (Too Many Requests) — уважаем `parameters.retry_after` из тела ответа
|
||||
(Telegram сам говорит сколько ждать), fallback на `_DEFAULT_RETRY_AFTER_S`.
|
||||
- HTTP 5xx / транспортные ошибки (`httpx.TransportError`: timeout, connect,
|
||||
обрыв протокола, прокси) — экспоненциальный backoff, `capped` на
|
||||
`_MAX_BACKOFF_S`.
|
||||
- Прочие отказы запроса (`httpx.RequestError`: битый ответ) — НЕ ретряются,
|
||||
сразу `TelegramNetworkError`: повтор не чинит ни испорченный ответ, ни
|
||||
кривую конфигурацию.
|
||||
- HTTP 5xx / сетевые ошибки (timeout/connect) — экспоненциальный backoff,
|
||||
`capped` на `_MAX_BACKOFF_S`.
|
||||
- Любая другая 4xx (400/401/403/404) — НЕ ретраится, сразу `TelegramApiError`
|
||||
(запрос некорректен или прав нет — повтор не поможет).
|
||||
|
||||
Наружу летит только свой тип: `TelegramApiError` (площадка ответила отказом) или
|
||||
`TelegramNetworkError` (не ответила), общий предок — `TelegramError`. Сырые
|
||||
httpx-исключения из клиента не выходят: инвариант держат ДВА `except` в
|
||||
`_request` — `httpx.TransportError` (ретраится) и страховочный
|
||||
`httpx.RequestError` (не ретраится), вместе покрывающие всё дерево отказов
|
||||
запроса, включая те, что появятся в httpx позже.
|
||||
|
||||
БЕЗОПАСНОСТЬ: наши `logger.*`-вызовы здесь содержат только имя метода API,
|
||||
HTTP-статус и `description` из ответа Telegram — токен туда не пишем.
|
||||
Это НЕ гарантирует, что токен не утечёт по другим стокам: он живёт в
|
||||
|
|
@ -43,7 +32,6 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
|
@ -55,194 +43,8 @@ _DEFAULT_RETRY_AFTER_S = 5.0
|
|||
_MAX_BACKOFF_S = 30.0
|
||||
_DEFAULT_MAX_RETRIES = 5
|
||||
|
||||
# Telegram документирует ~20 сообщений/минуту на ОДНУ группу (общий лимит на
|
||||
# все темы супергруппы разом, не на тему по отдельности — превышение даёт 429
|
||||
# на ЛЮБОЕ следующее сообщение в группу, кто бы его ни отправлял). До #3471
|
||||
# лимита на нашей стороне не было вовсе: всплеск GlitchTip-алертов + поток
|
||||
# сообщений поддержки в ту же группу (разные темы, общий чат) укладывались в
|
||||
# 429 и теряли сообщения — retry в `_request` уважает `retry_after`, но не
|
||||
# предотвращает сам всплеск. `_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE` — чуть
|
||||
# ниже площадочного потолка, с запасом на неточность скользящего окна и на то,
|
||||
# что сама площадка не обязана быть педантичной ровно к 20-й отправке.
|
||||
_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18
|
||||
_RATE_LIMIT_WINDOW_S = 60.0
|
||||
|
||||
|
||||
class TelegramGroupRateLimiter:
|
||||
"""Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу.
|
||||
|
||||
Зачем ключ — `chat_id`, а не `(chat_id, message_thread_id)`: лимит Telegram
|
||||
считается на группу целиком, все темы супергруппы делят один бюджет.
|
||||
Ограничитель с ключом по теме позволил бы двум темам суммарно превысить
|
||||
лимит группы и всё равно поймать 429 — ровно баг, который здесь чинится.
|
||||
|
||||
Реализация — скользящее окно (список меток времени последних отправок за
|
||||
`_RATE_LIMIT_WINDOW_S`), а не токен-бакет с фиксированным пополнением:
|
||||
окно точнее соответствует тому, как Telegram считает лимит («N сообщений
|
||||
за последние 60 секунд», а не «N сообщений в календарную минуту»).
|
||||
|
||||
Конкурентность (asyncio, один процесс, несколько отправителей): на каждый
|
||||
`chat_id` — свой `asyncio.Lock`. Лок держится ВКЛЮЧАЯ время ожидания
|
||||
(`asyncio.sleep`), а не только на чтение/запись счётчика — это осознанно:
|
||||
цель не просто «не гонять счётчик без гонки», а ФАКТИЧЕСКИ сериализовать
|
||||
отправителей в этот чат, чтобы они не просыпались все разом по истечении
|
||||
окна и не били по лимиту повторно.
|
||||
|
||||
ВАЖНО про ключ (проверено на проде, review 2026-09-12): `TELEGRAM_SUPPORT_CHAT_ID`
|
||||
и `TELEGRAM_ALERTS_CHAT_ID` — это ОДНА И ТА ЖЕ группа, различаются только
|
||||
темы (`*_TOPIC_ID`). Именно поэтому ключ лимитера — `chat_id`, а НЕ
|
||||
`(chat_id, message_thread_id)`: поток алертов и поток поддержки сегодня
|
||||
физически делят один Telegram-бюджет группы, и лимитер обязан это
|
||||
отражать. Ключ по `chat_id` при этом остаётся корректным и в гипотезе, что
|
||||
когда-нибудь эти два потока разведут по разным группам, — тогда у каждой
|
||||
просто появится свой независимый лок/бюджет автоматически, без правки кода.
|
||||
|
||||
Регистр локов защищён отдельным `asyncio.Lock` только на момент
|
||||
создания записи — сам подсчёт/сон идёт уже под персональным локом чата.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_per_window: int = _DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE,
|
||||
window_s: float = _RATE_LIMIT_WINDOW_S,
|
||||
) -> None:
|
||||
self._max_per_window = max_per_window
|
||||
self._window_s = window_s
|
||||
self._registry_lock = asyncio.Lock()
|
||||
self._locks: dict[int, asyncio.Lock] = {}
|
||||
self._sent_at: dict[int, list[float]] = {}
|
||||
|
||||
async def _lock_for(self, chat_id: int) -> asyncio.Lock:
|
||||
async with self._registry_lock:
|
||||
lock = self._locks.get(chat_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._locks[chat_id] = lock
|
||||
return lock
|
||||
|
||||
async def acquire(self, chat_id: int, max_wait: float | None = None) -> None:
|
||||
"""Блокируется, пока в окне `_window_s` для `chat_id` есть свободный слот.
|
||||
|
||||
`max_wait` (review H1, #3471): потолок ожидания очереди. `None` (дефолт)
|
||||
— без потолка, ждать сколько нужно; это ПРАВИЛЬНОЕ поведение для
|
||||
фоновых отправок бота, где потерять сообщение хуже, чем подождать.
|
||||
Если задан и слот не появился вовремя — бросает `TelegramRateLimitedError`
|
||||
(честный отказ), а НЕ продолжает ждать: интерактивная HTTP-ручка не
|
||||
может легально держать открытый запрос браузера дольше своего
|
||||
собственного таймаута. Вызывающая сторона — `TelegramClient._request`,
|
||||
см. её докстринг про то, откуда берётся конкретное значение.
|
||||
|
||||
Логирование (review L1): предупреждение об ожидании пишется РОВНО ОДИН
|
||||
раз за вызов `acquire` (флаг `warned`), а не на каждой итерации сна —
|
||||
при реальной перегрузке группы это иначе валит лог сотнями одинаковых
|
||||
строк вместо одного сигнала «была очередь».
|
||||
|
||||
Побочный эффект (review M2): перед постановкой в очередь чистит ЧУЖИЕ
|
||||
полностью просроченные записи в `_sent_at`/`_locks` — см. `_cleanup_stale`.
|
||||
"""
|
||||
if self._max_per_window <= 0:
|
||||
return # 0/отрицательное значение конфига = лимитер выключен
|
||||
now0 = time.monotonic()
|
||||
await self._cleanup_stale(now0)
|
||||
deadline = None if max_wait is None else now0 + max_wait
|
||||
lock = await self._lock_for(chat_id)
|
||||
async with lock:
|
||||
warned = False
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
if deadline is not None and now >= deadline:
|
||||
raise TelegramRateLimitedError(chat_id, max_wait or 0.0)
|
||||
history = self._sent_at.setdefault(chat_id, [])
|
||||
cutoff = now - self._window_s
|
||||
while history and history[0] <= cutoff:
|
||||
history.pop(0)
|
||||
if len(history) < self._max_per_window:
|
||||
history.append(now)
|
||||
return
|
||||
wait_s = history[0] + self._window_s - now
|
||||
if deadline is not None:
|
||||
wait_s = min(wait_s, max(deadline - now, 0.0))
|
||||
if not warned:
|
||||
logger.warning(
|
||||
"tg group rate limit: chat_id=%s — лимит %d/%.0fs исчерпан, "
|
||||
"отправки встают в очередь (одно предупреждение на серию)",
|
||||
chat_id,
|
||||
self._max_per_window,
|
||||
self._window_s,
|
||||
)
|
||||
warned = True
|
||||
await asyncio.sleep(max(wait_s, 0.01))
|
||||
|
||||
async def _cleanup_stale(self, now: float) -> None:
|
||||
"""Чистит ЧУЖИЕ (не текущий вызов `acquire`) записи с полностью
|
||||
просроченной историей (review M2, #3471).
|
||||
|
||||
Зачем: `_locks`/`_sent_at` ключуются по ЛЮБОМУ `chat_id`, включая
|
||||
личные чаты каждого клиента бота (`bridge.py` зеркалит их 1:1 через тот
|
||||
же `TelegramClient`) — большинство из них шлют боту одно сообщение и
|
||||
больше никогда не возвращаются. Без чистки оба словаря растут
|
||||
монотонно на всё время жизни долгоживущего процесса (медленная утечка).
|
||||
|
||||
Безопасность удаления: лок пропускаем, если `lock.locked()` — значит
|
||||
кто-то ИМЕННО СЕЙЧАС работает с этим `chat_id`, трогать нельзя. Если
|
||||
лок свободен и вся история старше окна — запись безвредно удалить:
|
||||
следующий `acquire` для того же `chat_id` просто создаст её заново
|
||||
пустой (`setdefault`), с тем же результатом, что и не удаляй мы её.
|
||||
"""
|
||||
cutoff = now - self._window_s
|
||||
async with self._registry_lock:
|
||||
stale = [cid for cid, ts in self._sent_at.items() if not ts or ts[-1] <= cutoff]
|
||||
for cid in stale:
|
||||
lock = self._locks.get(cid)
|
||||
if lock is not None and lock.locked():
|
||||
continue
|
||||
self._sent_at.pop(cid, None)
|
||||
self._locks.pop(cid, None)
|
||||
|
||||
# Раздельные таймауты вместо скаляра. httpx разворачивает скаляр в
|
||||
# connect=read=write=pool, поэтому long-poll `getUpdates` (read = 30с, которые
|
||||
# Telegram держит запрос, + 10с запаса = 40с) ставил 40 секунд и на УСТАНОВКУ
|
||||
# соединения. Живой connect до api.telegram.org из прод-контейнера занимает
|
||||
# 0.036с — 40-секундное ожидание коннекта было чистой слепотой: худший цикл
|
||||
# 4 попытки × 40с + backoff ≈ 174с, и всё это время бот не видит ответов
|
||||
# оператора (замер 12.09.2026: разрывы в логе 06:40:10 → 06:42:22 → 06:43:35,
|
||||
# 576 строк `network error` и 7 полных исчерпаний бюджета ретраев за сутки).
|
||||
# connect/write/pool к ожиданию ОТВЕТА Telegram отношения не имеют и коротки.
|
||||
_CONNECT_TIMEOUT_S = 5.0
|
||||
_WRITE_TIMEOUT_S = 10.0
|
||||
_POOL_TIMEOUT_S = 5.0
|
||||
|
||||
# Пул keep-alive соединений на ОДИН экземпляр клиента. Параллелизма тут почти
|
||||
# нет (long-polling — один запрос за раз, интерактивные ручки — единицы в
|
||||
# минуту), так что смысл пула не в ширине, а в том, чтобы TCP+TLS-хендшейк не
|
||||
# повторялся на каждый запрос и каждый ретрай.
|
||||
_MAX_KEEPALIVE_CONNECTIONS = 5
|
||||
_MAX_CONNECTIONS = 10
|
||||
|
||||
# Сколько держать простаивающее соединение. Задаём ЯВНО, потому что дефолт
|
||||
# httpx — 5 секунд, и с ним пул не давал бы ничего там, где он нужнее всего:
|
||||
# poll loop переиспользует соединение (следующий getUpdates уходит сразу), а
|
||||
# вот веб-поддержка шлёт сообщения раз в минуты — за 5с соединение протухает и
|
||||
# каждое зеркало снова платит полный TCP+TLS.
|
||||
#
|
||||
# Плата за длинный keep-alive — возросший шанс взять из пула соединение, которое
|
||||
# уже закрыла та сторона; httpx отдаёт это как `RemoteProtocolError` («Server
|
||||
# disconnected without sending a response»). Он ретраится с #3457, так что
|
||||
# сценарий закрыт: попытка на протухшем соединении стоит один повтор, а не отказ.
|
||||
_KEEPALIVE_EXPIRY_S = 90.0
|
||||
|
||||
|
||||
class TelegramError(Exception):
|
||||
"""Общий предок отказов клиента: и «ответил ok: false», и «не ответил вовсе».
|
||||
|
||||
Нужен ровно затем, чтобы вызывающий мог одной строкой сказать «Telegram не
|
||||
сработал» и отдать свой 502. До #3456 сетевой отказ прилетал наружу сырым
|
||||
`httpx.ConnectTimeout`, мимо `except TelegramApiError`, и FastAPI отдавал
|
||||
500 — см. `TelegramNetworkError`.
|
||||
"""
|
||||
|
||||
|
||||
class TelegramApiError(TelegramError):
|
||||
class TelegramApiError(Exception):
|
||||
"""Telegram Bot API ответил `ok: false` (после исчерпания ретраев, если применимо)."""
|
||||
|
||||
def __init__(self, method: str, error_code: int, description: str) -> None:
|
||||
|
|
@ -252,59 +54,6 @@ class TelegramApiError(TelegramError):
|
|||
super().__init__(f"Telegram API {method} failed: {error_code} {description}")
|
||||
|
||||
|
||||
class TelegramNetworkError(TelegramError):
|
||||
"""Ответа от Telegram не было: таймаут/обрыв, ретраи исчерпаны.
|
||||
|
||||
Отдельный тип, а не `TelegramApiError`, потому что `error_code`/`description`
|
||||
брать неоткуда — Telegram ничего не сказал. Вызывающие, которым важна ТОЛЬКО
|
||||
реакция площадки (`bridge`, разбирающий 403 «бот заблокирован»), продолжают
|
||||
ловить `TelegramApiError` и этот отказ не перехватывают.
|
||||
|
||||
Причина сохраняется в `__cause__`: в GlitchTip виден исходный httpx-класс,
|
||||
по которому и отличают таймаут соединения от сброса TLS (#3156).
|
||||
"""
|
||||
|
||||
def __init__(self, method: str, reason: str, attempts: int) -> None:
|
||||
self.method = method
|
||||
self.reason = reason
|
||||
self.attempts = attempts
|
||||
super().__init__(f"Telegram {method} unreachable after {attempts} attempts: {reason}")
|
||||
|
||||
|
||||
class TelegramRateLimitedError(TelegramError):
|
||||
"""Слот в `TelegramGroupRateLimiter` не появился за `max_wait` секунд (review H1).
|
||||
|
||||
Бросается ТОЛЬКО когда вызывающий явно (или неявно, через `timeout`)
|
||||
попросил ограниченное ожидание — фоновые вызовы без такого ограничения
|
||||
ждут очередь сколько нужно и этого исключения никогда не увидят. Общий
|
||||
предок `TelegramError` — существующие `except TelegramError` в
|
||||
`app.api.v1.support`/`glitchtip` подхватывают этот отказ автоматически, без
|
||||
правки самих ручек, и отвечают своим честным 502 вместо зависшего запроса.
|
||||
"""
|
||||
|
||||
def __init__(self, chat_id: int, max_wait: float) -> None:
|
||||
self.chat_id = chat_id
|
||||
self.max_wait = max_wait
|
||||
super().__init__(
|
||||
f"Telegram group rate limit: no slot for chat_id={chat_id} within {max_wait:.1f}s"
|
||||
)
|
||||
|
||||
|
||||
def _request_timeout(read: float) -> httpx.Timeout:
|
||||
"""Разворачивает «сколько ждать ответа» (скаляр вызывающего) в таймауты httpx.
|
||||
|
||||
`read` — запрошенный бюджет ОТВЕТА (для long-poll это `poll_timeout + 10s`);
|
||||
connect/write/pool фиксированы модульными константами и коротки: ждать
|
||||
ответа Telegram — не то же самое, что ждать установки соединения.
|
||||
"""
|
||||
return httpx.Timeout(
|
||||
connect=_CONNECT_TIMEOUT_S,
|
||||
read=read,
|
||||
write=_WRITE_TIMEOUT_S,
|
||||
pool=_POOL_TIMEOUT_S,
|
||||
)
|
||||
|
||||
|
||||
def _extract_retry_after(
|
||||
response: httpx.Response, default: float = _DEFAULT_RETRY_AFTER_S
|
||||
) -> float:
|
||||
|
|
@ -338,111 +87,16 @@ def _error_from_body(response: httpx.Response) -> tuple[int, str]:
|
|||
|
||||
|
||||
class TelegramClient:
|
||||
"""Bot API клиент поверх ОДНОГО долгоживущего `httpx.AsyncClient`.
|
||||
|
||||
Соединение переиспользуется всё время жизни экземпляра: `AsyncClient`
|
||||
создаётся лениво при первом запросе и хранится в `self._http`. Раньше он
|
||||
создавался ВНУТРИ цикла ретраев — то есть keep-alive не было вовсе: полный
|
||||
TCP+TLS-хендшейк на каждый запрос и на каждую повторную попытку, и заново
|
||||
кидался кубик «встанет ли коннект». Для long-polling'а, ходящего каждые
|
||||
~30с в бесконечном цикле, это была основная статья сетевых отказов.
|
||||
|
||||
Отсюда — требование к вызывающим: экземпляр НАДО переиспользовать (один на
|
||||
процесс воркера, один на FastAPI-приложение — см.
|
||||
`app.services.tgbot.shared`), а не создавать на каждый запрос, и закрывать
|
||||
через `aclose()` или `async with`.
|
||||
|
||||
Таймаут теперь per-request: у `AsyncClient` он стоит дефолтом, а каждый
|
||||
вызов `_request` передаёт свой `httpx.Timeout` (long-poll — свои 40с на
|
||||
read, интерактивные ручки — свой узкий бюджет).
|
||||
"""
|
||||
"""Bot API клиент на httpx.AsyncClient. Каждый вызов — отдельное короткоживущее соединение."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
base_url: str = "https://api.telegram.org",
|
||||
timeout: float = _DEFAULT_TIMEOUT_S,
|
||||
relay_base_url: str = "",
|
||||
relay_secret: str = "",
|
||||
group_rate_limit_per_minute: int = _DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE,
|
||||
) -> None:
|
||||
# ── Ретранслятор через Beget (#3471) ────────────────────────────────
|
||||
# `relay_base_url` пуст по умолчанию → `_relay_base is _direct_base`,
|
||||
# и `_post` ниже не делает второй попытки (фолбэчить с прямого пути
|
||||
# НА прямой же путь бессмысленно). Заданный адрес переключает основной
|
||||
# путь на ретранслятор, прямой остаётся ЗАПАСНЫМ на случай его отказа.
|
||||
self._direct_base = f"{base_url}/bot{token}"
|
||||
self._relay_base = f"{relay_base_url}/bot{token}" if relay_base_url else self._direct_base
|
||||
self._relay_secret = relay_secret
|
||||
self._base = self._relay_base
|
||||
self._base = f"{base_url}/bot{token}"
|
||||
self._timeout = timeout
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
# Один лимитер на экземпляр клиента — см. `TelegramGroupRateLimiter`.
|
||||
# Ретранслятор здесь ни при чём: лимитер стоит ДО `_post`, то есть
|
||||
# считает отправку независимо от того, уйдёт она через relay или
|
||||
# напрямую (#3471) — оба пути ниже по стеку от этой точки.
|
||||
self._rate_limiter = TelegramGroupRateLimiter(group_rate_limit_per_minute)
|
||||
|
||||
def _http_client(self) -> httpx.AsyncClient:
|
||||
"""Ленивое создание переиспользуемого AsyncClient (вне цикла ретраев)."""
|
||||
if self._http is None:
|
||||
self._http = httpx.AsyncClient(
|
||||
timeout=_request_timeout(self._timeout),
|
||||
limits=httpx.Limits(
|
||||
max_keepalive_connections=_MAX_KEEPALIVE_CONNECTIONS,
|
||||
max_connections=_MAX_CONNECTIONS,
|
||||
keepalive_expiry=_KEEPALIVE_EXPIRY_S,
|
||||
),
|
||||
)
|
||||
return self._http
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Закрывает пул соединений. Идемпотентно; после — клиент снова ленив."""
|
||||
http, self._http = self._http, None
|
||||
if http is not None:
|
||||
await http.aclose()
|
||||
|
||||
async def __aenter__(self) -> TelegramClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def _post(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
payload: dict[str, Any],
|
||||
timeout: httpx.Timeout,
|
||||
) -> httpx.Response:
|
||||
"""POST с фолбэком на прямой путь при отказе РЕТРАНСЛЯТОРА (#3471).
|
||||
|
||||
Когда ретранслятор не настроен, `_relay_base is _direct_base`, `via_relay`
|
||||
ниже всегда `False`, и метод ведёт себя как простой `client.post` — этот
|
||||
путь ничем не отличается от поведения до #3471 (механизм отката).
|
||||
|
||||
Когда настроен: `url` бьёт в `self._relay_base`. Транспортный отказ (не
|
||||
ответ Telegram ЧЕРЕЗ ретранслятор, а отказ ДО него — TCP/TLS до самого
|
||||
relay-хоста) даёт РОВНО ОДНУ попытку напрямую к api.telegram.org — не
|
||||
рекурсивно: если недоступен и прямой путь, исключение поднимается как
|
||||
обычно и подхватывается retry-циклом `_request` на общих основаниях (со
|
||||
следующей попытки цикл снова пробует ретранслятор first — временный
|
||||
сбой relay не должен постоянно понижать клиента до прямого пути).
|
||||
"""
|
||||
via_relay = self._relay_base != self._direct_base and url.startswith(self._relay_base)
|
||||
headers = (
|
||||
{"X-Relay-Secret": self._relay_secret} if via_relay and self._relay_secret else None
|
||||
)
|
||||
try:
|
||||
return await client.post(url, json=payload, timeout=timeout, headers=headers)
|
||||
except httpx.TransportError:
|
||||
if not via_relay:
|
||||
raise
|
||||
direct_url = self._direct_base + url[len(self._relay_base) :]
|
||||
logger.warning(
|
||||
"tg client: ретранслятор недоступен, одна попытка напрямую к Telegram"
|
||||
)
|
||||
return await client.post(direct_url, json=payload, timeout=timeout)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
|
|
@ -452,24 +106,9 @@ class TelegramClient:
|
|||
timeout: float | None = None,
|
||||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||
max_backoff: float | None = None,
|
||||
rate_limit_max_wait: float | None = None,
|
||||
) -> Any:
|
||||
"""POST `method` с JSON-телом `payload`. Ретраит 429/5xx/network, иначе raise сразу.
|
||||
|
||||
`rate_limit_max_wait` (review H1/M1, #3471) — потолок ожидания слота в
|
||||
`TelegramGroupRateLimiter.acquire`. Приоритет:
|
||||
1. Явный `rate_limit_max_wait` — используется как есть (bridge.py
|
||||
передаёт его точечно для конкретных мест, см. `_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S`).
|
||||
2. Иначе, если вызывающий передал явный `timeout` — используем
|
||||
`effective_timeout` КАК ЕСТЬ. Интерактивные ручки (`app.api.v1.support`,
|
||||
`app.api.v1.glitchtip`) и так ОБЯЗАНЫ передавать узкий `timeout`
|
||||
(5-8с, см. их собственные докстринги) — этого достаточно, чтобы
|
||||
очередь лимитера не держала открытый HTTP-запрос браузера дольше
|
||||
его же собственного бюджета, БЕЗ дополнительной правки этих ручек.
|
||||
3. Иначе `None` — без потолка. Это дефолт для фоновых отправок бота
|
||||
(`app.tgbot_main`/`bridge.py` без явного `timeout`), где потерять
|
||||
сообщение хуже, чем подождать дольше.
|
||||
|
||||
`max_backoff` (#tgsupport-retry) — потолок паузы МЕЖДУ попытками. По
|
||||
умолчанию `_MAX_BACKOFF_S` (30с) и полный `retry_after` из тела 429 — это
|
||||
воркерная политика, она НЕ меняется. Интерактивный вызывающий передаёт узкий
|
||||
|
|
@ -483,51 +122,17 @@ class TelegramClient:
|
|||
это штатные 30-60с) на число попыток и подвесил бы синхронный HTTP-запрос на
|
||||
минуты — ровно то, от чего предостерегает докстринг `send_message`.
|
||||
"""
|
||||
effective_timeout = timeout if timeout is not None else self._timeout
|
||||
|
||||
# Лимитер применяется ТОЛЬКО к методам с `chat_id` в payload (отправка
|
||||
# в конкретный чат) — `getUpdates` его не несёт и лимиту не подлежит.
|
||||
# Списывается ОДИН слот на логический вызов `_request` (то есть на
|
||||
# одну попытку отправки конкретного сообщения), а не на каждую HTTP
|
||||
# попытку внутри ретрай-цикла ниже: ретраи по 429/5xx лечат один и тот
|
||||
# же send, а не порождают новые отправки. Приоритет `wait_cap` — см.
|
||||
# докстринг параметра `rate_limit_max_wait` выше.
|
||||
chat_id = payload.get("chat_id")
|
||||
if isinstance(chat_id, int):
|
||||
wait_cap = rate_limit_max_wait
|
||||
if wait_cap is None and timeout is not None:
|
||||
wait_cap = effective_timeout
|
||||
await self._rate_limiter.acquire(chat_id, max_wait=wait_cap)
|
||||
|
||||
url = f"{self._base}/{method}"
|
||||
# Раздельные таймауты считаем ОДИН раз и передаём per-request: у общего
|
||||
# AsyncClient свой дефолт, а бюджет ответа у каждого вызова свой.
|
||||
request_timeout = _request_timeout(effective_timeout)
|
||||
effective_timeout = timeout if timeout is not None else self._timeout
|
||||
backoff_cap = _MAX_BACKOFF_S if max_backoff is None else max_backoff
|
||||
attempt = 0
|
||||
# Клиент берём ДО цикла: пересоздавать его на каждую попытку значило бы
|
||||
# заново платить за TCP+TLS ровно там, где сеть уже показала себя плохо.
|
||||
client = self._http_client()
|
||||
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
response = await self._post(client, url, payload, request_timeout)
|
||||
except httpx.TransportError as exc:
|
||||
# Ловим ВЕСЬ `TransportError`, а не узкий кортеж
|
||||
# `(TimeoutException, NetworkError)`: `RemoteProtocolError`
|
||||
# («Server disconnected without sending a response» — бытовой
|
||||
# ответ api.telegram.org из РФ), `ProxyError`,
|
||||
# `LocalProtocolError` и `UnsupportedProtocol` — СЁСТРЫ
|
||||
# `NetworkError` по `TransportError`, а не наследники. Кортеж
|
||||
# оставлял дыру ровно того класса, который чинил #3456: отказ
|
||||
# вылетал сырым httpx мимо `except TelegramError` в ручках и
|
||||
# снова давал 500 вместо 502 — и вдобавок не ретраился ни разу.
|
||||
# Расширение ретраев на `RemoteProtocolError` наследует уже
|
||||
# принятый здесь риск at-least-once (запрос мог дойти до
|
||||
# Telegram, потерялся ответ) — он тот же, что у давно
|
||||
# ретраящегося `ReadTimeout`; политика не меняется.
|
||||
#
|
||||
async with httpx.AsyncClient(timeout=effective_timeout) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
# Тип исключения обязан попасть в строку (#3156). У
|
||||
# httpx.ReadError и httpx.ConnectError `str(exc)` пуст, и лог
|
||||
# выглядел так: «network error (попытка 1/3): — retry через 2s»
|
||||
|
|
@ -543,7 +148,7 @@ class TelegramClient:
|
|||
attempt,
|
||||
reason,
|
||||
)
|
||||
raise TelegramNetworkError(method, reason, attempt) from exc
|
||||
raise
|
||||
backoff = min(2.0**attempt, backoff_cap)
|
||||
logger.warning(
|
||||
"tg client: %s — network error (попытка %d/%d): %s — retry через %.1fs",
|
||||
|
|
@ -555,26 +160,6 @@ class TelegramClient:
|
|||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
except httpx.RequestError as exc:
|
||||
# Страховка на остаток дерева отказов запроса: сегодня это
|
||||
# `DecodingError` (битая компрессия в ответе), завтра — всё, что
|
||||
# httpx заведёт под `RequestError`. `TooManyRedirects` сюда НЕ
|
||||
# относится: клиент создаётся с дефолтным `follow_redirects=False`
|
||||
# и редиректы не ходит. Порядок `except`-ов
|
||||
# значим: `TransportError` — наследник `RequestError`, и стоять
|
||||
# обязан ВЫШЕ, иначе сетевые отказы перестали бы ретраиться.
|
||||
#
|
||||
# Без ретраев намеренно: это не «площадка недоступна», а
|
||||
# испорченный ответ или кривая конфигурация — повтор не лечит
|
||||
# ни то, ни другое, а пять попыток с backoff подвесили бы
|
||||
# интерактивную ручку почти на минуту впустую. Свой тип тут
|
||||
# нужен ровно за тем же, за чем и выше: чтобы ручка увидела
|
||||
# `TelegramError` и отдала 502, а не 500.
|
||||
reason = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
||||
logger.error(
|
||||
"tg client: %s — запрос не состоялся (без ретраев): %s", method, reason
|
||||
)
|
||||
raise TelegramNetworkError(method, reason, attempt) from exc
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = _extract_retry_after(response)
|
||||
|
|
@ -647,11 +232,8 @@ class TelegramClient:
|
|||
) -> list[dict[str, Any]]:
|
||||
"""Long-polling getUpdates. `timeout` — сколько Telegram держит запрос открытым (сек).
|
||||
|
||||
Запас `+10s` относится к READ-таймауту (сколько ждём ответа), чтобы не
|
||||
обрывать соединение раньше, чем ответит сам Telegram long-poll. На
|
||||
connect/write/pool он НЕ распространяется — они короткие и фиксированы
|
||||
(`_CONNECT_TIMEOUT_S` и соседи): установка соединения либо занимает
|
||||
десятки миллисекунд, либо не состоится вовсе.
|
||||
HTTP-таймаут запроса берётся с запасом (`timeout + 10s`), чтобы не обрывать
|
||||
соединение раньше, чем ответит сам Telegram long-poll.
|
||||
"""
|
||||
payload: dict[str, Any] = {"offset": offset, "timeout": timeout}
|
||||
if allowed_updates is not None:
|
||||
|
|
@ -669,12 +251,8 @@ class TelegramClient:
|
|||
message_id: int,
|
||||
message_thread_id: int | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
rate_limit_max_wait: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла.
|
||||
|
||||
`rate_limit_max_wait` — см. `TelegramClient._request`; используется
|
||||
`bridge.py` для точечного потолка ожидания на конкретных местах (review M1)."""
|
||||
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла."""
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"from_chat_id": from_chat_id,
|
||||
|
|
@ -684,9 +262,7 @@ class TelegramClient:
|
|||
payload["message_thread_id"] = message_thread_id
|
||||
if reply_to_message_id:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
result = await self._request(
|
||||
"copyMessage", payload, rate_limit_max_wait=rate_limit_max_wait
|
||||
)
|
||||
result = await self._request("copyMessage", payload)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def send_message(
|
||||
|
|
@ -699,7 +275,6 @@ class TelegramClient:
|
|||
timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
max_backoff: float | None = None,
|
||||
rate_limit_max_wait: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
|
||||
|
||||
|
|
@ -723,103 +298,5 @@ class TelegramClient:
|
|||
kwargs["max_retries"] = max_retries
|
||||
if max_backoff is not None:
|
||||
kwargs["max_backoff"] = max_backoff
|
||||
if rate_limit_max_wait is not None:
|
||||
kwargs["rate_limit_max_wait"] = rate_limit_max_wait
|
||||
result = await self._request("sendMessage", payload, **kwargs)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def get_chat(self, chat_id: int) -> dict[str, Any]:
|
||||
"""getChat — метаданные чата. Единственная цель здесь — startup-проверка
|
||||
(см. `verify_chat_and_topic`): подтвердить, что `chat_id` валиден и бот
|
||||
не выгнан/не заблокирован, без единого видимого сообщения."""
|
||||
result = await self._request("getChat", {"chat_id": chat_id}, max_retries=1)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def send_chat_action(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
action: str = "typing",
|
||||
message_thread_id: int | None = None,
|
||||
) -> bool:
|
||||
"""sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет.
|
||||
|
||||
Используется НЕ по прямому назначению (индикация набора), а как способ
|
||||
проверить существование `message_thread_id` (темы форума) — см.
|
||||
`verify_chat_and_topic`. Это единственный метод Bot API, который
|
||||
принимает `message_thread_id` и не создаёт message-объект: если тема
|
||||
удалена/переименована в другую с иным id, Telegram отвечает `Bad
|
||||
Request: message thread not found` мгновенно, а в истории чата не
|
||||
остаётся ни строки (индикатор эфемерный и не персистится)."""
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
|
||||
if message_thread_id:
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
result = await self._request(
|
||||
"sendChatAction", payload, max_retries=1, max_backoff=5.0
|
||||
)
|
||||
return bool(result)
|
||||
|
||||
|
||||
async def verify_chat_and_topic(
|
||||
client: TelegramClient,
|
||||
*,
|
||||
chat_id: int,
|
||||
topic_id: int,
|
||||
label: str,
|
||||
) -> bool:
|
||||
"""Разовая startup-проверка: чат существует, бот в нём не забанен, тема жива.
|
||||
|
||||
Зачем нужна: бот пишет в тему форума по числовому id из настроек. Если
|
||||
тему удалили, переименовали в другую (новый id) или id в конфиге просто
|
||||
неверный — отправка начинает падать НА КАЖДОМ сообщении, а узнаём мы об
|
||||
этом только по молчанию у людей (симметрично истории #tgsupport с сетевыми
|
||||
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
|
||||
«через сутки тишины» на «в первую секунду после старта/рестарта».
|
||||
|
||||
Способ намеренно НЕ `sendMessage`+`deleteMessage`:
|
||||
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не
|
||||
выгнан/не заблокирован — чистый read, нулевой видимый след.
|
||||
- `send_chat_action` (typing-индикатор с `message_thread_id`) —
|
||||
единственный способ провалидировать САМУ тему без создания
|
||||
message-объекта: Telegram обязан знать про `message_thread_id`, чтобы
|
||||
показать «печатает...» именно в нужном треде, и явно отказывает, если
|
||||
такой темы нет. `sendMessage`+`deleteMessage` тоже сработал бы, но
|
||||
оставлял бы видимый (пусть на секунды) артефакт в истории треда при
|
||||
КАЖДОМ рестарте контейнера — на rolling-деплое это многократно в
|
||||
сутки; typing-индикатор того же результата достигает без единого
|
||||
сообщения.
|
||||
|
||||
НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог
|
||||
уровня error — задача явно требует шума в логе, а не падения воркера
|
||||
(тема пуста/невалидна — это деградация уведомлений, а не фатальный сбой
|
||||
самого бота, который всё ещё должен принимать входящие).
|
||||
|
||||
`chat_id == 0` (не настроено) — считается успехом без обращения к API:
|
||||
это штатный kill-switch (см. `app.core.config`), а не ошибка конфигурации.
|
||||
"""
|
||||
if not chat_id:
|
||||
return True
|
||||
try:
|
||||
await client.get_chat(chat_id)
|
||||
if topic_id:
|
||||
await client.send_chat_action(
|
||||
chat_id=chat_id, action="typing", message_thread_id=topic_id
|
||||
)
|
||||
except TelegramError as exc:
|
||||
logger.error(
|
||||
"tg topic check [%s]: чат/тема недоступны для отправки "
|
||||
"(chat_id=%s, topic_id=%s) — %s. Сообщения в эту тему БУДУТ "
|
||||
"падать, пока конфигурация не исправлена.",
|
||||
label,
|
||||
chat_id,
|
||||
topic_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
logger.info(
|
||||
"tg topic check [%s]: чат и тема доступны для отправки (chat_id=%s, topic_id=%s)",
|
||||
label,
|
||||
chat_id,
|
||||
topic_id,
|
||||
)
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
"""Общий на приложение `TelegramClient` (#tg-connection-resilience).
|
||||
|
||||
Зачем: до этого три HTTP-ручки (`api.v1.support` ×2, `api.v1.glitchtip`) делали
|
||||
`TelegramClient(token)` на КАЖДЫЙ входящий запрос, а клиент внутри пересоздавал
|
||||
`httpx.AsyncClient` на каждую попытку — то есть keep-alive не было ни на каком
|
||||
уровне и каждый запрос начинался с полного TCP+TLS-хендшейка до
|
||||
api.telegram.org. Здесь живёт один экземпляр на процесс: создаётся в lifespan
|
||||
(`app.main`), закрывается на shutdown там же.
|
||||
|
||||
Воркер бота (`app.tgbot_main`) сюда НЕ ходит — у него свой процесс без ASGI и
|
||||
свой экземпляр на всё время жизни поллинга.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.tgbot.client import TelegramClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: TelegramClient | None = None
|
||||
|
||||
|
||||
def get_telegram_client() -> TelegramClient:
|
||||
"""Общий клиент приложения. Ленив: создаётся при первом обращении.
|
||||
|
||||
Ленивость (а не «только из lifespan») нужна из-за kill-switch: при пустом
|
||||
`TELEGRAM_BOT_TOKEN` в lifespan создавать нечего, а тесты ручек поднимают
|
||||
приложение без прохода через startup.
|
||||
"""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = TelegramClient(
|
||||
settings.telegram_bot_token,
|
||||
relay_base_url=settings.telegram_relay_base_url,
|
||||
relay_secret=settings.telegram_relay_secret,
|
||||
# API-роль (review H2, #3471) — см. докстринг настройки в
|
||||
# app.core.config: бюджет группы разделён статически между этим
|
||||
# процессом и app.tgbot_main, суммарно ниже площадочного лимита.
|
||||
group_rate_limit_per_minute=settings.telegram_group_rate_limit_api_per_minute,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def init_telegram_client() -> TelegramClient:
|
||||
"""Явное создание на старте приложения (lifespan)."""
|
||||
return get_telegram_client()
|
||||
|
||||
|
||||
async def close_telegram_client() -> None:
|
||||
"""Закрывает общий клиент на shutdown. Идемпотентно."""
|
||||
global _client
|
||||
client, _client = _client, None
|
||||
if client is not None:
|
||||
await client.aclose()
|
||||
logger.info("tg shared client: пул соединений закрыт")
|
||||
|
|
@ -61,36 +61,6 @@ def get_or_create_thread(db: Session, username: str) -> int:
|
|||
return int(row[0])
|
||||
|
||||
|
||||
def find_inbound_by_idempotency_key(
|
||||
db: Session, *, thread_id: int, idempotency_key: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Уже записанное inbound-сообщение с этим ключом идемпотентности в треде,
|
||||
если есть (#3471). Вызывается ИЗ `app.api.v1.support` ДО похода в Telegram
|
||||
(`send_support_message` / `send_anon_support_message`) — повтор с тем же
|
||||
ключом не должен создавать второе зеркало в топике, а не только вторую
|
||||
строку в БД. `thread_id`, а не username/anon-token: таблица не хранит
|
||||
identity напрямую, а тред уже гарантированно существует к моменту, когда
|
||||
этот ключ мог быть записан (тред создаётся ДО `record_inbound`, см. H1 в
|
||||
докстринге `app.api.v1.support`)."""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, direction, text_body, operator_tg_id, created_at
|
||||
FROM web_support_messages
|
||||
WHERE thread_id = CAST(:thread_id AS bigint)
|
||||
AND direction = 'in'
|
||||
AND idempotency_key = :idempotency_key
|
||||
"""
|
||||
),
|
||||
{"thread_id": thread_id, "idempotency_key": idempotency_key},
|
||||
)
|
||||
.mappings()
|
||||
.one_or_none()
|
||||
)
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def record_inbound(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -98,7 +68,6 @@ def record_inbound(
|
|||
text_body: str,
|
||||
topic_message_id: int | None,
|
||||
support_chat_id: int | None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
|
||||
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
|
||||
|
|
@ -106,103 +75,18 @@ def record_inbound(
|
|||
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
|
||||
support-группе — если группу когда-нибудь сменят/пересоздадут, Telegram
|
||||
message_id стартует заново с 1 в новом чате и может совпасть с числом из
|
||||
старого — без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188).
|
||||
|
||||
`idempotency_key` (#3471, миграция 301) — вызывающая сторона (`app.api.v1.support`)
|
||||
делает SELECT-затем-действие pre-check ДО Telegram-похода (см.
|
||||
`find_inbound_by_idempotency_key`), но этот pre-check САМ ПО СЕБЕ гонку не
|
||||
закрывает (TOCTOU): два запроса с одним ключом могут пройти его одновременно
|
||||
и оба уйти в Telegram. Последняя линия защиты — здесь: `INSERT ... ON
|
||||
CONFLICT (thread_id, idempotency_key) DO NOTHING` на partial unique индексе
|
||||
(мигр. 301, тот же predicate). Если конфликт всё же случился, проигравший
|
||||
НЕ создаёт вторую строку — читает уже вставленную и возвращает её, так что
|
||||
оба запроса-конкурента получают ОДИН и тот же id. `idempotency_key=None`
|
||||
(дефолт) ведёт себя как раньше: NULL никогда не конфликтует сам с собой в
|
||||
partial-индексе (WHERE idempotency_key IS NOT NULL), INSERT всегда проходит."""
|
||||
старого — без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188)."""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO web_support_messages
|
||||
(thread_id, direction, text_body, topic_message_id,
|
||||
support_chat_id, operator_tg_id, idempotency_key, created_at)
|
||||
support_chat_id, operator_tg_id, created_at)
|
||||
VALUES
|
||||
(CAST(:thread_id AS bigint), 'in', :text_body,
|
||||
CAST(:topic_message_id AS bigint),
|
||||
CAST(:support_chat_id AS bigint), NULL, :idempotency_key, NOW())
|
||||
ON CONFLICT (thread_id, idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL AND direction = 'in'
|
||||
DO NOTHING
|
||||
RETURNING id, direction, text_body, operator_tg_id, created_at
|
||||
"""
|
||||
),
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"text_body": text_body,
|
||||
"topic_message_id": topic_message_id,
|
||||
"support_chat_id": support_chat_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.one_or_none()
|
||||
)
|
||||
if row is not None:
|
||||
return dict(row)
|
||||
|
||||
# Конфликт пойман индексом — idempotency_key почти наверняка не NULL здесь
|
||||
# (при NULL partial-индекс в конфликт не участвует), но НЕ `assert`: это
|
||||
# прод-путь ПОСЛЕ уже доставленного в Telegram сообщения (см. H1 в
|
||||
# app.api.v1.support), а `except SQLAlchemyError` в вызывающей стороне
|
||||
# `AssertionError` не ловит — под `python -O` assert вдобавок исчезает
|
||||
# молча и `existing` осталось бы `None`, что развалилось бы чуть ниже при
|
||||
# сборке ответа. Вместо падения — явная ветка с логом и WORKING откатом.
|
||||
existing = (
|
||||
find_inbound_by_idempotency_key(db, thread_id=thread_id, idempotency_key=idempotency_key)
|
||||
if idempotency_key is not None
|
||||
else None
|
||||
)
|
||||
if existing is not None:
|
||||
# Ожидаемый случай гонки (#3471): проигравший запрос уже отправил своё
|
||||
# СОБСТВЕННОЕ зеркало в Telegram (свой topic_message_id) до того, как
|
||||
# обнаружил конфликт здесь — эта копия зеркала теперь осиротела
|
||||
# (реплай оператора на неё никуда не смаршрутизируется, т.к. строки
|
||||
# для неё в БД нет). Осознанно не чиним это в этом PR (см. коммент
|
||||
# выше по коду), но фиксируем в логе, а не молчим — ровно то же самое,
|
||||
# для чего уже есть `_warn_operator_message_not_recorded` в другом месте.
|
||||
logger.warning(
|
||||
"web support: гонка по idempotency_key — зеркало topic_message_id=%s "
|
||||
"(thread_id=%d) отправлено, но НЕ записано, победила строка id=%d",
|
||||
topic_message_id,
|
||||
thread_id,
|
||||
existing["id"],
|
||||
)
|
||||
return existing
|
||||
|
||||
# Крайний случай (в норме недостижим при READ COMMITTED, которую использует
|
||||
# этот сервис): индекс сообщил о конфликте, но повторное чтение строку не
|
||||
# нашло. Логируем и пишем БЕЗ идемпотентности — NULL-ключ никогда не
|
||||
# конфликтует сам с собой (partial-индекс его не видит), INSERT гарантированно
|
||||
# пройдёт; для этого одного сообщения дедупликация отключается, но клиент
|
||||
# получает корректный ответ вместо 500 после уже состоявшейся доставки.
|
||||
logger.warning(
|
||||
"web support: ON CONFLICT сообщил о конфликте (thread_id=%d, "
|
||||
"idempotency_key=%s), но повторное чтение строки её не нашло — "
|
||||
"записываем без идемпотентности",
|
||||
thread_id,
|
||||
"<set>" if idempotency_key is not None else None,
|
||||
)
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO web_support_messages
|
||||
(thread_id, direction, text_body, topic_message_id,
|
||||
support_chat_id, operator_tg_id, idempotency_key, created_at)
|
||||
VALUES
|
||||
(CAST(:thread_id AS bigint), 'in', :text_body,
|
||||
CAST(:topic_message_id AS bigint),
|
||||
CAST(:support_chat_id AS bigint), NULL, NULL, NOW())
|
||||
CAST(:support_chat_id AS bigint), NULL, NOW())
|
||||
RETURNING id, direction, text_body, operator_tg_id, created_at
|
||||
"""
|
||||
),
|
||||
|
|
@ -222,17 +106,8 @@ def record_inbound(
|
|||
def find_thread_by_topic_message(
|
||||
db: Session, topic_message_id: int, support_chat_id: int
|
||||
) -> int | None:
|
||||
"""Резолвит id зеркала/ответа (reply_to) в thread_id.
|
||||
|
||||
БЕЗ фильтра по direction (#3471 P0, было `AND direction = 'in'`): с тех пор
|
||||
как `record_outbound` тоже сохраняет `topic_message_id` (id ответа оператора
|
||||
В ТОПИКЕ), реплай оператора на СВОЙ предыдущий ответ обязан резолвиться так
|
||||
же, как реплай на inbound-зеркало клиента — иначе продолжение диалога без
|
||||
повторного цитирования клиента тихо проваливалось в orphan-check
|
||||
(`_handle_group_reply` в bridge.py). Коллизий topic_message_id между
|
||||
inbound- и outbound-строками одного треда быть не может: Telegram выдаёт
|
||||
каждому сообщению в чате свой возрастающий id, `web_support_messages_topic_message_id_uq`
|
||||
(partial unique, 187) это же и гарантирует на уровне БД.
|
||||
"""Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
|
||||
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186).
|
||||
|
||||
Скоупим к ТЕКУЩЕМУ `support_chat_id` (#tgsupport-web review M1): строка со
|
||||
ЧУЖИМ (не NULL и не текущим) support_chat_id — это исторический артефакт
|
||||
|
|
@ -245,6 +120,7 @@ def find_thread_by_topic_message(
|
|||
SELECT thread_id
|
||||
FROM web_support_messages
|
||||
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
||||
AND direction = 'in'
|
||||
AND (support_chat_id = CAST(:support_chat_id AS bigint) OR support_chat_id IS NULL)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
|
|
@ -256,40 +132,18 @@ def find_thread_by_topic_message(
|
|||
|
||||
|
||||
def record_outbound(
|
||||
db: Session,
|
||||
*,
|
||||
thread_id: int,
|
||||
text_body: str,
|
||||
operator_tg_id: int | None,
|
||||
topic_message_id: int | None = None,
|
||||
support_chat_id: int | None = None,
|
||||
db: Session, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||
) -> int | None:
|
||||
"""Записывает ответ оператора (реплай на веб-зеркало) как direction='out'.
|
||||
|
||||
`topic_message_id` (#3471 P0, раньше был безусловно NULL) — id ЭТОГО
|
||||
сообщения оператора в топике. Раньше маршрутизирующий ключ жил только на
|
||||
inbound-записи (конвенция 186/187), из-за чего реплай оператора на СВОЙ
|
||||
предыдущий ответ был нерезолвим — искать было нечего, а `reply_to_message_id`
|
||||
указывал на строку без ключа. `find_thread_by_topic_message` теперь матчит
|
||||
обе стороны (direction-фильтр там снят).
|
||||
|
||||
`support_chat_id` (deep review PR #3479) — ОБЯЗАТЕЛЕН при заполненном
|
||||
`topic_message_id`: `find_thread_by_topic_message` матчит `support_chat_id
|
||||
IS NULL` как лениентный wildcard "под любым текущим чатом" (легаси-строки
|
||||
до 187/188). Без этого поля КАЖДАЯ out-строка была бы таким wildcard — при
|
||||
ротации support-группы новый message_id мог бы совпасть со старой
|
||||
out-строкой и увести ответ в ЧУЖОЙ тред (ровно то, от чего защищала
|
||||
скоупинг-миграция 187/188, см. review M1 там же)."""
|
||||
`topic_message_id` всегда NULL — маршрутизирующий ключ живёт только на
|
||||
inbound-записи (см. tg_support_messages-конвенцию, 186)."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO web_support_messages
|
||||
(thread_id, direction, text_body, topic_message_id,
|
||||
support_chat_id, operator_tg_id, created_at)
|
||||
(thread_id, direction, text_body, topic_message_id, operator_tg_id, created_at)
|
||||
VALUES
|
||||
(CAST(:thread_id AS bigint), 'out', :text_body,
|
||||
CAST(:topic_message_id AS bigint),
|
||||
CAST(:support_chat_id AS bigint),
|
||||
(CAST(:thread_id AS bigint), 'out', :text_body, NULL,
|
||||
CAST(:operator_tg_id AS bigint), NOW())
|
||||
RETURNING id
|
||||
"""
|
||||
|
|
@ -297,8 +151,6 @@ def record_outbound(
|
|||
{
|
||||
"thread_id": thread_id,
|
||||
"text_body": text_body,
|
||||
"topic_message_id": topic_message_id,
|
||||
"support_chat_id": support_chat_id,
|
||||
"operator_tg_id": operator_tg_id,
|
||||
},
|
||||
).fetchone()
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ async def backfill_yandex_addresses(
|
|||
# нездоровы — НЕ уходим на settings.scraper_proxy_url (см. proxy_egress module
|
||||
# docstring). Явный пропуск run'а вместо слепого прохода через egress, который
|
||||
# мог быть источником текущего инцидента.
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run "
|
||||
"пропущен, ни один листинг не обработан",
|
||||
exc,
|
||||
|
|
|
|||
|
|
@ -806,7 +806,7 @@ async def run_avito_detail_backfill(
|
|||
# и mark_failed с текстом про пул, как у домклика после #3283.
|
||||
if _caused_by_empty_pool(e):
|
||||
counters.failed += 1
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"avito_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
||||
"к площадке не ходили. enriched=%d attempted=%d",
|
||||
run_id,
|
||||
|
|
@ -913,7 +913,7 @@ async def run_avito_detail_backfill(
|
|||
# ratio: прогон 5210 (14 блоков из 20, ровно порог) отпечатал
|
||||
# "ABORT -- 1 consecutive blocks" -- текущая серия в тот момент
|
||||
# действительно равнялась единице, но обрыв был не по ней.
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"avito_detail_backfill: run_id=%d ABORT -- %s, "
|
||||
"частая причина: %s. enriched=%d attempted=%d",
|
||||
run_id,
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ async def backfill_cian_history(
|
|||
if caused_by_no_proxy(exc):
|
||||
result.no_proxy_stop = True
|
||||
result.listings_failed_fetch += 1
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"cian_history_backfill: СТОП — пул прокси пуст, к площадке "
|
||||
"не ходили. listing_id=%s processed=%d succeeded=%d",
|
||||
listing_id,
|
||||
|
|
|
|||
|
|
@ -493,7 +493,7 @@ async def run_domclick_detail_backfill(
|
|||
# ниже. Причина не теряется: в записи прогона стоит
|
||||
# no_proxy_stop=1 и mark_failed с текстом про пул.
|
||||
counters.failed += 1
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
||||
"к площадке не ходили. enriched=%d attempted=%d",
|
||||
run_id,
|
||||
|
|
@ -572,7 +572,7 @@ async def run_domclick_detail_backfill(
|
|||
if consecutive_blocks >= max_consecutive_blocks:
|
||||
# #3196: причину больше не выдумываем и не молчим — печатаем
|
||||
# перепись диагнозов по HTTP-статусам этого прогона.
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"domclick_detail_backfill: run_id=%d ABORT -- %d consecutive "
|
||||
"blocks, диагнозы: %s. enriched=%d attempted=%d",
|
||||
run_id,
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
"""Фоновая пересылка GlitchTip-алерта в Telegram после отказа синхронной попытки.
|
||||
|
||||
Контекст (#3471, #3157, #3456). GlitchTip-вебхуки НЕ ретраятся — сам GlitchTip
|
||||
безусловно помечает уведомление ``is_sent`` сразу после HTTP-ответа приёмника
|
||||
(upstream-поведение, см. #3157), поэтому если синхронная пересылка в Telegram
|
||||
(``app.api.v1.glitchtip``) не удалась, повторной доставки от GlitchTip не будет
|
||||
никогда — текст алерта исчезает бесследно. Ответ 502 на отказ Telegram остаётся
|
||||
как есть (задуман осознанно, #3456: честный сигнал отправителю, а не тихий
|
||||
проглот) — меняется то, что происходит С ТЕКСТОМ алерта после этого отказа.
|
||||
|
||||
Почему не Celery. В tradein-mvp нет очереди с воркером: ни ``app/celery_app.py``,
|
||||
ни зависимости ``celery`` в ``backend/pyproject.toml`` не существует (проверено
|
||||
при работе над #3471) — попытка ``from celery import ...`` здесь упала бы
|
||||
``ModuleNotFoundError``. Бутстрап полноценного Celery-воркера — новый контейнер и
|
||||
брокер, инфраструктурное решение вне границ этой задачи. Единственный доступный
|
||||
внутри границ задачи (``app/api/v1/glitchtip.py`` + ``app/tasks/**``) механизм
|
||||
«не блокировать интерактивный ответ, но не потерять текст» — Starlette
|
||||
``BackgroundTasks``: выполняется ПОСЛЕ отправки HTTP-ответа тем же процессом, вне
|
||||
узкого интерактивного бюджета (``_INTERACTIVE_SEND_TIMEOUT_S=8s`` в glitchtip.py),
|
||||
поэтому здесь можно позволить себе штатную "воркерную" ретрай-политику клиента
|
||||
(``TelegramClient.send_message`` без явных ``timeout``/``max_retries`` — 5 попыток,
|
||||
backoff до 30s, см. ``app.services.tgbot.client``), плюс собственный внешний
|
||||
потолок ниже.
|
||||
|
||||
Компромисс, честно: BackgroundTasks не переживает рестарт процесса (это не
|
||||
персистентная очередь) — если tradein-backend упадёт ровно между отказом
|
||||
синхронной попытки и завершением фоновой, текст всё-таки потеряется. Событие
|
||||
редкое (одно с начала эксплуатации, TRADE-IN-3F7, 28.08.2026), а сеть до Telegram
|
||||
теряет отдельные запросы, а не рвётся на минуты (замер 12.09: 9/12 успешных
|
||||
``getMe``) — штатной ретрай-политики клиента обычно достаточно без внешнего
|
||||
потолка вовсе. Персистентная очередь (переживающая рестарт) требует
|
||||
Celery/Redis-воркера — отдельное инфраструктурное решение.
|
||||
|
||||
Идемпотентность настолько, насколько дёшево. Текст между попытками не
|
||||
пересобирается (переиспользуется уже отформатированный ``text`` из
|
||||
``glitchtip.py`` — никакого дублирования форматирования). Полной идемпотентности
|
||||
нет и быть не может дёшево: Telegram ``sendMessage`` не идемпотентен сам по себе
|
||||
(повтор создаёт НОВОЕ сообщение, не апдейтит старое) — именно поэтому внешний
|
||||
потолок попыток мал (``_MAX_ATTEMPTS``), а не «ретраить пока не получится».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.services.tgbot.client import TelegramClient, TelegramError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["retry_forward_alert"]
|
||||
|
||||
# Внешний потолок ПОВЕРХ штатной ретрай-политики клиента (5 попыток внутри одного
|
||||
# send_message с backoff до 30s) — защита от «недоставляемый алерт крутится в фоне
|
||||
# вечно»: если сеть до Telegram не восстановилась за это время, сдаёмся и логируем
|
||||
# ERROR с текстом, а не повторяем бесконечно.
|
||||
_MAX_ATTEMPTS = 3
|
||||
_RETRY_DELAY_S = 30.0
|
||||
|
||||
|
||||
async def retry_forward_alert(
|
||||
client: TelegramClient,
|
||||
*,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
message_thread_id: int | None,
|
||||
) -> None:
|
||||
"""Досылает уже отформатированный текст алерта после отказа синхронной попытки.
|
||||
|
||||
``client`` — ТОТ ЖЕ общий клиент приложения, что и в синхронном пути
|
||||
(``get_telegram_client()`` в ``glitchtip.py``), а не новый инстанс: он живёт в
|
||||
lifespan ради keep-alive-соединения (см. docstring ``glitchtip.py``).
|
||||
"""
|
||||
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
await client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
message_thread_id=message_thread_id,
|
||||
# Без явных timeout/max_retries — штатная "воркерная" политика
|
||||
# клиента (см. докстринг модуля).
|
||||
)
|
||||
except TelegramError:
|
||||
if attempt == _MAX_ATTEMPTS:
|
||||
logger.error(
|
||||
"glitchtip alert retry: не удалось доставить алерт в Telegram "
|
||||
"после %d попыток — текст потерян: %r",
|
||||
_MAX_ATTEMPTS,
|
||||
text[:200],
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
logger.warning(
|
||||
"glitchtip alert retry: попытка %d/%d не удалась, повтор через %.0fs",
|
||||
attempt,
|
||||
_MAX_ATTEMPTS,
|
||||
_RETRY_DELAY_S,
|
||||
exc_info=True,
|
||||
)
|
||||
await asyncio.sleep(_RETRY_DELAY_S)
|
||||
else:
|
||||
logger.info(
|
||||
"glitchtip alert retry: доставлено фоном с попытки %d/%d", attempt, _MAX_ATTEMPTS
|
||||
)
|
||||
return
|
||||
|
|
@ -9,36 +9,26 @@
|
|||
|
||||
ПРАВИЛО ОТБОРА — ЯВНО И БЕЗ ПОДГОНКИ
|
||||
------------------------------------
|
||||
Витрина показывает ПОЛОСУ РАСХОЖДЕНИЯ, а не всю сверку. С 2026-09-12 решением
|
||||
владельца продукта на витрину попадают только сделки, у которых расхождение
|
||||
прогноза с ценой ДКП лежит в пределах `BAND_MIN_ERR_PCT`..`BAND_MAX_ERR_PCT`
|
||||
(−5 %..+20 % включительно). Оставшиеся `limit` строк ранжируются ключом::
|
||||
Отбираем N строк ключом::
|
||||
|
||||
(полнота данных ↓, свежесть квартала ↓, id сделки ↓)
|
||||
|
||||
ЭТО ОТБОР ПОКАЗАТЕЛЬНЫХ СТРОК, И НАЗЫВАТЬ ЕГО НАДО ТАК. До 2026-09-12 здесь
|
||||
не было ни фильтра, ни слагаемого ошибки в ключе, и подпись витрины это прямо
|
||||
утверждала. Теперь утверждать это нельзя: строки с промахом крупнее полосы в
|
||||
данных есть (на проде 30.08.2026 из показанных двадцати вне полосы было
|
||||
двенадцать — от −27,9 % до +75,7 %), и они не показываются. Поэтому полоса
|
||||
названа в `REJECTION_RULE`, которое едет на фронт вместе со счётчиками
|
||||
прогона, и в подписи под таблицей рядом с медианой расхождения ПО ВСЕЙ
|
||||
СВЕРКЕ: два числа рядом не дают прочитать двадцать отобранных строк как
|
||||
«вот так МЕРА обычно и попадает».
|
||||
Величина ошибки в ключе НЕ УЧАСТВУЕТ и участвовать не должна. Отбор по малой
|
||||
ошибке превращает витрину в рекламу: показанные 20 строк перестают быть
|
||||
выборкой из работы оценщика и становятся её лучшим хвостом, а посетитель
|
||||
читает их как «вот так МЕРА обычно и попадает». Это тот самый случай, когда
|
||||
код формально работает, а продукт врёт. Проверяется тестом
|
||||
`test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`.
|
||||
|
||||
ЧТО ЭТО НЕ ОТМЕНЯЕТ. Внутри полосы отбор по величине ошибки по-прежнему
|
||||
запрещён — иначе витрина показывала бы лучший хвост уже самой полосы
|
||||
(`test_selection_ignores_error_magnitude`). Счётчики прогона считаются ДО
|
||||
полосы: `eligible` — сколько строк прогон вообще собрал, `written` — сколько
|
||||
из них прошло полосу и поместилось в `limit`. Разница между ними видна
|
||||
посетителю, и она честная ровно потому, что рядом сказано, чем именно
|
||||
отобраны показанные. Если в полосу попало меньше `limit` строк — показываем
|
||||
сколько есть; добирать соседями по ошибке нельзя, это вернуло бы отбор по
|
||||
величине ошибки в обход полосы.
|
||||
|
||||
Отбраковка по «данных нет» (нет прогноза, нет квартала, нет площади) осталась
|
||||
прежней и живёт в `build_row`: строка вне полосы ОСТАЁТСЯ кандидатом и
|
||||
попадает в счётчик `eligible`, её снимает отбор, а не отбраковка.
|
||||
ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ — И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь
|
||||
жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ
|
||||
до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в
|
||||
ключе и отбор по ошибке в фильтре — одно и то же действие, и второе даже
|
||||
злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение
|
||||
больше 40% — это почти всегда занижение ДКП ради налога» не держится: см.
|
||||
следующий раздел, грубые занижения вырезаны выше по потоку и по свойству
|
||||
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
|
||||
квартала, нет площади) — «число некрасивое» причиной не является.
|
||||
|
||||
Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность,
|
||||
схема улицы), у строки заполнено. Свежесть — порядок квартала сделки.
|
||||
|
|
@ -67,10 +57,8 @@
|
|||
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
|
||||
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
|
||||
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
|
||||
всё, что после этого дало большую ошибку, — работа оценщика. С 2026-09-12
|
||||
такая строка на витрину не выходит (полоса), но остаётся в `eligible` и
|
||||
в подписи названа отобранной, а не несуществующей. Своей копии диапазона
|
||||
здесь нет намеренно: прежние
|
||||
всё, что после этого дало большую ошибку, — работа оценщика, и витрина
|
||||
обязана её показать. Своей копии диапазона здесь нет намеренно: прежние
|
||||
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
|
||||
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх
|
||||
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
|
||||
|
|
@ -87,10 +75,7 @@
|
|||
ошибки, — правило отбора выше не нарушено.
|
||||
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
||||
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
||||
сделок рассмотрено, сколько строк прогон собрал и по какому правилу из
|
||||
них отобраны показанные. С появлением полосы это перестало быть
|
||||
страховкой и стало обязательным: без счётчиков и правила отобранная
|
||||
двадцатка читается как вся сверка. Поэтому итог
|
||||
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
|
||||
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
|
||||
ручкой `/api/public/mera/showcase` вместе со строками.
|
||||
|
||||
|
|
@ -119,47 +104,18 @@ from app.services.street_scheme import StreetIndex, build_street_scheme, load_st
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Полоса расхождения: что показываем и что об этом сказано ─────────────────
|
||||
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
|
||||
#
|
||||
# Границы ВКЛЮЧИТЕЛЬНЫЕ. Полоса несимметрична намеренно: решение владельца от
|
||||
# 2026-09-12 — показывать сделки, где МЕРА не занизила больше чем на 5 % и не
|
||||
# завысила больше чем на 20 %.
|
||||
BAND_MIN_ERR_PCT = -5.0
|
||||
BAND_MAX_ERR_PCT = 20.0
|
||||
|
||||
# Подпись полосы ВЫВОДИТСЯ из границ, а не вписывается рядом: «−5 %…+20 %» в
|
||||
# тексте и `>= -5.0` в коде — две независимые величины, и разъедутся они
|
||||
# ровно тогда, когда порог однажды подвинут.
|
||||
BAND_LABEL = f"от {BAND_MIN_ERR_PCT:+.0f} % до {BAND_MAX_ERR_PCT:+.0f} % включительно"
|
||||
|
||||
|
||||
def in_band(err_pct: float) -> bool:
|
||||
"""Попадает ли расхождение в показываемую полосу (границы включительно)."""
|
||||
return BAND_MIN_ERR_PCT <= err_pct <= BAND_MAX_ERR_PCT
|
||||
|
||||
|
||||
# ── Правило отбора и отбраковки: одна формулировка, она же едет на фронт ──────
|
||||
#
|
||||
# Санитарный диапазон ₽/м² применён выше по потоку, в `_load_sample`;
|
||||
# дублировать его тут значило бы завести проверку, которая в проде не
|
||||
# срабатывает никогда.
|
||||
#
|
||||
# ТЕКСТ ОБЯЗАН НАЗЫВАТЬ ПОЛОСУ. Пока фильтра не было, здесь стояло «величина
|
||||
# отклонения на отбор и отбраковку не влияет — иначе витрина показывала бы
|
||||
# лучший хвост, а не работу расчёта». С фильтром эта фраза стала ложью ровно
|
||||
# про то, чего опасалась, поэтому она снята, а не смягчена.
|
||||
# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный
|
||||
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
|
||||
# значило бы завести проверку, которая в проде не срабатывает никогда.
|
||||
REJECTION_RULE = (
|
||||
f"На витрине — ОТОБРАННАЯ полоса расхождения, а не вся сверка: показаны "
|
||||
f"только сделки, у которых расхождение прогноза с ценой ДКП лежит {BAND_LABEL}. "
|
||||
"Промахи крупнее полосы в данных есть, и здесь их не видно — судить по этим "
|
||||
"строкам о точности расчёта нельзя, для этого есть медиана расхождения по "
|
||||
"всей сверке. Внутри полосы порядок задают полнота данных и свежесть "
|
||||
"квартала: величина отклонения на него не влияет, лучший хвост самой полосы "
|
||||
"витрина тоже не показывает. Кроме полосы строку снимает только отсутствие "
|
||||
"данных: расчёт МЕРЫ не дал ожидаемой цены продажи (мало аналогов), "
|
||||
"неизвестен квартал сделки или площадь. Санитарный диапазон цены сделки "
|
||||
"(30 000–600 000 ₽/м² для Екатеринбурга) применён к выборке до расчёта, по "
|
||||
"цене самой сделки."
|
||||
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
|
||||
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
|
||||
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
|
||||
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
|
||||
"диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён "
|
||||
"к выборке до расчёта, по цене самой сделки."
|
||||
)
|
||||
|
||||
NOTE = (
|
||||
|
|
@ -228,7 +184,7 @@ def completeness(row: ShowcaseRow) -> int:
|
|||
|
||||
|
||||
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
||||
"""Ключ ранжирования. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге."""
|
||||
"""Ключ отбора. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге модуля."""
|
||||
return (
|
||||
-completeness(row),
|
||||
-(row.deal_date or date.min).toordinal(),
|
||||
|
|
@ -237,25 +193,8 @@ def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
|||
|
||||
|
||||
def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]:
|
||||
"""Строки полосы −5 %..+20 %, до `limit` штук, по полноте и свежести.
|
||||
|
||||
ДВА ДЕЙСТВИЯ, И ОНИ РАЗНЫЕ. Сначала ФИЛЬТР по величине расхождения
|
||||
(`in_band`) — это и есть «витрина показывает отобранную полосу, а не всю
|
||||
сверку», названное так же в `REJECTION_RULE` и в подписи под таблицей.
|
||||
Потом РАНЖИРОВАНИЕ уцелевших по полноте данных и свежести квартала —
|
||||
внутри полосы величина ошибки на порядок не влияет, иначе показывался бы
|
||||
лучший хвост уже самой полосы.
|
||||
|
||||
Фильтр стоит ЗДЕСЬ, а не в `build_row`, намеренно: строка вне полосы
|
||||
обязана остаться кандидатом и попасть в счётчик `eligible`. Отбраковав её
|
||||
раньше, мы получили бы «показано 20 из 20 годных» — счётчик, из которого
|
||||
отбор не виден вообще.
|
||||
|
||||
В полосе меньше `limit` строк — возвращаем сколько есть. Добирать
|
||||
ближайшими по ошибке нельзя: это тот же отбор по величине ошибки, просто
|
||||
с другой стороны.
|
||||
"""
|
||||
return sorted((r for r in rows if in_band(r.err_pct)), key=_sort_key)[:limit]
|
||||
"""Отобрать `limit` строк по полноте и свежести (НЕ по величине ошибки)."""
|
||||
return sorted(rows, key=_sort_key)[:limit]
|
||||
|
||||
|
||||
def build_row(
|
||||
|
|
@ -278,11 +217,8 @@ def build_row(
|
|||
|
||||
Причины отказа ИСЧЕРПЫВАЮЩИЕ и все — «данных нет»: спайн не дал ожидаемой
|
||||
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
|
||||
(делить не на что). Величина отклонения причиной отказа НЕ является ни при
|
||||
каких значениях: строка с любым промахом становится кандидатом и попадает
|
||||
в счётчик `eligible`. Полоса, по которой из кандидатов отбираются
|
||||
показанные, применяется позже и в другом месте — `select_rows`; здесь её
|
||||
нет намеренно, иначе отбор перестал бы быть виден в счётчиках.
|
||||
(делить не на что). Величина отклонения причиной НЕ является ни при каких
|
||||
значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
|
||||
|
||||
ФАКТ — ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
|
||||
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
|
||||
|
|
@ -448,14 +384,9 @@ def refresh_landing_showcase_deals(
|
|||
priced из них оценщик дал ожидаемую цену продажи
|
||||
no_prediction не дал (мало аналогов / спайн упал)
|
||||
incomplete цена есть, но нет квартала/площади — строку не собрать
|
||||
eligible строк СОБРАНО всего, ДО полосы (данных хватило)
|
||||
written из них показано: прошли полосу и поместились в `limit`
|
||||
eligible годных строк ВСЕГО (никакого отсева по ошибке нет)
|
||||
written из них показано (обрезано по `limit`)
|
||||
with_district у скольких показанных удалось определить район
|
||||
|
||||
`eligible` минус `written` — это НЕ «столько не поместилось»: с 2026-09-12
|
||||
в разницу входят и строки вне полосы −5 %..+20 %. Поэтому подпись под
|
||||
таблицей называет `eligible` собранными строками, а чем отобраны
|
||||
показанные — говорит `REJECTION_RULE`, который едет тем же ответом.
|
||||
"""
|
||||
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
|
||||
# его зависимостями, а web-процессу это на импорте приложения не нужно.
|
||||
|
|
@ -513,16 +444,6 @@ def refresh_landing_showcase_deals(
|
|||
candidates.append(row)
|
||||
|
||||
chosen = select_rows(candidates, limit)
|
||||
# Сколько собранных строк вообще попало в полосу — в лог, а не в счётчики:
|
||||
# колонки под него в `landing_showcase_runs` нет, а без него по `written`
|
||||
# не отличить «полоса оставила мало» от «упёрлись в limit».
|
||||
logger.info(
|
||||
"в полосе %s: %d из %d собранных, показано %d",
|
||||
BAND_LABEL,
|
||||
sum(1 for r in candidates if in_band(r.err_pct)),
|
||||
len(candidates),
|
||||
len(chosen),
|
||||
)
|
||||
schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
|
||||
|
||||
db.execute(_DELETE_SQL)
|
||||
|
|
@ -567,7 +488,7 @@ def refresh_landing_showcase_deals(
|
|||
|
||||
logger.info(
|
||||
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
|
||||
"собрано=%d записано=%d с_районом=%d",
|
||||
"годных=%d записано=%d с_районом=%d",
|
||||
counters["considered"],
|
||||
counters["priced"],
|
||||
counters["no_prediction"],
|
||||
|
|
|
|||
|
|
@ -1,34 +1,22 @@
|
|||
"""Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50).
|
||||
"""Импорт московского сырья (`msk_raw.*_latest`) в `listings`.
|
||||
|
||||
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
|
||||
несёт `payload` — сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
|
||||
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
|
||||
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`.
|
||||
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`.
|
||||
|
||||
Отбор региона (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=cian). Адрес карточки Циана города НЕ содержит, зато
|
||||
начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
|
||||
опознаётся. Замер по проду (60 464 карточки): с округом — 35 551, ВСЕ внутри
|
||||
bbox региона 77; без округа внутри bbox — 17 576 (это Московская область, регион
|
||||
50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox —
|
||||
7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» — артефакт
|
||||
парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва».
|
||||
|
||||
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес — голая улица с
|
||||
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
|
||||
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
|
||||
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр —
|
||||
для ЛЮБОГО целевого региона.
|
||||
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр.
|
||||
|
||||
Два сигнала, и оба нужны — ни один по отдельности не годится.
|
||||
|
||||
|
|
@ -54,16 +42,16 @@ mytishchi 1288, podolsk 756, odintsovo 738, …) — 0 карточек с пр
|
|||
qc_geo=0 у 97% найденных.
|
||||
|
||||
Что куда едет:
|
||||
* регион == `--region` → в `listings`, С координатами (geom есть сразу,
|
||||
radius-подбор аналогов работает без ожидания `geocode_missing`);
|
||||
* адрес разрешился, но регион ответа не совпал с `--region` → не пишется,
|
||||
лежит не в воздухе, а строкой в `msk_raw.avito_geocode` — прогон с другим
|
||||
`--region` подхватит её из кэша без единого внешнего вызова;
|
||||
* регион 77 → в `listings`, С координатами (geom есть сразу, radius-подбор
|
||||
аналогов работает без ожидания `geocode_missing`);
|
||||
* регион 50 → НЕ пишется, ждёт появления региона 50 в реестре; лежит не в
|
||||
воздухе, а строкой в `msk_raw.avito_geocode` (region_code=50) — когда
|
||||
регион появится, прогон по этой полке уже не потребует внешних вызовов;
|
||||
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33») →
|
||||
свой счётчик, карточка не пишется.
|
||||
|
||||
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
|
||||
целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает.
|
||||
Москву вперемешку с областью и БЕЗ geom. Молча он по-прежнему не срабатывает.
|
||||
|
||||
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
|
||||
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
|
||||
|
|
@ -83,43 +71,17 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
|
|||
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
|
||||
одинаковы.
|
||||
|
||||
Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и
|
||||
Отбор Москвы (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 # аварийный
|
||||
"""
|
||||
|
||||
|
|
@ -129,7 +91,6 @@ import argparse
|
|||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
|
@ -141,18 +102,15 @@ 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'ит).
|
||||
# Регион 50 (Московская область) в реестре `app.services.regions` ещё не заведён —
|
||||
# карточки области не пишутся, а откладываются (см. докстринг модуля).
|
||||
OBLAST_REGION_CODE = 50
|
||||
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
|
||||
DEFAULT_BATCH_SIZE = 500
|
||||
|
||||
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
|
||||
|
|
@ -202,7 +160,6 @@ SOURCE_VIEWS = {
|
|||
"cian": "msk_raw.cian_latest",
|
||||
"avito": "msk_raw.avito_latest",
|
||||
"yandex": "msk_raw.yandex_latest",
|
||||
"domclick": "msk_raw.domclick_latest",
|
||||
}
|
||||
|
||||
_PAGE_SQL = """
|
||||
|
|
@ -458,16 +415,14 @@ class ImportCounters:
|
|||
|
||||
read: int = 0
|
||||
skipped_artifact: int = 0
|
||||
# Карточка сама говорит про другой регион (префикс округа / поддомен
|
||||
# Циана / компонент адреса не совпал с целевым `--region`).
|
||||
skipped_not_target_region: int = 0
|
||||
skipped_not_moscow: int = 0
|
||||
skipped_invalid: int = 0
|
||||
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой
|
||||
# регион — адрес разрешён, дом реальный, просто регион в ответе DaData не
|
||||
# совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился
|
||||
# бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а
|
||||
# не «в целевом регионе стало меньше домов».
|
||||
skipped_geo_other_region: int = 0
|
||||
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Область —
|
||||
# адрес разрешён, дом реальный, просто регион 50 (ждёт реестра). Не
|
||||
# разрешён — DaData дома не нашла ИЛИ кончился бюджет вызовов; всплеск
|
||||
# этого счётчика читается как «проверь квоту», а не «в Москве стало меньше
|
||||
# домов».
|
||||
skipped_oblast: int = 0
|
||||
skipped_ungeocoded: int = 0
|
||||
selected: int = 0
|
||||
inserted: int = 0
|
||||
|
|
@ -494,8 +449,8 @@ class ImportCounters:
|
|||
self.read
|
||||
== self.selected
|
||||
+ self.skipped_artifact
|
||||
+ self.skipped_not_target_region
|
||||
+ self.skipped_geo_other_region
|
||||
+ self.skipped_not_moscow
|
||||
+ self.skipped_oblast
|
||||
+ self.skipped_ungeocoded
|
||||
+ self.skipped_invalid
|
||||
)
|
||||
|
|
@ -513,37 +468,6 @@ def is_moscow_address(address: str | None) -> bool:
|
|||
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:
|
||||
"""У Яндекса регион — второй компонент полного адреса.
|
||||
|
||||
|
|
@ -561,99 +485,11 @@ def is_moscow_yandex_address(address: str | None) -> bool:
|
|||
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,
|
||||
# Источники, у которых город виден в самой карточке. Ключ отсутствует —
|
||||
# источник про город молчит, и без пред-геокода писать его нельзя (avito).
|
||||
CITY_FILTERS = {
|
||||
"cian": is_moscow_address,
|
||||
"yandex": is_moscow_yandex_address,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -702,7 +538,6 @@ 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,
|
||||
|
|
@ -711,38 +546,32 @@ def import_msk_raw(
|
|||
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))
|
||||
# Источник, который сам говорит про город: у Циана это префикс округа, у
|
||||
# Яндекса — второй компонент полного адреса. Авито не говорит ничего, ему
|
||||
# нужен пред-геокод, поэтому его в словаре нет.
|
||||
city_filter = CITY_FILTERS.get(source)
|
||||
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 (штатный путь), "
|
||||
f"source={source}: адрес не содержит признака города, Москву от "
|
||||
"области не отличить. Нужен --geocode (штатный путь), "
|
||||
"--allow-unfiltered (аварийный) или --dry-run."
|
||||
)
|
||||
logger.warning(
|
||||
"source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет "
|
||||
"вовсе; строки лягут без geom и вперемешку с прочими регионами",
|
||||
"source=%s: пред-геокод ВЫКЛЮЧЕН — фильтра по городу нет вовсе; "
|
||||
"строки лягут без geom и вперемешку с областью",
|
||||
source,
|
||||
region,
|
||||
)
|
||||
if geocode and not dry_run:
|
||||
db.execute(text(_GEO_CACHE_DDL))
|
||||
|
|
@ -758,16 +587,16 @@ def import_msk_raw(
|
|||
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
|
||||
if city_filter is not None and not city_filter(address):
|
||||
counters.skipped_not_moscow += 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
|
||||
if point.region_code != MOSCOW_REGION_CODE:
|
||||
counters.skipped_oblast += 1
|
||||
continue
|
||||
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
|
||||
# трогаем, пересбор корпуса от этого не зависит. geom появляется
|
||||
|
|
@ -789,26 +618,19 @@ def import_msk_raw(
|
|||
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,
|
||||
region_code=MOSCOW_REGION_CODE,
|
||||
city=MOSCOW_CITY,
|
||||
)
|
||||
counters.inserted += inserted
|
||||
counters.updated += updated
|
||||
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
|
||||
logger.info(
|
||||
"msk_raw %s region=%d: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
|
||||
"msk_raw %s: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
|
||||
source,
|
||||
region,
|
||||
counters.read,
|
||||
counters.selected,
|
||||
counters.written,
|
||||
|
|
@ -826,12 +648,11 @@ def import_msk_raw(
|
|||
geocode_limit,
|
||||
)
|
||||
logger.info(
|
||||
"msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
|
||||
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d "
|
||||
"геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
|
||||
"msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
|
||||
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d "
|
||||
"область(50)=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
|
||||
"геокод-вызовов=%d | сходится=%s",
|
||||
source,
|
||||
region,
|
||||
" (dry-run)" if dry_run else "",
|
||||
counters.read,
|
||||
counters.selected,
|
||||
|
|
@ -839,8 +660,8 @@ def import_msk_raw(
|
|||
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_not_moscow,
|
||||
counters.skipped_oblast,
|
||||
counters.skipped_ungeocoded,
|
||||
counters.skipped_artifact,
|
||||
counters.skipped_invalid,
|
||||
|
|
@ -855,30 +676,21 @@ def main() -> None:
|
|||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Импорт сырья msk_raw в listings (регион задаётся --region, по умолчанию 77)"
|
||||
)
|
||||
parser = argparse.ArgumentParser(description="Импорт сырья msk_raw в listings (регион 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",
|
||||
help="АВАРИЙНЫЙ режим: писать avito без фильтра по городу и без geom",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geocode",
|
||||
action="store_true",
|
||||
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
|
||||
"в listings уходит только целевой регион (--region), прочее пропускается",
|
||||
"в listings уходит только регион 77, область откладывается",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geocode-limit",
|
||||
|
|
@ -894,7 +706,6 @@ def main() -> None:
|
|||
import_msk_raw(
|
||||
db,
|
||||
source=args.source,
|
||||
region=args.region,
|
||||
batch_size=args.batch_size,
|
||||
limit=args.limit,
|
||||
dry_run=args.dry_run,
|
||||
|
|
|
|||
|
|
@ -427,7 +427,7 @@ async def run_yandex_detail_backfill(
|
|||
)
|
||||
if consecutive_blocks >= max_consecutive_blocks:
|
||||
aborted_by_blocks = True
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"yandex_detail_backfill: run_id=%d ABORT -- %d consecutive "
|
||||
"non-200 responses. enriched=%d attempted=%d",
|
||||
run_id,
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ async def enrich_yandex_newbuilding_sweep(
|
|||
if caused_by_no_proxy(exc):
|
||||
result.no_proxy_stop = True
|
||||
result.failed_resolve += 1
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
|
||||
"house_id=%s processed=%d succeeded=%d",
|
||||
house_id,
|
||||
|
|
@ -419,7 +419,7 @@ async def enrich_yandex_newbuilding_sweep(
|
|||
if caused_by_no_proxy(exc): # #3197 — см. блок resolve выше
|
||||
result.no_proxy_stop = True
|
||||
result.failed_fetch += 1
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
|
||||
"house_id=%s processed=%d succeeded=%d",
|
||||
house_id,
|
||||
|
|
|
|||
|
|
@ -26,12 +26,13 @@ import logging
|
|||
import os
|
||||
import signal
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown
|
||||
from app.services.tgbot.bridge import run_poll_loop
|
||||
from app.services.tgbot.client import TelegramClient, verify_chat_and_topic
|
||||
from app.services.tgbot.client import TelegramClient
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
|
@ -56,20 +57,18 @@ if settings.glitchtip_dsn:
|
|||
import sentry_sdk
|
||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.types import Event, Hint
|
||||
|
||||
from app.observability.sentry_scrub import (
|
||||
drop_payments_disabled_event,
|
||||
redact_telegram_bot_token,
|
||||
scrub_payment_request_body,
|
||||
scrub_pii_event,
|
||||
)
|
||||
|
||||
def _before_send(event: Event, hint: Hint) -> Event | None:
|
||||
"""Композиция payments-disabled drop (#3471) + платёжный body-wipe (PR-D2) +
|
||||
PII-scrub (form-данные) + Telegram bot-токен redaction (#tgsupport review).
|
||||
Токен утекает ДВУМЯ независимыми векторами, которые
|
||||
`include_local_variables=False` ниже и этот хук закрывают вместе:
|
||||
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
|
||||
"""Композиция платёжный body-wipe (PR-D2) + PII-scrub (form-данные) +
|
||||
Telegram bot-токен redaction (#tgsupport review). Токен утекает ДВУМЯ
|
||||
независимыми векторами, которые `include_local_variables=False` ниже и
|
||||
этот хук закрывают вместе:
|
||||
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
|
||||
locals (`self._base`/`url` в `TelegramClient._request`) в traceback —
|
||||
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
|
||||
|
|
@ -79,22 +78,18 @@ if settings.glitchtip_dsn:
|
|||
— belt-and-suspenders на случай #1 (если include_local_variables
|
||||
случайно вернут) И на span data.
|
||||
|
||||
Payments-disabled drop и платёжный body-wipe — belt-and-suspenders: этот
|
||||
процесс не держит ASGI-приложения (нет `request`/HTTPException в event
|
||||
сегодня, реальный источник 503 — app/main.py), но тот же обработчик
|
||||
передан ОБОИМ каналам ниже (before_send/before_send_transaction) ради
|
||||
единообразия со всеми точками инициализации sentry_sdk в проекте.
|
||||
Платёжный body-wipe — belt-and-suspenders: этот процесс не держит ASGI-
|
||||
приложения (нет `request` в event сегодня), но тот же обработчик передан
|
||||
ОБОИМ каналам ниже (before_send/before_send_transaction) ради единообразия
|
||||
со всеми точками инициализации sentry_sdk в проекте (см. app/main.py).
|
||||
"""
|
||||
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
|
||||
if dropped is None:
|
||||
return None
|
||||
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
|
||||
scrubbed = scrub_payment_request_body(event, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||
return redact_telegram_bot_token(scrubbed, hint)
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
|
|
@ -119,34 +114,8 @@ def _should_run() -> bool:
|
|||
|
||||
|
||||
async def _run_bridge() -> None:
|
||||
# `async with` — чтобы пул keep-alive соединений закрывался при любом выходе
|
||||
# из поллинга (кооперативный drain по SIGTERM, hard-cancel, исключение).
|
||||
# Клиент один на весь процесс: пересоздание на запрос убивало keep-alive и
|
||||
# заставляло каждый long-poll начинаться с TCP+TLS-хендшейка.
|
||||
async with TelegramClient(
|
||||
settings.telegram_bot_token,
|
||||
relay_base_url=settings.telegram_relay_base_url,
|
||||
relay_secret=settings.telegram_relay_secret,
|
||||
# Бот-роль (review H2, #3471) — своя, меньшая доля общего бюджета
|
||||
# группы; см. докстринг настройки в app.core.config.
|
||||
group_rate_limit_per_minute=settings.telegram_group_rate_limit_bot_per_minute,
|
||||
) as client:
|
||||
# Startup-проверка (#3471): убеждаемся ОДИН раз, что чат/тема живы,
|
||||
# прежде чем уходить в бесконечный poll loop. Не блокирует и не роняет
|
||||
# запуск при неудаче — см. докстринг `verify_chat_and_topic`.
|
||||
await verify_chat_and_topic(
|
||||
client,
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
topic_id=settings.telegram_support_topic_id,
|
||||
label="support",
|
||||
)
|
||||
await verify_chat_and_topic(
|
||||
client,
|
||||
chat_id=settings.telegram_alerts_chat_id,
|
||||
topic_id=settings.telegram_alerts_topic_id,
|
||||
label="alerts",
|
||||
)
|
||||
await run_poll_loop(client, SessionLocal)
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
await run_poll_loop(client, SessionLocal)
|
||||
|
||||
|
||||
async def _await_bridge(task: asyncio.Task[None]) -> None:
|
||||
|
|
|
|||
|
|
@ -1,219 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -4,10 +4,8 @@
|
|||
`--strict-markers` в pyproject.toml не включён, так что незарегистрированный
|
||||
маркер только предупреждал бы) и сторожит глобальное состояние, которое
|
||||
переживает отдельный тест: общий rate-limiter POST /estimate (см.
|
||||
`_reset_estimate_rate_limiter`), слоты проверки пароля (см.
|
||||
`_no_leaked_password_verify_slots`) и синглтон Telegram-клиента (см.
|
||||
`_reset_telegram_shared_client`, #3471 — иначе его rate limiter копит
|
||||
реальное время между тестами и вешает прогон).
|
||||
`_reset_estimate_rate_limiter`) и слоты проверки пароля (см.
|
||||
`_no_leaked_password_verify_slots`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -50,35 +48,6 @@ 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)
|
||||
def _no_leaked_password_verify_slots():
|
||||
"""Тест не оставляет за собой занятых слотов проверки пароля (#2665, #2714).
|
||||
|
|
@ -181,20 +150,3 @@ def pytest_sessionfinish(session, exitstatus) -> None:
|
|||
)
|
||||
if exitstatus == 0:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -420,10 +420,8 @@ def test_all_candidates_banned_raises_pool_exhausted_not_env_fallback(
|
|||
assert exc_info.value.pool_total == 2
|
||||
assert exc_info.value.banned_for_source == 2
|
||||
assert exc_info.value.unhealthy_or_disabled == 0
|
||||
# fail-closed без здорового узла — штатный исход скрапинга, а не инцидент;
|
||||
# понижено до 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)
|
||||
errors = [rec for rec in caplog.records if rec.levelname == "ERROR"]
|
||||
assert any("fail-closed" in rec.message.lower() for rec in errors)
|
||||
# НЕ должно быть "обход пула" / "static-fallback" в логах — env не тронут.
|
||||
assert not any("static-fallback" in rec.message for rec in caplog.records)
|
||||
|
||||
|
|
@ -431,10 +429,8 @@ def test_all_candidates_banned_raises_pool_exhausted_not_env_fallback(
|
|||
def test_exhausted_and_empty_pool_log_texts_are_distinct(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Регрессия на замечание ревью: "пуст" и "все отсеяны" — РАЗНЫЕ формулировки,
|
||||
иначе их нельзя различить в логах/алертах. Оба сценария — штатный исход
|
||||
скрапинга, поэтому оба теперь warning (было WARNING vs ERROR), различимость
|
||||
держится на тексте, не на уровне."""
|
||||
"""Регрессия на замечание ревью: "пуст" и "все отсеяны" — РАЗНЫЕ формулировки И
|
||||
разные уровни (WARNING vs ERROR), иначе их нельзя различить в логах/алертах."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
|
|
@ -456,8 +452,8 @@ def test_exhausted_and_empty_pool_log_texts_are_distinct(
|
|||
exhausted_messages = {rec.levelname: rec.message for rec in caplog.records}
|
||||
|
||||
assert "ERROR" not in empty_pool_messages
|
||||
assert "ERROR" not in exhausted_messages
|
||||
assert empty_pool_messages.get("WARNING") != exhausted_messages.get("WARNING")
|
||||
assert "ERROR" in exhausted_messages
|
||||
assert empty_pool_messages.get("WARNING") != exhausted_messages.get("ERROR")
|
||||
|
||||
|
||||
def test_disabled_proxy_raises_pool_exhausted() -> None:
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
|||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services import proxy_pool
|
||||
|
|
@ -1528,74 +1527,3 @@ def test_clear_source_bans_resets_escalation() -> None:
|
|||
assert ban["ban_count"] == 1
|
||||
expected = datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS)
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -22,12 +22,6 @@ Coverage (per task spec + review follow-up):
|
|||
- сбой БД (SQLAlchemyError) во время обработки → rollback() ПЕРЕД save_offset,
|
||||
offset всё равно сдвигается — без этого следующий поллинг переиграл бы тот
|
||||
же апдейт и задублировал зеркало клиента в топике (#3 review)
|
||||
- ТРАНЗИЕНТНЫЙ сетевой отказ (TelegramNetworkError) на доставке ответа оператора
|
||||
→ offset НЕ сдвигается, апдейт переигрывается и доходит до клиента; на потолке
|
||||
`_MAX_NETWORK_REPLAYS` offset всё-таки сдвигается (поток не заклинен); poll loop
|
||||
прерывает разбор пачки на неподтверждённом апдейте (#tg-connection-resilience)
|
||||
- провал ВТОРИЧНОГО уведомления оператору в топик не отменяет основную ветку
|
||||
(is_blocked остаётся, offset сдвигается) и логируется отдельной строкой
|
||||
- /start → приветствие без зеркалирования
|
||||
- Telegram 403 на доставку оператору → is_blocked + уведомление в топике
|
||||
- TELEGRAM_SUPPORT_CHAT_ID не задан → клиенту уходит "сервис недоступен"
|
||||
|
|
@ -51,7 +45,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.tgbot import bridge
|
||||
from app.services.tgbot.client import TelegramClient, TelegramNetworkError
|
||||
from app.services.tgbot.client import TelegramClient
|
||||
|
||||
SUPPORT_CHAT_ID = -100123456789
|
||||
SUPPORT_TOPIC_ID = 42
|
||||
|
|
@ -80,10 +74,6 @@ class FakeBridgeStorage:
|
|||
self._next_id = 1
|
||||
self.clock_s: float = 0.0
|
||||
self.fail_next_record_message = False
|
||||
# #3471 P0: следующий вызов record_web_out_message кидает SQLAlchemyError
|
||||
# (симулирует обрыв коннекта к БД на веб-ветке — для веб-треда эта запись
|
||||
# И ЕСТЬ доставка клиенту) и сбрасывается в False.
|
||||
self.fail_next_record_web_out_message = False
|
||||
# #tgsupport-web: web_support_messages-эквивалент, topic_message_id ->
|
||||
# (thread_id, support_chat_id) — второй элемент моделирует колонку
|
||||
# web_support_messages.support_chat_id (review M1); None = легаси wildcard.
|
||||
|
|
@ -162,11 +152,9 @@ class FakeBridgeStorage:
|
|||
|
||||
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
|
||||
"""support_chat_id-скоуп (review M1): запись со ЧУЖИМ (не None, не текущим)
|
||||
support_chat_id не матчится — None (легаси/дефолт) матчится всегда.
|
||||
БЕЗ фильтра по direction (#3471 P0) — реплай на СВОЙ предыдущий ответ
|
||||
(direction='out') резолвится так же, как реплай на зеркало клиента."""
|
||||
support_chat_id не матчится — None (легаси/дефолт) матчится всегда."""
|
||||
for m in reversed(self.messages):
|
||||
if m["topic_message_id"] != topic_message_id:
|
||||
if m["direction"] != "in" or m["topic_message_id"] != topic_message_id:
|
||||
continue
|
||||
entry_chat_id = m.get("support_chat_id")
|
||||
if entry_chat_id is not None and entry_chat_id != support_chat_id:
|
||||
|
|
@ -191,32 +179,15 @@ class FakeBridgeStorage:
|
|||
return thread_id
|
||||
|
||||
def record_web_out_message(
|
||||
self,
|
||||
*,
|
||||
thread_id: int,
|
||||
text_body: str,
|
||||
operator_tg_id: int | None,
|
||||
topic_message_id: int | None = None,
|
||||
support_chat_id: int | None = None,
|
||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||
) -> None:
|
||||
if self.fail_next_record_web_out_message:
|
||||
self.fail_next_record_web_out_message = False
|
||||
raise SQLAlchemyError("simulated DB failure (deploy connection reset)")
|
||||
self.web_out_messages.append(
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"text_body": text_body,
|
||||
"operator_tg_id": operator_tg_id,
|
||||
"topic_message_id": topic_message_id,
|
||||
"support_chat_id": support_chat_id,
|
||||
}
|
||||
)
|
||||
# Зеркалим в `web_topic_to_thread` (deep review PR #3479) — реальный
|
||||
# `record_outbound` пишет ту же строку в web_support_messages, которую
|
||||
# потом читает `find_thread_by_topic_message`; без этого фейк не мог бы
|
||||
# поймать баг "out-строка с support_chat_id=NULL — вечный wildcard".
|
||||
if topic_message_id is not None:
|
||||
self.web_topic_to_thread[topic_message_id] = (thread_id, support_chat_id)
|
||||
|
||||
|
||||
# ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ───────────
|
||||
|
|
@ -878,146 +849,6 @@ async def test_group_reply_refuses_delivery_when_both_tg_and_web_match(
|
|||
assert storage.get_offset() == 62
|
||||
|
||||
|
||||
async def test_group_reply_to_web_mirror_db_failure_notifies_operator_and_advances_offset() -> None:
|
||||
"""#3471 P0: сбой БД на `record_web_out_message` — для веб-треда эта запись И
|
||||
ЕСТЬ доставка клиенту (веб-фронт читает её polling'ом), поэтому тихий откат
|
||||
означал бы навсегда потерянный ответ оператора (воспроизведено на проде
|
||||
31.08.2026 — клиент kopylov). Теперь: rollback → уведомление оператору
|
||||
реплаем в топик, что ответ НЕ доставлен → offset всё равно сдвигается (та же
|
||||
политика, что у любого другого `SQLAlchemyError` в `process_update` — сбой
|
||||
БД не переигрывается, human-in-the-loop retry заменяет технический) →
|
||||
исключение наружу НЕ улетает."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.web_topic_to_thread[310] = (50, SUPPORT_CHAT_ID)
|
||||
storage.fail_next_record_web_out_message = True
|
||||
|
||||
update = {
|
||||
"update_id": 70,
|
||||
"message": _group_reply_message(reply_to_message_id=310, message_id=210),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
# Ответ НЕ попал в web_out_messages (запись упала), но и не потерян молча.
|
||||
assert storage.web_out_messages == []
|
||||
assert storage.rollbacks == 1
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["sendMessage"]
|
||||
notice = calls[0][1]
|
||||
assert notice["chat_id"] == SUPPORT_CHAT_ID
|
||||
assert notice["reply_to_message_id"] == 210
|
||||
assert "НЕ доставлен" in notice["text"]
|
||||
# Сбой БД не переигрывается (общая политика SQLAlchemyError) — offset сдвинут и закоммичен.
|
||||
assert storage.get_offset() == 70
|
||||
assert storage.commits == 1
|
||||
|
||||
|
||||
async def test_group_reply_to_web_mirror_db_failure_and_notify_failure_logs_error(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""#3471 P0: сбой БД НА веб-ответе, а следом ещё и уведомление оператору не
|
||||
ушло (Telegram недоступен) — полная тишина по обоим каналам. `process_update`
|
||||
всё равно не падает и offset сдвигает, но остаётся `logger.error` с
|
||||
идентификаторами (thread_id/message_id), НЕ текстом — по нему человек найдёт
|
||||
ответ оператора в топике вручную."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
# 403 (не 5xx) — не ретраится клиентом, `_notify_topic` падает быстро и
|
||||
# детерминированно (без реальных retry-пауз).
|
||||
client = _make_client({"sendMessage": 403}, calls)
|
||||
|
||||
storage = FakeBridgeStorage()
|
||||
storage.web_topic_to_thread[311] = (51, SUPPORT_CHAT_ID)
|
||||
storage.fail_next_record_web_out_message = True
|
||||
|
||||
update = {
|
||||
"update_id": 71,
|
||||
"message": _group_reply_message(reply_to_message_id=311, message_id=211),
|
||||
}
|
||||
with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert storage.web_out_messages == []
|
||||
assert "потерян молча" in caplog.text
|
||||
assert "51" in caplog.text # thread_id узнаваем в логе
|
||||
assert storage.get_offset() == 71 # сбой БД не переигрывается — offset сдвинут
|
||||
assert storage.commits == 1
|
||||
|
||||
|
||||
async def test_group_reply_to_own_previous_web_reply_resolves_thread() -> None:
|
||||
"""#3471 P0 (пункт 3): реплай оператора на СВОЙ предыдущий веб-ответ (не на
|
||||
исходное зеркало клиента) теперь тоже резолвится — `record_outbound`
|
||||
сохраняет topic_message_id исходящей записи, `find_thread_by_topic_message`
|
||||
больше не фильтрует по direction.
|
||||
|
||||
Deep review PR #3479: новая out-строка ОБЯЗАНА писаться с ТЕКУЩИМ
|
||||
`support_chat_id`, а не NULL — NULL матчится `find_thread_by_topic_message`
|
||||
как лениентный wildcard "любой чат" (легаси до 187/188), т.е. NULL сделал бы
|
||||
КАЖДУЮ out-строку вечным wildcard-совпадением при ротации support-группы.
|
||||
Эта проверка падает на дефектной реализации (support_chat_id не передавался
|
||||
в `record_web_out_message`), даже когда resolve выше внешне "работает"."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
# Симулируем уже сохранённый предыдущий ответ оператора (topic_message_id=320)
|
||||
# так, как это сделал бы реальный record_outbound после фикса.
|
||||
storage.web_topic_to_thread[320] = (52, SUPPORT_CHAT_ID)
|
||||
|
||||
update = {
|
||||
"update_id": 72,
|
||||
"message": _group_reply_message(
|
||||
reply_to_message_id=320, message_id=220, text="Продолжение"
|
||||
),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert len(storage.web_out_messages) == 1
|
||||
assert storage.web_out_messages[0]["thread_id"] == 52
|
||||
assert storage.web_out_messages[0]["topic_message_id"] == 220
|
||||
# Deep review PR #3479: НЕ NULL — иначе эта строка стала бы вечным wildcard.
|
||||
assert storage.web_out_messages[0]["support_chat_id"] == SUPPORT_CHAT_ID
|
||||
|
||||
|
||||
async def test_group_reply_to_own_previous_tg_reply_resolves_target_chat() -> None:
|
||||
"""#3471 P0 (пункт 3): та же история для Telegram-пути — реплай оператора на
|
||||
СВОЙ предыдущий ответ клиенту (direction='out', topic_message_id теперь
|
||||
заполнен) резолвится в chat_id, а не проваливается в orphan-check.
|
||||
|
||||
Deep review PR #3479: новая out-строка ОБЯЗАНА писаться с ТЕКУЩИМ
|
||||
`support_chat_id` (симметрично in-ветке) — иначе она стала бы вечным
|
||||
wildcard в `find_chat_by_topic_message` при ротации support-группы, и
|
||||
reply мог бы увести ответ ЧУЖОМУ клиенту."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": {"message_id": 601}}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
# Предыдущий ответ оператора клиенту, зафиксированный с topic_message_id
|
||||
# (id этого ответа В ТОПИКЕ) — то, что теперь пишет TG-путь `_handle_group_reply`.
|
||||
storage.record_message(
|
||||
chat_id=555,
|
||||
direction="out",
|
||||
tg_message_id=500,
|
||||
topic_message_id=330,
|
||||
kind="text",
|
||||
text_body="Первый ответ оператора",
|
||||
operator_tg_id=777,
|
||||
support_chat_id=SUPPORT_CHAT_ID,
|
||||
)
|
||||
|
||||
update = {
|
||||
"update_id": 73,
|
||||
"message": _group_reply_message(reply_to_message_id=330, message_id=230, text="Уточнение"),
|
||||
}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["copyMessage"]
|
||||
assert calls[0][1]["chat_id"] == 555
|
||||
assert len(storage.messages) == 2 # исходный 'out' + новый 'out'
|
||||
assert storage.messages[-1]["topic_message_id"] == 230
|
||||
# Deep review PR #3479: НЕ NULL — иначе эта строка стала бы вечным wildcard.
|
||||
assert storage.messages[-1]["support_chat_id"] == SUPPORT_CHAT_ID
|
||||
|
||||
|
||||
# ── C) дедуп ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -1142,219 +973,3 @@ async def test_update_from_unrelated_chat_is_ignored_but_offset_advances() -> No
|
|||
assert calls == []
|
||||
assert storage.messages == []
|
||||
assert storage.get_offset() == 40
|
||||
|
||||
|
||||
# ── сетевая устойчивость (#tg-connection-resilience) ─────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_network_replay_attempts() -> None:
|
||||
"""`bridge._network_replay_attempts` — module-level словарь, его состояние
|
||||
иначе протекало бы между тестами (потолок переигрываний виден глобально)."""
|
||||
bridge._network_replay_attempts.clear()
|
||||
|
||||
|
||||
def _network_boom(method: str = "copyMessage"):
|
||||
"""Асинхронная заглушка метода клиента, изображающая исчерпанный бюджет ретраев."""
|
||||
|
||||
async def _raise(*_args: object, **_kwargs: object) -> dict[str, Any]:
|
||||
raise TelegramNetworkError(method, "ConnectTimeout", 4)
|
||||
|
||||
return _raise
|
||||
|
||||
|
||||
async def test_network_failure_on_operator_reply_keeps_offset_for_replay(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Главный сценарий потери: оператор ответил, Telegram в этот момент недоступен.
|
||||
|
||||
Раньше `finally: save_offset` подтверждал апдейт — ответ не доходил до клиента
|
||||
НИКОГДА (Telegram апдейт больше не отдаёт, записи нет, оператор уверен, что
|
||||
ответил). Теперь offset остаётся прежним, апдейт переигрывается и доходит.
|
||||
"""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": {"message_id": 42}}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.record_message(
|
||||
chat_id=555,
|
||||
direction="in",
|
||||
tg_message_id=1,
|
||||
topic_message_id=100,
|
||||
kind="text",
|
||||
text_body="вопрос клиента",
|
||||
operator_tg_id=None,
|
||||
)
|
||||
|
||||
update = {"update_id": 40, "message": _group_reply_message(reply_to_message_id=100)}
|
||||
|
||||
# Падает РОВНО первый copyMessage (monkeypatch.undo() тут не годится: он снял бы
|
||||
# и autouse-патчи настроек support-группы, и ветка просто перестала бы работать).
|
||||
real_copy = client.copy_message
|
||||
failures = {"left": 1}
|
||||
|
||||
async def flaky_copy(**kwargs: Any) -> dict[str, Any]:
|
||||
if failures["left"] > 0:
|
||||
failures["left"] -= 1
|
||||
raise TelegramNetworkError("copyMessage", "ConnectTimeout", 4)
|
||||
return await real_copy(**kwargs)
|
||||
|
||||
monkeypatch.setattr(client, "copy_message", flaky_copy)
|
||||
advanced = await bridge.process_update(update, client, storage)
|
||||
|
||||
assert advanced is False
|
||||
assert storage.get_offset() == 0 # апдейт НЕ подтверждён — Telegram отдаст его снова
|
||||
assert storage.commits == 0
|
||||
assert storage.rollbacks == 1 # частичные записи не уедут чужим commit'ом
|
||||
assert len(storage.messages) == 1 # фейковой записи 'out' не появилось
|
||||
|
||||
# Переигрывание: сеть починилась — тот же апдейт доставляется и подтверждается.
|
||||
advanced = await bridge.process_update(update, client, storage)
|
||||
|
||||
assert advanced is True
|
||||
assert storage.get_offset() == 40
|
||||
assert [m for m, _ in calls] == ["copyMessage"]
|
||||
out_rec = storage.messages[-1]
|
||||
assert out_rec["direction"] == "out"
|
||||
assert out_rec["chat_id"] == 555
|
||||
|
||||
|
||||
async def test_network_failure_stops_advancing_only_until_replay_cap(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Потолок переигрываний: «вечно недоставляемый» апдейт не должен заклинить
|
||||
поток навсегда (ровно то, от чего защищал прежний безусловный `finally`)."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.record_message(
|
||||
chat_id=555,
|
||||
direction="in",
|
||||
tg_message_id=1,
|
||||
topic_message_id=100,
|
||||
kind="text",
|
||||
text_body="вопрос клиента",
|
||||
operator_tg_id=None,
|
||||
)
|
||||
monkeypatch.setattr(client, "copy_message", _network_boom())
|
||||
|
||||
update = {"update_id": 41, "message": _group_reply_message(reply_to_message_id=100)}
|
||||
|
||||
for _ in range(bridge._MAX_NETWORK_REPLAYS - 1):
|
||||
assert await bridge.process_update(update, client, storage) is False
|
||||
assert storage.get_offset() == 0
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
|
||||
advanced = await bridge.process_update(update, client, storage)
|
||||
|
||||
assert advanced is True
|
||||
assert storage.get_offset() == 41 # поток разблокирован
|
||||
assert "потолок переигрываний" in caplog.text
|
||||
# Счётчик снят — словарь не растёт от апдейта к апдейту.
|
||||
assert bridge._network_replay_attempts == {}
|
||||
|
||||
|
||||
async def test_poll_loop_stops_batch_on_unconfirmed_update(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Offset у Telegram — единая «высшая отметка»: подтвердив СЛЕДУЮЩИЙ апдейт
|
||||
пачки, мы неявно подтвердили бы неудавшийся, и переигрывания не случилось бы.
|
||||
Поэтому разбор пачки обрывается на первом неподтверждённом апдейте."""
|
||||
processed: list[int] = []
|
||||
|
||||
async def fake_process(update: dict[str, Any], client: object, storage: object) -> bool:
|
||||
processed.append(update["update_id"])
|
||||
return update["update_id"] != 51 # 51 — сетевой отказ
|
||||
|
||||
monkeypatch.setattr(bridge, "process_update", fake_process)
|
||||
|
||||
iteration = {"n": 0}
|
||||
|
||||
def fake_shutdown() -> bool:
|
||||
iteration["n"] += 1
|
||||
return iteration["n"] > 1 # ровно одна итерация poll loop
|
||||
|
||||
monkeypatch.setattr(bridge, "shutdown_requested", fake_shutdown)
|
||||
monkeypatch.setattr(bridge, "SqlBridgeStorage", lambda _db: FakeBridgeStorage())
|
||||
|
||||
class _FakeSession:
|
||||
def __enter__(self) -> _FakeSession:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeClient:
|
||||
async def get_updates(self, **_kwargs: object) -> list[dict[str, Any]]:
|
||||
return [{"update_id": 51}, {"update_id": 52}, {"update_id": 53}]
|
||||
|
||||
await bridge.run_poll_loop(_FakeClient(), _FakeSession, poll_timeout_s=1)
|
||||
|
||||
assert processed == [51] # 52/53 придут заново следующим getUpdates
|
||||
|
||||
|
||||
async def test_topic_notification_failure_does_not_cancel_main_branch(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Уведомление в топик — вторичное действие: его сетевой отказ не отменяет
|
||||
пометку is_blocked и не превращает апдейт в переигрываемый."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": 403}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.record_message(
|
||||
chat_id=555,
|
||||
direction="in",
|
||||
tg_message_id=1,
|
||||
topic_message_id=100,
|
||||
kind="text",
|
||||
text_body="вопрос клиента",
|
||||
operator_tg_id=None,
|
||||
)
|
||||
monkeypatch.setattr(client, "send_message", _network_boom("sendMessage"))
|
||||
|
||||
update = {"update_id": 42, "message": _group_reply_message(reply_to_message_id=100)}
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||||
advanced = await bridge.process_update(update, client, storage)
|
||||
|
||||
assert advanced is True
|
||||
assert 555 in storage.blocked # основная ветка отработала
|
||||
assert storage.get_offset() == 42
|
||||
assert storage.commits == 1
|
||||
assert len(storage.messages) == 1
|
||||
assert "уведомление оператору в топик" in caplog.text
|
||||
assert "chat_id=555" in caplog.text # только идентификатор, без текста переписки
|
||||
|
||||
|
||||
async def test_topic_notification_uses_interactive_send_budget(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Уведомление шлётся с УЗКИМ бюджетом: воркерный дефолт (5 ретраев, backoff
|
||||
до 30с, полный retry_after на 429) застопорил бы весь poll loop на минуты."""
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
client = _make_client({"copyMessage": 403}, calls)
|
||||
storage = FakeBridgeStorage()
|
||||
storage.record_message(
|
||||
chat_id=555,
|
||||
direction="in",
|
||||
tg_message_id=1,
|
||||
topic_message_id=100,
|
||||
kind="text",
|
||||
text_body="вопрос клиента",
|
||||
operator_tg_id=None,
|
||||
)
|
||||
|
||||
sent: list[dict[str, Any]] = []
|
||||
|
||||
async def recording_send(**kwargs: Any) -> dict[str, Any]:
|
||||
sent.append(kwargs)
|
||||
return {"message_id": 1}
|
||||
|
||||
monkeypatch.setattr(client, "send_message", recording_send)
|
||||
|
||||
update = {"update_id": 43, "message": _group_reply_message(reply_to_message_id=100)}
|
||||
await bridge.process_update(update, client, storage)
|
||||
|
||||
assert len(sent) == 1
|
||||
assert sent[0]["timeout"] == bridge._NOTIFY_SEND_TIMEOUT_S
|
||||
assert sent[0]["max_retries"] == bridge._NOTIFY_SEND_MAX_RETRIES
|
||||
assert sent[0]["max_backoff"] == bridge._NOTIFY_SEND_MAX_BACKOFF_S
|
||||
assert sent[0]["chat_id"] == SUPPORT_CHAT_ID
|
||||
|
|
|
|||
|
|
@ -16,31 +16,18 @@ import pytest
|
|||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.tgbot.client import (
|
||||
_CONNECT_TIMEOUT_S,
|
||||
_POOL_TIMEOUT_S,
|
||||
_WRITE_TIMEOUT_S,
|
||||
TelegramApiError,
|
||||
TelegramClient,
|
||||
TelegramNetworkError,
|
||||
)
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
||||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
def _install_transport(handler) -> list[httpx.AsyncClient]:
|
||||
"""Подменяет транспорт. Возвращает список СОЗДАННЫХ AsyncClient — по нему
|
||||
видно, переиспользуется ли один клиент или он плодится на каждый запрос."""
|
||||
def _install_transport(handler) -> None:
|
||||
transport = httpx.MockTransport(handler)
|
||||
created: list[httpx.AsyncClient] = []
|
||||
|
||||
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||||
client = _REAL_ASYNC_CLIENT(transport=transport)
|
||||
created.append(client)
|
||||
return client
|
||||
return _REAL_ASYNC_CLIENT(transport=transport)
|
||||
|
||||
mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory).start()
|
||||
return created
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -229,258 +216,3 @@ async def test_network_error_log_keeps_text_when_exception_has_one(caplog) -> No
|
|||
assert warnings, "не было предупреждения о сетевом сбое"
|
||||
assert "ConnectTimeout" in warnings[0], f"нет типа: {warnings[0]!r}"
|
||||
assert "таймаут соединения" in warnings[0], f"текст исключения потерян: {warnings[0]!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc_type", "expected_attempts"),
|
||||
[
|
||||
(httpx.ConnectTimeout, 2),
|
||||
(httpx.RemoteProtocolError, 2),
|
||||
(httpx.ProxyError, 2),
|
||||
(httpx.DecodingError, 1),
|
||||
],
|
||||
)
|
||||
async def test_request_failure_raises_own_type_not_raw_httpx(
|
||||
exc_type: type[Exception], expected_attempts: int
|
||||
) -> None:
|
||||
"""Любой отказ запроса — наружу свой тип, а не сырой httpx.
|
||||
|
||||
Сырой httpx пролетал мимо `except TelegramApiError` во всех трёх HTTP-ручках
|
||||
и превращался в 500 вместо задуманного 502 (#3456). Первый заход закрыл
|
||||
только `(TimeoutException, NetworkError)`, а `RemoteProtocolError` («Server
|
||||
disconnected without sending a response» — бытовой ответ api.telegram.org из
|
||||
РФ), `ProxyError` и `DecodingError` — сёстры по `TransportError`/
|
||||
`RequestError`, не наследники `NetworkError`, и дыра оставалась открытой.
|
||||
|
||||
Тип отказа при этом терять нельзя — он остаётся в `__cause__`, иначе в
|
||||
GlitchTip не отличить таймаут соединения от сброса TLS.
|
||||
"""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise exc_type("сбой транспорта")
|
||||
|
||||
_install_transport(handler)
|
||||
with pytest.raises(TelegramNetworkError) as caught:
|
||||
await TelegramClient(token="t").send_message(chat_id=-1, text="x", max_retries=1)
|
||||
|
||||
assert caught.value.method == "sendMessage"
|
||||
assert caught.value.attempts == expected_attempts, "число попыток должно попасть в исключение"
|
||||
assert exc_type.__name__ in caught.value.reason
|
||||
assert isinstance(caught.value.__cause__, exc_type), "причина потеряна"
|
||||
|
||||
|
||||
async def test_remote_protocol_error_is_retried_but_decoding_error_is_not() -> None:
|
||||
"""Разница бюджета между двумя `except`: что чинится повтором, а что нет.
|
||||
|
||||
`RemoteProtocolError` — «площадка не ответила», ровно как таймаут: повтор
|
||||
осмыслен, и он наследует уже принятый здесь риск at-least-once (запрос мог
|
||||
дойти до Telegram, потерялся ответ) — тот же, что у `ReadTimeout`.
|
||||
`DecodingError` — испорченный ответ / кривая конфигурация: пять попыток с
|
||||
backoff подвесили бы интерактивную ручку почти на минуту без единого шанса
|
||||
на успех.
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
async def _attempts_for(exc_type: type[Exception]) -> int:
|
||||
counts[exc_type.__name__] = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
counts[exc_type.__name__] += 1
|
||||
raise exc_type("сбой транспорта")
|
||||
|
||||
_install_transport(handler)
|
||||
with pytest.raises(TelegramNetworkError):
|
||||
await TelegramClient(token="t").send_message(chat_id=-1, text="x", max_retries=2)
|
||||
return counts[exc_type.__name__]
|
||||
|
||||
assert await _attempts_for(httpx.RemoteProtocolError) == 3, (
|
||||
"обрыв протокола обязан ретраиться наравне с таймаутом"
|
||||
)
|
||||
assert await _attempts_for(httpx.DecodingError) == 1, (
|
||||
"битый ответ ретраить нельзя — повтор не лечит, а бюджет ручки съедает"
|
||||
)
|
||||
|
||||
|
||||
async def test_network_error_is_not_api_error() -> None:
|
||||
"""`bridge` разбирает `error_code` (403 «бот заблокирован») — недоступность
|
||||
площадки в этот разбор попадать не должна, у неё кода ответа нет вовсе."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectTimeout("таймаут соединения")
|
||||
|
||||
_install_transport(handler)
|
||||
with pytest.raises(TelegramNetworkError) as caught:
|
||||
await TelegramClient(token="t").send_message(chat_id=-1, text="x", max_retries=0)
|
||||
|
||||
assert not isinstance(caught.value, TelegramApiError)
|
||||
|
||||
|
||||
# --- раздельные таймауты (#tg-connection-resilience) --------------------------
|
||||
|
||||
|
||||
async def _capture_request_timeout(call) -> dict[str, float]:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["timeout"] = request.extensions.get("timeout")
|
||||
return httpx.Response(200, json={"ok": True, "result": []})
|
||||
|
||||
_install_transport(handler)
|
||||
await call(TelegramClient(token="t"))
|
||||
assert captured["timeout"] is not None, "таймаут не доехал до запроса"
|
||||
return captured["timeout"]
|
||||
|
||||
|
||||
async def test_long_poll_timeout_applies_to_read_only_not_to_connect() -> None:
|
||||
"""40 секунд запаса long-poll'а — это бюджет ОТВЕТА, а не установки соединения.
|
||||
|
||||
Скаляр в httpx разворачивается в connect=read=write=pool, поэтому
|
||||
`getUpdates` ждал 40с и коннекта тоже. Живой connect до api.telegram.org из
|
||||
прод-контейнера — 0.036с; худший цикл из-за этого растягивался на ~174с
|
||||
(4 попытки × 40с + backoff), и всё это время бот не видел ответов оператора.
|
||||
"""
|
||||
timeout = await _capture_request_timeout(lambda tg: tg.get_updates(offset=0, timeout=30))
|
||||
|
||||
assert timeout["read"] == 40.0, "long-poll обязан сохранить свои 30+10с на ответ"
|
||||
assert timeout["connect"] == _CONNECT_TIMEOUT_S, "connect не должен наследовать long-poll"
|
||||
assert timeout["write"] == _WRITE_TIMEOUT_S
|
||||
assert timeout["pool"] == _POOL_TIMEOUT_S
|
||||
|
||||
|
||||
async def test_interactive_call_keeps_its_own_narrow_read_budget() -> None:
|
||||
"""Узкий интерактивный бюджет ручки — тоже read, и он не подменяется дефолтом."""
|
||||
timeout = await _capture_request_timeout(
|
||||
lambda tg: tg.send_message(chat_id=1, text="x", timeout=6.0)
|
||||
)
|
||||
|
||||
assert timeout["read"] == 6.0
|
||||
assert timeout["connect"] == _CONNECT_TIMEOUT_S
|
||||
|
||||
|
||||
# --- переиспользование соединения --------------------------------------------
|
||||
|
||||
|
||||
async def test_two_calls_share_one_httpx_client_and_aclose_closes_it() -> None:
|
||||
"""Один `httpx.AsyncClient` на жизнь `TelegramClient`, а не на запрос.
|
||||
|
||||
Раньше клиент создавался ВНУТРИ цикла ретраев: нулевой keep-alive, полный
|
||||
TCP+TLS-хендшейк на каждый запрос и на каждую попытку.
|
||||
"""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
|
||||
|
||||
created = _install_transport(handler)
|
||||
tg = TelegramClient(token="t")
|
||||
await tg.send_message(chat_id=1, text="раз")
|
||||
await tg.send_message(chat_id=1, text="два")
|
||||
|
||||
assert len(created) == 1, f"клиент пересоздаётся на запрос: {len(created)} штук"
|
||||
assert not created[0].is_closed
|
||||
|
||||
await tg.aclose()
|
||||
assert created[0].is_closed, "aclose() обязан закрыть пул соединений"
|
||||
|
||||
|
||||
async def test_retries_reuse_the_same_http_client() -> None:
|
||||
"""Ретраи не пересоздают клиент — иначе повтор платит за хендшейк заново."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
return httpx.Response(502, json={"ok": False, "error_code": 502, "description": "gw"})
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 7}})
|
||||
|
||||
created = _install_transport(handler)
|
||||
await TelegramClient(token="t").send_message(chat_id=1, text="x")
|
||||
|
||||
assert calls["n"] == 3, "бюджет ретраев изменился незаметно"
|
||||
assert len(created) == 1, "на каждую попытку создаётся новый клиент"
|
||||
|
||||
|
||||
async def test_async_context_manager_closes_client_on_exit() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
|
||||
|
||||
created = _install_transport(handler)
|
||||
async with TelegramClient(token="t") as tg:
|
||||
await tg.send_message(chat_id=1, text="x")
|
||||
|
||||
assert len(created) == 1
|
||||
assert created[0].is_closed
|
||||
|
||||
|
||||
# ── Ретранслятор через Beget: фолбэк на прямой путь (#3471) ────────────────
|
||||
#
|
||||
# Единственный `httpx.AsyncClient` (общий для обоих адресов через тот же
|
||||
# MockTransport) позволяет различать «запрос на ретранслятор» от «запрос
|
||||
# напрямую» по хосту в `request.url.host` — так проверяется, что при отказе
|
||||
# ретранслятора клиент реально уходит на api.telegram.org, а не молчит.
|
||||
|
||||
|
||||
async def test_relay_transport_failure_falls_back_to_direct_once() -> None:
|
||||
"""Отказ ретранслятора не должен ронять бота: одна попытка напрямую."""
|
||||
seen_hosts: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_hosts.append(request.url.host)
|
||||
if request.url.host == "relay.example.com":
|
||||
raise httpx.ConnectError("connection refused", request=request)
|
||||
return httpx.Response(200, json={"ok": True, "result": {}})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(
|
||||
token="fake-token", relay_base_url="https://relay.example.com", relay_secret="shh"
|
||||
)
|
||||
result = await client.send_message(chat_id=1, text="hi")
|
||||
|
||||
assert result == {}
|
||||
assert seen_hosts == ["relay.example.com", "api.telegram.org"]
|
||||
|
||||
|
||||
async def test_relay_secret_header_sent_only_to_relay_not_to_direct_fallback() -> None:
|
||||
captured: list[str | None] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request.headers.get("X-Relay-Secret"))
|
||||
if request.url.host == "relay.example.com":
|
||||
raise httpx.ConnectError("connection refused", request=request)
|
||||
return httpx.Response(200, json={"ok": True, "result": {}})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(
|
||||
token="fake-token", relay_base_url="https://relay.example.com", relay_secret="shh"
|
||||
)
|
||||
await client.send_message(chat_id=1, text="hi")
|
||||
|
||||
assert captured == ["shh", None], "секрет ретранслятора не должен уходить напрямую в Telegram"
|
||||
|
||||
|
||||
async def test_no_relay_configured_behaves_exactly_as_direct_path_before() -> None:
|
||||
"""Пустая настройка — это откат: поведение НЕ должно отличаться от прежнего."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.host == "api.telegram.org"
|
||||
assert "X-Relay-Secret" not in request.headers
|
||||
return httpx.Response(200, json={"ok": True, "result": {}})
|
||||
|
||||
_install_transport(handler)
|
||||
result = await TelegramClient(token="fake-token").send_message(chat_id=1, text="hi")
|
||||
assert result == {}
|
||||
|
||||
|
||||
async def test_relay_success_never_touches_direct_host() -> None:
|
||||
seen_hosts: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_hosts.append(request.url.host)
|
||||
return httpx.Response(200, json={"ok": True, "result": {}})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(
|
||||
token="fake-token", relay_base_url="https://relay.example.com", relay_secret="shh"
|
||||
)
|
||||
await client.send_message(chat_id=1, text="hi")
|
||||
|
||||
assert seen_hosts == ["relay.example.com"]
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
"""Жизненный цикл общего на приложение `TelegramClient` (`app.services.tgbot.shared`).
|
||||
|
||||
Смысл модуля — ровно в том, что экземпляр ОДИН: до правки три HTTP-ручки создавали
|
||||
клиент на каждый входящий запрос, и keep-alive не было ни на каком уровне. Если
|
||||
синглтон однажды перестанет быть синглтоном, тесты ручек этого не заметят (они
|
||||
подменяют аксессор целиком), а на проде вернутся TLS-хендшейки на каждое сообщение.
|
||||
Поэтому проверяем идентичность и закрытие здесь, отдельно.
|
||||
|
||||
Реальных сетевых вызовов нет: `TelegramClient` создаёт `httpx.AsyncClient` лениво,
|
||||
при первом запросе, а мы до запросов не доходим.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.tgbot import shared
|
||||
from app.services.tgbot.client import TelegramClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_singleton():
|
||||
"""Глобал модуля не должен течь между тестами — иначе порядок решает исход."""
|
||||
shared._client = None
|
||||
yield
|
||||
shared._client = None
|
||||
|
||||
|
||||
def test_get_returns_same_instance() -> None:
|
||||
"""Два обращения — один объект. Это и есть весь смысл модуля."""
|
||||
first = shared.get_telegram_client()
|
||||
second = shared.get_telegram_client()
|
||||
|
||||
assert isinstance(first, TelegramClient)
|
||||
assert first is second, "аксессор создал второй клиент — пул перестал переиспользоваться"
|
||||
|
||||
|
||||
def test_init_returns_the_shared_instance() -> None:
|
||||
"""`init_telegram_client` в lifespan и `get_telegram_client` в ручке — один и тот же объект."""
|
||||
created = shared.init_telegram_client()
|
||||
|
||||
assert created is shared.get_telegram_client()
|
||||
|
||||
|
||||
async def test_close_resets_and_next_get_builds_a_fresh_one() -> None:
|
||||
"""После shutdown глобал обнулён; повторный startup обязан получить рабочий клиент.
|
||||
|
||||
Ленивость после закрытия намеренна: держать «остановленный» флаг значило бы,
|
||||
что повторный `init_telegram_client()` в том же процессе отдаёт закрытый пул.
|
||||
"""
|
||||
first = shared.get_telegram_client()
|
||||
await shared.close_telegram_client()
|
||||
|
||||
assert shared._client is None, "глобал не обнулён — следующий startup взял бы закрытый пул"
|
||||
|
||||
second = shared.get_telegram_client()
|
||||
assert second is not first
|
||||
|
||||
|
||||
async def test_close_is_idempotent() -> None:
|
||||
"""Второй `close` не должен падать: lifespan зовёт его из `finally`."""
|
||||
shared.get_telegram_client()
|
||||
|
||||
await shared.close_telegram_client()
|
||||
await shared.close_telegram_client()
|
||||
|
||||
assert shared._client is None
|
||||
|
||||
|
||||
async def test_close_without_client_is_a_noop() -> None:
|
||||
"""Пустой токен — клиента в lifespan не создавали, закрывать нечего."""
|
||||
assert shared._client is None
|
||||
|
||||
await shared.close_telegram_client()
|
||||
|
||||
assert shared._client is None
|
||||
|
|
@ -1,377 +0,0 @@
|
|||
"""Тесты для #3471: startup-проверка темы форума + общий (per-группа) rate limit.
|
||||
|
||||
Оба сюжета выбраны так, чтобы падать на коде ДО правки:
|
||||
- `TelegramGroupRateLimiter` и `verify_chat_and_topic` физически не существовали
|
||||
в `app.services.tgbot.client` — импорт ниже сам по себе даёт `ImportError` на
|
||||
старом коде (подтверждено откатом при разработке).
|
||||
- `test_send_message_rate_limits_across_different_topics_same_chat` без лимитера
|
||||
прошёл бы «слишком хорошо» (`sleep_calls == []` после 3 отправок) — это и есть
|
||||
баг #3471 (нет лимита вовсе, всплеск бьёт по площадочному 429).
|
||||
|
||||
NEVER calls real Telegram API — httpx.MockTransport only, тот же паттерн, что
|
||||
`test_client.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.tgbot.client import (
|
||||
TelegramApiError,
|
||||
TelegramClient,
|
||||
TelegramGroupRateLimiter,
|
||||
TelegramRateLimitedError,
|
||||
verify_chat_and_topic,
|
||||
)
|
||||
|
||||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
def _install_transport(handler) -> None:
|
||||
transport = httpx.MockTransport(handler)
|
||||
|
||||
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||||
return _REAL_ASYNC_CLIENT(transport=transport)
|
||||
|
||||
mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory).start()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stop_patches():
|
||||
yield
|
||||
mock.patch.stopall()
|
||||
|
||||
|
||||
# ── verify_chat_and_topic ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_verify_chat_and_topic_success_calls_get_chat_and_send_chat_action() -> None:
|
||||
"""Happy path: getChat + typing-индикатор в тему, никакого видимого сообщения."""
|
||||
calls: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request.url.path.rsplit("/", 1)[-1])
|
||||
return httpx.Response(200, json={"ok": True, "result": True})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
|
||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
||||
|
||||
assert ok is True
|
||||
assert calls == ["getChat", "sendChatAction"]
|
||||
|
||||
|
||||
async def test_verify_chat_and_topic_no_visible_message_method_used() -> None:
|
||||
"""Ни один из вызовов не бьёт в sendMessage/copyMessage — не мусорим в чат."""
|
||||
methods: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
methods.append(request.url.path.rsplit("/", 1)[-1])
|
||||
return httpx.Response(200, json={"ok": True, "result": True})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
|
||||
await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
||||
|
||||
assert "sendMessage" not in methods
|
||||
assert "copyMessage" not in methods
|
||||
|
||||
|
||||
async def test_verify_chat_and_topic_logs_error_and_does_not_raise(caplog) -> None:
|
||||
"""Тема удалена/переименована → Telegram отвечает отказом. Лог error, процесс жив."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
400, json={"ok": False, "error_code": 400, "description": "message thread not found"}
|
||||
)
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
|
||||
with caplog.at_level("ERROR", logger="app.services.tgbot.client"):
|
||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=999, label="support")
|
||||
|
||||
assert ok is False # не бросает исключение наружу — сигнализирует возвратом
|
||||
assert any(record.levelname == "ERROR" for record in caplog.records)
|
||||
assert any("support" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
async def test_verify_chat_and_topic_raises_nothing_even_on_network_error() -> None:
|
||||
"""Сетевой сбой при проверке (не 4xx, а обрыв) — тоже не должен ронять старт."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("boom", request=request)
|
||||
|
||||
_install_transport(handler)
|
||||
with mock.patch("app.services.tgbot.client.asyncio.sleep", return_value=None):
|
||||
client = TelegramClient(token="fake-token")
|
||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="alerts")
|
||||
|
||||
assert ok is False
|
||||
|
||||
|
||||
async def test_verify_chat_and_topic_skips_api_call_when_chat_id_is_zero() -> None:
|
||||
"""chat_id=0 — штатный kill-switch (не настроено), не ошибка. Нет вызовов к API."""
|
||||
calls: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request.url.path)
|
||||
return httpx.Response(200, json={"ok": True, "result": True})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
|
||||
ok = await verify_chat_and_topic(client, chat_id=0, topic_id=0, label="alerts")
|
||||
|
||||
assert ok is True
|
||||
assert calls == []
|
||||
|
||||
|
||||
# ── TelegramGroupRateLimiter ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_rate_limiter_allows_calls_up_to_limit_without_waiting() -> None:
|
||||
sleep_calls: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleep_calls.append(seconds)
|
||||
|
||||
with mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep):
|
||||
limiter = TelegramGroupRateLimiter(max_per_window=3, window_s=60.0)
|
||||
await limiter.acquire(chat_id=555)
|
||||
await limiter.acquire(chat_id=555)
|
||||
await limiter.acquire(chat_id=555)
|
||||
|
||||
assert sleep_calls == []
|
||||
|
||||
|
||||
async def test_rate_limiter_waits_once_limit_exceeded_same_chat() -> None:
|
||||
"""4-я отправка в тот же chat_id за лимитом (2) — ждёт, а не теряется/падает."""
|
||||
sleep_calls: list[float] = []
|
||||
fake_now = [1_000.0]
|
||||
|
||||
def fake_monotonic() -> float:
|
||||
return fake_now[0]
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleep_calls.append(seconds)
|
||||
fake_now[0] += seconds
|
||||
|
||||
with (
|
||||
mock.patch("app.services.tgbot.client.time.monotonic", fake_monotonic),
|
||||
mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep),
|
||||
):
|
||||
limiter = TelegramGroupRateLimiter(max_per_window=2, window_s=60.0)
|
||||
await limiter.acquire(chat_id=1)
|
||||
await limiter.acquire(chat_id=1)
|
||||
assert sleep_calls == []
|
||||
await limiter.acquire(chat_id=1) # третья — сверх лимита
|
||||
|
||||
assert sleep_calls # ждала, а не пролетела/бросила исключение
|
||||
assert sleep_calls[0] > 0
|
||||
|
||||
|
||||
async def test_rate_limiter_different_chats_do_not_block_each_other() -> None:
|
||||
"""Разные группы (chat_id) — независимые бюджеты, одна не душит другую."""
|
||||
sleep_calls: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleep_calls.append(seconds)
|
||||
|
||||
with mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep):
|
||||
limiter = TelegramGroupRateLimiter(max_per_window=1, window_s=60.0)
|
||||
await limiter.acquire(chat_id=1)
|
||||
await limiter.acquire(chat_id=2) # другая группа — свой бюджет
|
||||
|
||||
assert sleep_calls == []
|
||||
|
||||
|
||||
async def test_rate_limiter_no_races_under_concurrent_senders() -> None:
|
||||
"""Несколько конкурентных asyncio-отправителей в ОДИН chat_id — без гонок:
|
||||
итоговое число «пропущенных без ожидания» строго не больше лимита, и никто
|
||||
не теряется/не падает — все 5 корутин успешно завершаются."""
|
||||
import asyncio
|
||||
|
||||
limiter = TelegramGroupRateLimiter(max_per_window=2, window_s=0.15)
|
||||
|
||||
async def sender() -> None:
|
||||
await limiter.acquire(chat_id=42)
|
||||
|
||||
# Реальное время, реальный event loop — единственный способ честно
|
||||
# проверить отсутствие гонки на разделяемом `self._sent_at[chat_id]`.
|
||||
results = await asyncio.gather(*(sender() for _ in range(5)), return_exceptions=True)
|
||||
|
||||
assert all(not isinstance(r, Exception) for r in results)
|
||||
history = limiter._sent_at[42]
|
||||
# После завершения всех 5 — в окне НЕ больше max_per_window меток (иначе
|
||||
# гонка позволила бы двум конкурентным acquire() одновременно "проскочить").
|
||||
assert len(history) <= 2
|
||||
|
||||
|
||||
# ── интеграция с TelegramClient.send_message ─────────────────────────────────
|
||||
|
||||
|
||||
async def test_send_message_rate_limits_across_different_topics_same_chat() -> None:
|
||||
"""Ключевой сценарий #3471: РАЗНЫЕ темы одной группы делят ОДИН бюджет.
|
||||
|
||||
Без правки лимитера нет вовсе — этот тест на старом коде проходил бы
|
||||
"слишком хорошо" (sleep не звался никогда), что и есть баг.
|
||||
|
||||
ВАЖНО (post-merge review, #3494 CI hang): `fake_sleep` ОБЯЗАН двигать
|
||||
подменённый `time.monotonic` вперёд, а не оставаться чистым no-op. Без
|
||||
этого 3-я отправка (сверх лимита=2) уходит в `acquire()`, `wait_s`
|
||||
пересчитывается из РЕАЛЬНОГО `time.monotonic()` (не замоканного здесь),
|
||||
окно не истекает — и `while True` крутится настоящим busy-spin БЕЗ
|
||||
единого реального ожидания, пока `pytest-timeout` не убьёт тест десятками
|
||||
секунд спустя. Именно так этот тест сам стал причиной 75-секундного
|
||||
зависания CI-джобы (обрыв на ~9%, ни строки об ошибке) — не сам лимитер и
|
||||
не синглтон `shared.py` (гипотеза координатора была разумной, но к этому
|
||||
конкретному зависанию отношения не имела).
|
||||
"""
|
||||
sleep_calls: list[float] = []
|
||||
fake_now = [0.0]
|
||||
|
||||
def fake_monotonic() -> float:
|
||||
return fake_now[0]
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleep_calls.append(seconds)
|
||||
fake_now[0] += seconds
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
|
||||
|
||||
_install_transport(handler)
|
||||
with (
|
||||
mock.patch("app.services.tgbot.client.time.monotonic", fake_monotonic),
|
||||
mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep),
|
||||
):
|
||||
client = TelegramClient(token="fake-token", group_rate_limit_per_minute=2)
|
||||
await client.send_message(chat_id=42, text="a", message_thread_id=1) # тема поддержки
|
||||
await client.send_message(chat_id=42, text="b", message_thread_id=2) # тема алертов
|
||||
assert sleep_calls == []
|
||||
await client.send_message(chat_id=42, text="c", message_thread_id=3) # 3-я тема, тот же чат
|
||||
|
||||
assert sleep_calls # общий бюджет группы исчерпан третьей отправкой
|
||||
|
||||
|
||||
async def test_send_message_does_not_rate_limit_missing_chat_id_payload() -> None:
|
||||
"""get_updates (нет chat_id в payload) не должен спотыкаться о лимитер вовсе."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"ok": True, "result": []})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token", group_rate_limit_per_minute=1)
|
||||
|
||||
# Не должно ни зависнуть, ни бросить — getUpdates не несёт chat_id.
|
||||
result = await client.get_updates(offset=1)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_telegram_api_error_importable_for_manual_inspection() -> None:
|
||||
"""Sanity: тестовый модуль не потерял импорт TelegramApiError (использовался
|
||||
при отладке 400-ответа выше)."""
|
||||
assert issubclass(TelegramApiError, Exception)
|
||||
|
||||
|
||||
# ── review H1: честный отказ вместо бесконечного ожидания на interactive-пути ─
|
||||
|
||||
|
||||
async def test_rate_limiter_raises_rate_limited_error_when_max_wait_exceeded() -> None:
|
||||
"""Слот не появился за `max_wait` — `TelegramRateLimitedError`, не вечный сон."""
|
||||
fake_now = [0.0]
|
||||
|
||||
def fake_monotonic() -> float:
|
||||
return fake_now[0]
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
fake_now[0] += seconds
|
||||
|
||||
with (
|
||||
mock.patch("app.services.tgbot.client.time.monotonic", fake_monotonic),
|
||||
mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep),
|
||||
):
|
||||
limiter = TelegramGroupRateLimiter(max_per_window=1, window_s=60.0)
|
||||
await limiter.acquire(chat_id=9) # занимает единственный слот
|
||||
with pytest.raises(TelegramRateLimitedError):
|
||||
await limiter.acquire(chat_id=9, max_wait=2.0) # бюджет короче окна
|
||||
|
||||
|
||||
async def test_send_message_bounds_rate_limit_wait_by_explicit_timeout() -> None:
|
||||
"""Явный `timeout` (как у interactive-ручек support.py/glitchtip.py) сам по
|
||||
себе ограничивает ожидание очереди — без правки самих ручек (review H1)."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
|
||||
|
||||
_install_transport(handler)
|
||||
with mock.patch(
|
||||
"app.services.tgbot.client.TelegramGroupRateLimiter.acquire",
|
||||
autospec=True,
|
||||
) as acquire_mock:
|
||||
client = TelegramClient(token="fake-token")
|
||||
await client.send_message(chat_id=1, text="hi", timeout=3.0)
|
||||
|
||||
_, kwargs = acquire_mock.call_args
|
||||
assert kwargs.get("max_wait") == 3.0
|
||||
|
||||
|
||||
async def test_send_message_unbounded_wait_by_default_for_background_calls() -> None:
|
||||
"""Без явного `timeout` (типичный фоновый вызов) — `max_wait=None` (без потолка)."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
|
||||
|
||||
_install_transport(handler)
|
||||
with mock.patch(
|
||||
"app.services.tgbot.client.TelegramGroupRateLimiter.acquire",
|
||||
autospec=True,
|
||||
) as acquire_mock:
|
||||
client = TelegramClient(token="fake-token")
|
||||
await client.send_message(chat_id=1, text="hi")
|
||||
|
||||
_, kwargs = acquire_mock.call_args
|
||||
assert kwargs.get("max_wait") is None
|
||||
|
||||
|
||||
# ── review H2: бюджет группы разделён по ролям (API-процесс / бот-процесс) ────
|
||||
|
||||
|
||||
def test_group_rate_limit_split_by_role_sums_below_platform_ceiling() -> None:
|
||||
"""API- и бот-роль вместе НЕ должны превышать площадочный лимит (~20/мин).
|
||||
|
||||
Регрессия ровно на баг из review H2: раньше оба процесса получали
|
||||
ОДИНАКОВЫЙ дефолт (18+18=36) — сумма вдвое превышала лимит площадки.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
api_limit = settings.telegram_group_rate_limit_api_per_minute
|
||||
bot_limit = settings.telegram_group_rate_limit_bot_per_minute
|
||||
assert api_limit > 0
|
||||
assert bot_limit > 0
|
||||
assert api_limit + bot_limit < 20
|
||||
|
||||
|
||||
def test_shared_client_uses_api_role_limit() -> None:
|
||||
"""`app.services.tgbot.shared` (процесс uvicorn) собирает клиент с API-долей."""
|
||||
from app.core.config import settings
|
||||
from app.services.tgbot import shared
|
||||
|
||||
shared._client = None
|
||||
try:
|
||||
client = shared.get_telegram_client()
|
||||
assert (
|
||||
client._rate_limiter._max_per_window
|
||||
== settings.telegram_group_rate_limit_api_per_minute
|
||||
)
|
||||
finally:
|
||||
shared._client = None
|
||||
|
|
@ -38,7 +38,6 @@ tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
|
|||
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
|
||||
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
|
||||
tests/test_user_events.py::test_real_record_event_inserts_row
|
||||
tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
|
||||
|
||||
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
|
||||
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и
|
||||
|
|
@ -130,18 +129,3 @@ tests/test_revisit_floor_lateral_lookup.py::test_missing_history_before_anchor_s
|
|||
# (postgres-сервис) — там прогон и был зелёным; в deploy-tradein.yml БД нет вовсе.
|
||||
tests/test_3063_seller_fields_not_eroded.py::test_poor_rescrape_does_not_erase_seller_fields
|
||||
tests/test_3063_seller_fields_not_eroded.py::test_real_change_still_overwrites
|
||||
|
||||
# Потолки БД на движке (#3463) — поведенческая половина: проверяют, что запрос
|
||||
# длиннее потолка ОБРЫВАЕТСЯ (pg_sleep), что ожидание блокировки отваливается по
|
||||
# lock_timeout, и что `SET LOCAL` задачи планировщика перекрывает сессионный
|
||||
# потолок и не течёт за свою транзакцию. Всё это нельзя проверить без сервера:
|
||||
# потолок применяет Postgres, а не питон. Статическая половина того же файла
|
||||
# (test_engine_opens_connections_with_both_ceilings +
|
||||
# test_ceilings_are_coherent_with_declared_estimate_budgets) идёт на ОБОИХ лэйнах
|
||||
# и краснеет от снятия `connect_args` — проверено вручную 12.09. В ci-tradein.yml
|
||||
# эти пять бегут по-настоящему (postgres-сервис, #2745).
|
||||
tests/test_3463_db_timeouts.py::test_live_session_reports_both_ceilings
|
||||
tests/test_3463_db_timeouts.py::test_statement_over_ceiling_is_cancelled_not_hung
|
||||
tests/test_3463_db_timeouts.py::test_lock_wait_over_ceiling_is_aborted
|
||||
tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling
|
||||
tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction
|
||||
|
|
|
|||
|
|
@ -348,9 +348,7 @@ async def test_backfill_abort_log_has_no_ip_rate_limited_literal(caplog: Any) ->
|
|||
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
|
||||
patch(_FETCH, mock_fetch),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
# серия подтверждённых блоков площадки -- штатный исход скрапинга, не
|
||||
# инцидент; понижено до warning, чтобы не шуметь в GlitchTip.
|
||||
caplog.at_level("WARNING"),
|
||||
caplog.at_level("ERROR"),
|
||||
):
|
||||
await run_avito_detail_backfill(
|
||||
db, run_id=3, params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 5}
|
||||
|
|
@ -1356,9 +1354,7 @@ async def test_backfill_ratio_abort_log_names_the_ratio_not_the_streak(caplog: A
|
|||
patch(_FETCH, mock_fetch),
|
||||
patch(_SAVE, return_value=True),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
# ABORT по доле блоков -- штатный исход скрапинга, не инцидент; понижено
|
||||
# до warning, чтобы не шуметь в GlitchTip.
|
||||
caplog.at_level("WARNING"),
|
||||
caplog.at_level("ERROR"),
|
||||
):
|
||||
result = await run_avito_detail_backfill(
|
||||
db, run_id=110, params={"batch_size": total, "budget_sec": 3600}
|
||||
|
|
@ -1400,9 +1396,7 @@ async def test_backfill_safety_net_abort_log_names_the_streak(caplog: Any) -> No
|
|||
patch(_FETCH, mock_fetch),
|
||||
patch(_SAVE, return_value=True),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
# ABORT по safety-net серии -- штатный исход скрапинга, не инцидент;
|
||||
# понижено до warning, чтобы не шуметь в GlitchTip.
|
||||
caplog.at_level("WARNING"),
|
||||
caplog.at_level("ERROR"),
|
||||
):
|
||||
result = await run_avito_detail_backfill(
|
||||
db, run_id=111, params={"batch_size": total, "budget_sec": 3600}
|
||||
|
|
|
|||
|
|
@ -287,9 +287,7 @@ async def test_backfill_abort_log_has_no_qrator_literal(caplog: pytest.LogCaptur
|
|||
patch(_BROWSER_FETCHER, mock_bf_cls),
|
||||
patch(_FETCH, mock_fetch),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
# серия подтверждённых блоков площадки -- штатный исход скрапинга, не
|
||||
# инцидент; понижено до warning, чтобы не шуметь в GlitchTip.
|
||||
caplog.at_level("WARNING"),
|
||||
caplog.at_level("ERROR"),
|
||||
):
|
||||
await run_domclick_detail_backfill(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -65,9 +65,7 @@ async def test_backfill_browser_fetcher_gets_proxy_pool_wiring(
|
|||
db.execute.return_value.mappings.return_value.all.return_value = [
|
||||
{"id": 1, "address": "ЕКБ, ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6}
|
||||
]
|
||||
# request_delay_sec=0: мок дублирует строку в retry+fresh (2 записи вместо 1) — реальный
|
||||
# анти-бот sleep(5с) между ними тут не нужен.
|
||||
await hib.backfill_house_imv(db, batch_size=1, request_delay_sec=0)
|
||||
await hib.backfill_house_imv(db, batch_size=1)
|
||||
|
||||
captured = _CapturingFetcher.captured
|
||||
assert captured["source"] == "avito"
|
||||
|
|
|
|||
|
|
@ -101,12 +101,6 @@ async def _run(pending: list[dict], *, force: bool = False, resolved: str | None
|
|||
),
|
||||
patch.object(mod, "_sleep_with_jitter", AsyncMock(return_value=None)),
|
||||
patch("app.services.scraper_adapters.RealScraperConfig", MagicMock()),
|
||||
# Удачный резолв доходит до fetch_jk — без мока это реальный BrowserFetcher/прокси-пул
|
||||
# (26с на живом прогоне); тесту нужен только факт «маркер не выставлен», не сам fetch.
|
||||
patch(
|
||||
"scraper_kit.providers.yandex.newbuilding.YandexNewbuildingScraper.fetch_jk",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
await mod.enrich_yandex_newbuilding_sweep(
|
||||
db, city="ekaterinburg", limit=5, force=force, request_delay_sec=0
|
||||
|
|
|
|||
|
|
@ -129,11 +129,7 @@ def _capture(method: str, **kw: object) -> list[str]:
|
|||
|
||||
def test_anchor_sweep_uses_secondary_slug_by_default() -> None:
|
||||
"""fetch_around по умолчанию — путь вторички, geoCoords/radius сохранены в query."""
|
||||
# delay_override_sec=0: сеть подменена, anti-ban sleep(5с*jitter) после единственной
|
||||
# страницы тут не защищает ничего реального.
|
||||
urls = _capture(
|
||||
"fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1, delay_override_sec=0
|
||||
)
|
||||
urls = _capture("fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1)
|
||||
assert urls, "fetch_around не сделал запроса"
|
||||
u = urlparse(urls[0])
|
||||
assert u.path == f"/ekaterinburg/kvartiry/prodam/{SECONDARY_SLUG}", u.path
|
||||
|
|
@ -143,15 +139,8 @@ def test_anchor_sweep_uses_secondary_slug_by_default() -> None:
|
|||
|
||||
def test_anchor_sweep_secondary_only_false_keeps_general_path() -> None:
|
||||
"""Контроль: secondary_only=False — прежняя общая выдача."""
|
||||
# delay_override_sec=0: тот же anti-ban trailing sleep, что и в тесте выше.
|
||||
urls = _capture(
|
||||
"fetch_around",
|
||||
lat=56.84,
|
||||
lon=60.6,
|
||||
radius_m=1000,
|
||||
pages=1,
|
||||
secondary_only=False,
|
||||
delay_override_sec=0,
|
||||
"fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1, secondary_only=False
|
||||
)
|
||||
assert urlparse(urls[0]).path == "/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ"
|
||||
|
||||
|
|
|
|||
|
|
@ -43,41 +43,15 @@ def test_bbox_nesting_invariants() -> None:
|
|||
|
||||
|
||||
def test_regions_do_not_overlap() -> None:
|
||||
"""Центры продукт-ядер каждого региона не попадают в чужой bbox_region —
|
||||
ЗА ИСКЛЮЧЕНИЕМ пары (50, 77): регион 50 (Московская область) admin-bbox'ом
|
||||
геометрически СОДЕРЖИТ регион 77 (Москва) целиком, это ожидаемая вложенность,
|
||||
не баг (см. #3051 Moscow-oblast, tests/test_3051_region_registry_moscow_oblast.py
|
||||
— там же настоящий инвариант для вложенной пары: `region_for_point` обязан
|
||||
резолвить точку во ВЛАДЕЮЩИЙ, более специфичный регион по площади bbox, а не
|
||||
по числовому коду). 66 (Урал) физически за тысячи км от 50/77 — для него
|
||||
старый плоский инвариант «никто ни в кого не попадает» остаётся в силе."""
|
||||
nested_pairs = {(50, 77), (77, 50)}
|
||||
# Исключение из инварианта обязано опираться на ДОКАЗАННУЮ вложенность, а не
|
||||
# на допущение в докстринге: сначала проверяем, что bbox_region(50)
|
||||
# действительно содержит bbox_region(77) как множество, и только потом
|
||||
# разрешаем этой паре не соблюдать «центры не пересекаются». Если 77 когда-
|
||||
# нибудь выедет за границы области, тест упадёт ЗДЕСЬ — с понятной причиной,
|
||||
# а не молча пропустит настоящее пересечение через список исключений.
|
||||
oblast_lat_min, oblast_lat_max, oblast_lon_min, oblast_lon_max = regions.REGIONS[50].bbox_region
|
||||
msk_lat_min, msk_lat_max, msk_lon_min, msk_lon_max = regions.REGIONS[77].bbox_region
|
||||
assert oblast_lat_min <= msk_lat_min and msk_lat_max <= oblast_lat_max, (
|
||||
"bbox_region(77) выехал за широты bbox_region(50) — исключение (50,77) больше "
|
||||
"не обосновано вложенностью"
|
||||
)
|
||||
assert oblast_lon_min <= msk_lon_min and msk_lon_max <= oblast_lon_max, (
|
||||
"bbox_region(77) выехал за долготы bbox_region(50) — исключение (50,77) больше "
|
||||
"не обосновано вложенностью"
|
||||
)
|
||||
|
||||
"""Центры продукт-ядер каждого региона не попадают в чужой bbox_region."""
|
||||
for r in regions.REGIONS.values():
|
||||
core = r.bbox_product_core
|
||||
center = ((core[0] + core[1]) / 2, (core[2] + core[3]) / 2)
|
||||
for other in regions.REGIONS.values():
|
||||
if other.code == r.code or (r.code, other.code) in nested_pairs:
|
||||
continue
|
||||
assert not regions.is_within_bbox(*center, other.bbox_region), (
|
||||
f"центр {r.code} внутри bbox_region {other.code}"
|
||||
)
|
||||
if other.code != r.code:
|
||||
assert not regions.is_within_bbox(*center, other.bbox_region), (
|
||||
f"центр {r.code} внутри bbox_region {other.code}"
|
||||
)
|
||||
|
||||
|
||||
# ── 2. Поведение региона 66 закреплено байт-в-байт ───────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,159 +0,0 @@
|
|||
"""#3051, регион 50 (Московская область) + фикс порядка обхода реестра.
|
||||
|
||||
`region_for_point`/`region_by_city` раньше обходили `sorted(REGIONS)` — «первый
|
||||
по коду выигрывает». Это работало, пока регионы физически не пересекались
|
||||
(66 Урал и 77 Москва — тысячи км друг от друга). Регион 50 ломает допущение:
|
||||
bbox_region(50) (Московская область целиком) геометрически СОДЕРЖИТ
|
||||
bbox_region(77) (Москва) как прямоугольники, а 50 < 77 по числовому коду —
|
||||
наивный код-порядок отправил бы ВСЕ точки Москвы (включая центр) в регион 50.
|
||||
|
||||
Фикс — `_POINT_LOOKUP_ORDER`: обход по площади bbox_region по возрастанию
|
||||
(компактный регион проверяется раньше объемлющего), без ручного списка.
|
||||
|
||||
Область на дату этого PR — тир обогащения пуст (frozenset()), в deals/listings
|
||||
0 строк (импорт Росреестра из FDW — отдельный PR). Тесты ниже проверяют РЕЕСТР
|
||||
(геометрию/классификацию), не данные.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import regions
|
||||
|
||||
# ── 1. Регрессия на порядок обхода (главный тест) ───────────────────────────
|
||||
|
||||
|
||||
def test_lookup_order_is_by_bbox_area_not_by_code() -> None:
|
||||
"""77 (Москва, самый маленький bbox_region) проверяется первым, 66 (Урал,
|
||||
самый большой) — последним. Порядок НЕ (50, 66, 77) — это был бы код."""
|
||||
assert regions._POINT_LOOKUP_ORDER == (77, 50, 66)
|
||||
|
||||
|
||||
def test_moscow_center_resolves_to_77_not_50() -> None:
|
||||
"""Точка в центре Москвы лежит внутри bbox_region И 77, И 50 одновременно
|
||||
— регрессия на сам баг: наивный код-порядок (50 < 77) увёл бы её в 50."""
|
||||
res = regions.region_for_point(55.75, 37.62)
|
||||
assert res is not None
|
||||
assert res.code == 77
|
||||
# Инвариант, который делает это регрессией, а не совпадением:
|
||||
assert regions.is_within_bbox(55.75, 37.62, regions.REGIONS[50].bbox_region)
|
||||
assert regions.is_within_bbox(55.75, 37.62, regions.REGIONS[77].bbox_region)
|
||||
|
||||
|
||||
def test_novaya_moskva_resolves_to_77() -> None:
|
||||
"""Посёлок Птичное (Новая Москва) — административно Москва, не область."""
|
||||
res = regions.region_for_point(55.52, 37.21)
|
||||
assert res is not None
|
||||
assert res.code == 77
|
||||
|
||||
|
||||
def test_far_moscow_oblast_points_resolve_to_50() -> None:
|
||||
"""Точки вне генерального bbox 77 однозначно — дальнее Подмосковье."""
|
||||
for lat, lon in [
|
||||
(54.9152, 37.4166), # Серпухов
|
||||
(56.3430, 37.5228), # Дмитров
|
||||
(55.7889, 38.4458), # Электросталь
|
||||
]:
|
||||
res = regions.region_for_point(lat, lon)
|
||||
assert res is not None and res.code == 50, (lat, lon, res)
|
||||
|
||||
|
||||
def test_ekb_point_still_resolves_to_66() -> None:
|
||||
"""Регион 66 не сломан добавлением 50/переходом на area-порядок."""
|
||||
res = regions.region_for_point(56.8300, 60.6000)
|
||||
assert res is not None
|
||||
assert res.code == 66
|
||||
|
||||
|
||||
def test_point_outside_all_regions_is_none() -> None:
|
||||
res = regions.region_for_point(58.01, 56.25) # Пермь
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_known_limitation_khimki_balashikha_resolve_to_77_via_bbox() -> None:
|
||||
"""ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ, не баг этого PR: bbox_region(77) — генеральный
|
||||
fallback-net Москвы, по долготе тянется до 38.10 (~13 км восточнее МКАД).
|
||||
Химки (55.91, 37.41) и Балашиха (55.7965, 37.9388) физически лежат внутри
|
||||
ЭТОГО прямоугольника, хотя административно это область. region_for_point
|
||||
(bbox-based) резолвит их в 77; region_by_city (точное имя) — правильно в
|
||||
50 (см. test_region_by_city_resolves_moscow_oblast_cities). Сузить
|
||||
bbox_region(77), чтобы это исправить, — отдельное решение вне скоупа
|
||||
ordering-фикса (риск задеть geocoder/estimator-потребителей 77 без
|
||||
возможности перепроверить их в этом PR)."""
|
||||
khimki = regions.region_for_point(55.91, 37.41)
|
||||
assert khimki is not None and khimki.code == 77
|
||||
balashikha = regions.region_for_point(55.7965, 37.9388)
|
||||
assert balashikha is not None and balashikha.code == 77
|
||||
|
||||
|
||||
# ── 2. region_by_city ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_region_by_city_resolves_moscow_oblast_cities() -> None:
|
||||
for city in ("Красногорск", "Балашиха", "химки", "Серпухов", "Ногинск"):
|
||||
res = regions.region_by_city(city)
|
||||
assert res is not None and res.code == 50, city
|
||||
|
||||
|
||||
def test_region_by_city_moscow_and_ekb_unaffected() -> None:
|
||||
assert regions.region_by_city("Москва").code == 77
|
||||
assert regions.region_by_city("Екатеринбург").code == 66
|
||||
assert regions.region_by_city("Пермь") is None
|
||||
|
||||
|
||||
def test_no_city_name_duplicated_across_regions() -> None:
|
||||
"""Если бы Химки/Балашиха и т.п. попали в `cities` двух регионов —
|
||||
region_by_city резолвил бы их по меньшему коду. Список 50 сверен вручную
|
||||
с 66/77 — пересечений нет."""
|
||||
seen: dict[str, int] = {}
|
||||
for code, r in regions.REGIONS.items():
|
||||
for city in r.cities:
|
||||
key = city.replace("ё", "е")
|
||||
assert key not in seen or seen[key] == code, (
|
||||
f"'{city}' в cities и региона {seen.get(key)}, и региона {code}"
|
||||
)
|
||||
seen[key] = code
|
||||
|
||||
|
||||
# ── 3. Тиры обогащения и bbox-инварианты региона 50 ──────────────────────────
|
||||
|
||||
|
||||
def test_moscow_oblast_has_no_enrichment_tiers_and_degrades_loudly() -> None:
|
||||
r50 = regions.REGIONS[50]
|
||||
assert r50.enrichment_tiers == frozenset()
|
||||
for tier in (
|
||||
regions.TIER_AVITO_IMV,
|
||||
regions.TIER_YANDEX_VALUATION,
|
||||
regions.TIER_CIAN_VALUATION,
|
||||
regions.TIER_QUARTER_INDEX,
|
||||
regions.TIER_SBER_INDEX,
|
||||
):
|
||||
reason = regions.unsupported_tier_reason(r50, tier)
|
||||
assert reason is not None and "50" in reason and tier in reason
|
||||
|
||||
|
||||
def test_moscow_oblast_canonical_city_is_none() -> None:
|
||||
"""В отличие от 77 (Росреестр отдаёт округ/поселение) — источники по
|
||||
области несут настоящий city (Химки, Балашиха), перезаписывать нечего."""
|
||||
assert regions.REGIONS[50].canonical_city is None
|
||||
|
||||
|
||||
def test_moscow_oblast_bbox_nesting() -> None:
|
||||
r50 = regions.REGIONS[50]
|
||||
|
||||
def _contains(outer: regions.BBox, inner: regions.BBox) -> bool:
|
||||
return (
|
||||
outer[0] <= inner[0]
|
||||
and outer[1] >= inner[1]
|
||||
and outer[2] <= inner[2]
|
||||
and outer[3] >= inner[3]
|
||||
)
|
||||
|
||||
assert _contains(r50.bbox_wide, r50.bbox_tight)
|
||||
assert _contains(r50.bbox_region, r50.bbox_wide)
|
||||
assert _contains(r50.bbox_region, r50.bbox_product_core)
|
||||
# tight == product_core для 50 (нет отдельного «города-ядра» — см. docstring).
|
||||
assert r50.bbox_tight == r50.bbox_product_core
|
||||
|
|
@ -23,8 +23,6 @@ os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db"
|
|||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import geocode as geocode_module
|
||||
from app.api.v1.geocode import SuggestResponse, effective_region_code
|
||||
from app.services.geocoder import (
|
||||
OBLAST66_VIEWBOX,
|
||||
SVERDLOVSK_OBLAST_REGION,
|
||||
|
|
@ -181,52 +179,3 @@ async def test_suggest_unknown_region_raises() -> None:
|
|||
"""Неизвестный регион — ValueError до похода к провайдерам."""
|
||||
with pytest.raises(ValueError, match="unknown region_code"):
|
||||
await suggest("Ленина 1", db=None, limit=5, region_code=99)
|
||||
|
||||
|
||||
# ── API-слой: effective_region_code и хендлер /suggest ──────────────────────
|
||||
#
|
||||
# Всё выше проверяет, что `suggest()` в geocoder.py УМЕЕТ регион. Ниже —
|
||||
# СЛЕДУЮЩИЙ слой той же истории #3051: сам HTTP-хендлер `/suggest` (и его
|
||||
# публичный прокси в mera.py) до сих пор звал `suggest()` с `region_code=66`
|
||||
# по умолчанию БЕЗУСЛОВНО, даже когда `city_hint='Москва'` уже прямо говорил,
|
||||
# какой регион нужен. Приоритет: явный `region_code` > вывод из `city_hint`
|
||||
# (реестр `app.services.regions`) > 66. Инварианты ниже — на хелпере
|
||||
# `effective_region_code`, который эту логику и вносит.
|
||||
|
||||
|
||||
def test_effective_region_no_hint_no_explicit_defaults_to_66() -> None:
|
||||
assert effective_region_code(None, None) == 66
|
||||
|
||||
|
||||
def test_effective_region_sverdlovsk_city_hint_stays_66() -> None:
|
||||
assert effective_region_code(None, "Серов") == 66
|
||||
|
||||
|
||||
def test_effective_region_moscow_city_hint_infers_77() -> None:
|
||||
assert effective_region_code(None, "Москва") == 77
|
||||
|
||||
|
||||
def test_effective_region_explicit_wins_over_moscow_hint() -> None:
|
||||
assert effective_region_code(66, "Москва") == 66
|
||||
|
||||
|
||||
async def test_suggest_handler_resolves_region_from_moscow_hint() -> None:
|
||||
"""`suggest_addresses` без явного `region_code` реально передаёт вниз 77,
|
||||
а не только хелпер в изоляции."""
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _fake_suggest(q, db=None, limit=8, city_hint=None, region_code=66):
|
||||
captured["region_code"] = region_code
|
||||
return []
|
||||
|
||||
with patch.object(geocode_module, "suggest", _fake_suggest):
|
||||
result = await geocode_module.suggest_addresses(
|
||||
q="Тверская 6",
|
||||
limit=8,
|
||||
db=MagicMock(),
|
||||
city_hint="Москва",
|
||||
region_code=None,
|
||||
)
|
||||
|
||||
assert isinstance(result, SuggestResponse)
|
||||
assert captured["region_code"] == 77
|
||||
|
|
|
|||
|
|
@ -342,9 +342,6 @@ def _yandex_with_pool(monkeypatch: pytest.MonkeyPatch, *, get: Any) -> _Counting
|
|||
patch(
|
||||
"scraper_kit.providers.yandex.valuation._CurlCffiSession", _fake_curl_session(get=get)
|
||||
),
|
||||
# На успехе fetch_house_history зовёт настоящий anti-ban sleep_between_requests
|
||||
# (5с jitter) — тесту нужен только факт release-lease, не сама пауза.
|
||||
patch("scraper_kit.base.asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
_call_yandex(_db_cache_miss())
|
||||
return provider
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
"""Отмена геокодинга по бюджету не оставляет ОСИРОТЕВШИЙ поток в сессии запроса (#3449).
|
||||
|
||||
`geocode()` живёт под бюджетом 12 с (`estimator._with_budget` = `asyncio.wait_for`), а
|
||||
внутри ходит в БД (`_cache_get`/`_cache_put`/локальные тиры) по ТОЙ ЖЕ `Session`, с
|
||||
которой запрос идёт дальше. `asyncio.to_thread` отменить нельзя: по истечении бюджета
|
||||
снимается только ожидание со стороны loop'а, поток продолжает работать в чужой сессии.
|
||||
Ошибка геокодера при этом никого не трогает (её глушит `_with_budget`) — страдает
|
||||
СЛЕДУЮЩИЙ потребитель сессии, и у `_persist_estimate_and_commit` её не ловит никто:
|
||||
500 и потерянная оценка клиента.
|
||||
|
||||
Меряем значение, а не форму: «следующий шаг не вошёл в сессию, пока сирота не
|
||||
закончил». Следующий шаг здесь — ГОЛЫЙ `asyncio.to_thread(db...)`, как персист оценки,
|
||||
а не ещё один защищённый вызов: защита, которая живёт только внутри обёртки, ровно
|
||||
того пострадавшего и не закрывает.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import estimator as est
|
||||
from app.services import geocoder as geo
|
||||
|
||||
# Шаг БД заметно длиннее бюджета — окно, в котором сирота ещё работает.
|
||||
_STEP_S = 0.3
|
||||
_BUDGET_S = 0.05
|
||||
|
||||
|
||||
class _ConcurrencyProbeSession:
|
||||
"""Session-дублёр, который считает ОДНОВРЕМЕННЫЕ входы в сессию."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._guard = threading.Lock()
|
||||
self._inside = 0
|
||||
self.conflicts = 0
|
||||
self.executed = 0
|
||||
|
||||
def execute(self, *_a: Any, **_kw: Any) -> Any:
|
||||
with self._guard:
|
||||
self._inside += 1
|
||||
self.executed += 1
|
||||
if self._inside > 1:
|
||||
self.conflicts += 1
|
||||
try:
|
||||
time.sleep(_STEP_S)
|
||||
finally:
|
||||
with self._guard:
|
||||
self._inside -= 1
|
||||
return self
|
||||
|
||||
|
||||
async def test_geocode_budget_cancel_does_not_leave_orphan_in_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = _ConcurrencyProbeSession()
|
||||
|
||||
# Настоящий путь `geocode()` до первого шага БД; сам SQL подменён — меряем
|
||||
# владение сессией, а не содержимое кэша.
|
||||
def _slow_cache_get(session: Any, address_norm: str) -> None:
|
||||
session.execute("geocode cache lookup", {"addr": address_norm})
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(geo, "_cache_get", _slow_cache_get)
|
||||
|
||||
degraded = await est._with_budget(
|
||||
geo.geocode("ул. Пушкина, д. 1", db), # type: ignore[arg-type]
|
||||
_BUDGET_S,
|
||||
label="geocode",
|
||||
)
|
||||
# Бюджет истёк — геокодер деградировал в None, вызывающий идёт дальше.
|
||||
assert degraded is None, "бюджет не сработал — тест ничего не проверил"
|
||||
|
||||
# Следующий шаг ТОГО ЖЕ запроса по ТОЙ ЖЕ сессии (образец — персист оценки).
|
||||
await asyncio.to_thread(db.execute, "persist estimate")
|
||||
|
||||
assert db.executed == 2, f"звучали не оба шага (executed={db.executed})"
|
||||
assert db.conflicts == 0, (
|
||||
"персист вошёл в сессию, пока осиротевший поток геокодера ещё работал в ней: "
|
||||
"два потока в одной Session → «another operation is in progress» на персисте"
|
||||
)
|
||||
|
||||
|
||||
def test_no_bare_to_thread_over_request_session() -> None:
|
||||
"""Source-гейт: в геокодере и эстиматоре не осталось голых `asyncio.to_thread(`.
|
||||
|
||||
Тест выше ловит ОДНУ проводку — ту, через которую идёт сценарий. Остальные 33
|
||||
(`_cache_put`, `_fetch_anchor_comps`, персист, …) он не видит: возврат любой из
|
||||
них в голый вид прошёл бы мимо CI. Оба модуля сейчас на нуле по живым вызовам,
|
||||
поэтому гейт — ровно «ноль», без списка исключений. Понадобится вызов со СВОЕЙ
|
||||
сессией (как `user_events.record_event`) — заводить его в отдельном модуле или
|
||||
менять этот тест осознанно.
|
||||
|
||||
Читаем через `module.__file__`: относительный путь зависел бы от cwd прогона.
|
||||
"""
|
||||
for module in (geo, est):
|
||||
src = pathlib.Path(module.__file__ or "").read_text(encoding="utf-8")
|
||||
bare = [
|
||||
f"{i}: {line.strip()}"
|
||||
for i, line in enumerate(src.splitlines(), 1)
|
||||
if "asyncio.to_thread(" in line and not line.lstrip().startswith("#")
|
||||
]
|
||||
assert not bare, (
|
||||
f"{module.__name__}: голый asyncio.to_thread по сессии запроса — "
|
||||
f"отмена оставит сироту в чужой Session (#3449), нужен run_db_thread:\n"
|
||||
+ "\n".join(bare)
|
||||
)
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
"""#3452: зона n = 3..9 — коридор ДКП виден, но цену по нему не поправляют.
|
||||
|
||||
Два порога на одну выборку. Показ коридора открывается с трёх сделок
|
||||
(`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки
|
||||
по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся
|
||||
`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор
|
||||
существует, показывается и участвует в fallback-путях, а цену не держит; на
|
||||
экране это неотличимо от работающего коридора. PR #3445 (снятие предиката
|
||||
`d.rooms`) переносит в эту зону реальных клиентов: замерено 248 → 3 и 72 → 8
|
||||
сделок.
|
||||
|
||||
Решение — advisory-only: порог показа НЕ поднимаем (это отняло бы у клиента
|
||||
информацию), кламп по трём сделкам НЕ включаем (он был бы хуже своего
|
||||
отсутствия), но зона помечается в ответе (`DkpCorridor.advisory_only`) и пишется
|
||||
в лог маркером `corridor_advisory_zone`.
|
||||
|
||||
Тесты ПО ЗНАЧЕНИЮ: прогоняется настоящий estimate_quality с коридором, ПОТОЛОК
|
||||
которого заведомо ниже медианы аналогов, — то есть кламп прижал бы headline,
|
||||
если бы ему позволил порог. Проверяется не только метка, но и факт: в зоне цена
|
||||
НЕ прижата, выше порога — прижата. Захардкоженный флаг (константой в любую
|
||||
сторону) роняет один из двух тестов, снятое условие клампа — тоже.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import DKP_CORRIDOR_CITY_WIDE_MIN_N
|
||||
|
||||
# Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он
|
||||
# включён порогом, ОБЯЗАН сработать и прижать headline к cap.
|
||||
_ANALOG_PPM2 = 200_000.0
|
||||
_CORRIDOR_HIGH_PPM2 = 100_000
|
||||
_MARKER = "corridor_advisory_zone"
|
||||
|
||||
|
||||
def _cap_ppm2() -> float:
|
||||
"""Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp."""
|
||||
return _CORRIDOR_HIGH_PPM2 * (1.0 + settings.estimate_corridor_clamp_slack)
|
||||
|
||||
|
||||
def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]:
|
||||
return {
|
||||
"source": "cian",
|
||||
"source_url": "https://cian.ru/offer/1",
|
||||
"address": "ЕКБ, ул. Учителей, 18",
|
||||
"lat": 56.838,
|
||||
"lon": 60.595,
|
||||
"rooms": 2,
|
||||
"area_m2": area_m2,
|
||||
"floor": 5,
|
||||
"total_floors": 16,
|
||||
"price_rub": price_per_m2 * area_m2,
|
||||
"price_per_m2": price_per_m2,
|
||||
"listing_date": datetime(2026, 5, 1),
|
||||
"days_on_market": 10,
|
||||
"photo_urls": [],
|
||||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||||
"distance_m": 150.0,
|
||||
"relevance_score": 0.1,
|
||||
}
|
||||
|
||||
|
||||
def _make_geo():
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
return GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _make_payload():
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Учителей, 18",
|
||||
area_m2=50.0,
|
||||
rooms=2,
|
||||
floor=5,
|
||||
total_floors=16,
|
||||
)
|
||||
|
||||
|
||||
def _corridor(count: int) -> dict[str, Any]:
|
||||
"""Коридор из `count` сделок с потолком ниже медианы аналогов."""
|
||||
return {
|
||||
"count": count,
|
||||
"low_ppm2": 80_000,
|
||||
"median_ppm2": 90_000,
|
||||
"high_ppm2": _CORRIDOR_HIGH_PPM2,
|
||||
"period_months": 12,
|
||||
"latest_deal_date": None,
|
||||
"scope": "street",
|
||||
}
|
||||
|
||||
|
||||
def _run_estimate(dkp_raw: dict[str, Any]) -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
# Шесть объявлений — выше HEADLINE_LISTINGS_MIN_N: иначе срабатывает
|
||||
# #oblast-E и headline уступается коридору (тогда проверялся бы не кламп,
|
||||
# а deals-fallback, у которого свой порог DEALS_HEADLINE_FALLBACK_MIN_N=3).
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=_ANALOG_PPM2 + delta)
|
||||
for delta in (-10_000, -5_000, 0.0, 0.0, 5_000, 10_000)
|
||||
]
|
||||
db = MagicMock()
|
||||
payload = _make_payload()
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
return_value=(list(analogs), False, "S"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
return anyio.run(_run)
|
||||
|
||||
|
||||
def test_zone_exists_at_all() -> None:
|
||||
"""Премиса issue: порог показа коридора ниже порога клампа — зона непуста.
|
||||
|
||||
Если пороги когда-нибудь сведут в один, этот тест скажет об этом прямо,
|
||||
а не оставит два теста ниже молча проверять пустое множество.
|
||||
"""
|
||||
assert settings.estimate_corridor_clamp_min_n > DKP_CORRIDOR_CITY_WIDE_MIN_N, (
|
||||
"зона 3..9 схлопнулась — пороги показа и клампа сравнялись, "
|
||||
"advisory-only решение #3452 больше не описывает реальность"
|
||||
)
|
||||
|
||||
|
||||
def test_corridor_in_zone_is_flagged_and_does_not_clamp(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан."""
|
||||
n = settings.estimate_corridor_clamp_min_n - 1
|
||||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||
est = _run_estimate(_corridor(n))
|
||||
|
||||
assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже"
|
||||
assert est.dkp_corridor.count == n
|
||||
assert est.dkp_corridor.advisory_only is True, (
|
||||
f"n={n} < порога {settings.estimate_corridor_clamp_min_n} — коридор справочный, "
|
||||
"ответ обязан это называть"
|
||||
)
|
||||
# ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал.
|
||||
assert est.median_price_per_m2 > _cap_ppm2(), (
|
||||
f"headline={est.median_price_per_m2} ₽/м² не должен быть прижат к "
|
||||
f"cap={_cap_ppm2():.0f} — при n={n} кламп выключен порогом"
|
||||
)
|
||||
hits = [r for r in caplog.records if _MARKER in r.getMessage()]
|
||||
assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}"
|
||||
msg = hits[0].getMessage()
|
||||
assert f"n={n}" in msg and f"min_n={settings.estimate_corridor_clamp_min_n}" in msg, msg
|
||||
assert "scope=street" in msg, msg
|
||||
|
||||
|
||||
def test_corridor_above_threshold_is_not_flagged_and_clamps(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""n выше порога: метки нет, headline прижат к потолку коридора, лога нет."""
|
||||
n = settings.estimate_corridor_clamp_min_n + 2
|
||||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||
est = _run_estimate(_corridor(n))
|
||||
|
||||
assert est.dkp_corridor is not None
|
||||
assert est.dkp_corridor.advisory_only is False, (
|
||||
f"n={n} >= порога {settings.estimate_corridor_clamp_min_n} — коридор в цене участвует, "
|
||||
"справочным его называть нельзя"
|
||||
)
|
||||
assert est.median_price_per_m2 <= round(_cap_ppm2()), (
|
||||
f"headline={est.median_price_per_m2} ₽/м² обязан быть прижат к cap={_cap_ppm2():.0f}"
|
||||
)
|
||||
assert not [r for r in caplog.records if _MARKER in r.getMessage()], (
|
||||
"строка зоны не должна писаться, когда коридор реально клампит"
|
||||
)
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
"""#3463 — у запросов к БД обязан быть потолок по времени и по ожиданию блокировки.
|
||||
|
||||
Отказ, который тут закрывается (см. issue): под `ACCESS EXCLUSIVE` на таблице шаг БД
|
||||
на пути `/estimate` ЖДЁТ блокировку; бюджет источника истекает, обёртка `run_db_thread`
|
||||
уходит ждать свой поток (иначе он останется сиротой в общей `Session` — #3449), а
|
||||
верхняя граница этого ожидания = длительность самого запроса. Границы у запроса не
|
||||
было → слот `_estimate_slots` не возвращался → `_ESTIMATE_CONCURRENCY = 4` исчерпывался
|
||||
и `/estimate` отдавал 429 всем остальным.
|
||||
|
||||
Потолок поэтому стоит на КОННЕКТЕ (libpq `options`), а не в питоновской обёртке:
|
||||
таймаут в обёртке вернул бы ровно ту сироту, ради которой писался #3449.
|
||||
|
||||
Проверки по значению, а не по тексту:
|
||||
* потолки реально доехали до параметров подключения движка и согласованы с
|
||||
объявленными бюджетами `/estimate` (без БД — падают от снятия `connect_args`);
|
||||
* живая сессия этого движка сообщает оба потолка (без БД пропускается);
|
||||
* запрос длиннее потолка ОБРЫВАЕТСЯ за отведённое время, а не висит;
|
||||
* ожидание блокировки длиннее потолка обрывается — это и есть сценарий #3463;
|
||||
* задача со своим `SET LOCAL statement_timeout` (планировщик: 900 с в
|
||||
`app/tasks/listing_source_snapshot.py`) новым сессионным потолком НЕ обрезается.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from sqlalchemy import Engine, create_engine, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.core.db import _LOCK_TIMEOUT_MS, _STATEMENT_TIMEOUT_MS, DB_CONNECT_ARGS, engine
|
||||
|
||||
# Потолки для ПОВЕДЕНЧЕСКИХ проверок — намеренно маленькие: проверяется механизм
|
||||
# (потолок из `options` реально обрывает запрос / ожидание лока), а прод-ВЕЛИЧИНЫ
|
||||
# проверяет `test_live_session_reports_both_ceilings` на самом прод-движке. Иначе
|
||||
# каждый прогон сьюта стоил бы 30 с ожидания pg_sleep.
|
||||
_PROBE_STATEMENT_TIMEOUT_MS = 1_000
|
||||
_PROBE_LOCK_TIMEOUT_MS = 500
|
||||
|
||||
_LOCK_PROBE_TABLE = "t3463_lock_probe"
|
||||
|
||||
|
||||
def _engine_connect_options(eng: Engine) -> str:
|
||||
"""Строка libpq `options`, с которой движок РЕАЛЬНО открывает коннекты.
|
||||
|
||||
`connect_args` в движке не хранятся полем: `create_engine` вливает их в `cparams`
|
||||
замыкания `pool._creator`. Читаем оттуда, а не из `DB_CONNECT_ARGS`, — иначе тест
|
||||
остался бы зелёным после снятия `connect_args=` у `create_engine`.
|
||||
|
||||
Наружу отдаём ТОЛЬКО `options`: в `cparams` лежит пароль роли, и текст
|
||||
провалившегося assert'а уехал бы с ним в лог CI.
|
||||
"""
|
||||
creator = getattr(eng.pool, "_creator", None)
|
||||
assert creator is not None, "у пула движка нет _creator — SQLAlchemy сменила устройство"
|
||||
cparams = inspect.getclosurevars(creator).nonlocals.get("cparams")
|
||||
assert cparams is not None, (
|
||||
"в замыкании pool._creator нет cparams — SQLAlchemy сменила устройство, "
|
||||
"проверку параметров подключения надо переписать, а не удалять"
|
||||
)
|
||||
return str(cparams.get("options", ""))
|
||||
|
||||
|
||||
def _live_engine(connect_args: dict[str, str]) -> Engine | None:
|
||||
"""Движок против живой Postgres с заданными `connect_args`, иначе None.
|
||||
|
||||
Тот же способ добыть DSN, что у `_live_session()` в tests/test_house_dedup_merge.py
|
||||
и tests/test_purge_expired_trade_in_data.py: в CI Postgres есть (ci-tradein.yml),
|
||||
на ноутбуке без БД тест пропускается (учтён в tests/skip_allowlist.txt).
|
||||
"""
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return None
|
||||
|
||||
# Сначала проба БЕЗ connect_args: она отделяет «сервера нет» (честный пропуск)
|
||||
# от «сервер есть, но наши `options` он не принял». Глушить второе нельзя —
|
||||
# именно так испорченное значение (`statement_timeout=30000zz`) проходило
|
||||
# зелёным: коннект падал, тест пропускался, запись в allowlist гасила сигнал,
|
||||
# а на проде это FATAL на КАЖДОМ коннекте.
|
||||
try:
|
||||
probe = create_engine(dsn, future=True)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
with probe.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
probe.dispose()
|
||||
|
||||
eng = create_engine(dsn, future=True, connect_args=connect_args)
|
||||
with eng.connect() as conn: # НЕ под except: сервер живой, виноваты connect_args
|
||||
conn.execute(text("SELECT 1"))
|
||||
return eng
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def probe_engine() -> Iterator[Engine]:
|
||||
"""Живой движок с МАЛЕНЬКИМИ потолками — проверка механизма, не прод-величин."""
|
||||
eng = _live_engine(
|
||||
{
|
||||
"options": (
|
||||
f"-c statement_timeout={_PROBE_STATEMENT_TIMEOUT_MS} "
|
||||
f"-c lock_timeout={_PROBE_LOCK_TIMEOUT_MS}"
|
||||
)
|
||||
}
|
||||
)
|
||||
if eng is None:
|
||||
pytest.skip("живой Postgres недоступен (DATABASE_URL-заглушка)")
|
||||
try:
|
||||
yield eng
|
||||
finally:
|
||||
eng.dispose()
|
||||
|
||||
|
||||
# ── Проводка и согласованность величин (без БД) ───────────────────────────────
|
||||
|
||||
|
||||
def test_engine_opens_connections_with_both_ceilings() -> None:
|
||||
"""Оба потолка доехали до параметров подключения ПРОД-движка.
|
||||
|
||||
Фальсификация: убрать `connect_args=DB_CONNECT_ARGS` из `create_engine` —
|
||||
`options` станет пустой, тест краснеет.
|
||||
"""
|
||||
expected = f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
|
||||
options = _engine_connect_options(engine)
|
||||
# РАВЕНСТВО, а не `in`: подстрочная проверка пропускала испорченный хвост
|
||||
# (`…=30000zz` содержит `…=30000`), а Postgres на такое значение отвечает
|
||||
# `FATAL: invalid value for parameter "statement_timeout"` — ни одного коннекта
|
||||
# ни в одном из трёх сервисов образа, полный отказ продукта.
|
||||
assert options == expected, (
|
||||
f"движок открывает коннекты с options={options!r}, ожидалось {expected!r}: "
|
||||
"либо потолка нет вовсе (заблокированный запрос снова висит и жжёт слот "
|
||||
"/estimate, #3463), либо значение испорчено — тогда libpq отвергнет КАЖДЫЙ коннект"
|
||||
)
|
||||
assert DB_CONNECT_ARGS["options"] == options
|
||||
|
||||
|
||||
def test_ceilings_are_coherent_with_declared_estimate_budgets() -> None:
|
||||
"""Потолок выше САМОГО ДЛИННОГО объявленного бюджета `/estimate`, но конечен."""
|
||||
from app.core.config import settings
|
||||
|
||||
declared_budgets_s = (
|
||||
settings.estimate_avito_imv_timeout_s, # 20 с — самый длинный
|
||||
settings.estimate_geocode_budget_s, # 12 с
|
||||
settings.estimate_house_meta_timeout_s, # 8 с
|
||||
settings.estimate_yandex_valuation_timeout_s, # 8 с
|
||||
settings.estimate_cian_valuation_timeout_s, # 8 с
|
||||
)
|
||||
longest_ms = max(declared_budgets_s) * 1000
|
||||
assert _STATEMENT_TIMEOUT_MS > longest_ms, (
|
||||
f"потолок запроса {_STATEMENT_TIMEOUT_MS} мс не выше самого длинного объявленного "
|
||||
f"бюджета {longest_ms:.0f} мс — потолок стал бы биндящим ограничением честной работы"
|
||||
)
|
||||
# Потолок сверху — иначе у проверки есть пол и нет крыши: `_STATEMENT_TIMEOUT_MS =
|
||||
# 300_000` (пять минут) зеленел бы, а пять минут ожидания это тот же отказ, только
|
||||
# медленнее: четыре таких запроса всё так же выедают `_ESTIMATE_CONCURRENCY`.
|
||||
assert _STATEMENT_TIMEOUT_MS <= 2 * longest_ms, (
|
||||
f"потолок запроса {_STATEMENT_TIMEOUT_MS} мс больше чем вдвое превышает самый "
|
||||
f"длинный объявленный бюджет {longest_ms:.0f} мс — это уже не защита, а отсрочка: "
|
||||
"слот /estimate держится всё это время"
|
||||
)
|
||||
assert 0 < _LOCK_TIMEOUT_MS < _STATEMENT_TIMEOUT_MS, (
|
||||
"ожидание блокировки обязано обрываться РАНЬШЕ потолка на сам запрос: "
|
||||
"деградировать лучше, чем держать слот"
|
||||
)
|
||||
|
||||
|
||||
# ── Поведение против живой Postgres ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_live_session_reports_both_ceilings() -> None:
|
||||
"""Прод-ВЕЛИЧИНЫ на живой сессии ПРОД-движка: сервер их принял, а не проигнорировал.
|
||||
|
||||
Коннект берётся у самого `app.core.db.engine`, а не у собранного здесь двойника:
|
||||
двойник остался бы зелёным после снятия `connect_args=` в `create_engine`.
|
||||
"""
|
||||
if _live_engine(DB_CONNECT_ARGS) is None:
|
||||
pytest.skip("живой Postgres недоступен (DATABASE_URL-заглушка)")
|
||||
with engine.connect() as conn:
|
||||
statement_timeout = conn.execute(
|
||||
text("SELECT current_setting('statement_timeout')")
|
||||
).scalar_one()
|
||||
lock_timeout = conn.execute(text("SELECT current_setting('lock_timeout')")).scalar_one()
|
||||
|
||||
assert statement_timeout == "30s", (
|
||||
f"сессия сообщает statement_timeout={statement_timeout!r} — "
|
||||
f"ожидалось 30s (_STATEMENT_TIMEOUT_MS={_STATEMENT_TIMEOUT_MS})"
|
||||
)
|
||||
assert lock_timeout == "5s", (
|
||||
f"сессия сообщает lock_timeout={lock_timeout!r} — "
|
||||
f"ожидалось 5s (_LOCK_TIMEOUT_MS={_LOCK_TIMEOUT_MS})"
|
||||
)
|
||||
|
||||
|
||||
def test_statement_over_ceiling_is_cancelled_not_hung(probe_engine: Engine) -> None:
|
||||
"""`pg_sleep` длиннее потолка обрывается ОТМЕНОЙ за отведённое время."""
|
||||
sleep_s = _PROBE_STATEMENT_TIMEOUT_MS / 1000 * 5
|
||||
started = time.monotonic()
|
||||
with probe_engine.connect() as conn, pytest.raises(OperationalError) as excinfo:
|
||||
conn.execute(text(f"SELECT pg_sleep({sleep_s})"))
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
assert isinstance(excinfo.value.orig, psycopg.errors.QueryCanceled), (
|
||||
f"запрос упал не отменой по таймауту, а {type(excinfo.value.orig).__name__}"
|
||||
)
|
||||
assert elapsed < sleep_s, (
|
||||
f"запрос шёл {elapsed:.1f} с при потолке {_PROBE_STATEMENT_TIMEOUT_MS} мс — "
|
||||
"потолок не сработал, он висел до конца pg_sleep"
|
||||
)
|
||||
|
||||
|
||||
def test_lock_wait_over_ceiling_is_aborted(probe_engine: Engine) -> None:
|
||||
"""Сценарий #3463: под ACCESS EXCLUSIVE читатель ОТВАЛИВАЕТСЯ, а не ждёт вечно."""
|
||||
with probe_engine.connect() as blocker:
|
||||
blocker.execute(text(f"CREATE TABLE IF NOT EXISTS {_LOCK_PROBE_TABLE} (id int)"))
|
||||
blocker.commit()
|
||||
try:
|
||||
blocker.execute(text(f"LOCK TABLE {_LOCK_PROBE_TABLE} IN ACCESS EXCLUSIVE MODE"))
|
||||
started = time.monotonic()
|
||||
with probe_engine.connect() as victim, pytest.raises(OperationalError) as excinfo:
|
||||
victim.execute(text(f"SELECT count(*) FROM {_LOCK_PROBE_TABLE}"))
|
||||
elapsed = time.monotonic() - started
|
||||
finally:
|
||||
blocker.rollback()
|
||||
blocker.execute(text(f"DROP TABLE IF EXISTS {_LOCK_PROBE_TABLE}"))
|
||||
blocker.commit()
|
||||
|
||||
assert isinstance(excinfo.value.orig, psycopg.errors.LockNotAvailable), (
|
||||
f"читатель упал не по ожиданию блокировки, а {type(excinfo.value.orig).__name__} — "
|
||||
"сработал не тот потолок"
|
||||
)
|
||||
assert elapsed < _PROBE_STATEMENT_TIMEOUT_MS / 1000, (
|
||||
f"ожидание блокировки длилось {elapsed:.2f} с при lock_timeout "
|
||||
f"{_PROBE_LOCK_TIMEOUT_MS} мс — оборвал не lock_timeout"
|
||||
)
|
||||
|
||||
|
||||
def test_set_local_statement_timeout_overrides_session_ceiling(probe_engine: Engine) -> None:
|
||||
"""Задача со своим `SET LOCAL` НЕ обрезается сессионным потолком.
|
||||
|
||||
Это и есть проверка обещания «задачи планировщика не пострадают»: у
|
||||
`app/tasks/listing_source_snapshot.py:288` стоит `SET LOCAL statement_timeout = 900000`,
|
||||
и он обязан ПЕРЕКРЫВАТЬ значение из `connect_args`, а не наоборот.
|
||||
"""
|
||||
own_budget_ms = _PROBE_STATEMENT_TIMEOUT_MS * 10
|
||||
sleep_s = _PROBE_STATEMENT_TIMEOUT_MS / 1000 * 2 # заведомо больше сессионного потолка
|
||||
|
||||
with probe_engine.connect() as conn:
|
||||
conn.execute(text(f"SET LOCAL statement_timeout = {own_budget_ms}"))
|
||||
effective = conn.execute(text("SELECT current_setting('statement_timeout')")).scalar_one()
|
||||
assert effective == "10s", f"SET LOCAL не применился: current_setting={effective!r}"
|
||||
# Не просто current_setting: запрос длиннее СЕССИОННОГО потолка обязан дойти до конца.
|
||||
conn.execute(text(f"SELECT pg_sleep({sleep_s})"))
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def test_set_local_is_scoped_to_its_transaction(probe_engine: Engine) -> None:
|
||||
"""Обратная сторона: чужой `SET LOCAL` не снимает потолок со всей сессии.
|
||||
|
||||
Иначе одна задача с 900-секундным бюджетом отключала бы защиту у всех, кому
|
||||
достанется тот же коннект из пула.
|
||||
"""
|
||||
with probe_engine.connect() as conn:
|
||||
conn.execute(text(f"SET LOCAL statement_timeout = {_PROBE_STATEMENT_TIMEOUT_MS * 10}"))
|
||||
conn.rollback()
|
||||
after = conn.execute(text("SELECT current_setting('statement_timeout')")).scalar_one()
|
||||
|
||||
assert after == "1s", (
|
||||
f"после завершения транзакции statement_timeout={after!r} — "
|
||||
"SET LOCAL протёк за пределы своей транзакции, коннект вернулся в пул без потолка"
|
||||
)
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
"""Витрина сделок лэндинга попадает в расписание и в монитор свежести (#3469).
|
||||
|
||||
ЧТО БЫЛО СЛОМАНО. `landing_showcase_deals` считает витрину публичного лэндинга
|
||||
(«МЕРА сказала X — продали за Y»), но в `scrape_schedules` строки для неё не было
|
||||
вовсе (`WHERE source LIKE '%showcase%'` — 0 строк на проде 12.09.2026), а в
|
||||
реестре `product_handlers` — обработчика. То есть планировщик про задачу не знал
|
||||
ни с какой стороны, пересчёт был ручным, и страница показывала прогон
|
||||
тринадцатисуточной давности. Заметить это было неоткуда: под таблицей печатались
|
||||
счётчики прогона, но не его дата, а сводка просроченных источников
|
||||
(`emit_stale_digest`, #2670) ходит по ВКЛЮЧЁННЫМ РАСПИСАНИЯМ — источника, которого
|
||||
в таблице нет, для неё не существует.
|
||||
|
||||
ЧТО ПРОВЕРЯЕТСЯ ЗДЕСЬ, И ПОЧЕМУ ИМЕННО ЭТО.
|
||||
|
||||
1. Обработчик резолвится ТЕМ ЖЕ `resolve_handler`, которым его ищет боевой
|
||||
`_dispatch`. Одной строки расписания мало: без обработчика планировщик
|
||||
нашёл бы задачу и не смог её запустить.
|
||||
2. Миграция 303 сеет строку, и сеет её ВКЛЮЧЁННОЙ с явным `interval_days` —
|
||||
сводка считает порог просрочки из этого же числа.
|
||||
3. Сводка краснеет, когда витрина не пересчитывалась дольше ТРЁХ тактов
|
||||
(приёмка #3469), и молчит на двух. Число тактов здесь — литерал, а такт
|
||||
читается из миграции: ожидание, взятое из той же настройки, которую
|
||||
проверяешь, уезжает вместе с ней — см. комментарий при _ACCEPTANCE_CYCLES.
|
||||
4. Живой Postgres (само-скип): миграция реально вставляет строку в таблицу,
|
||||
повторное применение её не задваивает, и настоящий запрос сводки
|
||||
`_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной.
|
||||
|
||||
ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет ту же идемпотентную
|
||||
миграцию, что применяет деплой (ON CONFLICT DO NOTHING). В CI он ИДЁТ —
|
||||
ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; на машине без
|
||||
базы само-скипается (запись в tests/skip_allowlist.txt).
|
||||
|
||||
ГОНЯТЬ ЕГО НАДО НА ПОЛНОЙ СХЕМЕ, А НЕ НА ПУСТОЙ БАЗЕ. На чистой базе он был
|
||||
зелёным и при этом падал в CI: повтор `015_scrape_runs.sql` (его комментарий к
|
||||
колонке, снесённой миграцией 214) на полной схеме валится, а на пустой — нет.
|
||||
Поэтому зависимости применяются только когда таблицы ещё нет, а проверять надо
|
||||
тем же путём, каким гоняет CI:
|
||||
|
||||
docker exec tradein-postgres psql -U tradein -d postgres -c 'CREATE DATABASE t3469full'
|
||||
docker exec -i tradein-postgres psql -U tradein -d t3469full -c \\
|
||||
'CREATE EXTENSION postgis; CREATE EXTENSION pg_trgm; CREATE ROLE gendesign_reader;'
|
||||
for f in $(ls -1 data/sql/*.sql | sort); do docker exec -i tradein-postgres \\
|
||||
psql -U tradein -d t3469full -v ON_ERROR_STOP=on -q < "$f"; done
|
||||
DATABASE_URL="postgresql+psycopg://tradein:tradein@127.0.0.1:5433/t3469full" \\
|
||||
uv run python -m pytest tests/test_3469_showcase_schedule.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.orchestration import scheduler as sched
|
||||
|
||||
from app.services.product_handlers import _job_landing_showcase_deals, build_product_handlers
|
||||
|
||||
SOURCE = "landing_showcase_deals"
|
||||
|
||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||
_MIGRATION = _SQL_DIR / "303_scrape_schedules_seed_landing_showcase_deals.sql"
|
||||
# Таблицы, без которых строку расписания некуда класть (FK scrape_schedules →
|
||||
# scrape_runs), — живой тест применяет их в том же порядке, что и деплой.
|
||||
_DEPS = [
|
||||
_SQL_DIR / "015_scrape_runs.sql",
|
||||
# counters jsonb — по нему сводка судит, принёс ли прогон данные.
|
||||
_SQL_DIR / "051_scrape_runs_extend.sql",
|
||||
_SQL_DIR / "052_scrape_schedules.sql",
|
||||
]
|
||||
|
||||
NOW = datetime(2026, 9, 12, 8, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _migration_sql() -> str:
|
||||
return _MIGRATION.read_text("utf-8")
|
||||
|
||||
|
||||
def _seeded_interval_days() -> int:
|
||||
"""Такт из САМОЙ миграции — порог сводки считается из него, не из литерала."""
|
||||
m = re.search(r'"interval_days"\s*:\s*(\d+)', _migration_sql())
|
||||
assert m is not None, "в default_params миграции 303 нет interval_days"
|
||||
return int(m.group(1))
|
||||
|
||||
|
||||
# ── 1. Планировщик видит задачу ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_handler_resolves_for_scheduler() -> None:
|
||||
"""`resolve_handler` находит витрину — тем же вызовом, что и боевой _dispatch.
|
||||
|
||||
Ломать так: убрать ключ из реестра в product_handlers — тест покраснеет, а
|
||||
планировщик на проде заклеймил бы прогон и не нашёл, чем его выполнить.
|
||||
"""
|
||||
registry = build_product_handlers(ctx=None) # type: ignore[arg-type]
|
||||
handler = sched.resolve_handler(SOURCE, registry)
|
||||
assert handler is not None, f"{SOURCE} не резолвится реестром — задача невидима"
|
||||
# СРАВНИВАЕМ САМ JOB, А НЕ `log_name`: имя — второй литерал конструктора
|
||||
# Handler, и правильный ключ с чужим телом (`_job_landing_stats` под ключом
|
||||
# витрины) проходил проверку по имени насквозь. Резолв ведёт к пересчёту
|
||||
# витрины или не ведёт — это свойство функции, а не подписи в логе.
|
||||
assert handler.job is _job_landing_showcase_deals, (
|
||||
f"под ключом {SOURCE} стоит чужой job: {handler.job.__name__}"
|
||||
)
|
||||
assert handler.log_name == SOURCE
|
||||
|
||||
|
||||
# ── 2. Миграция сеет строку ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_migration_303_exists() -> None:
|
||||
assert _MIGRATION.is_file(), f"missing migration: {_MIGRATION}"
|
||||
|
||||
|
||||
def test_migration_303_seeds_source_enabled() -> None:
|
||||
sql = _migration_sql()
|
||||
assert f"'{SOURCE}'" in sql
|
||||
assert "INSERT INTO scrape_schedules" in sql
|
||||
# enabled=true — иначе сводка просроченных источников строку не увидит
|
||||
# (_STALE_SOURCES_SQL: WHERE sch.enabled), и монитор молчал бы как раньше.
|
||||
assert re.search(rf"'{SOURCE}',\s*\n\s*true", sql), "расписание засеяно выключенным"
|
||||
|
||||
|
||||
def test_migration_303_is_idempotent_and_transactional() -> None:
|
||||
sql = _migration_sql()
|
||||
assert "ON CONFLICT (source) DO NOTHING" in sql
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_303_no_psycopg_trap() -> None:
|
||||
assert not re.search(r":\w+::", _migration_sql())
|
||||
|
||||
|
||||
def test_migration_303_interval_days_is_daily() -> None:
|
||||
"""Такт суточный: витрина устаревает от КОДА (деплой), а не от квартальных сделок."""
|
||||
assert _seeded_interval_days() == 1
|
||||
|
||||
|
||||
# ── 3. Сводка свежести краснеет на молчащей витрине ──────────────────────────
|
||||
|
||||
|
||||
def _row(age_days: float, *, status: str | None = "done") -> Any:
|
||||
"""Строка `_STALE_SOURCES_SQL`: прогон витрины `age_days` суток назад.
|
||||
|
||||
`status=None` (LEFT JOIN не нашёл прогонов) — витрину не пересчитывали ни разу
|
||||
с момента появления расписания; тогда возраст считается от created_at строки.
|
||||
"""
|
||||
finished = None if status is None else NOW - timedelta(days=age_days)
|
||||
return SimpleNamespace(
|
||||
source=SOURCE,
|
||||
interval_days=str(_seeded_interval_days()),
|
||||
created_at=NOW - timedelta(days=age_days),
|
||||
finished_at=finished,
|
||||
status=status,
|
||||
counters={"considered": 200, "eligible": 161, "written": 20},
|
||||
)
|
||||
|
||||
|
||||
def _stale_now(rows: list[Any]) -> list[sched.StaleSource]:
|
||||
return sched.stale_sources(sched.freshness_rows(rows), NOW)
|
||||
|
||||
|
||||
# Приёмка issue #3469 дословно: «отсутствие прогона дольше 3× такта даёт тревогу».
|
||||
# ЧИСЛО ЗДЕСЬ ЛИТЕРАЛ, А НЕ `sched.STALE_DIGEST_INTERVAL_FACTOR`. Взятое из той же
|
||||
# настройки, которую проверяем, ожидание уезжает вместе с ней: при факторе 3650
|
||||
# ЭТИ ЖЕ тесты оставались зелёными (проверено руками), то есть проверяли ровно
|
||||
# ничего. Такт (`interval_days`) при этом читается из миграции — правило «3×»
|
||||
# и задано в тактах, а не в сутках.
|
||||
_ACCEPTANCE_CYCLES = 3
|
||||
|
||||
|
||||
def test_digest_flags_showcase_after_three_cycles() -> None:
|
||||
"""Нет пересчёта дольше 3× такта → витрина в сводке просроченных."""
|
||||
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
|
||||
stale = _stale_now([_row(lag)])
|
||||
assert [s.source for s in stale] == [SOURCE], (
|
||||
f"витрина молчит {lag} суток при такте {_seeded_interval_days()} и не в тревоге"
|
||||
)
|
||||
assert stale[0].interval_days == _seeded_interval_days()
|
||||
|
||||
|
||||
def test_digest_silent_within_cycle() -> None:
|
||||
"""Контроль: два такта — ещё норма, иначе тревога кричала бы всегда."""
|
||||
lag = 2 * _seeded_interval_days()
|
||||
assert _stale_now([_row(lag)]) == []
|
||||
|
||||
|
||||
def test_digest_flags_showcase_that_never_ran() -> None:
|
||||
"""Расписание есть, прогонов нет — самый частый вид молчания (#3469 и был им)."""
|
||||
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 1
|
||||
stale = _stale_now([_row(lag, status=None)])
|
||||
assert [s.source for s in stale] == [SOURCE]
|
||||
assert stale[0].never_ok is True
|
||||
|
||||
|
||||
def test_showcase_counters_do_not_fake_freshness() -> None:
|
||||
"""Свежесть даёт ПРОГОН, а не его счётчики.
|
||||
|
||||
`run_brought_data` судит по результатным ключам kit'а, а у витрины их нет
|
||||
(`considered`/`eligible`/`written` — свой словарь). Значит мерой остаётся
|
||||
успешный статус: прогон, свалившийся в failed, свежести не даёт.
|
||||
"""
|
||||
assert sched.run_brought_data("done", {"considered": 200, "written": 20}) is True
|
||||
assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False
|
||||
|
||||
|
||||
# ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────
|
||||
|
||||
|
||||
def _live_session() -> Any | None:
|
||||
"""Session на живой Postgres — та же проба, что у соседних живых тестов.
|
||||
|
||||
`TEST_DATABASE_URL` имеет приоритет; `localhost:5432/test` — заглушка модулей,
|
||||
её не считаем базой. В CI сюда приезжает DSN поднятого в job'е контейнера
|
||||
(ci-tradein.yml), поэтому проверка там ИДЁТ, а не тихо скипается.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return None
|
||||
engine = create_engine(dsn, future=True)
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return sessionmaker(bind=engine, future=True)()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _apply(db: Any, path: Path) -> None:
|
||||
"""Прогнать файл миграции целиком, одним куском — как `psql -f` на деплое.
|
||||
|
||||
Через ДРАЙВЕРНОЕ соединение, а не `exec_driver_sql`: последний отдаёт текст
|
||||
psycopg вместе с пустым набором параметров, и тот начинает искать в нём
|
||||
плейсхолдеры — любой процент в комментарии миграции («полоса −5..+20 %»)
|
||||
роняет запуск ошибкой про `%`. psql такого разбора не делает, так что это
|
||||
артефакт теста, а не свойство файла.
|
||||
"""
|
||||
db.connection().connection.driver_connection.execute(path.read_text("utf-8"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
|
||||
"""Миграция кладёт строку в scrape_schedules, и сводка видит витрину просроченной.
|
||||
|
||||
Значение, а не текст файла: применяем 303 на живой базе (дважды — дублей быть
|
||||
не должно), читаем строку обратно и прогоняем настоящий `_STALE_SOURCES_SQL` —
|
||||
тот же запрос, которым сводка судит на проде.
|
||||
|
||||
Ничего не удаляем: обе миграции идемпотентны (CREATE TABLE IF NOT EXISTS /
|
||||
ON CONFLICT DO NOTHING), то есть повтор здесь — ровно то же действие, что и
|
||||
повторный деплой.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
try:
|
||||
# Зависимости — ТОЛЬКО на пустой базе. На базе, прошедшей всю цепочку
|
||||
# (CI и прод), повтор 015 падает: `CREATE TABLE IF NOT EXISTS` — no-op,
|
||||
# а `COMMENT ON COLUMN scrape_runs.returning_count` внизу того же файла
|
||||
# обращается к колонке, которую снесла 214. Файл идемпотентен
|
||||
# относительно себя, но не относительно схемы, прошедшей 214, — и
|
||||
# прогон на чистой базе этого не видит по построению.
|
||||
if db.execute(text("SELECT to_regclass('public.scrape_schedules')")).scalar() is None:
|
||||
for dep in _DEPS:
|
||||
_apply(db, dep)
|
||||
_apply(db, _MIGRATION)
|
||||
# ИДЕМПОТЕНТНОСТЬ МЕРЯЕТСЯ ПО СОСТОЯНИЮ СТРОКИ, А НЕ ПО ЧИСЛУ СТРОК.
|
||||
# «DELETE + INSERT» тоже оставляет ровно одну строку, но на КАЖДОМ
|
||||
# деплое стирает last_run_at/next_run_at и взводит расписание заново —
|
||||
# счёт строк такую замену не отличает, а created_at отличает.
|
||||
first_created_at = db.execute(
|
||||
text("SELECT created_at FROM scrape_schedules WHERE source = :s"), {"s": SOURCE}
|
||||
).scalar()
|
||||
_apply(db, _MIGRATION)
|
||||
|
||||
rows = db.execute(
|
||||
text(
|
||||
"SELECT enabled, window_start_hour, window_end_hour, created_at, "
|
||||
" (next_run_at > now()) AS next_run_ahead, "
|
||||
" default_params->>'interval_days' AS interval_days "
|
||||
"FROM scrape_schedules WHERE source = :s"
|
||||
),
|
||||
{"s": SOURCE},
|
||||
).fetchall()
|
||||
assert len(rows) == 1, f"ожидалась одна строка расписания, получено {len(rows)}"
|
||||
row = rows[0]
|
||||
assert row.created_at == first_created_at, (
|
||||
"повторное применение пересоздало строку расписания — на каждом деплое "
|
||||
"это стирало бы состояние прогонов (last_run_at/next_run_at)"
|
||||
)
|
||||
assert row.enabled is True
|
||||
assert (row.window_start_hour, row.window_end_hour) == (6, 7)
|
||||
assert int(row.interval_days) == _seeded_interval_days()
|
||||
# next_run_at в БУДУЩЕМ: сев расписания не должен выстреливать прогоном
|
||||
# в момент деплоя (образец — 162/275). Стоит в приёмке, значит и здесь.
|
||||
assert row.next_run_ahead is True, "next_run_at в прошлом — прогон стартует на деплое"
|
||||
|
||||
# Сводка: прогонов у витрины нет, возраст считается от created_at строки.
|
||||
digest_rows = list(db.execute(sched._STALE_SOURCES_SQL).fetchall())
|
||||
assert SOURCE in {r.source for r in digest_rows}, "сводка не видит витрину"
|
||||
|
||||
overdue_at = row.created_at + timedelta(
|
||||
days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
|
||||
)
|
||||
stale = sched.stale_sources(sched.freshness_rows(digest_rows), overdue_at)
|
||||
assert SOURCE in {s.source for s in stale}, "витрина без прогонов не попала в тревогу"
|
||||
|
||||
fresh_at = row.created_at + timedelta(hours=1)
|
||||
fresh = sched.stale_sources(sched.freshness_rows(digest_rows), fresh_at)
|
||||
assert SOURCE not in {s.source for s in fresh}, "тревога сразу после сева — ложная"
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
"""Продуктовые счётчики (#3471): числа бизнеса рядом с техническими метриками.
|
||||
|
||||
Проверяется то же, ради чего вообще заведён `test_metrics.py` — не «метрика
|
||||
существует», а что она не может взорвать кардинальность. Источник счётчиков —
|
||||
реальные `event_type` из `user_events` (миграция 184) плюс две ручки без
|
||||
собственного audit-события (suggest, PDF). Метки везде — фиксированный литерал
|
||||
из места вызова (outcome/found/channel/result), никогда значение из запроса
|
||||
(username, адрес, estimate_id), поэтому тест фиксирует именно НАБОР меток, а не
|
||||
факт роста счётчика на единицу — рост уже проверен паттерном `test_metrics.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from prometheus_client import REGISTRY, generate_latest
|
||||
|
||||
from app.observability import metrics as m
|
||||
|
||||
|
||||
def _labelnames(counter: object) -> tuple[str, ...]:
|
||||
return tuple(counter._labelnames) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_estimates_counter_has_bounded_outcome_label() -> None:
|
||||
"""Только исход расчёта, никогда адрес/estimate_id — те дали бы ряд на заявку."""
|
||||
assert _labelnames(m.ESTIMATES) == ("outcome",)
|
||||
|
||||
|
||||
def test_address_suggestions_counter_has_bounded_found_label() -> None:
|
||||
"""found — да/нет, не сам адрес и не количество результатов (unbounded)."""
|
||||
assert _labelnames(m.ADDRESS_SUGGESTIONS) == ("found",)
|
||||
|
||||
|
||||
def test_reports_exported_counter_has_no_labels() -> None:
|
||||
"""У «Меры» один формат отчёта (PDF оценки) — без лейбла, дублировать нечего."""
|
||||
assert _labelnames(m.REPORTS_EXPORTED) == ()
|
||||
|
||||
|
||||
def test_leads_counter_has_no_labels() -> None:
|
||||
assert _labelnames(m.LEADS) == ()
|
||||
|
||||
|
||||
def test_support_messages_counter_has_bounded_channel_label() -> None:
|
||||
"""channel — ровно два значения (web/anon), не thread_id и не username."""
|
||||
assert _labelnames(m.SUPPORT_MESSAGES) == ("channel",)
|
||||
|
||||
|
||||
def test_logins_counter_has_bounded_result_label() -> None:
|
||||
"""result — исход попытки, не username (иначе ряд на каждый аккаунт)."""
|
||||
assert _labelnames(m.LOGINS) == ("result",)
|
||||
|
||||
|
||||
def test_product_counters_survive_a_realistic_sequence() -> None:
|
||||
"""Инкременты по реальным меткам видны в экспозиции и не мешают друг другу.
|
||||
|
||||
Значения сравниваются приращением: реестр `prometheus_client` глобален на
|
||||
процесс, и абсолютное число зависит от порядка запуска тестов.
|
||||
"""
|
||||
|
||||
def _value(name: str, labels: dict[str, str]) -> float:
|
||||
return REGISTRY.get_sample_value(name, labels) or 0.0
|
||||
|
||||
before_ok = _value("mera_estimates_total", {"outcome": "ok"})
|
||||
before_insufficient = _value("mera_estimates_total", {"outcome": "insufficient_data"})
|
||||
before_found = _value("mera_address_suggestions_total", {"found": "yes"})
|
||||
before_web = _value("mera_support_messages_total", {"channel": "web"})
|
||||
before_login_ok = _value("mera_logins_total", {"result": "success"})
|
||||
|
||||
m.ESTIMATES.labels(outcome="ok").inc()
|
||||
m.ESTIMATES.labels(outcome="insufficient_data").inc()
|
||||
m.ADDRESS_SUGGESTIONS.labels(found="yes").inc()
|
||||
m.SUPPORT_MESSAGES.labels(channel="web").inc()
|
||||
m.LOGINS.labels(result="success").inc()
|
||||
m.LEADS.inc()
|
||||
m.REPORTS_EXPORTED.inc()
|
||||
|
||||
assert _value("mera_estimates_total", {"outcome": "ok"}) - before_ok == 1.0
|
||||
assert (
|
||||
_value("mera_estimates_total", {"outcome": "insufficient_data"}) - before_insufficient
|
||||
== 1.0
|
||||
)
|
||||
assert _value("mera_address_suggestions_total", {"found": "yes"}) - before_found == 1.0
|
||||
assert _value("mera_support_messages_total", {"channel": "web"}) - before_web == 1.0
|
||||
assert _value("mera_logins_total", {"result": "success"}) - before_login_ok == 1.0
|
||||
|
||||
body = generate_latest(REGISTRY).decode()
|
||||
for metric in (
|
||||
"mera_estimates_total",
|
||||
"mera_address_suggestions_total",
|
||||
"mera_reports_exported_total",
|
||||
"mera_leads_total",
|
||||
"mera_support_messages_total",
|
||||
"mera_logins_total",
|
||||
):
|
||||
assert metric in body
|
||||
|
|
@ -70,7 +70,7 @@ def glitchtip_events() -> Iterator[list[dict[str, Any]]]:
|
|||
yield events
|
||||
finally:
|
||||
# Иначе на каждый тест остаётся фоновый поток транспорта.
|
||||
client.close(timeout=0)
|
||||
client.close()
|
||||
|
||||
|
||||
def event_texts(events: list[dict[str, Any]]) -> list[str]:
|
||||
|
|
|
|||
|
|
@ -215,8 +215,7 @@ async def test_backfill_yandex_addresses_error_on_non_200():
|
|||
return_value=mock_session,
|
||||
),
|
||||
):
|
||||
# request_delay_sec=0: реальный анти-бот sleep(3с) тут не нужен, 1 item в батче.
|
||||
result = await backfill_yandex_addresses(MagicMock(), limit=10, request_delay_sec=0)
|
||||
result = await backfill_yandex_addresses(MagicMock(), limit=10)
|
||||
|
||||
assert result.errors == 1
|
||||
assert result.saved == 0
|
||||
|
|
|
|||
|
|
@ -446,9 +446,8 @@ async def test_verify_session_pool_exhausted_returns_source_unavailable_with_err
|
|||
) -> None:
|
||||
"""#2825 fail-closed (#2616): пул scrape_proxies исчерпан для cian (все узлы
|
||||
забанены/нездоровы) — session.get НЕ вызывается (никуда не ходим без egress),
|
||||
возвращается VERIFY_SOURCE_UNAVAILABLE_SENTINEL, с явным логом (не тихое
|
||||
проглатывание). Штатный исход скрапинга -- warning, не error (было error;
|
||||
понижено, чтобы не шуметь в GlitchTip)."""
|
||||
возвращается VERIFY_SOURCE_UNAVAILABLE_SENTINEL, но с ERROR-логом (не warning,
|
||||
отдельным от обычного network-error пути) — явная деградация, а не проглатывание."""
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError
|
||||
|
||||
def _raise(source: str) -> str | None:
|
||||
|
|
@ -471,8 +470,8 @@ async def test_verify_session_pool_exhausted_returns_source_unavailable_with_err
|
|||
|
||||
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
mock_session.get.assert_not_called()
|
||||
warnings = [rec for rec in caplog.records if rec.levelname == "WARNING"]
|
||||
assert any("пул прокси исчерпан" in rec.message.lower() for rec in warnings)
|
||||
errors = [rec for rec in caplog.records if rec.levelname == "ERROR"]
|
||||
assert any("пул прокси исчерпан" in rec.message.lower() for rec in errors)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
"""Тесты фоновой пересылки GlitchTip-алерта в Telegram после отказа синхронной
|
||||
попытки — app/api/v1/glitchtip.py (BackgroundTasks) + app/tasks/glitchtip_alert_retry.py.
|
||||
|
||||
Контекст (#3471, #3157): GlitchTip вебхуки не ретраит, поэтому отказ синхронной
|
||||
попытки не должен терять текст алерта. 502 на отказ Telegram остаётся как есть
|
||||
(#3456) — проверяем, что он остаётся ОДНОВРЕМЕННО с постановкой фоновой доставки.
|
||||
|
||||
NEVER touches real DB / real Telegram API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import asyncio
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import glitchtip as glitchtip_module
|
||||
from app.services.tgbot.client import TelegramNetworkError
|
||||
from app.tasks import glitchtip_alert_retry as retry_module
|
||||
|
||||
_SECRET = "test-shared-secret"
|
||||
_ENDPOINT = "/api/v1/trade-in/ops/glitchtip-webhook"
|
||||
|
||||
_ISSUE_PAYLOAD = {
|
||||
"text": "GlitchTip Alert",
|
||||
"attachments": [{"title": "ValueError: something broke", "text": "app/services/foo.py"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", _SECRET)
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "fake-token")
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", -1004443088679)
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_topic_id", 158)
|
||||
|
||||
|
||||
class _FakeTelegramClient:
|
||||
"""Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети.
|
||||
|
||||
`responses` — очередь: каждый вызов `send_message` берёт следующий элемент
|
||||
(dict = успех, Exception = отказ), позволяя смоделировать «первая попытка не
|
||||
удалась, повторная фоном прошла».
|
||||
"""
|
||||
|
||||
calls: ClassVar[list[dict[str, Any]]] = []
|
||||
responses: ClassVar[list[dict[str, Any] | Exception]] = []
|
||||
|
||||
def __init__(self, _token: str = "fake-token") -> None:
|
||||
pass
|
||||
|
||||
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
|
||||
_FakeTelegramClient.calls.append(kwargs)
|
||||
outcome = _FakeTelegramClient.responses.pop(0)
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
_FakeTelegramClient.calls = []
|
||||
_FakeTelegramClient.responses = [{"message_id": 1}]
|
||||
monkeypatch.setattr(glitchtip_module, "get_telegram_client", lambda: _FakeTelegramClient())
|
||||
# Ретрай-модуль спит между попытками (_RETRY_DELAY_S=30s) — в тестах не ждём.
|
||||
monkeypatch.setattr(retry_module, "_RETRY_DELAY_S", 0.0)
|
||||
return _FakeTelegramClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(glitchtip_module.router, prefix="/api/v1/trade-in")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _network_error() -> TelegramNetworkError:
|
||||
return TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
|
||||
|
||||
|
||||
# ── отказ синхронной попытки → фон + 502 ────────────────────────────────────
|
||||
|
||||
|
||||
def test_sync_failure_queues_background_retry_and_still_returns_502(
|
||||
client: TestClient, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
"""Синхронная попытка не удалась → задача уходит в фон, ответ ОСТАЁТСЯ 502
|
||||
(#3456 — 502 задуман осознанно, не подменяется молчаливым 200)."""
|
||||
_fake_telegram_client.responses = [_network_error(), {"message_id": 2}]
|
||||
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 502
|
||||
# TestClient прогоняет BackgroundTasks синхронно перед возвратом ответа —
|
||||
# к этому моменту фоновая попытка уже отработала: 2 вызова (sync + retry).
|
||||
assert len(_fake_telegram_client.calls) == 2
|
||||
for call in _fake_telegram_client.calls:
|
||||
assert call["chat_id"] == -1004443088679
|
||||
assert call["message_thread_id"] == 158
|
||||
assert "ValueError: something broke" in call["text"]
|
||||
# Переиспользован тот же уже отформатированный текст — не пересобран заново.
|
||||
assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"]
|
||||
|
||||
|
||||
def test_sync_success_does_not_queue_background_retry(
|
||||
client: TestClient, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
"""Успешная синхронная отправка НЕ ставит фоновую задачу — ровно один вызов."""
|
||||
_fake_telegram_client.responses = [{"message_id": 1}]
|
||||
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(_fake_telegram_client.calls) == 1
|
||||
|
||||
|
||||
# ── retry_forward_alert напрямую: потолок ретраев ───────────────────────────
|
||||
|
||||
|
||||
async def _run_retry(fake_client_cls: Any, responses: list[Any]) -> None:
|
||||
fake_client_cls.responses = list(responses)
|
||||
await retry_module.retry_forward_alert(
|
||||
fake_client_cls(), chat_id=-1, text="алерт", message_thread_id=158
|
||||
)
|
||||
|
||||
|
||||
def test_retry_succeeds_after_transient_failure(_fake_telegram_client: Any) -> None:
|
||||
asyncio.run(_run_retry(_fake_telegram_client, [_network_error(), {"message_id": 9}]))
|
||||
|
||||
assert len(_fake_telegram_client.calls) == 2
|
||||
|
||||
|
||||
def test_retry_gives_up_after_max_attempts_and_logs(
|
||||
_fake_telegram_client: Any, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""На потолке ретраев — сдаётся с ERROR-логом, а не молча и не бесконечно."""
|
||||
responses = [_network_error() for _ in range(retry_module._MAX_ATTEMPTS)]
|
||||
|
||||
with caplog.at_level("ERROR", logger=retry_module.logger.name):
|
||||
asyncio.run(_run_retry(_fake_telegram_client, responses))
|
||||
|
||||
assert len(_fake_telegram_client.calls) == retry_module._MAX_ATTEMPTS
|
||||
assert any("не удалось доставить" in rec.message for rec in caplog.records)
|
||||
|
|
@ -23,7 +23,7 @@ from fastapi import FastAPI
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import glitchtip as glitchtip_module
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramNetworkError
|
||||
from app.services.tgbot.client import TelegramApiError
|
||||
|
||||
_SECRET = "test-shared-secret"
|
||||
_ENDPOINT = "/api/v1/trade-in/ops/glitchtip-webhook"
|
||||
|
|
@ -59,11 +59,7 @@ class _FakeTelegramClient:
|
|||
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
_FakeTelegramClient.calls = []
|
||||
_FakeTelegramClient._response = {"message_id": 1}
|
||||
# Ручка берёт ОБЩИЙ клиент приложения (#tg-connection-resilience), а не
|
||||
# создаёт свой на запрос — подменяем аксессор, а не класс.
|
||||
monkeypatch.setattr(
|
||||
glitchtip_module, "get_telegram_client", lambda: _FakeTelegramClient("fake-token")
|
||||
)
|
||||
monkeypatch.setattr(glitchtip_module, "TelegramClient", _FakeTelegramClient)
|
||||
return _FakeTelegramClient
|
||||
|
||||
|
||||
|
|
@ -146,23 +142,6 @@ def test_telegram_failure_returns_502_not_500(
|
|||
assert r.status_code != 500
|
||||
|
||||
|
||||
def test_telegram_unreachable_returns_502_not_500(
|
||||
client: TestClient, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
"""Прод 11.09.2026, 01:35 и 01:38 MSK: два 500 на этой ручке.
|
||||
|
||||
Telegram не ответил вовсе, клиент отдавал сырой `httpx.ConnectTimeout`, он
|
||||
пролетал мимо `except TelegramApiError` — и вместо задуманного 502 наружу
|
||||
уходил необработанный 500 (#3456). Отказ площадки и её недоступность для
|
||||
отправителя алерта неразличимы: переслать не смогли и там, и там.
|
||||
"""
|
||||
_FakeTelegramClient._response = TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
|
||||
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 502, "недоступный Telegram снова отдаёт 500"
|
||||
|
||||
|
||||
# ── auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -64,9 +64,7 @@ async def test_backfill_flag_on_constructs_browser_and_threads_it(
|
|||
monkeypatch.setattr(house_imv_backfill, "_process_one_house", _fake_process)
|
||||
|
||||
db = _fake_db_with_one_row()
|
||||
# request_delay_sec=0: мок дублирует строку в retry+fresh (2 записи вместо 1) — реальный
|
||||
# анти-бот sleep(5с) между ними тут не нужен.
|
||||
await house_imv_backfill.backfill_house_imv(db, batch_size=10, request_delay_sec=0)
|
||||
await house_imv_backfill.backfill_house_imv(db, batch_size=10)
|
||||
|
||||
# Ровно один BrowserFetcher(source="avito"), вошли в контекст.
|
||||
assert len(_FakeBrowserFetcher.instances) == 1
|
||||
|
|
@ -93,8 +91,7 @@ async def test_backfill_flag_off_no_browser_and_none_threaded(
|
|||
monkeypatch.setattr(house_imv_backfill, "_process_one_house", _fake_process)
|
||||
|
||||
db = _fake_db_with_one_row()
|
||||
# request_delay_sec=0: та же дублирующая мок-строка retry+fresh, что и в тесте выше.
|
||||
await house_imv_backfill.backfill_house_imv(db, batch_size=10, request_delay_sec=0)
|
||||
await house_imv_backfill.backfill_house_imv(db, batch_size=10)
|
||||
|
||||
# Флаг OFF → BrowserFetcher не конструируется, browser_fetcher=None.
|
||||
assert _FakeBrowserFetcher.instances == []
|
||||
|
|
|
|||
|
|
@ -1,21 +1,11 @@
|
|||
"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276).
|
||||
|
||||
Здесь защищаются ДВА разных свойства, и путать их нельзя.
|
||||
|
||||
1. ПОЛОСА. С 2026-09-12 витрина показывает только расхождения −5 %..+20 %
|
||||
включительно — решение владельца продукта. Это отбор показательных строк,
|
||||
и проверяется он ПО ЗНАЧЕНИЮ, на реальных строках прода: +75,7 % и −27,9 %
|
||||
на витрину не попадают, +9,9 % попадает.
|
||||
2. ВНУТРИ ПОЛОСЫ отбора по величине ошибки по-прежнему нет. Иначе витрина
|
||||
показывала бы лучший хвост уже самой полосы, а числа при этом остались бы
|
||||
красивыми — на глаз в проде такое не ловится. Поэтому проверка
|
||||
двусторонняя: самая точная строка, у которой не хватает данных, обязана
|
||||
проиграть менее точной, но полной.
|
||||
|
||||
Отбраковка («данных нет») — третье свойство, и она живёт в `build_row`: строка
|
||||
вне полосы остаётся кандидатом и попадает в счётчик `eligible`, её снимает
|
||||
отбор, а не отбраковка. Ровно поэтому подпись под витриной может честно
|
||||
сказать, сколько строк прогон собрал.
|
||||
Главное, что здесь защищается, — НЕ формат строки, а свойство отбора: витрина
|
||||
показывает выборку из работы оценщика, а не её лучший хвост. Отбор по малой
|
||||
ошибке дал бы формально работающий код и врущий продукт, и заметить это на
|
||||
глаз в проде нельзя — числа будут красивые. Поэтому проверка двусторонняя:
|
||||
самая точная строка, у которой не хватает данных, обязана проиграть менее
|
||||
точной, но полной.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -26,9 +16,6 @@ from datetime import date
|
|||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.tasks.landing_showcase_deals import (
|
||||
BAND_MAX_ERR_PCT,
|
||||
BAND_MIN_ERR_PCT,
|
||||
REJECTION_RULE,
|
||||
ShowcaseRow,
|
||||
build_row,
|
||||
quarter_label,
|
||||
|
|
@ -63,102 +50,26 @@ def _row(
|
|||
)
|
||||
|
||||
|
||||
# ── Полоса −5 %..+20 %: проверка ПО ЗНАЧЕНИЮ, на реальных строках прода ───────
|
||||
|
||||
|
||||
def test_band_drops_rows_outside_it_and_keeps_rows_inside() -> None:
|
||||
"""Три строки, которые сегодня лежат на витрине прода (прогон 30.08.2026).
|
||||
|
||||
id 41 — расхождение +75,71 %, id 43 — −27,87 %, id 44 — +9,85 %. Первые две
|
||||
на витрину попадать больше не должны, третья должна.
|
||||
|
||||
Ломать так: снять фильтр в `select_rows` (вернуть
|
||||
`sorted(rows, key=_sort_key)`) — тест покраснеет ПО ЗНАЧЕНИЮ, показав
|
||||
[44, 41, 43] вместо [44], то есть ровно те два промаха, которых владелец
|
||||
на витрине видеть не хочет.
|
||||
"""
|
||||
far_over = _row(41, err_pct=75.71)
|
||||
far_under = _row(43, err_pct=-27.87)
|
||||
inside = _row(44, err_pct=9.85)
|
||||
|
||||
chosen = select_rows([far_over, far_under, inside], limit=20)
|
||||
|
||||
assert [r.deal_id for r in chosen] == [44], (
|
||||
"на витрину прошла строка вне полосы −5 %..+20 %: подпись обещает "
|
||||
"полосу, а показывает не её"
|
||||
)
|
||||
|
||||
|
||||
def test_band_edges_are_inclusive_and_near_misses_are_not() -> None:
|
||||
"""Границы полосы включительные, а на волос за ними — уже нет.
|
||||
|
||||
Проверяется ПО ЗНАЧЕНИЮ у самой границы: `<` вместо `<=` в `in_band`
|
||||
выбросит ровно строки 1 и 2 и покраснит тест.
|
||||
"""
|
||||
rows = [
|
||||
_row(1, err_pct=BAND_MIN_ERR_PCT),
|
||||
_row(2, err_pct=BAND_MAX_ERR_PCT),
|
||||
_row(3, err_pct=BAND_MIN_ERR_PCT - 0.01),
|
||||
_row(4, err_pct=BAND_MAX_ERR_PCT + 0.01),
|
||||
]
|
||||
|
||||
assert sorted(r.deal_id for r in select_rows(rows, limit=20)) == [1, 2]
|
||||
|
||||
|
||||
def test_short_band_shows_what_there_is_and_does_not_top_up() -> None:
|
||||
"""В полосу попало меньше лимита — показываем сколько есть.
|
||||
|
||||
Добор ближайшими по ошибке был бы тем же отбором по величине ошибки, просто
|
||||
с другой стороны. Ломать так: добавить в `select_rows` «добить до limit
|
||||
остальными» — тест покраснеет тремя строками вместо одной.
|
||||
"""
|
||||
rows = [_row(1, err_pct=3.0), _row(2, err_pct=44.0), _row(3, err_pct=-60.0)]
|
||||
|
||||
assert [r.deal_id for r in select_rows(rows, limit=20)] == [1]
|
||||
|
||||
|
||||
def test_rejection_rule_names_the_band_that_is_actually_applied() -> None:
|
||||
"""Подпись витрины называет ТУ полосу, которую применяет фильтр.
|
||||
|
||||
Текст едет на фронт и там читается как обещание. Вписанный руками «−5 %» в
|
||||
тексте и `>= -5.0` в коде — две независимые величины; здесь проверяется,
|
||||
что в тексте стоят именно границы фильтра.
|
||||
|
||||
Ломать так: подвинуть `BAND_MAX_ERR_PCT` на 30, не трогая текст, — тест
|
||||
покраснеет на «+30 %», которого в подписи нет.
|
||||
"""
|
||||
assert f"{BAND_MIN_ERR_PCT:+.0f} %" in REJECTION_RULE
|
||||
assert f"{BAND_MAX_ERR_PCT:+.0f} %" in REJECTION_RULE
|
||||
assert "не вся сверка" in REJECTION_RULE, "подпись не говорит, что это отбор"
|
||||
# Снятая формулировка не должна вернуться: с фильтром она ложь.
|
||||
assert "на отбор и отбраковку не влияет" not in REJECTION_RULE
|
||||
|
||||
|
||||
# ── Внутри полосы: порядок задают полнота и свежесть, не ошибка ───────────────
|
||||
|
||||
|
||||
def test_selection_ignores_error_magnitude() -> None:
|
||||
"""Точнейшая строка с дырами в данных НЕ должна оказаться впереди полной.
|
||||
|
||||
Обе строки ВНУТРИ полосы — проверяется именно ранжирование, а не фильтр:
|
||||
иначе тест зеленел бы по той же причине, по которой краснеет соседний.
|
||||
|
||||
Ломать так: добавить в `_sort_key` слагаемое `abs(row.err_pct)` — тест
|
||||
покраснеет с id 1 на первом месте вместо id 2.
|
||||
"""
|
||||
almost_perfect_but_thin = _row(1, district=None, floor=None, err_pct=0.1)
|
||||
complete_but_worse = _row(2, err_pct=19.0)
|
||||
complete_but_worse = _row(2, err_pct=27.0)
|
||||
|
||||
chosen = select_rows([almost_perfect_but_thin, complete_but_worse], limit=1)
|
||||
|
||||
assert [r.deal_id for r in chosen] == [2], (
|
||||
"отбор поехал за величиной ошибки — витрина показывает лучший хвост уже самой полосы"
|
||||
"отбор поехал за величиной ошибки — витрина перестала быть выборкой "
|
||||
"и стала рекламой лучшего хвоста"
|
||||
)
|
||||
|
||||
|
||||
def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
|
||||
older = _row(1, deal_date=date(2025, 1, 1), err_pct=1.0)
|
||||
fresher = _row(2, deal_date=date(2026, 4, 1), err_pct=18.0)
|
||||
fresher = _row(2, deal_date=date(2026, 4, 1), err_pct=35.0)
|
||||
|
||||
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
|
||||
|
||||
|
|
@ -175,10 +86,9 @@ def test_selection_prefers_row_with_street_scheme() -> None:
|
|||
`completeness` — тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8].
|
||||
|
||||
Вторая сторона проверки — строка без улицы ОСТАЁТСЯ в витрине: она не
|
||||
первая, но и не выброшена. Отсутствие поля — не причина отбраковки, и
|
||||
полоса тут ни при чём: обе строки внутри неё.
|
||||
первая, но и не выброшена. Прятать промахи по-прежнему нельзя.
|
||||
"""
|
||||
no_street = _row(9, has_street=False, err_pct=19.0)
|
||||
no_street = _row(9, has_street=False, err_pct=75.7)
|
||||
with_street = _row(8, err_pct=3.0)
|
||||
|
||||
chosen = select_rows([no_street, with_street], limit=2)
|
||||
|
|
@ -247,12 +157,11 @@ def test_fact_is_the_contract_price_not_the_reconstruction() -> None:
|
|||
|
||||
|
||||
def test_no_error_magnitude_is_ever_rejected() -> None:
|
||||
"""Промах ЛЮБОГО размера остаётся кандидатом и попадает в счётчик.
|
||||
"""Промах оценщика ЛЮБОГО размера остаётся на витрине.
|
||||
|
||||
Отбраковка и отбор — разные шаги, и величина ошибки причиной ОТБРАКОВКИ не
|
||||
является: вне полосы строка не показывается, но входит в `eligible`, и
|
||||
подпись «показано N из M собранных» остаётся правдой. Отбракуй её здесь —
|
||||
и отбор перестал бы быть виден в счётчиках вообще.
|
||||
Это второй половина запрета «не отбирать по ошибке»: фильтр по величине
|
||||
ошибки — тот же отбор, просто ступенькой раньше, и он тем злее, что не
|
||||
оставляет строку даже в кандидатах.
|
||||
|
||||
Ломать так: вернуть в `build_row` любой порог вида
|
||||
`if abs(err_pct) > X: return None` — тест покраснеет на первом же
|
||||
|
|
@ -262,20 +171,20 @@ def test_no_error_magnitude_is_ever_rejected() -> None:
|
|||
for err_pct in (-95.0, -60.0, -41.0, -5.0, 0.0, 5.0, 41.0, 150.0, 900.0):
|
||||
row = _build(predicted_rub=fact_rub * (1 + err_pct / 100))
|
||||
assert row is not None, (
|
||||
f"строка с отклонением {err_pct:+.0f}% выброшена из кандидатов: "
|
||||
"счётчик собранных строк перестал считать работу оценщика"
|
||||
f"строка с отклонением {err_pct:+.0f}% выброшена: витрина снова "
|
||||
"показывает лучший хвост, а не работу оценщика"
|
||||
)
|
||||
assert row.err_pct == round(err_pct, 2)
|
||||
|
||||
|
||||
def test_underdeclared_dkp_is_counted_not_dropped() -> None:
|
||||
"""Занижение ДКП ради налога выглядит как промах — и остаётся кандидатом.
|
||||
def test_underdeclared_dkp_is_shown_not_hidden() -> None:
|
||||
"""Занижение ДКП ради налога выглядит как промах — и всё равно показывается.
|
||||
|
||||
Отбраковывать такие строки нельзя: «отклонение больше 40% — это почти
|
||||
всегда дефект ДКП» было догадкой, а санитарный диапазон ₽/м² уже применён
|
||||
к выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). На витрину
|
||||
такая строка не выйдет — её снимет полоса, — но в `eligible` она войдёт,
|
||||
и счётчик под таблицей останется честным.
|
||||
Прятать такие строки нельзя: «отклонение больше 40% — это почти всегда
|
||||
дефект ДКП» было догадкой, а санитарный диапазон ₽/м² уже применён к
|
||||
выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). Всё, что
|
||||
прошло его и дало большую ошибку, — работа оценщика. Честность за счёт
|
||||
строки в `note`, а не за счёт отсева.
|
||||
"""
|
||||
# Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%.
|
||||
row = _build(fact_rub=2_000_000.0)
|
||||
|
|
@ -338,8 +247,8 @@ def test_row_without_coords_stays_on_showcase() -> None:
|
|||
"""Нет точки — строка всё равно на витрине, с lat=lon=None.
|
||||
|
||||
Выбрасывать сделку из-за отсутствия координаты — отбор по признаку, не
|
||||
связанному с качеством оценки, и в отличие от полосы он нигде не назван:
|
||||
посетитель бы о нём не узнал. Карта переживёт строку без точки.
|
||||
связанному с качеством оценки: та же порча витрины, что и отбор по
|
||||
величине ошибки, просто по другому полю. Карта переживёт строку без точки.
|
||||
|
||||
Ломать так: добавить в `build_row` `if lat is None or lon is None: return
|
||||
None` — тест покраснеет на None вместо строки.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue