Четыре live-теста из правок по ревью без БД пропускаются, а гейт conftest
роняет прогон с DSN-заглушкой на необъявленных пропусках (rc=1 при
«6226 passed» в сводке). Внесены в skip_allowlist.txt к остальным тестам #3299.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ревью PR #3565: у нового предиката резерва две части не проверялись ни одним
тестом. Снятие `other.expires_at` из подзапроса acquire и снятие
`other.consecutive_fails` из подзапроса mark_banned оставляли прогон
test_3299 + services/test_proxy_pool.py зелёным (72 passed). Проверено тем же
способом, и нашлась третья такая часть: `other.expires_at` в mark_banned.
Добавлено по live-тесту на каждое условие: просроченный резерв в acquire,
резерв в карантине и просроченный резерв в mark_banned.
Попутно найден дефект рядом. Внешний отбор mark_banned («есть ли у источника
другой узел») срок аренды не проверял, хотя acquire такой узел не выдаёт, а
докстринг обещает «тем же правилом, что acquire()». Если у источника остались
только просроченные узлы, бан уходил последнему живому, и источник оставался
без прокси, пока healthcheck не наберёт просроченным узлам отказов. Добавлена
проверка `sp.expires_at` и тест на этот случай.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Без БД 11 новых live-тестов пропускаются, и гейт conftest роняет прогон
(«НЕУЧТЁННЫЙ ПРОПУСК», rc=1 при «6226 passed» в сводке). В ci-tradein.yml они
идут по-настоящему: локальный прогон на Postgres со схемой из миграций —
6278 passed, 1 skipped, rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fetch_detail без session/browser_fetcher входил в синхронный curl_proxy_url:
acquire/mark_health/release RealProxyProvider'а ходят в БД прямо на loop'е.
Вызывающий — админ-ручка истории цен Циана в публичном tradein-backend
(один воркер uvicorn): пара блокирующих вызовов на каждый листинг батча.
#3398 перевёл так три сайта /estimate, этот остался.
Теперь async with acurl_proxy_url: вход и выход в потоке, contextvars
(current_run_id) копируются, ProxyBanError/CianBlockedError изнутри блока
доходят до пула как раньше (test_2700/test_3402 зелёные). Попутно потолок в
докстринге acurl_proxy_url: pool_timeout теперь 5 с (#3444), не 30.
Тест по образцу test_3398: acquire спит 0.3 с, соседняя корутина тикает.
На синхронном входе — «тиков всего 0».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #3405 писал scrape_runs.proxy_id только из proxy_pool.acquire(). Прогоны,
которые берут прокси через proxy_egress.resolve_proxy_url без аренды
(yandex_detail_backfill, yandex_address_backfill, curl-ветка
avito_detail_backfill), атрибуцию не получали: прод 17.09 —
yandex_detail_backfill 0 из 56, yandex_address_backfill 0 из 1.
resolve_proxy_url при выбранном узле и выставленном current_run_id зовёт
attribute_run_proxy. Своей короткой сессией: db вызывающего — долгоживущая
сессия прогона посреди работы, а атрибуция коммитит и на сбое откатывает.
Тесты на живом Postgres: атрибуция внутри прогона (proxy_id и
counters.proxy_ids), вне прогона scrape_runs не тронут, незакоммиченная
работа вызывающего не коммитится. Красные прогоны: на старом коде
«assert None == 70»; с атрибуцией через db вызывающего — «атрибуция
закоммитила чужую транзакцию».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Защита последнего узла в mark_banned бережёт узел от бан-строки и пишет
«узел продолжит выдаваться», но curl_proxy_url на том же ProxyBanError
вслед за баном звал mark_health(ok=False). Три ответа 403/капчи подряд —
consecutive_fails = 3, и acquire отсекал узел для ВСЕХ источников: пул
пуст при живом узле. Браузерный путь это исправил в #3288
(report_platform_ban), curl-путь — нет; через него ходят cian detail,
ЖК-резолв, история цен Циана и оценщик.
Теперь на ProxyBanError зовётся mark_banned вместо mark_health(False).
Транспортные сбои по-прежнему засчитываются узлу.
Тест на живом Postgres настоящим трактом (curl_proxy_url →
RealProxyProvider → proxy_pool): единственный узел, три CianBlockedError →
consecutive_fails == 0, acquire('cian') выдаёт узел; на старом коде
«assert 3 == 0». Пять старых проверок ждали mark_health(False) на бане —
они фиксировали дефект, поправлены.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Запасной заход acquire() и защита последнего узла в mark_banned() спрашивали,
есть ли у выделенной привязки ВТОРОЙ узел той же привязки. Выделенный узел
штучный, поэтому ответ почти всегда «нет», и такой узел не выдавался никому,
хотя свой источник обслуживали 'any'-узлы. Прод 17.09: узел 15 (avito)
свободен и здоров, а cian/domclick его не получали; 30.08 так лёг добор
Домклика (прогоны 5449-5459).
Теперь резерв — любой узел той же привязки или 'any', здоровый
(consecutive_fails), с живой арендой порта и не забаненный источником
выделенной привязки. Предикат одинаковый в обоих местах. leased_by
намеренно не проверяется: аренда временная.
Тесты на живом Postgres (tests/test_3299_*): 6 случаев из приёмки, два
красные на старом SQL. Мок test_proxy_pool.py: маршрутизация primary/fallback
по полному фрагменту IN (:provider, 'any') и гейт нового предиката.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Две тревоги шумели в канале каждую ночь и каждые три часа, обе врали текстом.
NoActiveCeleryWorkers горела с 12.09 без перерыва при живом воркере
(`Up 29 hours`, 15 задач за 10 минут). Экспортёр не поднимался ни разу:
тега danihodovic/celery-exporter:0.13.0 на Docker Hub не существует
(`pull` → `not found`, логи джобы agent-apps 12.09 и 16.09), а под
`set -e` упавший pull обрывал ВСЮ джобу — с 12.09 на Poincare не доезжало
ничего из агента метрик: ни этот экспортёр, ни redis-exporter, ни новый
конфиг Alloy. Второй дефект за первым: флага `--queue` в 0.12.2 нет,
опция зовётся `--queues` — с одним исправленным тегом экспортёр упал бы
при старте. Тег и флаг сверены с исходником (src/cli.py, src/exporter.py)
и пробой на боевом брокере: временный контейнер 0.12.2 отдал
`celery_worker_up{hostname="687f9212bebb"} 1.0`. Адрес брокера не тронут —
алиас `gendesign-redis` на сети gendesign_shared существует и резолвится.
Правило разделено на две новости. NoActiveCeleryWorkers — только когда
экспортёр жив, а воркеров нет. QueueExporterDown (warning) — когда не
отвечает сам экспортёр, с текстом «авария наблюдаемости, не продукта».
Заодно прежняя ветка `count(celery_worker_up == 1) == 0` не сработала бы
никогда: count() от пустого вектора пуст, мёртвый воркер она пропускала.
ContainerNearMemoryLimit на tradein-postgres: working_set включает активный
кэш страниц, ночные сканы поднимают его к потолку. Анонимная память за
неделю 11–170 МБ из 3 ГБ, oom_kill = 0 за 21 сутки — «дальше OOM-kill» был
ложью. Добавлено второе условие: анонимная память > 50 % лимита.
Калибровка ЭТИМ выражением по 14 суткам истории: tradein-postgres 5 → 0
эпизодов, tradein-browser 2 → 2 (06.09 и 12.09, rss до 95 % — настоящие).
Юнит-тесты правил (ops/metrics/prometheus/tests/infra_test.yml, 6 случаев,
каждый уже случался на проде) исполняются деплоем перед reload. На старых
правилах promtool test rules → rc=1 (6 расхождений), на новых → rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ДомКлик: DomClickGeoProfile вместо зашитых _EKB_ADDRESS_GUID/_EKB_AREA_ID; GUID Москвы
и области проверены живьём, aids вне ЕКБ не нужен, гард по bbox профиля. Страница
прогрева 77/50 — апексный domclick.ru: субдомены msk./moskovskaya-oblast. отдают 301.
Циан/Авито/Яндекс: CityLocation.cian_host, три новых скоупа (moskva, moskovskaya_oblast,
moskva_i_mo), avito_slug_is_region, city=NULL у мультигородских скоупов. Неизвестный слаг
теперь падает с ValueError вместо молчаливого отката на Екатеринбург.
Якоря: Москва — сетка 25 точек под radius_m=8000; область — 22 города-спутника
(10 добраны из Nominatim) плюс 22 кластера лот-массы. Замер на проде: города радиусом
10 км дают 73.8% лот-массы области, вместе с кластерами — 95.2%.
Ценовые коридоры: планировщик plan_price_corridors со статистикой усечения плюс
BisectionStats в живом движке. Провайдеры их пока не передают — отдельный заход.
Расписаний scrape_schedules для 77/50 в этом PR нет: они пойдут после первого ручного
прогона, подтверждающего живость профилей.
Центроид строился по названию улицы без города — в области это схлопывало одноимённые
улицы разных городов. Ключ стал (region_code, населённый пункт, улица); НП берётся из
типа сегмента адреса, из реестра городов региона или из deals.city; при пустом НП ключ
у области отбрасывается, а не склеивается с чужим городом.
Фильтры по региону добавлены в SELECT кандидатов, в запрос домов и в UPDATE. Новый
--max-spread-km (5 км) отбрасывает бакет с разбросанными домами.
geocoder.py: поддержка региона 50 (маркер «московская» — намеренно не «москва»,
DaData-имя «Московская», новое поле Region.has_city_core=False, чтобы к адресу области
не приклеивался суффикс главного города).
Обе стороны раскладываются по одной сетке 0.1°x0.2°, медианы внутри ячейки, в итог
только ячейки с ≥10 сделок И ≥10 объявлений, агрегация взвешена числом сделок.
Замер на проде (SQL исполнен в транзакции с ROLLBACK): регион 50 — 0.8136 вместо
пулового 0.8851, регион 77 — 0.7287 вместо 0.7100. Разнонаправленный сдвиг — подпись
поправки состава. Регион 66 не тронут байт в байт.
Гарды: регион не получает строк при покрытии geom <50%, совпавших ячейках <3 или
попадании в пересечение <50% геокодированных сделок; DELETE идёт в любом случае.
Раздел /articles с 29.08 стоял с одной живой статьёй и двумя карточками без
ссылок. Обе дописаны — но не по темам из макета: те темы данными не
обеспечены.
«Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений».
24 535 договоров Росреестра за июль 2025 — июнь 2026, регион 66. Медиана
123 626 ₽/м². Несущий факт статьи контринтуитивен и проверен: кривая цены
метра по комнатности не падает, а прогибается — дешевле всего метр в
двухкомнатной (116 910 ₽), дороже всего в студии (134 507 ₽). Плюс
поквартальная динамика за все десять доступных кварталов (+20,6%) и восемь
городов области с порогом публикации в сто сделок.
«Почему квартира не продаётся: что происходит с ценой, пока вы ждёте».
49 620 объявлений области с историей цены площадок. Цена двигалась у 43,5%,
из них вниз у 60,2%, вверх у 38,0%. Медиана снижения −3,85% и −291 000 ₽.
ПОЧЕМУ НЕ ТЕ ТЕМЫ, ЧТО В МАКЕТЕ. Карточка «Сколько продаётся квартира»
требовала срока экспозиции, а он не измеряется: `days_on_market` пуст у всех
435 тысяч сделок Росреестра, а снятие объявления и продажа в наших данных
неразличимы — 41% «снятий» приходится на шесть дат, совпадающих с провалами
собственного обхода. Мерили бы мы свой сборщик, а не рынок. Вторая карточка
называлась «Завысить и торговаться»; запрос «завысить цену на квартиру»
уведён юридическим смыслом (завышение суммы в ДКП ради ипотеки), и статья
попала бы в чужую выдачу.
ДИСЦИПЛИНА ЧИСЕЛ. Замер прошёл адверсариальную проверку: из десяти
первоначальных величин воспроизвелись две, остальные отброшены как
непригодные к печати. Обе статьи пересчитаны заново с нуля. У каждого блока
стоит своё поле source/footnote с датой, выборкой и границей — чем это число
НЕ является. Неизмеримое названо неизмеримым прямо в тексте, а не обойдено
молчанием: первый раздел второй статьи объясняет читателю, почему средний
срок продажи не назовёт никто честный.
Обвязка: два ключа в PUBLIC_ROUTES, две страницы, sitemap, три матчера в
apps.caddy на каждый адрес, PUBLIC_SHORT_PATHS у RouteGuard,
PUBLIC_INDEXED_FILES в гейте noindex. Блок «Читайте дальше» перестал быть
статичным «СКОРО» и делает карточку ссылкой, как только у неё появился
route. Витрина и лэндинг обновились сами — обе перебирают ARTICLE_CARDS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
Тест применял 015/051/052 безусловно, и на базе, прошедшей всю цепочку
миграций (CI и прод), повтор 015 падал:
psycopg.errors.UndefinedColumn: column "returning_count" of relation
"scrape_runs" does not exist
Файл 015 идемпотентен относительно себя, но не относительно схемы,
прошедшей 214 (DROP COLUMN IF EXISTS returning_count): CREATE TABLE IF
NOT EXISTS — no-op, а COMMENT ON COLUMN в конце того же файла обращается
к снесённой колонке. На чистой базе, где 015 ложится с нуля, этого не
видно по построению — потому прогон и был зелёным там, где его гонял я,
и красным там, где его гоняет CI.
Зависимости теперь применяются только когда scrape_schedules ещё нет; в
докстроке — рецепт прогона на ПОЛНОЙ схеме, тем же путём, что у CI.
Заодно закрыты три дыры, которые находились мутациями:
- окно расписания и повторное применение проверяются на живой БД (до
этого 6,7 → 6,23 и DELETE+INSERT вместо ON CONFLICT проходили насквозь);
идемпотентность меряется created_at строки, а не числом строк — замена
«удалить и вставить» тоже оставляет ровно одну строку, но стирает
last_run_at/next_run_at на каждом деплое;
- next_run_at в будущем — утверждение стояло в приёмке и ничем не
проверялось;
- handler сравнивается по САМОМУ job'у, а не по log_name: имя — второй
литерал конструктора Handler, и чужое тело под верным ключом
(_job_landing_stats) проходило проверку по имени.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>