1739 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 192e10d32d |
Merge pull request 'fix(metrics): гасим crash-loop tg-relay через профиль relay' (#3492) from fix/3471-relay-secret-wiring into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Deploy Metrics / server (push) Successful in 44s
Deploy Metrics / agent-apps (push) Failing after 20s
Deploy Metrics / agent-infra (push) Successful in 30s
|
|||
| bbf70a3283 |
Merge pull request 'Продуктовые счётчики и дашборд воронки' (#3491) from feat/3471-product-metrics-dashboard into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 15s
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 17s
Deploy / build-frontend (push) Has been skipped
Deploy Metrics / agent-apps (push) Failing after 18s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Metrics / agent-infra (push) Successful in 30s
Deploy / build-backend (push) Successful in 2m22s
Deploy Trade-In / test (push) Has been cancelled
Deploy / build-worker (push) Successful in 3m31s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m41s
|
|||
|
|
347342bb5c |
fix(metrics): гасим crash-loop tg-relay пустым секретом через профиль relay
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
PR #3487 добавил сервис tg-relay без profiles: контейнер поднимался всегда и падал в SystemExit на пустом TG_RELAY_SECRET (на проде подтверждён Restarting в бесконечном цикле). - deploy-metrics.yml: TG_RELAY_SECRET прокинут в ssh-action по образцу ALERT_ACK_GLITCHTIP_SECRET; профиль relay включается независимо от alerts, только когда секрет непуст; ::warning на пустом секрете. Сравнение PROFILES с "alerts" переведено на case, иначе комбинация "alerts,relay" сломала бы прежнюю точную строковую проверку. - docker-compose.metrics.yml: tg-relay получил profiles: ["relay"]. - tradein-mvp/docker-compose.prod.yml: комментарий у tgbot — deploy-tradein.yml секреты приложения в CI не инжектит, TELEGRAM_RELAY_BASE_URL и TELEGRAM_RELAY_SECRET на продуктовом хосте заводятся так же, как прочие TELEGRAM_* — строкой в user-managed runtime-файле окружения backend на хосте, без правок workflow (существующий механизм этого файла, см. README-АДМИНУ.md). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG |
||
| ede6fd5974 |
Merge pull request 'Продуктовый Telegram-трафик уходит через ретранслятор на Beget' (#3487) from feat/3471-telegram-relay-beget into main
Some checks failed
Deploy Trade-In / test (push) Successful in 6m53s
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Infra Host / sync-infra-host (push) Successful in 7s
Deploy / changes (push) Successful in 13s
Deploy Trade-In / changes (push) Successful in 18s
Deploy Metrics / server (push) Successful in 25s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 45s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-frontend (push) Successful in 49s
Deploy / build-backend (push) Successful in 51s
Deploy Metrics / agent-apps (push) Failing after 15s
Deploy Metrics / agent-infra (push) Successful in 25s
Deploy / deploy (push) Successful in 1m13s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m46s
|
|||
|
|
24c2052057 |
style(tg): перенос длинной строки заголовков ретранслятора
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m58s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG |
||
| 2edaae148f |
Merge pull request 'Ответ оператора не исчезает при сбое БД, и реплай на собственный ответ снова маршрутизируется' (#3479) from fix/3471-bridge-db-failure-reply-loss into main
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 6m48s
Deploy Trade-In / build-backend (push) Successful in 1m13s
Deploy Trade-In / deploy (push) Has been cancelled
|
|||
| 382f266801 |
Merge pull request 'Алерт GlitchTip переживает недоступность Telegram: отправка уходит в фон, 502 остаётся' (#3485) from feat/3471-glitchtip-alert-queue into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 19s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
|
|||
|
|
690f1ef5d2 |
feat(metrics): продуктовые счётчики Prometheus для Меры и Птицы + дашборд
All checks were successful
CI / backend-tests (pull_request) Successful in 17m59s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m18s
CI Trade-In / backend-tests (pull_request) Successful in 5m59s
Владелец попросил вывести продукт в Графану — до этого там были только
технические панели (запросы/латентность/память). Список счётчиков взят из
реально пишущихся событий, а не выдуман:
Мера (tradein-mvp/backend/app/observability/metrics.py):
- mera_estimates_total{outcome=ok|insufficient_data} — POST /estimate,
зеркалит user_events.event_type=estimate_request (294 строки в БД),
insufficient_data — не ошибка, а исход без аналогов.
- mera_address_suggestions_total{found=yes|no} — GET /geocode/suggest,
своего user_events-события у ручки не было.
- mera_reports_exported_total (без лейблов) — GET /estimate/{id}/pdf.
- mera_leads_total (без лейблов) — POST /trade-in/lead.
- mera_support_messages_total{channel=web|anon} — POST /support/messages
и /support/anon/messages, счётчик после успешной доставки в Telegram.
- mera_logins_total{result=success|failed} — рядом с user_events
login_success/login_failed в auth.py (97/453 строк в БД).
Птица (backend/app/observability/metrics.py):
- sitefinder_reports_exported_total{format} — GET .../forecast/export
(md/json/tg/docx/pptx/pdf) и POST .../best-layouts/pdf.
Метки везде — фиксированный литерал из места вызова (outcome/found/channel/
result/format), никогда username/адрес/estimate_id/кадастровый номер —
это ровно то, что взрывает кардинальность ряда у Prometheus.
Дашборд ops/metrics/grafana/dashboards/product.json ("Продуктовые метрики",
uid gendesign-product) — воронка Меры (оценки/подсказки/лиды/отчёты/входы/
поддержка) + экспорт форматов Птицы, часовые increase()-панели без
стекирования (на соседней панели оно уже давало ложную тревогу, PR #3474).
Provisioning тот же, что у apps.json — сканирует директорию, отдельного
конфига не нужно.
ops/metrics/alloy/alloy-apps.alloy проверен: у job "apps" нет relabel-
фильтра по __name__ (в отличие от cadvisor) — новые счётчики уходят в
remote_write как есть, правки не потребовалось.
Refs #3471
|
||
|
|
9a93e575cc | Merge remote-tracking branch 'forgejo/main' into feat/3471-telegram-relay-beget | ||
| c826793a2a |
Merge pull request 'Меньше шума: выключенные платежи не засоряют ленту ошибок, провал прокси-пробы пишется строкой вместо трейса' (#3483) from fix/3471-observability-noise into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
|
|||
|
|
e0564d12fe |
feat(tg): продуктовый Bot API трафик уходит через ретранслятор на Beget
Замер 12.09.2026, оба хоста в одни и те же минуты: getMe из tradein-tgbot на Selectel — 9 успешных из 12, три ConnectTimeout; TCP-443 до адреса, резолвящегося на Selectel (149.154.167.220) — 5 из 6; TCP-443 до адреса, резолвящегося на Beget (149.154.166.110) — 8 из 8. За сутки в логе бота 508 строк network error, за 30 дней 92 обрыва итерации poll loop. Значит: путь до Telegram с Selectel лоссовый, с Beget чистый — Alertmanager (живёт на Beget) шлёт в тот же чат без проблем, а бот поддержки на Selectel часть отправок теряет. Добавлен ops/metrics/tg-relay — stdlib-only HTTP-сервис (тот же принцип, что у alert-ack: без зависимостей, поднимается даже когда всё остальное сломано), проксирует Bot API целиком (метод, путь, тело — sendMessage, copyMessage, getUpdates) на api.telegram.org. Токен из пути не логируется: log_request переопределён полностью, путь редактируется до записи в лог. Аутентификация — общий секрет в X-Relay-Secret, по образцу X-Internal-Auth-Secret из этого же стека. Клиент (tgbot/client.py) при транспортном отказе похода на ретранслятор делает одну попытку напрямую к api.telegram.org — хуже прямого пути быть не должно ни при каких условиях. Пустой TELEGRAM_RELAY_BASE_URL — прежнее поведение без изменений, это и есть механизм отката. Refs #3471 |
||
|
|
5e80b56bdc |
fix(tg): out-строки писались с support_chat_id=NULL — вечный wildcard-матч
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m17s
Deep review PR #3479 нашёл дефект в предыдущем фиксе (#3471 пункт 3): новые direction='out' строки стали видимы резолверам (find_chat_by_topic_message, find_thread_by_topic_message), но писались без support_chat_id. Резолверы матчат support_chat_id IS NULL как лениентный wildcard "любой текущий чат" (легаси-строки до 187/188) — то есть КАЖДАЯ out-строка становилась таким wildcard. При ротации support-группы новый message_id мог бы случайно совпасть со старой out-строкой: TG-путь увёл бы ответ ЧУЖОМУ клиенту через copyMessage, веб-путь записал бы ответ в чужой тред. Ровно от этого защищали миграции 187/188 (review M1). - bridge.py: TG- и веб-ветка `_handle_group_reply` теперь передают support_chat_id=settings.telegram_support_chat_id в record_message / record_web_out_message (симметрично уже существующей in-ветке). - web_support_storage.record_outbound: добавлен параметр support_chat_id, пишется в INSERT (колонка уже существовала, DDL не нужен). - Тест test_group_reply_to_own_previous_tg_reply_resolves_target_chat сидел предыдущую out-строку с уже заполненным support_chat_id вручную, хотя код писал NULL — маскировал дефект. Добавлены прямые проверки на записанное support_chat_id (TG и веб), обе падают на прежней реализации (проверено локальным откатом изменения — 2 failed, restore — 41 passed). - Комментарий про "апдейт частично применён в Telegram" в except-ветке веб-ответа был неверен для этого случая (на веб-пути ничего не уходит в Telegram до сбоя БД) — переписан на настоящую причину: сбой БД не переигрывается по общей политике process_update, а не из-за частичной доставки. Refs #3471 |
||
|
|
9eb42607b9 |
feat(glitchtip): фоновая ретрай-доставка алерта в Telegram при отказе синхронной попытки
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 27s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 8m54s
GlitchTip не ретраит вебхуки (#3157) — is_sent проставляется безусловно сразу после HTTP-ответа приёмника. При отказе Telegram синхронная попытка отвечала 502 и текст алерта пропадал безвозвратно (TRADE-IN-3F7, 28.08.2026; сеть до Telegram с хоста теряет ~каждый четвёртый запрос — замер 12.09). 502 при отказе Telegram ОСТАВЛЕН как есть — он задуман осознанно (#3456) как честный сигнал отправителю. Меняется судьба самого текста: перед возвратом 502 доставка ставится в фон через starlette.background.BackgroundTask на самом JSONResponse (app.tasks.glitchtip_alert_retry.retry_forward_alert), а не через FastAPI BackgroundTasks-зависимость — та привязывает задачи только к ответу, который вернул сам хендлер, а `raise HTTPException` строит отдельный ответ в exception-мидлваре, и такая задача не выполнилась бы вовсе (воспроизведено тестом при первой попытке реализации). Celery в проекте нет: ни app/celery_app.py, ни зависимости celery в backend/pyproject.toml не существует — бутстрап полноценной очереди с воркером вне границ этой задачи (новый контейнер/брокер). Фон использует штатную "воркерную" ретрай-политику TelegramClient.send_message (5 попыток, backoff до 30s) плюс свой внешний потолок в 3 попытки, чтобы недоставляемый алерт не крутился вечно — при исчерпании сдаётся с ERROR-логом текста. Переиспользует существующее форматирование (_build_message) и общий клиент приложения, без дублирования и новых переменных окружения. Refs #3471, #3157 |
||
| c3840019c5 |
Merge pull request 'Москва в реестре городов лендинга и кабинета' (#3472) from feat/msk-public-city-registry into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m3s
Deploy Trade-In / test (push) Successful in 5m21s
Deploy Trade-In / build-backend (push) Successful in 53s
Deploy Trade-In / deploy (push) Successful in 1m52s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m50s
|
|||
|
|
165c4a5edd |
fix(obs): убрать шум выключенных платежей и трейсы health-проб proxy_pool
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m59s
Два источника шума в error-ленте и логах: 1. GlitchTip группа TRADE-IN-3GG: 167 событий за 29.08-12.09 — 503 "payments are disabled" из payments.py._require_enabled, которые бьёт внутренний IP смоук-проверки (кнопки оплаты во фронте нет). sentry_sdk StarletteIntegration репортит любой HTTPException с кодом из 5xx как error-событие, даже когда FastAPI штатно обработал исключение и вернул корректный ответ. Добавлен before_send-фильтр drop_payments_disabled_event (app/observability/sentry_scrub.py), матчащий по (status_code=503, detail="payments are disabled") через hint["exc_info"] — не по коду 503 в целом, чтобы не проглотить другие 503. Подключён во всех трёх точках инициализации sentry_sdk.init (app/main.py — единственный реальный источник события, scheduler_main.py и tgbot_main.py — belt-and-suspenders для единообразия, по образцу scrub_payment_request_body). Само поведение ручки не меняется — 503 остаётся, фильтруется только репортинг в трекер. 2. proxy_pool._probe_proxy: httpx.ProxyError (407 от прокси-провайдера) не попадал ни под TimeoutException, ни под ConnectError и падал в generic except Exception с exc_info=True — 184 строки полного traceback в сутки на штатный провал health-пробы, хотя итоговая сводка checked/ok/failed и так его учитывает. Добавлена отдельная ветка except httpx.ProxyError с логом в одну строку (узел + причина текстом исключения, без трейса). Логика самой пробы, аренды узлов и правил пула не изменена. Тесты: tests/test_sentry_scrub.py (drop_payments_disabled_event — дропает целевой 503, пропускает прочие ошибки и прочие 503/detail-комбинации), tests/services/test_proxy_pool.py (ProxyError логируется одной строкой без exc_info, счётчики healthcheck не ломаются). Refs #3471 |
||
|
|
99f123e646 |
fix(tg): ответ оператора на веб-чат не теряется молча при сбое БД
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m56s
Для веб-треда запись в web_support_messages(direction='out') И ЕСТЬ доставка клиенту (веб-фронт читает её polling'ом). process_update на SQLAlchemyError безусловно делал rollback() и всё равно сдвигал offset — Telegram апдейт больше не отдавал, ответ оператора пропадал навсегда, а сам оператор был уверен, что ответил. Воспроизведено на проде 31.08.2026 (клиент kopylov). - `_handle_group_reply`: сбой БД на `record_web_out_message` теперь ловится локально — rollback → уведомление оператору реплаем в топик, что ответ НЕ доставлен и его нужно повторить; offset всё равно сдвигается (апдейт уже частично применён в Telegram, переигрывать нельзя). - `_notify_topic` возвращает bool: если само уведомление тоже упало (Telegram недоступен), пишем `logger.error` с thread_id/message_id (без текста переписки — ПДн в лог не идёт), чтобы это не осталось полностью немым. - Второй дефект того же узла: `direction='out'`-строки никогда не сохраняли topic_message_id, из-за чего реплай оператора на СВОЙ предыдущий ответ не резолвился (маршрут держался только на зеркале клиента). Теперь TG- и веб-путь сохраняют id ответа оператора в топике, `find_chat_by_topic_message` / `find_thread_by_topic_message` больше не фильтруют по direction. Колонка и partial unique индекс уже существовали (186/187) — миграция не потребовалась. Refs #3471 |
||
| 1eee4b955d |
Merge pull request 'ДКП-коридор по Москве не строился: имя улицы не извлекалось из московского формата адреса' (#3473) from fix/msk-street-name-suffix into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 22s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m27s
Deploy Trade-In / test (push) Successful in 5m8s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
|
|||
| 42daf8404f |
Merge pull request 'feat(mera/лендинг): витрина показывает полосу расхождения −5…+20 %; плитку «уверенность низкая» сменил замер 12.09' (#3468) from feat/landing-showcase-band into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
|
|||
| db47fa0ecd |
fix(mera/лендинг): утверждение про полосу проверяет само себя по показанным строкам
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m48s
CI Trade-In / backend-tests (pull_request) Successful in 6m0s
Дыра в выкате, найденная ревьюером до мержа. Подпись про полосу собиралась из констант `BAND_MIN_PCT`/`BAND_MAX_PCT` в коде фронта, а строки витрины и `rejection_rule` приезжают из БД, от ПОСЛЕДНЕГО прогона задачи `landing_showcase_deals`. Задачи нет в расписании — её запускают руками. Значит в окне «фронт выкачен, витрина не пересчитана» страница утверждала бы «показаны сделки с расхождением от −5 % до +20 %», а под утверждением лежали бы прежние двадцать строк: по замеру на проде 12 из 20 вне полосы, худшая +75,7 %. Утверждение и его опровержение в одном экране — хуже, чем было до правки. Чинится конструкцией, а не запуском задачи: `allWithinBand(deals)` в `deal-view.ts` спрашивает САМИ показанные строки теми же границами, что стоят в тексте. * Все показанные строки в полосе — печатаем прежнюю формулировку. * Хоть одна вне — про полосу НЕ утверждаем. В таблице: «Полосу расхождения от -5 % до +20 % эта подпись не обещает: среди показанных строк есть расхождения вне неё, то есть витрину собрал прогон с другим правилом — тем, что напечатано выше». Правило того прогона и так приезжает в `rejection_rule` из ТОГО ЖЕ прогона, что и строки, поэтому подпись с ними согласована по построению. В ленте остаётся только то, что посчитано по строкам: медиана и худшая. * Медиана по ВСЕЙ сверке (15,3 %, 325 сделок) печатается в обеих ветках — она и удерживает страницу честной независимо от того, пересчитана витрина. Тесты по значению в обе стороны: набор с одной строкой вне полосы (+75,71 % — реальная строка прода) → утверждения про полосу нет; все в полосе → есть. Фальсификация: `allWithinBand` обезврежен руками (всегда true) — краснеют оба новых теста, и красный текст показывает ровно тот дефект: «Это отобранная полоса расхождения от -5 % до +20 % … худшая 75,7 %». Проверка возвращена. Проверка заодно поймала мои же фикстуры ленты: −11,5 % ниже нижней границы полосы (−5 %), то есть «маленькое отклонение» ещё не значит «в полосе». Значения заменены на внутриполосные. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
|
d78b1f8881 |
fix(tradein): ДКП-коридор по Москве не строился — имя улицы не извлекалось
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m5s
`extract_street_name` возвращал None для любого московского адреса, потому что парсер ждёт тип улицы ПЕРЕД названием («ул. Малышева»), а в Москве он стоит после: «Тверская улица, 6». Keyword-регекс требует пробел сразу за типом, там запятая — совпадения нет вовсе; дальше fallback брал первый токен с большой буквы, получал «Москва» из стоп-списка и отдавал None. Следствие на проде (замер 12.09): оценка по московскому адресу отвечает 200 с 25 аналогами, но `dkp_corridor` в ответе — null, при 212 937 московских ДКП в базе. Коридор сделок по Москве не строился ни разу. Добавлен второй проход: ищем тип улицы без требования пробела и берём 1-3 слова ДО него в пределах той же запятой-секции. Прежний путь не тронут — «ул. X» и реверс-формат Nominatim разбираются как раньше; непустые результаты не меняются, новый проход даёт значение только там, где раньше был None. Списки типов улиц вынесены в общую константу, чтобы два регекса не разъехались при добавлении нового типа. Нумерованные проезды («Проектируемый проезд № 4062») намеренно остаются None: имя «Проектируемый» собрало бы коридор по сотням разных проездов. ## Регион-скоуп двух ручек Непустое имя улицы включает `/street-deals` и `/sales-vs-listings`, где раньше для Москвы был ранний выход. Обе скоупятся только по `_resolve_target_city` — словарю городов Свердловской области, — поэтому для Москвы фильтр города пуст, и остаётся один ILIKE по улице. Замер на проде: улица «Ясная» — 168 сделок в регионе 66 и 80 в 77, «Советская» — 1202 и 17. Без фильтра региона московский запрос смешал бы екатеринбургские сделки в медиану, то есть фикс парсера сам по себе открыл бы дыру. Поэтому в обе ручки добавлен обязательный фильтр по `region_code`; регион выводится из адреса через реестр регионов точным сравнением сегмента, а не подстрокой — иначе екатеринбургская «Московская улица» уехала бы в регион 77. В `deals` регион заполнен у всех строк (66 → 108 623, 77 → 212 937, NULL нет), так что фильтр ничего не отрезает у существующих запросов. У `/sales-vs-listings` табличная функция параметра региона не знает, её миграция в этот фикс не входит. Фильтр применён снаружи, соединением с `deals` по идентификатору сделки: сторона объявлений остаётся без регион-скоупа. Это осознанный компромисс, он описан в коде; полный фикс — отдельная миграция с параметром региона внутри функции. Тесты: 535 passed во всех файлах, затрагивающих коридор и уличную статистику (+13 новых), ruff чистый. |
||
|
|
c6711d05c4 |
feat(mera-public): Москва в реестре городов лендинга и кабинета
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 10s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m20s
CI Trade-In / backend-tests (pull_request) Successful in 6m2s
Реестр городов один на публичную форму и кабинет, и до сих пор он знал только Свердловскую область. Московский адрес нельзя было выбрать ни там, ни там, хотя бэкенд Москву поддерживает: реестр регионов знает 77, проба покрытия знает московские центроиды, оценка отрабатывает. Москва подана НЕ как ещё один «частично покрытый город области», а отдельной строкой: сбор по ней есть, а замера полноты покрытия нет, и приписывать ей формулировки области было бы неправдой. Для этого у `OblastCity` появилось поле `region`, а `SECONDARY_CITIES` теперь строится из `OBLAST_66_CITIES` — иначе Москва попала бы в перечисление городов области. Бэкенду поле не отправляется: это различение нужно только фронту. В `landing-facts.ts` строки Москвы сознательно нет — там лежат замеры покрытия по городам, а по Москве замера не делали. Придумывать цифру нельзя, поэтому паритет-тест копи сверяет замеры с `OBLAST_66_CITIES`. Тексты про географию переписаны в четырёх местах: плашка покрытия на главной, карточка бесплатной пробы, ответ FAQ про регионы и сообщение «адрес вне покрытия». Везде одна и та же честная формулировка: по области — полное и частичное покрытие, по Москве — считаем, но полноту не мерили. Юридический адрес в подвале не трогали, там «Свердловская область» — это адрес компании, а не география сервиса. Паритет-тест дропдауна и порогов покрытия на бэкенде дополнен Москвой: город, предлагаемый к выбору, обязан быть отвечаемым пробой. Фронт: 218 passed, tsc и eslint чистые. Бэкенд: 34 passed в затронутом файле. |
||
|
|
ef82a707fc |
fix(mera): московский city_hint больше не уходит молча в регион 66
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m31s
Публичный `/suggest` и кабинетный `/api/v1/geocode/suggest` всегда звали геокодер с `region_code=66`: публичная ручка регион не передавала вовсе, а у кабинетной он был обязательным параметром со значением по умолчанию. `city_hint="Москва"` на это не влиял — DaData и Nominatim получали свердловский hard-констрейнт и молча возвращали ПУСТО. С сайта и из кабинета московский адрес просто нельзя было ввести, хотя оценка, проба покрытия и реестр регионов Москву уже поддерживают. Добавлен `effective_region_code()`: явный `region_code` важнее вывода из `city_hint`, вывод идёт через существующий реестр `app.services.regions` (`REGIONS[77].cities` содержит «москва»), последний рубеж — прежний `DEFAULT_REGION_CODE`. Отдельного списка городов не заводим: разъехаться двум спискам — вопрос времени. Поведение сегодняшних клиентов не меняется байт-в-байт: без `city_hint` и с любым свердловским городом регион по-прежнему 66. Публичная схема принимает `region_code` на будущее — если фронт когда-нибудь начнёт его слать, он будет приоритетнее хинта; неизвестный регион как и раньше отдаёт 422 из геокодера, а не 500. Тесты: четыре инварианта на сам хелпер (нет хинта → 66; свердловский город → 66; Москва → 77; явный 66 поверх Москвы → 66) и по одному на каждую ручку — что вниз по потоку уезжает ожидаемый регион. Прежние тесты region-скоупа геокодера не тронуты. 189 passed в связанных файлах, ruff чистый. |
||
| 2467943200 |
feat(mera/лендинг): витрина показывает полосу расхождения −5…+20 %, плитку уверенности сменил замер 12.09
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m13s
CI Trade-In / backend-tests (pull_request) Successful in 5m29s
Владелец просит на витрине только сделки, где прогноз разошёлся с ценой ДКП в пределах от −5 % до +20 %. Фильтр живёт в продюсере (`select_rows`), поэтому таблица сверок и бегущая строка берут ОДИН набор, а не два. Чтобы страница от этого не начала врать: * `REJECTION_RULE` переписан. Прежняя формулировка («величина отклонения на отбор и отбраковку не влияет — иначе витрина показывала бы лучший хвост») после фильтра стала ложью ровно про то, чего опасалась, поэтому снята, а не смягчена. Новая называет полосу и говорит, что это отбор показательных строк, а не вся сверка. Границы в текст ПОДСТАВЛЯЮТСЯ из констант `BAND_MIN_ERR_PCT`/`BAND_MAX_ERR_PCT` — подпись не может разъехаться с фильтром, и это проверяется тестом. * Фильтр стоит в `select_rows`, а не в `build_row`: строка вне полосы остаётся кандидатом и попадает в `eligible`. Отбраковав её раньше, мы получили бы «показано 20 из 20 годных» — счётчик, из которого отбор не виден вообще. * Счётчики разъехались с подписью, и подпись поправлена: `eligible − written` больше не значит «столько не поместилось», в разницу входят отсеянные полосой. Под таблицей теперь «показано N строк из M собранных прогоном». * «В пределах 20 % — N из N» из подписи снято: при потолке полосы +20 счёт всегда выходил бы N из N и читался бы как замер попадания. Неработающая проверка читается как работающая. * Медиана по ВСЕЙ сверке (15,3 %, 325 сделок) в подписи осталась и теперь сторожится тестом: без неё разброс отобранной двадцатки читается как точность расчёта. * Полоса названа и в подписи ленты — она висит над первым экраном, её числа читают раньше любых оговорок блока «Точность». * Меньше лимита в полосе — показываем сколько есть, добора нет. Плитка «400 из 400 расчётов с пометкой „уверенность низкая“» заменена на свежий замер 12.09.2026 (engine=full, 290 сделок, медиана трёх пересборок с солями 11/22/33): «52,7 % сделок — расхождение в пределах ±20 %». Запись `confidenceLow` не удалена, а помечена снятой (прогон 29.08 на кластеризованной выборке) — до решения владельца. Оговорки новой величины называют три вещи, без которых она льстит: замер не point-in-time, разброс пересборок 46,2–56,6 %, и что медианное расхождение того же прогона (19,1 %) ВЫШЕ прежних 15,3 % от 31.08 — на странице два числа разных дат, и молчать о том, что свежий прогон вышел хуже, нельзя. `priceError` и `coverage` не тронуты. Сторож свежести теперь следит за ОБЕИМИ датами замеров, а не только за 31.08. Проверено: на проде из 20 сегодняшних строк витрины в полосу попадают 8 (40 %), что сходится с 35,5 % «доли в полосе» из бэктеста 12.09. Фальсификация: снятие фильтра руками красит 3 теста, ключевой — по значению ([44, 43, 41] вместо [44] на реальных строках прода +75,7 / −27,9 / +9,9 %). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 03d9745b4f |
Отмена по бюджету больше не оставляет поток в сессии запроса — оценка не теряется на 500 (#3449)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m28s
Deploy Trade-In / test (push) Successful in 4m38s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 1m46s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
|
|||
| dfc39355f9 |
Merge pull request 'Коридор сделок с малой выборкой честно помечен справочным' (#3462) from fix/3452-corridor-advisory-zone into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
|
|||
| 668ac40631 |
fix(tradein): подпись коридора говорит про выборку, а не про алгоритм
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m9s
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
Ревью #3462 поймало ложь в микрокопии: «оценку по ним не корректировали» утверждает про АЛГОРИТМ то, чего код не гарантирует. Порог estimate_corridor_clamp_min_n гейтит только две страховки — кламп headline и radius-floor. Третий ценовой путь, гейт Tier C (#1795 шаг 3, estimator.py), сравнивает якорь с потолком коридора БЕЗ порога вообще: коридор из пяти сделок там способен уронить headline на треть (воспроизведено ревьюером: якорь Tier C 300 000 ₽/м², с коридором 200 000 против 300 500 без него). Плюс deals-headline-fallback берёт медиану коридора начиная с трёх сделок. Формулировка переписана на утверждение о ДАННЫХ — оно истинно во всех достижимых состояниях: «справочно: сделок мало (N) — коридор ориентировочный». Ветка «объявлений рядом нет» (n_analogs = 0) больше не молчит: раньше там возвращался null, и клиент не узнавал, что вся его цена стоит на трёх сделках. Теперь — «оценка построена на этих сделках — их всего N». Докстринги advisory_only в схеме и комментарий у лога тоже перестали обещать «коридор в цену не пошёл»: поле значит ровно «страховки выключены». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| be9aa2f907 |
Гейт на ВСЕ 34 проводки + запрет вложенных бюджетов (ревью #3460)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
Сценарный тест ловил одну проводку из 34 — ту, через которую сам и шёл (`geocoder._cache_get`). Мутационный прогон ревьюера: возврат голого `asyncio.to_thread` в 5 из 6 других мест тест НЕ краснит, то есть регресс «кто-то вернул вызов в голый вид» прошёл бы мимо CI в 33 случаях из 34. `test_no_bare_to_thread_over_request_session` читает исходники geocoder и estimator (через `module.__file__`, не по относительному пути — он зависел бы от cwd прогона) и требует нуля живых `asyncio.to_thread(`. Оба модуля сейчас на нуле, поэтому гейт без списка исключений. Фальсификация — голый `to_thread` у `_fetch_anchor_comps` (estimator:4973, сценарным тестом не покрыт): гейт краснеет с номером строки. Второе: защита `run_db_thread` одноразовая — `except asyncio.CancelledError` ловит ОДНУ отмену, вторая вылетает из самого `asyncio.wait([step])`, и поток остаётся сиротой. Живых путей нет (`_with_budget` нигде не вложен, Starlette не отменяет задачу на дисконнекте, uvicorn стартует без `--timeout-graceful-shutdown`), поэтому кода не трогаю — фиксирую инвариант «не вкладывать бюджеты» в докстринге `_with_budget`, чтобы вложение не завезли как безобидное. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| de2d67c7f9 |
docs(mera/sales-vs-listings): якорь и докстринг по замечаниям ревью PR #3461
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m15s
Ревью справедливо поймало два места, где текст после снятия предиката стал неточным: 1. Якорь в deploy/import-rosreestr.sh обещал «потребителей, фильтрующих по d.rooms, больше нет» — это верно только про предикаты РАВЕНСТВА. app/tasks/asking_to_sold_ratio.py:148,152 по-прежнему КЛЮЧУЕТСЯ этим бакетом (GROUP BY LEAST(GREATEST(rooms,0),4)) и намеренно зеркалит ту же синтетику на листинговой стороне (#2620). Прежняя формулировка сказала бы будущему редактору, что проверять некого, — а в сценарии «поменяли CASE на реальную комнатность» вернулся бы именно #2620. 2. Докстринг GET /sales-vs-listings обещал listing «с такими же rooms». После снятия предиката это верно для пары запрос↔объявление, но не для пары сделка↔объявление: deal_rooms может не совпадать с запрошенным rooms. Кода правка не касается. Полный сьют: 5946 passed, 35 skipped; ruff чист. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| b71f3f9957 |
fix(tradein): коридор ДКП с малым числом сделок помечен справочным
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m16s
CI Trade-In / backend-tests (pull_request) Successful in 5m32s
Показ коридора открывается с трёх сделок (DKP_CORRIDOR_CITY_WIDE_MIN_N), а обе ценовые страховки по нему — soft-кламп headline сверху и radius-floor снизу — включаются с десяти (estimate_corridor_clamp_min_n). В зоне n=3..9 коридор существует, показывается и участвует в fallback-путях, но цену не держит, и на экране это неотличимо от работающего коридора. PR #3445 (снятие предиката d.rooms) переносит туда реальных клиентов: замерено 248 → 3 и 72 → 8 сделок. Решение — advisory-only. Порог показа не поднят (это отняло бы у клиента информацию), кламп по трём сделкам не включён (был бы хуже своего отсутствия), но зона теперь названа вслух: - DkpCorridor.advisory_only — computed-поле от count и ЕДИНСТВЕННОГО порога estimate_corridor_clamp_min_n, так что верно во всех конструкторах коридора (POST /estimate и GET-rehydrate) и не дублирует порог вторым числом; - лог INFO с маркером corridor_advisory_zone (n, порог, scope street/city_wide, id оценки) — одна строка на оценку, считается за сутки одним grep -c; - _fetch_dkp_corridor отдаёт служебный ключ scope: «мало сделок на улице» и «мало сделок во всём городе» — разные новости, и лог обязан их различать; - на экране (v1 hero + плитка ДКП в v2) подпись «справочно: мало сделок — оценку по ним не корректировали». Подпись молчит, когда headline ПОСТРОЕН из этого же коридора (n_analogs = 0, deals-fallback): там показанная цена и есть медиана этих сделок, и подпись была бы ложью в другую сторону. Тесты по значению (test_3452_corridor_advisory_zone.py) гоняют настоящий estimate_quality с коридором, потолок которого заведомо ниже медианы аналогов: в зоне headline НЕ прижат и метка стоит, выше порога — прижат к cap и метки нет. Захардкоженный флаг в любую сторону и снятый порог клампа роняют тесты (проверено руками). Closes #3452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 4053adad06 |
fix(mera/sales-vs-listings): снят предикат d.rooms в TVF — он был вторым фильтром по площади (#3451)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m23s
`street_sales_vs_listings()` фильтровала сделки по `d.rooms`, а `deals.rooms` у источника 'rosreestr' — не комнатность, а бакет площади: импортёр пишет туда `CASE WHEN area < 30 THEN 0 ... ELSE 4 END`, и на проде 321 559 строк из 321 560 удовлетворяют `rooms == area_bucket(area_m2)`. Предикат работал вторым фильтром по площади поверх полосы ±15 %, которую функция считает сама: клиенту 49 м² / 1к полоса 41.7–56.4 м² урезалась до «меньше 44 м²». Ровно эта патология снята в #3256 (PR #3445) на четырёх сделочных площадках эстиматора. Здесь — последний оставшийся потребитель, и ключ асимметричный: `d.rooms` снят, `l.rooms` ОСТАВЛЕН (у объявлений комнатность настоящая, это единственный признак ассортимента на листинговой стороне). Миграция 300 = тело 211 минус ровно одна строка; сигнатура и RETURNS TABLE побайтово те же (иначе CREATE OR REPLACE создал бы вторую перегрузку, #2627), сегментный гард #2660/#1186 и city-предикаты #2583 H4 перенесены дословно. Прод-замер (2026-09-12, 1160 реальных клиентских запросов из trade_in_estimates, улица извлеклась у 954; тем же путём, что у продукта — 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) — предикат умел только резать. Прогноз в issue был «те же 180 клиентов»; измерено 94 — оценка 180 бралась по коридору эстиматора с другими period/tolerance, в файл положено измеренное. Тело проверено EXPLAIN'ом на боевой БД (только чтение) — планировщик принимает. Тесты: tests/test_migration_300_sales_vs_listings_deals_rooms.py — статические гарды. Фальсификация обоих направлений: вернул `d.rooms = p_rooms` → красные test_deals_side_has_no_rooms_predicate + test_only_the_rooms_predicate_differs_from_211; снял заодно `l.rooms = p_rooms` («починил симметрично») → красный test_listings_side_keeps_rooms_predicate. Полный сьют: 5946 passed, 35 skipped. Якорь в deploy/import-rosreestr.sh обновлён: потребителей, фильтрующих по d.rooms, больше нет. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| c251c02f1e |
Отмена по бюджету больше не оставляет сироту в сессии запроса (#3449)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m24s
`asyncio.to_thread` отменить нельзя: по истечении бюджета (`_with_budget` = `asyncio.wait_for`, у геокодера 12 с) снимается только ожидание со стороны loop'а — поток продолжает работать с ТОЙ ЖЕ `Session`, что и весь запрос. Вызывающий тем временем идёт дальше: следующий источник, `_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной `Session` дают «another operation is in progress» / InvalidRequestError на СЛЕДУЮЩЕМ шаге. У источников эту ошибку глушит `except` вокруг вызова, у персиста оценки не глушит никто — 500 и потерянная оценка клиента. `app/core/db.py: run_db_thread` — ТОЛЬКО защита от сироты: `ensure_future` + `shield`, на отмене дождаться потока (`asyncio.wait`), прочитать `step.exception()` (иначе asyncio печатает «Task exception was never retrieved» без контекста) и пробросить отмену. Commit/rollback туда НЕ вынесены: посреди геокодинга commit зафиксировал бы частичное состояние оценки. `estimator._db_step` переписан поверх и добавляет свои commit/rollback сам — его поведение не меняется, гейт tests/test_3408_db_step_cancel_orphan.py остаётся зелёным. Заменено 34 вызова, работающих по сессии запроса: 12 в geocoder.py (кэш-чтение и записи, геопортал, кадастр, houses, reverse, suggest), 19 в estimator.py (в т.ч. `_backfill_house_fias`, `_save_yandex_history_items`, `_fetch_anchor_comps`, `_price_from_inputs` с db-резолверами, персист оценки, `_fetch_price_trend`, `_is_premium_building`), 2 в api/v1/geocode.py, 1 в api/v1/privacy_admin.py. Не тронуты вызовы со СВОЕЙ сессией: `user_events.schedule_event` (внутри `record_event` свой `SessionLocal`) и `sber_index` (сессия задачи планировщика, отменять её некому). Гейт по значению — tests/test_3449_geocoder_cancel_orphan.py: отмена по бюджету во время шага БД геокодера, следом ГОЛЫЙ `to_thread(db.execute, ...)` (образец персиста); проверяется, что он не вошёл в сессию, пока сирота ещё в ней. На исходном коде тест краснеет: conflicts == 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
|
|
10ffa93a1c |
fix(support): доставленное сообщение не теряется при сбое БД, отказы Telegram расходуют бюджет
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m11s
CI Trade-In / backend-tests (pull_request) Successful in 5m26s
Два последних дефекта из разбора телеграм-стека, оба в ручках веб-поддержки. Предыдущие три PR (#3456, #3457, #3458) чинили клиент и мост; эти — сами ручки. ## Сбой БД уже ПОСЛЕ доставки в топик Порядок «сначала Telegram, потом БД» осознанный, но блок записи не был обёрнут ничем, в отличие от шага отправки. `SQLAlchemyError` там означал: сообщение оператору доставлено, а клиент получил 500. Дальше по цепочке — пользователь шлёт повторно, в топике дубль, а на осиротевшее зеркало оператор отвечает в пустоту, потому что треда в БД нет и мост на реплай пишет только WARNING. Обе ручки теперь ловят `SQLAlchemyError` вокруг блока БД, тихо откатывают сессию, предупреждают оператора реплаем к доставленному зеркалу и отдают клиенту успех. Успех, а не отказ: доставка правда состоялась, и отказ спровоцировал бы ровно тот дубль, которого избегаем. Анонимная ветка на этом пути дополнительно ставит куку, хотя штатно ставит её только на успехе: треда нет, но идентичность посетителя обязана пережить сбой, иначе следующее сообщение заведёт второй тред. ## Успеха мало — клиент должен об этом узнать Первая версия правки отдавала успех молча, и это было неотличимо от тишины. Фронт выбрасывает тело POST и рендерит переписку только из GET, а сообщения там нет: поле ввода очищается, в списке пусто, баннера нет. Пользователь решает, что не отправилось, и шлёт снова — тот самый дубль. Нашло adversarial-ревью, и это подтверждено чтением `useSupportChat.ts` и `SupportChatPanel.tsx`. Поэтому `SupportMessageOut` получил поле `persisted` со значением `True` по умолчанию — все существующие пути и `GET /support/messages` отдают его без изменений. На пути деградации приходит `False`, и панель показывает рядом с композером предупреждение: сообщение получено оператором, но в переписке его не будет, отправлять ещё раз не нужно. Баннер гаснет на следующей нормально записанной отправке. Анонимный виджет рендерит ту же панель и получает это поведение автоматически. Текст предупреждения оператору тоже переписан: он больше не рассчитывает на то, что клиент напишет снова, и прямо говорит, что ответить через бота не получится. ## Рейт-лимит переставал считаться при недоступном Telegram `retry_after()` — это peek, а `record()` звался только на успехе. Верно для «не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram лежит, лимита нет вообще, и каждый повтор стоит до четырёх попыток к api.telegram.org, не расходуя ни один бюджет. Двух-трёх вкладок с авто-повтором хватает, чтобы выесть лимиты группы ровно тогда, когда канал и так еле жив. Добавлен отдельный счётчик отказов на тех же ключах: пять подряд в окне тридцати секунд включают cooldown, и ручка отвечает 429 не доходя до Telegram. Пять подряд на живом канале практически недостижимы, а `reset()` на успехе стирает историю — считаем именно подряд. Тридцать секунд заведомо короче реальной недоступности, так что после восстановления пользователя не наказывают. Основной «успешный» бюджет и non-destructive peek не тронуты. `SlidingWindowLimiter.reset(key)` добавлен аддитивно, с оговоркой в докстринге, что лимитерам-бюджетам он противопоказан. Барьер рассчитан на несколько вкладок с авто-повтором, а не на одиночного последовательного клиента: один отказавший запрос сам занимает до двадцати трёх секунд, и пять таких в окно не укладываются. Это принято сознательно — ловить одиночку значило бы наказывать обычного пользователя за чужую аварию. ## Тесты Отказ БД в обеих ручках: клиент получает успех с `persisted=False`, оператору уходит предупреждение, текст обращения в него не попадает, 500 не возникает. Отказ самого уведомления ручку не роняет. Серия отказов включает cooldown, и до Telegram запрос не доходит. Окончание окна cooldown снимает. Успешный путь и существующий рейт-лимит не изменились. Бэкенд: 117 passed, ruff чистый. Фронт: type-check чистый, lint без новых замечаний. |
||
|
|
1fa65eba6b |
fix(tg): связь с Telegram не встаёт колом, ответ оператора не теряется
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m19s
Замер прода за сутки 12.09.2026: 576 строк `network error` в логе `tradein-tgbot` и 7 полных исчерпаний бюджета ретраев, после которых падала итерация poll loop. Три причины, все подтверждены на коде и в рантайме. ## Ответ оператора мог пропасть навсегда `process_update` заканчивался безусловным `finally: save_offset(update_id)`. Замысел верный — «ядовитый» апдейт не должен блокировать поток, — но он не отличал неисправимый апдейт от транзиентного сетевого отказа. Оператор отвечает клиенту в топике, `copy_message` падает по сети, `TelegramNetworkError` улетает в общий `except Exception`, offset сдвигается. Telegram этот апдейт больше не отдаст, `record_message` не выполнился, оператор уверен, что ответил. Следа нет нигде, кроме строчки в логе. Теперь `process_update` возвращает `bool`. На `TelegramNetworkError` делается `rollback()`, offset НЕ сохраняется, возвращается `False`, и `run_poll_loop` прерывает разбор пачки — offset у Telegram единая «высшая отметка», подтверждение любого следующего апдейта неявно подтвердило бы и этот. Остаток пачки Telegram отдаст заново. Переигрывания ограничены сверху `_MAX_NETWORK_REPLAYS = 3`: без потолка «вечно недоставляемый» апдейт заклинил бы очередь навсегда, а это хуже потери одного сообщения. На потолке offset всё-таки двигается, но с `logger.error` и с `chat_id`/`message_id`, по которым человек найдёт ответ в топике и перешлёт руками. Текст переписки в лог по-прежнему не идёт. Дубли: `TelegramNetworkError` означает исчерпанный бюджет ретраев, при этом запрос мог дойти до Telegram, а ответ потеряться. Переигрывание тогда доставит сообщение второй раз. Это осознанный at-least-once компромисс — дубль видят и клиент, и оператор, а тихая потеря не видна никому. Полная идемпотентность по паре (update_id, target_chat_id) потребовала бы новой персистентной таблицы ради редкого случая; вместо неё число дублей жёстко ограничено сверху. Ветка `except TelegramApiError` с разбором `error_code == 403` («бот заблокирован») не тронута — там повтор действительно ничего не изменит. ## Таймаут задавался скаляром, поэтому connect ждал сорок секунд `httpx.AsyncClient(timeout=effective_timeout)` разворачивается в connect=read=write=pool. Для `getUpdates` бюджет ответа 40 секунд (30 держит Telegram плюс запас), и те же 40 секунд уходили на установку соединения — при живом connect в 0.036 секунды. Худший цикл: четыре попытки по 40 секунд плюс backoff, около трёх минут, в течение которых бот не видит ответов оператора. В логе это ровно те разрывы: 06:40:10, 06:42:22, 06:43:35. Теперь `httpx.Timeout(connect=5, read=<бюджет вызывающего>, write=10, pool=5)`, значения в именованных константах. Запас `+10s` у `get_updates` относится к read, докстринг поправлен. ## Клиент создавался заново на каждую попытку `httpx.AsyncClient` стоял ВНУТРИ цикла ретраев — keep-alive не было вовсе: полный TCP+TLS-хендшейк на каждый запрос и на каждый повтор, и заново кидался кубик «встанет ли коннект». Для long-polling это была основная статья сетевых отказов. Плюс три HTTP-ручки создавали `TelegramClient` на каждый входящий запрос. Теперь один ленивый переиспользуемый `AsyncClient` на экземпляр, с `aclose()` и `async with`. Общий клиент приложения живёт в новом `app/services/tgbot/shared.py`, создаётся и закрывается в lifespan; воркер бота держит свой на время поллинга. `keepalive_expiry` задан явно: дефолт httpx — 5 секунд, и с ним пул не давал бы ничего там, где нужнее всего. Poll loop переиспользует соединение и так, а вот веб-поддержка шлёт раз в минуты и за 5 секунд теряла бы его каждый раз. Плата за длинный keep-alive — шанс взять из пула закрытое той стороной соединение; httpx отдаёт это как `RemoteProtocolError`, который ретраится с #3457. ## Уведомления оператору шли с воркерным бюджетом внутри poll loop Обе отправки в топик («бот заблокирован», «веб-чат не поддерживает медиа») звались без своего бюджета, то есть с дефолтом в 5 ретраев и backoff до 30 секунд. Одна такая отправка стопорила весь цикл на минуты, а её отказ решал судьбу апдейта. Вынесены в `_notify_topic` с узким бюджетом и собственным `except`: провал вторичного действия больше не отменяет основную ветку. ## Тесты `tests/services/tgbot/test_shared.py` — новый, на жизненный цикл общего клиента. В `test_bridge.py` — сетевой отказ оставляет offset нетронутым и апдейт переигрывается, потолок разблокирует поток, отказ уведомления не отменяет основную ветку, прежнее поведение на 403 не изменилось. В `test_client.py` — раздельные таймауты доезжают до httpx per-request, два вызова используют один `AsyncClient`, `aclose()` его закрывает. Прогон по затронутым файлам: 127 passed. Ruff check и format чистые. Прокси намеренно не добавлялся: замер был на восьми запросах, это не статистика, и решение инфраструктурное. Если обрывы останутся — мерить сотней попыток отдельно. |
||
|
|
087c48fef5 |
fix(tg): ретраим весь TransportError, остальной RequestError → 502 без ретраев
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m10s
Follow-up к #3456. Тот PR научил три HTTP-ручки ловить общий `TelegramError` и отдавать 502, но закрыл дыру не до конца: клиент по-прежнему выпускал наружу сырой httpx. Ретраящийся `except` перехватывал узкий кортеж `(httpx.TimeoutException, httpx.NetworkError)`, а `RemoteProtocolError`, `ProxyError`, `LocalProtocolError` и `UnsupportedProtocol` — не наследники `NetworkError`, а сёстры по `TransportError`. Проверено запуском на httpx 0.28.1, не по памяти. Практическое следствие — ровно тот отказ, который #3456 и чинил. `RemoteProtocolError` («Server disconnected without sending a response») для api.telegram.org из РФ — бытовой ответ, а не экзотика. Он вылетал из `_request` сырым, проходил мимо `except TelegramError` в glitchtip.py:227 и support.py:233 и :424, и FastAPI снова отдавал 500. Глобального обработчика, который поймал бы его выше, нет: в `core/http_errors.py` зарегистрирован только `RequestValidationError`. Вдобавок такой отказ не ретраился ни разу — вылетал с первой попытки, без backoff и без строки лога о сетевом сбое, так что в проде отличить его от исчерпания бюджета было нечем. Теперь два `except`, и вместе они покрывают всё дерево отказов запроса. Ретраящийся расширен до `httpx.TransportError` — тело не тронуто, те же reason, backoff, лог и `TelegramNetworkError` из #3156. Ниже страховочный `httpx.RequestError` без ретраев: сегодня это `DecodingError`, завтра — всё, что httpx заведёт под `RequestError`. Порядок значим — `TransportError` наследник `RequestError` и обязан стоять выше, иначе сетевые отказы перестали бы ретраиться. Повторов у страховочного нет намеренно: испорченный ответ и кривую конфигурацию повтор не лечит, а пять попыток с backoff подвесили бы интерактивную ручку почти на минуту впустую. Расширение ретраев на `RemoteProtocolError` наследует уже принятый в этом клиенте риск at-least-once: запрос мог дойти до Telegram, а ответ потеряться. Риск тот же, что у давно ретраящегося `ReadTimeout`, политика не меняется. Прецедент лова именно `TransportError` в этом же репозитории — `app/services/payments/tbank_client.py:136`. Не тронуто: ручки (они уже ловят предок), `bridge.py` (`except TelegramApiError` там намеренный — разбор 403 «бот заблокирован»), `_extract_retry_after`, обработка 429/5xx, потолки backoff. Тесты: прежний тест «наружу свой тип» параметризован по `ConnectTimeout`, `RemoteProtocolError`, `ProxyError`, `DecodingError` с ожидаемым числом попыток; новый тест фиксирует разницу бюджета — обрыв протокола ретраится, битый ответ нет. Прогон по четырём затронутым файлам: 80 passed. |
||
|
|
46326ba96e |
fix(tg): недоступный Telegram отдаёт 502, а не 500
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m8s
Прод 11.09.2026, 01:35 и 01:38 MSK — два 500 на glitchtip-webhook. Причина не в вебхуке: `TelegramClient._request` после исчерпания сетевых ретраев делал голый `raise`, наружу летел `httpx.ConnectTimeout`. Все три HTTP-ручки ловят `TelegramApiError` — сырой httpx пролетал мимо, и FastAPI отдавал 500 вместо задуманного 502. Отказ площадки и её недоступность для вызывающего неразличимы: переслать не смогли и там, и там. Клиент больше не выпускает наружу чужой тип. Появился общий предок `TelegramError`, под ним прежний `TelegramApiError` (ответили `ok: false`) и новый `TelegramNetworkError` (не ответили вовсе). Раздельно, а не наследником, потому что у сетевого отказа нет ни `error_code`, ни `description` — брать их неоткуда, а `bridge` по `error_code == 403` разбирает «бот заблокирован» и недоступность в этот разбор попадать не должна. Причина сохраняется в `__cause__`: в GlitchTip по-прежнему видно, таймаут это соединения или сброс TLS (#3156). Три ручки — вебхук GlitchTip и обе ручки поддержки, авторизованная и анонимная — ловят предок. Поведение воркеров не менялось: poll loop в `bridge` и так ловит `Exception`, бюджеты ретраев те же. Тесты: два в клиенте (свой тип наружу, причина не потеряна, это НЕ `TelegramApiError`), три на ручках (502 на недоступности, ничего не персистится, анонимной куки не выдаём). Четыре теста бюджета ретраев ждали `httpx.ConnectTimeout` — ждут новый тип, проверяемые паузы прежние. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh |
||
|
|
1bcc922e80 |
fix(msk-collector): сетевой обрыв на машине больше не убивает многочасовой проход
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
Полный проход Яндекса по Москве умер 12.09 на восьмом часу: `Page.goto: net::ERR_NETWORK_CHANGED`. Площадка была ни при чём — сразу после и realty.yandex.ru, и git.gendsgn.ru отвечали 200, а в ту же минуту по DNS отвалился и MCP-сервер ошибок. Моргнула сеть на самой машине. Ретрай в `_goto` ловил только `PlaywrightTimeoutError`, поэтому обычная сетевая ошибка уходила наверх и роняла прогон целиком. Сетевые отказы Chromium вынесены в `_TRANSIENT_NET_ERRORS` и считаются СВОИМ счётчиком: пять попыток с нарастающим ожиданием 15/30/45/60/120 с. Бюджет попыток по таймауту при этом не тратится — обрыв сети длится минуты, а таймаут навигации это совсем другой симптом. Исчерпали — стоп с причиной `nav_network`, прогон продолжается тем же batch-id с --resume. Всё, что не в списке, по-прежнему поднимается: тихий отказ площадки выглядит ровно так же, и молча проглоченный он превращается в пустой прогон. Тестов у сборщика не было вообще, при том что он уже дважды ронял многочасовой прогон на ошибке, которая отказом площадки не была. Заведён первый файл: он подгружает скрипт по пути (нет на месте — skip, не красный) и караулит границу «наше или ихнее» — распознавание сетевых ошибок, переживание короткого обрыва, остановка при длинном с правильной причиной, проброс незнакомой ошибки и неизменность прежнего пути по таймауту. Полный сьют бэкенда — 5902 passed, 37 skipped; ruff чист. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh |
||
|
|
ce9c45c3c2 |
feat(msk): импортёр знает Яндекс — город из адреса, ноль внешних вызовов
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
Сбор Яндекса по Москве уже идёт, а лить его было нечем: в SOURCE_VIEWS стояли только cian и avito. Отбор Москвы у Яндекса не требует ни префикса округа (как у Циана), ни пред-геокода (как у Авито). Адрес приходит полным и нормализованным — «Россия, Москва, Коробейников переулок, 1», регион читается вторым компонентом. Замер по 21 393 карточкам первого прохода: во втором компоненте ровно ДВА значения, «Москва» 10 610 и «Московская область» 10 783, третьего не встречается. Новая Москва отдельным значением не приходит — Троицк и Зеленоград Яндекс кладёт под «Москва», что совпадает с кодом региона 77. Координаты, адрес и ссылка заполнены у 100% карточек, поэтому geom появляется сразу и ждать ночного `geocode_missing` не нужно. `--geocode` для yandex отклоняется так же, как для cian: квота нужна только Авито. `filter_by_okrug` заменён словарём CITY_FILTERS — источник либо сам говорит про город, либо его в словаре нет и без пред-геокода писать его нельзя. Поведение cian и avito байт в байт прежнее. `uv run python -m pytest tests/test_msk_raw_import.py` — 42 passed, ruff чист. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh |
||
|
|
37494dd74a |
fix(msk): тест полос по округам разъехался с main — у _fetch_deals нет rooms
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
Post-merge прогон main после #3440 дал 5 failed: `_fetch_deals() got an unexpected keyword argument 'rooms'`. Семантический конфликт мержа, а не поломка: ветка отводилась до #3256, который УБРАЛ параметр `rooms` (сделки Росреестра комнатность не несут). Текстового конфликта git не увидел, pre-merge CI ветки был зелёным, красным стало только на объединённом дереве. В проде эффекта нет: `_fetch_deals` из `app/` не вызывается ни разу ни до, ни после мержа — функцию держат только эти тесты. `uv run python -m pytest tests/test_3051_moscow_okrug_bands.py` — 24 passed, ruff чист. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh |
||
| a99a9b870c |
Merge pull request 'Москва: пред-геокод Авито, Яндекс третьей площадкой, продукт отвечает по региону 77' (#3440) from feat/msk-collector-cian into main
Some checks failed
Deploy Trade-In / test (push) Failing after 4m18s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
|
|||
|
|
09f4f17ef9 |
fix(msk): lock_timeout в миграции 299 — гейт блокирующего DDL
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m23s
Джоба `changes` воркфлоу ci.yml валила PR по собственному правилу репозитория (#2752): блокирующий DDL без `SET LOCAL lock_timeout` встанет в очередь за чужой сессией и уведёт за собой запросы приложения. Нарушение было в 299 и до правки схемы — просто до гейта раньше не доходило. Добавлена первая строка после BEGIN, как в 36 соседних миграциях. Повторно проверено на пустой базе: применяется с ON_ERROR_STOP=1, пять таблиц на месте. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh |
||
| c4315b3dfb |
Merge pull request 'fix(mera/estimate): убран предикат d.rooms в коридоре ДКП — он был вторым фильтром по площади (#3256)' (#3445) from fix/3256-asking-to-sold-buckets into main
Some checks failed
Deploy Trade-In / build-browser (push) Successful in 47s
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 2s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / test (push) Failing after 4m18s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
|
|||
|
|
86cba37218 |
docs(#3256): докстринг коридора без «та же rooms»; якорь называет оставшегося потребителя (TVF 211)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
|
||
| 6df6f92a2b |
test(estimator): полоса площади — единственный фильтр похожести, а не просто «есть»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
`test_dkp_corridor_keeps_full_area_band` проверял только присутствие полосы ±15% в bind-параметрах — она есть и на origin/main, и на варианте с бакет-ключом, поэтому тест был зелёным по построению и ничего не охранял (мутационная проверка: при восстановлении предиката он оставался зелёным, пока остальные 4 краснели). Утверждение усилено до «полоса единственная»: тест дополнительно требует отсутствия предиката по rooms рядом с ней. На origin/main фильтров по площади ДВА (полоса и бакет через d.rooms), итоговое окно — их пересечение, поэтому теперь тест краснеет значением вместе с остальными. Refs #3256 |
|||
|
|
7b84e4d2a5 |
fix(msk): миграция 299 заводит схему msk_raw сама, а не полагается на прод
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s
CI Trade-In падал до единого теста: «schema "msk_raw" does not exist», job backend-tests, run 10854. Схему и первые две таблицы (`batches`, `avito_cards`) заводили на проде руками 08.09 — сбор сырья стартовал раньше модели данных, и msk_raw сознательно жила вне линейки миграций. На чистой базе CI этого контекста нет, а 299 сразу создаёт таблицы внутри схемы и вешает внешний ключ на `msk_raw.batches`. DDL продовских объектов повторён в 299 идемпотентно, определения сняты `pg_dump -s -n msk_raw`, чтобы CI и прод не разъехались молча. На проде это no-op. Проверено на пустой базе migtest_msk: применяется с ON_ERROR_STOP=1, даёт все пять таблиц и четыре вью, повторное применение проходит без ошибок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh |
||
| 16d99e0f1a |
fix(estimator): убрать предикат по deals.rooms, а не подставлять в него area-бакет
Разворот предыдущего коммита ветки ( |
|||
| ae6d28d5e2 |
fix(mera): pool_timeout 30→5 с — отдельным коммитом, с триггером отката
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Единственная правка ветки, которая меняет РЕЖИМ ОТКАЗА при исчерпании пула: было «медленно» (ждём коннект до 30 с), стало «быстро с ошибкой» (5 с и `sqlalchemy.exc.TimeoutError` → 500, глобального обработчика в app/main.py нет). И едет она во все сервисы образа — backend, scraper, tgbot (tradein-mvp/docker-compose.prod.yml), для скраппера и бота обоснования в коде нет: за 29 ч логов исчерпания пула не было ни разу, проверить новое значение на проде пока не на чем. Поэтому коммит последний в ветке: ветку можно мержить без него, а на проде — откатить одной командой (`git revert`). Обоснование самого значения: чекаут коннекта нельзя прервать `asyncio.wait_for`, он занимает поток `asyncio.to_thread` целиком, а пул потоков конечен (min(32, cpu+4)) — исчерпанный пул коннектов превращается в исчерпанный пул потоков. 5 с короче самого короткого бюджета источника (8 с Yandex/Cian/ house_meta; geocode 12 с, IMV 20 с — длиннее): занятый пул деградирует ОДИН источник, а не весь запрос. ТРИГГЕР ОТКАТА (вернуть 30 с) записан в комментарии рядом со значением: любое `QueuePool limit ... timed out` в логах бэкенда ЛИБО рост failed+zombie в `scrape_runs` после деплоя. Правка комментария по ревью (L2): «вчетверо больше любого бюджета внешнего источника (8 с)» было неточно — бюджеты 8 / 12 / 20 с, перечислены явно. Гейт `test_pool_checkout_wait_shorter_than_source_budget` переехал сюда же (в коммите без `pool_timeout` он был бы красным) и читает публичный `engine.pool.timeout()` вместо приватного `pool._timeout`. Refs #3083, #3408 |
|||
| cf71825c27 |
fix(mera): отмена по бюджету оставляла осиротевший поток в чужой Session
Ревью PR #3444, M1. `_with_budget` — это `asyncio.wait_for`, а `asyncio.to_thread` отменить нельзя: снимается только ожидание со стороны loop'а. Корутина умирает, поток продолжает работать с ТОЙ ЖЕ `Session`, а вызывающий тем временем идёт дальше по своим шагам ПО ТОЙ ЖЕ сессии — следующий источник, `_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной сессии дают «another operation is in progress» / InvalidRequestError на следующем шаге БД: у источников её глушит `except` вокруг вызова, у персиста оценки не глушит ничего — 500 и потерянная оценка клиента, ровно под нагрузкой, ради которой PR и делается. `_db_step` теперь пробрасывает отмену ПОСЛЕ того, как поток отпустил сессию (`asyncio.shield` + ожидание шага). Цена — бюджет источника переезжает на длину ОДНОГО шага БД, а не на длину фетча, ради которой бюджет заведён. Почему не `threading.Lock` на сессию (вариант из ревью): лок внутри `_db_step` сериализует только шаги, которые через `_db_step` и проходят, — а названный пострадавший `_persist_estimate_and_commit` (estimator.py:5203) это ГОЛЫЙ `asyncio.to_thread(db...)`, как и ещё 17 мест эстиматора; лока они не берут, и дыра осталась бы открытой ровно там, где она стоит 500. Ожидание же в точке отмены закрывает ВСЕХ последующих потребителей сессии разом и не заводит глобального состояния (`WeakKeyDictionary`). Гейт по значению — tests/test_3408_db_step_cancel_orphan.py: следующий шаг (голый `to_thread`, как персист) не входит в сессию, пока сирота не закончил. Семантика проверена на питоне прода (3.12): `wait_for` по-прежнему отдаёт TimeoutError, источник деградирует в None. Остальное из ревью: - M2: комментарий у `_MAX_DEFERRED_REFRESH_TASKS` обещал за ОБА фоновых источника, а верен только для Яндекса. Циан держит коннект весь фетч (до 25 с): транзакцию открывают `_load_from_cache`/`load_session`, закрывает `db.commit()` в конце (scraper_kit .../cian/valuation.py:163,176,595). Формулировка сужена, остаток назван явно: функция общая со скраппером (cian_history_backfill.py:458), где коммит в середине менял бы семантику батча, — нужен отдельный опт-ин путь. На ПОТОЛОК пула остаток не влияет (коннект на задачу один независимо от того, как долго держится), только на среднюю занятость. - L1: `db.rollback()` после упавшего `_db_step` (estimator.py:1186) удалён — откат уже сделан в потоке, а на loop'е это блокирующий вызов. - L4: в core/db.py записано, что «пул >= суммы объявленных потолков» — ПОЛ, а не гарантия: коннект держит и любая ручка с `Depends(get_db)`, а глобального обработчика `sqlalchemy.exc.TimeoutError` в app/main.py нет (проверено: единственный handler — RequestValidationError, core/http_errors.py:59). - L3: гейт пула больше не читает `pool._timeout` и не молчит при переименовании `_max_overflow` — публичный `pool.size()` + приватное поле за явным assert'ом. `pool_timeout` из этого коммита УБРАН намеренно: это единственная правка, которая меняет режим отказа с «медленно» на «быстро с ошибкой», и она едет во все сервисы образа (backend, scraper, tgbot). Возвращается отдельным коммитом в конце ветки — чтобы ветку можно было смержить без него или откатить одной командой. Refs #3083, #3408 |
|||
| a780e3e66e |
fix(estimator): ключевать сделки Росреестра area-бакетом, а не комнатностью клиента
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m10s
`deals.rooms` — не комнатность, а синтетика из площади: import-rosreestr.sh пишет тот же CASE 30/44/62/85, что `asking_to_sold_ratio.area_bucket`. Прод-замер 2026-09-11: 321 559 из 321 560 сделок удовлетворяют rooms == area_bucket(area_m2), max(rooms) = 4. Значит предикат `deals.rooms = <РЕАЛЬНЫЕ комнаты клиента>` — это переодетый фильтр по площади, который противоречит area-полосе ±15% рядом с ним, как только комнатность клиента нетипична для метража, и НИКОГДА не совпадает у клиентов с 5+ комнатами. Замер по 1177 реальным запросам (trade_in_estimates): ключ расходился с area-бакетом у 359 (30.5%); коридор ДКП пуст у 46.2% из них против 8.2% у совпадающих. По крупному жилью (≥85 м²): «3 комнаты» — 83.5% пустых коридоров, «5 комнат» и «6 комнат» — 100%, «4 комнаты» — 5%. Т.е. блок «реальные сделки» и клампы коридора (cap headline + radius-floor) молча выключались ровно у крупных лотов. Прогон тех же 1177 запросов через `_fetch_dkp_corridor` с обоими ключами: непустых коридоров 809 → 895, пригодных для клампа (n≥10) 567 → 623 (+66, −10), у 818 клиентов с совпадающей комнатностью выборка не меняется вовсе. Из 66 восстановленных коридоров 7 (5 из них ≥85 м²) обрезали бы headline вниз на медианных −10.1% — то есть сейчас часть крупных лотов оценивается выше, чем поддерживают реальные ДКП на той же улице. Правка — одно и то же во всех четырёх местах, где сделки фильтруются под клиента: `_fetch_dkp_corridor` (street + city-wide widen), `_fetch_deals` (радиус) и витрина `/street-deals`. Бэктест этим НЕ измеряется и в докстринг харнеса добавлена причина (каверза (e)): у всех 5500 сделок обеих прод-фикстур rooms == area_bucket, т.е. харнес кормит спайн синтетическим ключом и поэтому по построению не видит расхождения, которое в проде есть у 30.5% запросов. Refs #3256 |
|||
| 9f696299de |
fix(mera): sync-БД источников эстиматора — с event loop в поток и не через фетч
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m16s
Замер на проде 11.09 (изнутри хоста, тот же контейнер): - одна оценка 0.44 с (повтор адреса) / 0.97 с (новый адрес), из них БД 252/458 мс; - N=8 параллельных — все 200, heartbeat /health p95 5-7 мс, max 113-160 мс: loop сегодня НЕ голодает, «добавить воркеров uvicorn» замером не подтверждается (и умножило бы на N оба семафора, пять in-process лимитеров и пул); - зато одна фоновая догрузка Яндекса держала коннект пула 8.5 с (лиз прокси 33.856 → запись 42.334), а таких задач разрешено 8 при пуле 15. Правки: - estimator `_db_step`: SELECT/UPSERT кэша источников уходят в `asyncio.to_thread` и завершают транзакцию — коннект возвращается в пул ДО внешнего HTTP; - core/db: max_overflow 10→15 (потолок 20 на процесс ≥ 4+4+8 объявленных потолков одновременности) и pool_timeout 30→5 с (короче бюджета источника 8 с, иначе занятый пул съедает и бюджет запроса, и поток to_thread). Локальный замер ДО/ПОСЛЕ на тех же величинах: loop стоял 301 мс (0 тиков соседней корутины) → 0.2 мс (23.5k тиков); ожидание коннекта соседом во время фетча — таймаут пула → 0.1 мс. Refs #3083, #3408 |
|||
|
|
de5f4a32cd |
fix(msk): сухой прогон пред-геокода падал на отсутствующей кэш-таблице
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Failing after 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 58s
Кэш `msk_raw.avito_geocode` создаётся только боевым прогоном (`geocode and not dry_run`), а читается безусловно. На проде это роняло `--dry-run` первым же запросом — UndefinedTable msk_raw.avito_geocode, то есть ломалась ровно та репетиция, ради которой сухой прогон и существует. Наличие отношения проверяется через `to_regclass`, а не ловится исключением: в Postgres упавший оператор кладёт транзакцию целиком, и except потребовал бы rollback посреди чужого батча. Замер после правки (500 карточек, прод): отобрано 458, область 7, не разрешено 35 (7%), геокод-вызовов 338 на 500 карточек — дедупликация адреса внутри страницы работает. Счётчики сходятся. Заодно выяснилось, что дневная квота DaData на подсказки — 200 000, а не 10 000: `stat/daily` на проде показывает suggestions remaining 200000 при нулевом расходе. Весь корпус (21 565 различных адресов) проходит за один заход, дробить на трое суток не нужно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh |