merge_duplicate_houses кластеризовал дома по exact lower(trim(address)) →
варианты написания одного здания (ул. Вайнера,66 vs улица Вайнера, 66) не
схлопывались, расщепляя долю в sale-share. Ключ кластера → tradein_canon_addr
(ул→улица, стрип город/район, КОРПУС сохранён). Гарды против over-merge:
- гео ≤250м (canon стрипует город; прод-дубли Мраморская 34к4 в 222м; города
региона-66 в км+ → 250м безопасно от cross-town);
- canon обязан содержать цифру (номер дома) — исключает вырожденные
«екатеринбург»/«сооружение».
Re-pointing/audit/dry-run/идемпотентность не тронуты. 26 passed.
Три гарда в match_or_create_house (+ P2 в match_house_readonly):
- P1: не матчить/алиасить голую улицу (normalized_address без номера дома)
- P2: отклонять Tier-3 гео-кандидата, если номера домов различаются
- P3: не цементировать слабый гео-матч (0.7) как alias-ключ здания
Хелперы has_house_number/house_number_token (по хвостовому токену —
«улица 8 марта» это улица, не дом). Корень mis-bucketing: house 131237
стянул 8 разных Мамина-номеров + голую улицу. Юнит + поведенческие тесты,
603 matching/normalize/house зелёных. Аффектит только НОВЫЕ матчи;
чистка cemented-данных — отдельной миграцией (P4).
Листинги примагничиваются к НЕ ТОМУ дому (кривой house_id_fk): прод-замер 373
дома (5.5%) / 1685 листингов (7.2%) дальше 300м от своего дома. Пример — house
131237 «Мамина-Сибиряка 126» (ЖКХ=51 кв, знаменатель ВЕРНЫЙ): из 20 активных
листингов реально его ~2, остальные — Свердлова 32 / Азина 31 (до 2.2км) →
ложная доля 39% вместо ~4%.
- мигр.150: v_building_sale_share — числители (active/45д) + медианы считают
только листинги ≤300м от geom дома (ST_DistanceSphere; листинг/дом без geom —
keep). Знаменатель не тронут.
- buildings_query.build_listings_query: тот же гео-фильтр в детальной выдаче
(карточка дома больше не показывает чужие листинги).
Симуляция на проде: меняется 351 дом, обнуляется 164 (чистые «мусорные вёдра»),
числитель −8.3%. Мамина-126: 20→2. Это митигация на уровне view; корневой фикс
house_id_fk-матчера — отдельно. ruff clean, pytest 24 green.
Extend the cooperative shutdown-drain (Phase 2) to the long sweep and
full-load loops in scrape_pipeline.py so they stop CLEANLY on SIGTERM
instead of being hard-cancelled at the 80s _drain_inflight timeout.
At every between-unit checkpoint that already polls scrape_runs.is_cancelled
(user-cancel) we add an ALONGSIDE shutdown_requested() branch:
- anchor-loop sweeps (avito/yandex/cian city) + avito newbuilding +
domclick pre-SERP: update_heartbeat + mark_done(partial) + return;
- full-load on_bucket callbacks (cian/yandex/avito): raise a distinct
RuntimeError("shutdown") sentinel, handled next to the existing
"cancelled" sentinel -> mark_done(partial), skipping the detail phase;
- cian full-load detail loop: break -> existing post-loop mark_done.
Drain finalizes as mark_done(partial), NOT mark_cancelled (that stays
user-cancel only). is_cancelled -> user-cancel semantics are unchanged, and
behaviour is identical when shutdown_requested() is False. No resume_cursor
or paused status yet (Phase 3b, deferred): this is drain-coverage only.
Tests: tests/test_sweep_drain.py covers both structural variants -
run_avito_city_sweep / run_cian_city_sweep (anchor-loop) and
run_avito_full_load (on_bucket sentinel): shutdown flips True after the
first unit, asserting the sweep stops at the checkpoint, calls mark_done
with partial counters, never calls mark_cancelled, and does not process
later anchors/buckets/phases.
- Опц. поля больше не шлют непроверенные дефолты в ценовую модель:
ТИП/РЕМОНТ → «Не указано»→undefined, БАЛКОН tri-state (null пока не
трогали) → undefined.
- Валидация у поля: красная рамка + сообщение под адресом/площадью,
показываются ВСЕ блокеры (не только первый); низ — только серверная ошибка.
- Копирайт «вы»: «если знаете»; кэш-строка → «ДАННЫЕ АКТУАЛЬНЫ ·
ДЕЙСТВИТЕЛЕН 24 Ч»; drawer без жаргона (убраны (asking)/POI).
- Даты сделок Росреестра → «янв 2026» (месяц, не фейк-день 01.01);
бренды источников без method-суффиксов (РОСРЕЕСТР/АВИТО/ДОМКЛИК);
«эталон»→«опорная цена».
- График истории цен: зум оси Y к реальному диапазону данных + динамические
подписи оси; легенда серии показывается только при наличии линии.
- 3 цены весомее (font-weight 400); медиана-маркеры range-баров → тонкий тик
(не выглядит как draggable-ручка); фото здания — синий duotone +
десатурация (не крадёт первый взгляд у цен).
- Таблицы аналогов/сделок: площадь/комнаты/этаж вынесены из адресной ячейки
в колонку ПАРАМЕТРЫ; ↗→› в СВОДКЕ.
Сознательно НЕ тронуто: перераспределение accent-синего (item 9) — axe уже 0,
а широкое снятие акцента конфликтует с «не ломать HUD-палитру»; монотонность
тиров срока продажи не форсируется (это были бы выдуманные данные).
Pre-push review: _await_scheduler ждал только scheduler_loop COORDINATOR, но вся
scrape-работа крутится в detached asyncio.create_task детях (каждый trigger_* делал
`task = create_task(_run())` без join). На SIGTERM coordinator выходил из while True
и завершался → asyncio.run() teardown хард-кансельил ещё бегущих детей mid-await =
ровно #1182 failure mode. Кооперативный checkpoint спасал ребёнка лишь когда его
residual-время случайно перекрывало drain — вероятностно, не гарантированно.
Fix — coordinator теперь дренажит детей перед выходом:
- _spawn_tracked(coro): централизованный detached-spawn, кладёт задачу в module-level
registry _inflight_tasks (strong-ref = RUF006 keep-alive) + done-callback ретривит
exception и убирает из set'а. Заменил 26 одинаковых
`task = create_task(_run()); task.add_done_callback(...)` сайтов.
- _drain_inflight(): один asyncio.wait по живым детям с бюджетом
_CHILD_DRAIN_TIMEOUT_S=80s (< scheduler_main 100s < docker grace 120s). Кооперативные
дети (avito_detail_backfill, rosreestr-executor) дочекивают карточку/батч + mark_done
и резолвятся; некооперативные упираются в timeout и падают на внешний hard-cancel.
- scheduler_loop по выходу из tick-loop (только по SIGTERM) зовёт await _drain_inflight().
NB: raw asyncio.all_tasks()-minus-self здесь НЕЛЬЗЯ — в нашей топологии он захватывает
_run parent-task (блокирован на wait_for(coordinator)) и shutdown_waiter → циклическое
ожидание coordinator↔_run, всегда упирающееся в timeout. Точный registry это исключает.
Tests: tests/test_scheduler.py — detached cooperative child drained-not-cancelled,
non-cooperative child timeout→left for hard-cancel, no-op без детей, scheduler_loop→drain
wiring. Обновил 3 source-inspection теста под новый _spawn_tracked паттерн.
axe-фоллоуап к #2066: «скоро» (КОЭФ.ЛОКАЦИИ) muted2→body2 (2,3→6,3:1 на badgeTint); LocationDrawer aria-hidden когда закрыт (off-canvas вне лендмарка → снимает axe «region»).
В ветке `task.done()` без shutdown глушилось реальное исключение задачи. Зовём
task.result() → пробрасываем настоящий сбой scheduler_loop наружу (loud crash,
как делал прежний `await task`), вместо тихого "exited cleanly".
Деплой (docker recreate tradein-scraper) шлёт SIGTERM с stop_grace_period=120s
(Phase 1). Раньше scheduler_main отвечал hard task.cancel() → бегущий scrape-unit
получал CancelledError посреди браузер-карточки → карточка гибла без COMMIT.
Теперь SIGTERM/SIGINT лишь выставляют кооперативный флаг; tick-loop и длинные
задачи опрашивают его на СВОИХ существующих between-unit checkpoint'ах,
докоммичивают текущий unit и выходят сами.
- app/core/shutdown.py: новый standalone-модуль (asyncio.Event + request_shutdown
/ shutdown_requested / wait_for_shutdown), без app-зависимостей → нет циклов.
- scheduler_main._run: SIGTERM → request_shutdown() вместо task.cancel(); ждём
добровольного drain'а, safety-net wait_for(100s < docker grace) с fallback на
hard-cancel для некооперирующей задачи.
- scheduler_loop: проверка флага в начале тика и после каждого dispatch — не
reap/claim новые run'ы во время shutdown, выходим из loop'а.
- avito_detail_backfill: break на границе карточки рядом с budget-guard; snapshot
pending-query идемпотентен → следующий run сам резюмит остаток.
- import_rosreestr_dkp: расширен is_cancelled checkpoint — drain делает mark_done
(partial), не mark_cancelled; user-cancel семантика без изменений.
Без SIGTERM поведение идентично прежнему. reap_zombies не трогает drained-run'ы
(они mark_done, не 'running').
Tests: tests/core/test_shutdown.py, scheduler_main drain+timeout-fallback,
avito_detail_backfill partial-drain. 26 + 29 scheduler passed.
Quarterly Rosreestr dataset_КАДАСТРСТОИМОСТЬ (cadastral value) gives a
per-cad-quarter cadastral ₽/m². Registered deals priced below 0.7x that
quarter's cadastral median are dropped from mv_quarter_price_per_m2 as
noise (data-entry junk / non-arm's-length deals that drag medians down).
- 178_schema_cadastral_value.sql: rosreestr_cadastral_value (+ staging),
region-66-scoped, idempotent.
- 178b_load_cadastral_value.py: psycopg v3 staging COPY -> typed region-66
INSERT, idempotent skip-if-loaded. Run MANUALLY by operator after deploy.
- 179_mv_quarter_price_cadastral_floor.sql: recreates BOTH MVs (CASCADE
drops the dependent index MV). Adds cad_floor CTE + LEFT JOIN guard to
mv_quarter_price_per_m2; mv_quarter_price_index recreated verbatim.
Safe to auto-apply on prod before any cadastral data exists: empty
rosreestr_cadastral_value -> cad_floor has 0 rows -> floor_ppm2 IS NULL for
every quarter -> predicate keeps ALL deals -> MV byte-identical to the
pre-floor (95) definition. Proven by a rolled-back dry-run (66:% EXCEPT
both directions = 0). With the experiment cad source loaded the floor
moves 480 of 1972 quarters (median 66788 -> 67350).
NN bumped from spec's 172/173 to 178/178b/179 (172-177 already on main).
The tradein-scraper container runs the same image as backend
(python -m app.scheduler_main, in-app scheduler) and owns the in-flight
scrape sweeps (a browser card takes ~15-27s). Two problems compounded to
kill running jobs on every deploy:
Phase 0 (deploy-tradein.yml): SCRAPER_CHANGED also fired on the `infra`
paths-filter (compose / workflow / deploy/**), so any generic infra edit
recreated the scraper container and SIGKILLed the running job. Dropped the
`|| infra == 'true'` term — only real scraper-code paths (already covered
by the `scraper` paths-filter) or a manual workflow_dispatch recreate it.
Phase 1 (docker-compose.prod.yml): the scraper had no stop_grace_period,
so Docker's default 10s window SIGKILLed an in-flight card (15-27s) before
it could finish. Added stop_grace_period: 120s + explicit stop_signal:
SIGTERM so a running unit can finish + checkpoint. The cooperative-drain
handler that consumes SIGTERM lands in a later phase; this is the
foundation.
Browser audit of the live /trade-in/v2 found honesty + UX defects; fixes:
- 🔴 LocationDrawer: removed FABRICATED coef 0.87 / fake 'base×coef=result' / invented
POI factors / false 'OpenStreetMap' source. Now honest static 'ПОЯСНЕНИЕ К РАСЧЁТУ'
(real methodology) + 'коэф. локации в разработке' note. + Escape-to-close.
- 🔴 Map (ParamsPanel): removed static fixture price markers; renders REAL analog
markers via new mapMarkers(estimate) (projects lat/lon around target, cap 6).
- ParamsPanel: empty площадь/address now shows a validation message instead of
silently blocking submit; numeric placeholders made clearly placeholders ('напр. 54').
- HeroBar: dead 'КАК РАССЧИТАНО' button now opens the info drawer; building.png next/image
400 fixed (unoptimized + onError fallback — basePath wasn't applied to the optimizer URL).
- TopNav: real user from useMe() (name/initials/org/email) instead of fixture
'Андрей Петров'; dead Профиль/Настройки/Помощь dimmed/disabled; Выйти → logout().
- mappers: de-glue analog addresses ('69/2Геологическая'→'69/2 Геологическая', preserves
house letters like 14А); 'Продажи в доме'→'Продажи рядом' (radius data); торг sign fixed
(backend (start−last)/start positive=reduction → renders '−3.3%').
- HistoryView: '0 ЛОТА' → correct plural via pluralRu.
next build green; code-reviewer ✅ APPROVE.