Compare commits
No commits in common. "main" and "fix/kit-parser-unit-tests" have entirely different histories.
main
...
fix/kit-pa
79 changed files with 789 additions and 4527 deletions
|
|
@ -260,12 +260,10 @@ jobs:
|
|||
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
|
||||
# не бежали ни разу с момента написания.
|
||||
#
|
||||
# postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные
|
||||
# таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL
|
||||
# модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через
|
||||
# ST_DWithin по geography, без расширения его не исполнить. На plain
|
||||
# postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не
|
||||
# проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB.
|
||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
||||
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
|
||||
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
|
||||
# нужен, в отличие от tradein-лэйна.
|
||||
#
|
||||
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
|
||||
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
|
||||
|
|
@ -296,14 +294,15 @@ jobs:
|
|||
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
|
||||
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
|
||||
#
|
||||
# postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap
|
||||
# схемы здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
||||
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
|
||||
# здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||
run: |
|
||||
set -u
|
||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CI_PG" \
|
||||
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
||||
postgis/postgis:16-3.4
|
||||
postgres:16
|
||||
|
||||
ready=""
|
||||
for _ in $(seq 1 45); do
|
||||
|
|
|
|||
|
|
@ -91,16 +91,12 @@ jobs:
|
|||
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
|
||||
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
||||
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
|
||||
# #3589: URL внешнего deadman-приёмника Watchdog (healthchecks.io и
|
||||
# аналоги). Пусто — watchdog-ping остаётся без конфигов, см. блок
|
||||
# METRICS_WATCHDOG_PING_BLOCK ниже.
|
||||
METRICS_WATCHDOG_PING_URL: ${{ secrets.METRICS_WATCHDOG_PING_URL }}
|
||||
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
|
||||
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
|
||||
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
|
||||
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
|
||||
with:
|
||||
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET,METRICS_WATCHDOG_PING_URL
|
||||
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET
|
||||
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
||||
|
|
@ -197,24 +193,6 @@ jobs:
|
|||
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
|
||||
fi
|
||||
|
||||
# Watchdog-пинг во внешний deadman-приёмник (healthchecks.io и
|
||||
# аналоги) — заменяет регулярные сообщения в Telegram, см.
|
||||
# комментарий у watchdog-ping в alertmanager.yml.tmpl. Блок
|
||||
# целиком, а не значение — та же причина, что у
|
||||
# METRICS_TELEGRAM_INFRA_TOPIC_LINE: пустой `url:` в
|
||||
# receiver'е не деградирует, а валит amtool check-config
|
||||
# целиком, то есть роняет ВЕСЬ алертинг из-за одного
|
||||
# необязательного получателя. Секрет пока не заведён —
|
||||
# деградация корректна: receiver остаётся без `*_configs` и
|
||||
# молча ничего никуда не шлёт, amtool это пропускает.
|
||||
if [ -n "${METRICS_WATCHDOG_PING_URL:-}" ]; then
|
||||
METRICS_WATCHDOG_PING_BLOCK=$(printf ' webhook_configs:\n - url: "%s"\n send_resolved: false' "${METRICS_WATCHDOG_PING_URL}")
|
||||
echo "Watchdog: внешний deadman-пинг настроен."
|
||||
else
|
||||
METRICS_WATCHDOG_PING_BLOCK=""
|
||||
echo "::warning title=Watchdog без deadman-пинга::METRICS_WATCHDOG_PING_URL пуст — сторож мониторинга никуда не сообщает о своей живости. Заведи аккаунт healthchecks.io (или аналог) и секрет, иначе обрыв канала доставки не заметит никто (#3589)."
|
||||
fi
|
||||
|
||||
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
|
||||
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
|
||||
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
|
||||
|
|
@ -243,8 +221,7 @@ jobs:
|
|||
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
|
||||
METRICS_TELEGRAM_INFRA_TOPIC_LINE="$METRICS_TELEGRAM_INFRA_TOPIC_LINE" \
|
||||
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
|
||||
METRICS_WATCHDOG_PING_BLOCK="$METRICS_WATCHDOG_PING_BLOCK" \
|
||||
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL} ${METRICS_WATCHDOG_PING_BLOCK}' \
|
||||
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \
|
||||
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
|
||||
> ops/metrics/alertmanager/alertmanager.yml
|
||||
chmod 600 ops/metrics/alertmanager/alertmanager.yml
|
||||
|
|
|
|||
|
|
@ -166,74 +166,6 @@ _GEO_PRICE_RADIUS_M: float = 3000.0 # 3 км — городской радиу
|
|||
_GEO_PRICE_MIN_LOTS: int = 10
|
||||
_GEO_PRICE_MIN_COMPLEXES: int = 2
|
||||
|
||||
_GEO_RADIUS_PRICE_SQL = text("""
|
||||
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
|
||||
-- (мульти lot_id на физлот) → n/вес медианы/гейт n≥10
|
||||
-- были по пере-листингам. Дедупим INLINE через DISTINCT ON
|
||||
-- (physflat-ключ, последний снапшот), scope протолкнут В
|
||||
-- CTE через проекты ближних ЖК. НЕ через
|
||||
-- v_objective_lots_latest: view материализует ВСЮ таблицу
|
||||
-- (qual/join не проходят ниже DISTINCT ON) → seq-scan+sort
|
||||
-- 1.76M (~6.4 s на request-path analyze_parcel). Дедуп до price-фильтра:
|
||||
-- цена объективна по физлоту (последний снапшот).
|
||||
-- #3583: дедуп по проекту в LATERAL + premise_kind='квартира' → Index Only Scan
|
||||
-- objective_lots_physflat_covering_v2_idx без сортировки. Общий DISTINCT ON по
|
||||
-- всем снапшотам проектов шёл external merge на диск: прод-EXPLAIN 17.09, 16
|
||||
-- проектов, 690 мс против 62 мс. Апартаменты на проде 17.09 есть у 6 проектов из
|
||||
-- complex_sources, у всех complex без координат → в радиус не попадают, фильтр
|
||||
-- медиану не меняет (замер на 570 участках: 0 отличий).
|
||||
WITH nearby_cx AS (
|
||||
-- #3583: complex → проект Объектива из complex_sources (source='objective',
|
||||
-- 1:1), а НЕ из objective_lots.complex_id. Тот проставлен один раз миграцией 76,
|
||||
-- а еженедельный 70_parse_objective_raw.py UPSERT'ом по objective_lot_id
|
||||
-- переписывает project_name и не трогает complex_id → под id ближнего ЖК
|
||||
-- лежат лоты чужих (прод 17.09: 236 354 из 303 677 строк). Тот же дефект,
|
||||
-- что #2962 в competitors.py, и та же сверка имени: связь fuzzy, у «ЖК VEER
|
||||
-- PARK» стоит 'Clever Park' в 11.7 км, у «ЖК Графит» — 'Гранит'.
|
||||
SELECT c.id, cs.source_id AS project_name
|
||||
FROM complexes c
|
||||
JOIN complex_sources cs
|
||||
ON cs.complex_id = c.id
|
||||
AND cs.source = 'objective'
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT regexp_replace(lower(c.canonical_name), '[^0-9a-zа-яё]', '', 'g') AS cx_key,
|
||||
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
|
||||
) k
|
||||
WHERE c.latitude IS NOT NULL
|
||||
AND c.longitude IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
||||
ST_SetSRID(
|
||||
ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)), 4326
|
||||
)::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
AND ( k.cx_key LIKE '%' || k.project_key || '%'
|
||||
OR k.project_key LIKE '%' || k.cx_key || '%')
|
||||
),
|
||||
latest AS (
|
||||
SELECT l.price_per_m2_rub, nc.id AS complex_id
|
||||
FROM nearby_cx nc
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT DISTINCT ON (ol.corpus_name, ol.section, ol.floor, ol.lot_number)
|
||||
ol.price_per_m2_rub
|
||||
FROM objective_lots ol
|
||||
WHERE ol.project_name = nc.project_name
|
||||
AND ol.premise_kind = 'квартира'
|
||||
ORDER BY ol.corpus_name, ol.section, ol.floor, ol.lot_number,
|
||||
ol.snapshot_date DESC, ol.id DESC
|
||||
) l
|
||||
)
|
||||
SELECT
|
||||
percentile_cont(0.5) WITHIN GROUP (
|
||||
ORDER BY price_per_m2_rub
|
||||
) AS median,
|
||||
count(*) AS n,
|
||||
count(DISTINCT complex_id) AS n_complexes
|
||||
FROM latest
|
||||
WHERE price_per_m2_rub IS NOT NULL
|
||||
""")
|
||||
|
||||
# #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV
|
||||
# (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще
|
||||
# могла служить последним fallback'ом для карточки district.median_price_per_m2.
|
||||
|
|
@ -3630,7 +3562,7 @@ def analyze_parcel(
|
|||
# радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference:
|
||||
# только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё
|
||||
# проваливались в class_norm. У objective_lots нет geom — коорды берём через
|
||||
# complexes (latitude/longitude), проект — через complex_sources (#3583, как #2962).
|
||||
# complexes (latitude/longitude), join по ol.complex_id = c.id (паттерн competitors.py).
|
||||
geo_radius_price: dict[str, Any]
|
||||
# Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная
|
||||
# медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную
|
||||
|
|
@ -3641,7 +3573,60 @@ def analyze_parcel(
|
|||
with db.begin_nested():
|
||||
grp_row = (
|
||||
db.execute(
|
||||
_GEO_RADIUS_PRICE_SQL,
|
||||
text("""
|
||||
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
|
||||
-- (мульти lot_id на физлот) → n/вес медианы/гейт n≥10
|
||||
-- были по пере-листингам. Дедупим INLINE через DISTINCT ON
|
||||
-- (physflat-ключ, последний снапшот), scope протолкнут В
|
||||
-- CTE через complex_id ближних ЖК. НЕ через
|
||||
-- v_objective_lots_latest: view материализует ВСЮ таблицу
|
||||
-- (qual/join не проходят ниже DISTINCT ON) → seq-scan+sort
|
||||
-- 1.76M (~6.4 s на request-path analyze_parcel). Inline:
|
||||
-- geo-index по complexes → Nested Loop bitmap
|
||||
-- objective_lots_complex_idx по ~186 ЖК → ~120 ms
|
||||
-- (прод-EXPLAIN deep-review #1964). Дедуп до price-фильтра:
|
||||
-- цена объективна по физлоту (последний снапшот).
|
||||
WITH nearby_cx AS (
|
||||
SELECT c.id
|
||||
FROM complexes c
|
||||
WHERE c.latitude IS NOT NULL
|
||||
AND c.longitude IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(
|
||||
ST_MakePoint(c.longitude, c.latitude),
|
||||
4326
|
||||
)::geography,
|
||||
ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
CAST(:lon AS float),
|
||||
CAST(:lat AS float)
|
||||
), 4326
|
||||
)::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
),
|
||||
latest AS (
|
||||
SELECT DISTINCT ON (
|
||||
ol.project_name, ol.corpus_name, ol.section,
|
||||
ol.floor, ol.lot_number
|
||||
)
|
||||
ol.price_per_m2_rub,
|
||||
ol.complex_id
|
||||
FROM objective_lots ol
|
||||
WHERE ol.complex_id IN (SELECT id FROM nearby_cx)
|
||||
ORDER BY ol.project_name, ol.corpus_name, ol.section,
|
||||
ol.floor, ol.lot_number,
|
||||
ol.snapshot_date DESC, ol.id DESC
|
||||
)
|
||||
SELECT
|
||||
percentile_cont(0.5) WITHIN GROUP (
|
||||
ORDER BY price_per_m2_rub
|
||||
) AS median,
|
||||
count(*) AS n,
|
||||
count(DISTINCT complex_id) AS n_complexes
|
||||
FROM latest
|
||||
WHERE price_per_m2_rub IS NOT NULL
|
||||
"""),
|
||||
{
|
||||
"lon": centroid_lon,
|
||||
"lat": centroid_lat,
|
||||
|
|
|
|||
|
|
@ -627,7 +627,7 @@ class LayoutDataQuality(BaseModel):
|
|||
"""
|
||||
|
||||
objects_with_velocity_data: int # число комплексов с velocity-данными
|
||||
objects_total_in_radius: int # число комплексов после exclude/filter (знаменатель coverage)
|
||||
objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
|
||||
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
|
||||
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
|
||||
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
|
||||
|
|
|
|||
|
|
@ -587,6 +587,19 @@ class NSPDBulkClient:
|
|||
)
|
||||
return results
|
||||
|
||||
async def get_territorial_zones_in_bbox(
|
||||
self,
|
||||
bbox: tuple[float, float, float, float],
|
||||
*,
|
||||
grid_n: int = 7,
|
||||
) -> list[dict]:
|
||||
"""Grid-walk WMS GetFeatureInfo для layer 875838 (ПЗЗ территориальные зоны).
|
||||
|
||||
Returns: list of feature dicts с полями id, geometry, properties.
|
||||
Дедуплицирует по feature id.
|
||||
"""
|
||||
return await self.get_features_in_bbox_grid(875838, bbox, grid_n=grid_n)
|
||||
|
||||
# ── 4. list_objects_in_building ───────────────────────────────────────────
|
||||
# Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
|
||||
# Готов для per-building помещения/парковка фазы.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ Resumable: phase_state в cadastre_jobs показывает прогресс.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
|
@ -259,8 +260,10 @@ async def harvest_quarter(
|
|||
update_progress(done_progress)
|
||||
|
||||
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
|
||||
# Bug #1583: кварталы с broken/NULL geom дают quarter_bbox_3857() == None →
|
||||
# grid-walk следующего прогона молча пропускается. Чиним geom из snapshot.
|
||||
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
|
||||
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
|
||||
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
|
||||
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
|
||||
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
||||
if stats_features:
|
||||
upsert_quarter_stats(db, quarter, stats_features[0])
|
||||
|
|
@ -275,6 +278,25 @@ async def harvest_quarter(
|
|||
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
|
||||
db.commit()
|
||||
|
||||
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
|
||||
# Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
|
||||
# auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
|
||||
# получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
|
||||
quarter_bbox = quarter_bbox_3857(db, quarter)
|
||||
if quarter_bbox is not None:
|
||||
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
|
||||
try:
|
||||
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
|
||||
tz_count = _save_territorial_zones(db, quarter, tz_features)
|
||||
logger.info(
|
||||
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
|
||||
)
|
||||
except (NspdBulkWafError, NspdBulkRateLimitError):
|
||||
# #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст».
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
|
||||
|
||||
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
|
||||
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
||||
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
||||
|
|
@ -657,7 +679,7 @@ def upsert_features(
|
|||
cat = feature.category_id
|
||||
# Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case
|
||||
# GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx
|
||||
# квартала. Зеркалит _grid_walk_category:
|
||||
# квартала. Зеркалит _grid_walk_category (~368) и _save_territorial_zones (~1234):
|
||||
# begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик
|
||||
# инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными.
|
||||
try:
|
||||
|
|
@ -1454,6 +1476,114 @@ def upsert_quarter_stats(
|
|||
)
|
||||
|
||||
|
||||
# ── cad_territorial_zones upsert ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict]) -> int:
|
||||
"""UPSERT territorial_zones features в cad_territorial_zones по zone_id.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session.
|
||||
quarter_cad: кадастровый номер квартала (3 сегмента).
|
||||
features: list of raw feature dicts от get_features_in_bbox_grid.
|
||||
|
||||
Returns:
|
||||
Количество успешно upserted строк.
|
||||
"""
|
||||
inserted = 0
|
||||
for f in features:
|
||||
props: dict = f.get("properties") or {}
|
||||
geom = f.get("geometry")
|
||||
geom_geojson: str | None = json.dumps(geom) if geom else None
|
||||
|
||||
# zone_id — NSPD feature id или стабильный fallback на основе md5 от properties.
|
||||
# md5 гарантирует идемпотентность между runs (счётчик inserted сбрасывается).
|
||||
_raw_id = props.get("id") or props.get("zone_id") or f.get("id")
|
||||
if _raw_id:
|
||||
zone_id = str(_raw_id)
|
||||
else:
|
||||
_props_hash = hashlib.md5(
|
||||
json.dumps(props, sort_keys=True).encode("utf-8")
|
||||
).hexdigest()[:12]
|
||||
zone_id = f"{quarter_cad}_{_props_hash}"
|
||||
zone_code = (
|
||||
props.get("zone_code") or props.get("zone_index") or props.get("reg_numb_border")
|
||||
)
|
||||
zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone")
|
||||
permitted_use = props.get("permitted_use") or props.get("vri")
|
||||
|
||||
# cad_territorial_zones.geom — geography(MultiPolygon, 4326).
|
||||
# Polygon допустим (ST_Multi обернёт в SQL), но Point/LineString →
|
||||
# geography-INSERT fail → SAVEPOINT откат → строка дропается молча.
|
||||
# Зеркалит фильтр upsert_zouit (~1196).
|
||||
geom_type = geom.get("type") if isinstance(geom, dict) else None
|
||||
if geom_type not in ("Polygon", "MultiPolygon"):
|
||||
if geom_type:
|
||||
logger.info(
|
||||
"_save_territorial_zones: zone_id=%s geom type=%s (не Polygon/MultiPolygon)"
|
||||
" — geom=NULL",
|
||||
zone_id,
|
||||
geom_type,
|
||||
)
|
||||
geom_geojson = None
|
||||
|
||||
try:
|
||||
# begin_nested() требует активной outer-транзакции для SAVEPOINT.
|
||||
# SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом
|
||||
# db.execute() в этом loop — outer tx гарантирована.
|
||||
with db.begin_nested():
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO cad_territorial_zones
|
||||
(quarter_cad, zone_id, zone_code, zone_name,
|
||||
permitted_use, raw_props, geom)
|
||||
VALUES (
|
||||
CAST(:quarter_cad AS text),
|
||||
CAST(:zone_id AS text),
|
||||
CAST(:zone_code AS text),
|
||||
CAST(:zone_name AS text),
|
||||
CAST(:permitted_use AS text),
|
||||
CAST(:raw_props AS jsonb),
|
||||
CASE WHEN CAST(:geom AS text) IS NOT NULL
|
||||
THEN ST_Multi(
|
||||
ST_Transform(
|
||||
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
|
||||
4326
|
||||
)
|
||||
)::geography
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
ON CONFLICT (zone_id) DO UPDATE SET
|
||||
zone_code = EXCLUDED.zone_code,
|
||||
zone_name = EXCLUDED.zone_name,
|
||||
permitted_use = EXCLUDED.permitted_use,
|
||||
raw_props = EXCLUDED.raw_props,
|
||||
geom = EXCLUDED.geom,
|
||||
fetched_at = NOW()
|
||||
"""),
|
||||
{
|
||||
"quarter_cad": quarter_cad,
|
||||
"zone_id": zone_id,
|
||||
"zone_code": zone_code,
|
||||
"zone_name": zone_name,
|
||||
"permitted_use": permitted_use,
|
||||
"raw_props": json.dumps(props, ensure_ascii=False),
|
||||
"geom": geom_geojson,
|
||||
},
|
||||
)
|
||||
inserted += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"_save_territorial_zones: upsert failed zone_id=%s quarter=%s: %s",
|
||||
zone_id,
|
||||
quarter_cad,
|
||||
e,
|
||||
)
|
||||
db.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
# ── Утилиты ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1109,6 +1109,9 @@ def get_best_layouts(
|
|||
id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows]
|
||||
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts]
|
||||
raw_objects_total_in_radius = len(all_obj_ids)
|
||||
# Число комплексов ДО exclude/filter — знаменатель для empty-ответа (совместимо
|
||||
# со старой семантикой: пустой ответ отражал полный радиус, а не post-filter 0).
|
||||
groups_total_pre_filter = len(group_radius_objects(id_row_dicts))
|
||||
|
||||
# Применить exclude / filter из request (и к obj_ids, и к строкам для группировки)
|
||||
exclude_set = set(request.exclude_competitor_obj_ids)
|
||||
|
|
@ -1129,16 +1132,13 @@ def get_best_layouts(
|
|||
all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)]
|
||||
|
||||
# Группируем отфильтрованные obj_id в комплексы — знаменатель coverage.
|
||||
# objects_total_in_radius во ВСЕХ ветках = len(complex_groups) (после фильтра):
|
||||
# UI и PDF печатают его как N в «покрытие P% (Y из N комплексов)», а P считается
|
||||
# по отфильтрованным (#2464). Число до фильтра — в raw_objects_total.
|
||||
complex_groups = group_radius_objects(id_row_dicts)
|
||||
|
||||
if not all_obj_ids:
|
||||
return _empty_response(
|
||||
radius_km=request.radius_km,
|
||||
time_window=request.time_window,
|
||||
objects_total_in_radius=len(complex_groups),
|
||||
objects_total_in_radius=groups_total_pre_filter,
|
||||
raw_objects_total=raw_objects_total_in_radius,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -344,26 +344,21 @@ _COMPETITORS_SQL = text("""
|
|||
-- важна — velocity питает forecast). Спатиальный join дёшев, т.к.
|
||||
-- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj
|
||||
-- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT).
|
||||
--
|
||||
-- #2962: complex → проект Объектива берём из complex_sources (source=
|
||||
-- 'objective', строго 1:1), а НЕ из objective_lots.complex_id. Тот проставлен
|
||||
-- один раз миграцией 76 на загрузке 10.05, а еженедельный
|
||||
-- 70_parse_objective_raw.py UPSERT'ом по objective_lot_id переписывает
|
||||
-- project_name и не трогает complex_id → под одним complex_id лежат чужие ЖК
|
||||
-- (прод 17.09: 236 354 из 303 677 строк, у «ЖК Мичуринский» 129 проектов).
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id,
|
||||
cs.source_id AS project_name,
|
||||
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
|
||||
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
|
||||
-- «Парковый» ⊂ «Парковый квартал»)
|
||||
( k.comm_key LIKE '%' || k.project_key || '%'
|
||||
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
|
||||
c.id AS complex_id
|
||||
FROM latest_obj o
|
||||
JOIN complexes c
|
||||
ON c.latitude IS NOT NULL
|
||||
AND c.longitude IS NOT NULL
|
||||
AND c.canonical_name IS NOT NULL
|
||||
-- #968: только complexes, у которых ЕСТЬ objective velocity-данные →
|
||||
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
|
||||
-- без objective_lots съедал бы матч (prod: +37 наивно → +181 так).
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM objective_lots ol
|
||||
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
|
||||
)
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
||||
|
|
@ -374,20 +369,6 @@ _COMPETITORS_SQL = text("""
|
|||
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
||||
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
|
||||
)
|
||||
JOIN complex_sources cs
|
||||
ON cs.complex_id = c.id
|
||||
AND cs.source = 'objective'
|
||||
-- #968: только complexes, у которых ЕСТЬ objective-данные →
|
||||
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
|
||||
-- без objective_lots съедал бы матч (prod: +37 наивно → +181 так).
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM objective_lots ol
|
||||
WHERE ol.project_name = cs.source_id
|
||||
)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
|
||||
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
|
||||
) k
|
||||
WHERE o.comm_name IS NOT NULL
|
||||
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only
|
||||
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
|
||||
|
|
@ -395,13 +376,7 @@ _COMPETITORS_SQL = text("""
|
|||
ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
|
||||
) ASC,
|
||||
-- #2962: 1:1 complex → objective-проект держится только на данных
|
||||
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
|
||||
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
|
||||
-- брал любой, сверка его отвергала, и верный терялся.
|
||||
name_ok DESC,
|
||||
cs.source_id
|
||||
) ASC
|
||||
),
|
||||
mapped AS (
|
||||
-- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical.
|
||||
|
|
@ -409,18 +384,15 @@ _COMPETITORS_SQL = text("""
|
|||
cm.objective_complex_name
|
||||
FROM objective_complex_mapping cm
|
||||
UNION
|
||||
-- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а
|
||||
-- (корпуса у Объектива — corpus_name внутри проекта, не отдельные имена).
|
||||
-- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а
|
||||
-- (у комплекса может быть несколько корпус-project_name → velocity
|
||||
-- легитимно суммируется по ним, но НЕ по нескольким комплексам).
|
||||
SELECT nc.obj_id,
|
||||
nc.project_name AS objective_complex_name
|
||||
ol.project_name AS objective_complex_name
|
||||
FROM nearest_cx nc
|
||||
-- #2962: связь complex → проект в complex_sources почти вся fuzzy и не
|
||||
-- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» —
|
||||
-- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз
|
||||
-- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join
|
||||
-- планировщик считал regexp на всех парах объект × проект (383k) раньше
|
||||
-- гео-фильтра — 3 с против 0.4 с.
|
||||
WHERE nc.name_ok
|
||||
JOIN objective_lots ol
|
||||
ON ol.complex_id = nc.complex_id
|
||||
AND ol.project_name IS NOT NULL
|
||||
),
|
||||
velocity AS (
|
||||
SELECT
|
||||
|
|
@ -633,11 +605,9 @@ _SOLD_COUNT_SQL = text("""
|
|||
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
|
||||
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
|
||||
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
|
||||
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources
|
||||
# (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON
|
||||
# ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и
|
||||
# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под
|
||||
# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx).
|
||||
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
|
||||
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
|
||||
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
|
||||
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
|
||||
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||
|
|
@ -654,17 +624,16 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
nearest_cx AS (
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id,
|
||||
cs.source_id AS project_name,
|
||||
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
|
||||
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
|
||||
-- «Парковый» ⊂ «Парковый квартал»)
|
||||
( k.comm_key LIKE '%' || k.project_key || '%'
|
||||
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
|
||||
c.id AS complex_id
|
||||
FROM domrf_kn_objects o
|
||||
JOIN complexes c
|
||||
ON c.latitude IS NOT NULL
|
||||
AND c.longitude IS NOT NULL
|
||||
AND c.canonical_name IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM objective_lots ol
|
||||
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
|
||||
)
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
||||
|
|
@ -675,17 +644,6 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
||||
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
|
||||
)
|
||||
JOIN complex_sources cs
|
||||
ON cs.complex_id = c.id
|
||||
AND cs.source = 'objective'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM objective_lots ol
|
||||
WHERE ol.project_name = cs.source_id
|
||||
)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
|
||||
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
|
||||
) k
|
||||
WHERE o.obj_id = ANY(:obj_ids)
|
||||
AND o.latitude IS NOT NULL
|
||||
AND o.longitude IS NOT NULL
|
||||
|
|
@ -696,13 +654,7 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
|
||||
) ASC,
|
||||
-- #2962: 1:1 complex → objective-проект держится только на данных
|
||||
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
|
||||
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
|
||||
-- брал любой, сверка его отвергала, и верный терялся.
|
||||
name_ok DESC,
|
||||
cs.source_id
|
||||
) ASC
|
||||
),
|
||||
gapfill_price AS (
|
||||
SELECT
|
||||
|
|
@ -710,10 +662,8 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
ol.price_per_m2_rub AS price_per_m2_rub
|
||||
FROM nearest_cx nc
|
||||
JOIN objective_lots ol
|
||||
ON ol.project_name = nc.project_name
|
||||
ON ol.complex_id = nc.complex_id
|
||||
AND ol.price_per_m2_rub IS NOT NULL
|
||||
-- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962)
|
||||
WHERE nc.name_ok
|
||||
)
|
||||
SELECT
|
||||
p.obj_id,
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ def build_beat_schedule() -> dict:
|
|||
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
|
||||
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
|
||||
# zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD
|
||||
# territorial_zones в nspd_quarter_dumps (nspd_sync, layer 875838). Активный beat
|
||||
# territorial_zones dumps (Phase 2.5 bulk_harvest, layer 875838). Активный beat
|
||||
# каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал
|
||||
# ошибку как error → рекуррентил GlitchTip BACKEND-1B.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
"""Гео-радиусная цена участка берёт лоты проектов ближних ЖК, а не всё под complex_id (#3583).
|
||||
|
||||
`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный
|
||||
`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и
|
||||
не трогает complex_id: под id ближнего ЖК лежат лоты чужих. Тот же дефект, что #2962
|
||||
в competitors.py.
|
||||
|
||||
Тест герметичный и прогоняет НАСТОЯЩИЙ `_GEO_RADIUS_PRICE_SQL`: временные таблицы
|
||||
затеняют боевые в пределах сессии. Нужен Postgres с PostGIS (ST_DWithin по geography).
|
||||
В CI он есть, и там тест не пропускается: без PostGIS падает с настоящей причиной.
|
||||
Пропуск разрешён только вне CI и объявлен в skip_allowlist.txt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
def _dsn() -> str:
|
||||
raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"]
|
||||
return (
|
||||
raw
|
||||
if raw.startswith("postgresql+")
|
||||
else raw.replace("postgresql://", "postgresql+psycopg://")
|
||||
)
|
||||
|
||||
|
||||
def _postgis_reachable() -> tuple[bool, str]:
|
||||
try:
|
||||
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
||||
with eng.connect() as c:
|
||||
if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first():
|
||||
return True, ""
|
||||
return False, "нет расширения postgis"
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
_DB_OK, _DB_ERR = _postgis_reachable()
|
||||
_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"))
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}"
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
"""CREATE TEMP TABLE complexes (
|
||||
id bigint, canonical_name text, latitude double precision,
|
||||
longitude double precision) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE complex_sources (
|
||||
complex_id bigint, source text, source_id text) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE objective_lots (
|
||||
id bigint, project_name text, corpus_name text, section text, floor int,
|
||||
lot_number text, snapshot_date date, premise_kind text, complex_id bigint,
|
||||
price_per_m2_rub numeric) ON COMMIT DROP""",
|
||||
]
|
||||
|
||||
# Участок в (56.840, 60.600), радиус 3 км. Малахит — в ~10 км, вне радиуса.
|
||||
_DATA = [
|
||||
"""INSERT INTO complexes VALUES
|
||||
(10, 'ЖК Мичуринский', 56.841, 60.601),
|
||||
(20, 'ЖК VEER PARK', 56.845, 60.605),
|
||||
(30, 'СтудияПарк', 56.835, 60.595),
|
||||
(40, 'ЖК Малахит', 56.930, 60.600)""",
|
||||
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493).
|
||||
"""INSERT INTO complex_sources VALUES
|
||||
(10, 'objective', 'Мичуринский'),
|
||||
(20, 'objective', 'Clever Park'),
|
||||
(30, 'objective', 'Студия Парк'),
|
||||
(40, 'objective', 'Малахит')""",
|
||||
# Под complex_id=10 лежат свой лот и три лота чужого «Малахита» с устаревшим
|
||||
# complex_id; у новых лотов complex_id NULL. Лот «Мичуринский/1/1/3/3» в двух
|
||||
# снапшотах: в медиану идёт последний (120 тыс.), а не старый (50 тыс.).
|
||||
"""INSERT INTO objective_lots VALUES
|
||||
(1, 'Мичуринский', '1', '1', 1, '1', '2026-05-10', 'квартира', 10, 100000),
|
||||
(2, 'Мичуринский', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 110000),
|
||||
(3, 'Мичуринский', '1', '1', 3, '3', '2026-08-01', 'квартира', NULL, 50000),
|
||||
(4, 'Мичуринский', '1', '1', 3, '3', '2026-09-15', 'квартира', NULL, 120000),
|
||||
(5, 'Малахит', '1', '1', 1, '1', '2026-05-10', 'квартира', 10, 300000),
|
||||
(6, 'Малахит', '1', '1', 2, '2', '2026-05-10', 'квартира', 10, 300000),
|
||||
(7, 'Малахит', '1', '1', 3, '3', '2026-05-10', 'квартира', 10, 300000),
|
||||
(8, 'Clever Park', '1', '1', 1, '1', '2026-09-15', 'квартира', NULL, 500000),
|
||||
(9, 'Clever Park', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 500000),
|
||||
(10, 'Clever Park', '1', '1', 3, '3', '2026-09-15', 'квартира', NULL, 500000),
|
||||
(11, 'Студия Парк', '1', '1', 1, '1', '2026-09-15', 'квартира', NULL, 90000),
|
||||
(12, 'Студия Парк', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 95000)""",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def row() -> dict[str, float]:
|
||||
from app.api.v1.parcels import _GEO_PRICE_RADIUS_M, _GEO_RADIUS_PRICE_SQL
|
||||
|
||||
session = sessionmaker(bind=create_engine(_dsn()))()
|
||||
try:
|
||||
for stmt in _SCHEMA + _DATA:
|
||||
session.execute(text(stmt))
|
||||
r = (
|
||||
session.execute(
|
||||
_GEO_RADIUS_PRICE_SQL,
|
||||
{"lon": 60.600, "lat": 56.840, "radius_m": _GEO_PRICE_RADIUS_M},
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
return {k: float(v) for k, v in r.items()}
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
def test_median_counts_own_projects_of_nearby_complexes(row) -> None:
|
||||
"""Лоты 90/95 тыс. («Студия Парк») и 100/110/120 тыс. («Мичуринский») → медиана 100 тыс.
|
||||
|
||||
По устаревшему complex_id было бы 100 + три «Малахита» по 300 тыс. → 300 тыс.;
|
||||
без сверки имени добавились бы три лота «Clever Park» по 500 тыс. → 115 тыс.
|
||||
"""
|
||||
assert row == {"median": 100000.0, "n": 5.0, "n_complexes": 2.0}
|
||||
|
|
@ -264,10 +264,8 @@ def test_exclude_competitor_obj_ids_filter() -> None:
|
|||
# После исключения obj_id=20 список пуст → пустой ответ
|
||||
assert body["top_layouts"] == []
|
||||
assert body["data_quality"]["confidence"] == "low"
|
||||
# objects_total_in_radius — знаменатель coverage, после исключения 0 (#2464);
|
||||
# число obj_id в радиусе до исключения — raw_objects_total
|
||||
assert body["data_quality"]["objects_total_in_radius"] == 0
|
||||
assert body["data_quality"]["raw_objects_total"] == 1
|
||||
# objects_total_in_radius = 1 (до исключения)
|
||||
assert body["data_quality"]["objects_total_in_radius"] == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
|
|
|||
|
|
@ -21,11 +21,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
|
|
@ -183,48 +179,3 @@ def test_protuhshiy_token_ne_prinimaetsya(app: ModuleType, monkeypatch: pytest.M
|
|||
code, _ = app.do_ack(token)
|
||||
assert code == 404
|
||||
assert app.sent == []
|
||||
|
||||
|
||||
# ── Секрет из query не попадает в лог (#3576) ────────────────────────────────
|
||||
# GlitchTip шлёт секрет резервного вебхука только в `?secret=`, а http.server
|
||||
# печатает строку запроса целиком — и в строке доступа, и в тексте ошибки
|
||||
# разбора. Значение ниже выдуманное: проверяется, что его нет ни в одной записи.
|
||||
|
||||
_LEAK = "leak-probe-3576-VALUE"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_line", "masked", "lines_with_mask"),
|
||||
[
|
||||
# Строка доступа (log_request), путь резервного вебхука GlitchTip.
|
||||
(f"POST /glitchtip?secret={_LEAK} HTTP/1.1", "/glitchtip?secret=***", 1),
|
||||
# Имя с префиксом и соседний параметр — остальная строка цела.
|
||||
(f"GET /ack/x?a=1&access_token={_LEAK}&b=2 HTTP/1.1", "?a=1&access_token=***&b=2", 1),
|
||||
# Ошибка разбора (log_error): stdlib кладёт строку запроса в текст ошибки,
|
||||
# затем та же строка идёт в строку доступа с кодом 400 — обе записи.
|
||||
(f"POST /glitchtip?secret={_LEAK} junk HTTP/1.1", "/glitchtip?secret=***", 2),
|
||||
],
|
||||
)
|
||||
def test_sekret_iz_query_ne_popadaet_v_log(
|
||||
app: ModuleType,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
request_line: str,
|
||||
masked: str,
|
||||
lines_with_mask: int,
|
||||
) -> None:
|
||||
caplog.set_level(logging.INFO, logger="alert-ack")
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", 0), app.Handler)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
try:
|
||||
with socket.create_connection(srv.server_address, timeout=5) as sock:
|
||||
sock.sendall(f"{request_line}\r\nHost: x\r\nContent-Length: 0\r\n\r\n".encode())
|
||||
sock.shutdown(socket.SHUT_WR)
|
||||
while sock.recv(4096): # до закрытия: к этому моменту запись лога уже сделана
|
||||
pass
|
||||
finally:
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
|
||||
messages = [r.getMessage() for r in caplog.records if r.name == "alert-ack"]
|
||||
assert not [m for m in messages if _LEAK in m], f"значение секрета в логе: {messages}"
|
||||
assert sum(masked in m for m in messages) == lines_with_mask, messages
|
||||
|
|
|
|||
|
|
@ -21,9 +21,8 @@
|
|||
переменной — и инфраструктурная тема «метрики» оставалась пустой, а весь трафик,
|
||||
и клиентский, и инфраструктурный, копился в теме «алерты». Владелец решил
|
||||
развести: инфраструктура — в «метрики», клиентские инциденты — в «алерты».
|
||||
Тест ниже закрепляет именно это для единственного оставшегося прямого
|
||||
получателя — `telegram` (Watchdog с 17.09 больше не шлёт в Telegram вовсе,
|
||||
у него теперь внешний webhook-приёмник `watchdog-ping`, см. шаблон).
|
||||
Тесты ниже закрепляют именно это: `telegram`/`telegram-heartbeat` получают
|
||||
ИНФРАСТРУКТУРНУЮ тему, а не общую.
|
||||
|
||||
Тесты рендерят шаблон обоими способами и разбирают результат как YAML —
|
||||
проверяется фактический конфиг, а не наличие нужных слов в тексте.
|
||||
|
|
@ -44,56 +43,9 @@ WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
|||
|
||||
INFRA_TOPIC_LINE = " message_thread_id: 245"
|
||||
|
||||
# Заглушки для переменных, которые деплой может подставить. Значение
|
||||
# METRICS_WATCHDOG_PING_BLOCK по умолчанию пустое — это реальный дефолт
|
||||
# деплоя, когда секрет не заведён (#3589), а не тестовое упрощение.
|
||||
_DUMMY_VALUES = {
|
||||
"METRICS_TELEGRAM_BOT_TOKEN": "123:ABC",
|
||||
"METRICS_TELEGRAM_CHAT_ID": "-100123",
|
||||
"METRICS_TELEGRAM_ONCALL": "",
|
||||
"METRICS_WATCHDOG_PING_BLOCK": "",
|
||||
}
|
||||
|
||||
|
||||
def _envsubst_allowlist() -> set[str]:
|
||||
"""Реальный список переменных, которые деплой передаёт в envsubst.
|
||||
|
||||
Не хардкодим копию списка — #3589 случился именно так: шаблон завёл
|
||||
`${METRICS_WATCHDOG_PING_URL}`, а список envsubst в деплое не пополнили,
|
||||
и тест этого не заметил, потому что сам подставлял значение мимо деплоя.
|
||||
"""
|
||||
assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп"
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
m = re.search(r"envsubst '([^']+)'", text)
|
||||
assert m, "не нашёл вызов envsubst в деплое"
|
||||
return set(re.findall(r"\$\{(\w+)\}", m.group(1)))
|
||||
|
||||
|
||||
def _template_placeholders() -> set[str]:
|
||||
text = TMPL.read_text(encoding="utf-8")
|
||||
return set(re.findall(r"\$\{(\w+)\}", text))
|
||||
|
||||
|
||||
def test_every_template_placeholder_is_in_envsubst_allowlist() -> None:
|
||||
"""Регресс #3589: переменная шаблона обязана быть в allow-list envsubst.
|
||||
|
||||
Тогда в шаблоне появился `${METRICS_WATCHDOG_PING_URL}`, а список
|
||||
envsubst в деплое не пополнили. envsubst подставляет ТОЛЬКО
|
||||
перечисленные переменные — забытая долетает до `amtool check-config`
|
||||
литералом плейсхолдера и валит проверку (`unsupported scheme ""`), то
|
||||
есть роняет ВЕСЬ Alertmanager, а не только Watchdog.
|
||||
"""
|
||||
missing = _template_placeholders() - _envsubst_allowlist()
|
||||
assert not missing, f"эти переменные шаблона деплой не подставляет: {missing}"
|
||||
|
||||
|
||||
def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> dict:
|
||||
"""Повторяет ТОЧНО ТУ ЖЕ подстановку, что делает деплой, и разбирает YAML.
|
||||
|
||||
Подставляются только переменные из реального allow-list envsubst деплоя
|
||||
(`_envsubst_allowlist`) — не весь известный тесту набор. Так регресс
|
||||
#3589 (переменная в шаблоне, забытая в allow-list) ловится именно здесь:
|
||||
`assert "${" not in rendered` ниже упадёт, если что-то не подставилось.
|
||||
def _render(infra_topic_line: str) -> dict:
|
||||
"""Повторяет подстановку деплоя и разбирает результат как YAML.
|
||||
|
||||
Строка темы в шаблоне ровно одна — инфраструктурная (#3163). Тема
|
||||
клиентских инцидентов сюда не подставляется вовсе: маршрут
|
||||
|
|
@ -103,18 +55,11 @@ def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> di
|
|||
"""
|
||||
assert TMPL.is_file(), f"нет {TMPL} — шаблон переехал, гейт ослеп"
|
||||
text = TMPL.read_text(encoding="utf-8")
|
||||
|
||||
values = dict(_DUMMY_VALUES)
|
||||
values["METRICS_TELEGRAM_INFRA_TOPIC_LINE"] = infra_topic_line
|
||||
if watchdog_ping_block is not None:
|
||||
values["METRICS_WATCHDOG_PING_BLOCK"] = watchdog_ping_block
|
||||
|
||||
allowlist = _envsubst_allowlist()
|
||||
rendered = text
|
||||
for name, value in values.items():
|
||||
if name in allowlist:
|
||||
rendered = rendered.replace("${" + name + "}", value)
|
||||
|
||||
rendered = (
|
||||
text.replace("${METRICS_TELEGRAM_BOT_TOKEN}", "123:ABC")
|
||||
.replace("${METRICS_TELEGRAM_CHAT_ID}", "-100123")
|
||||
.replace("${METRICS_TELEGRAM_INFRA_TOPIC_LINE}", infra_topic_line)
|
||||
)
|
||||
assert "${" not in rendered, (
|
||||
"в отрендеренном конфиге остался литерал плейсхолдера — "
|
||||
"значит в шаблоне появилась подстановка, о которой тест не знает"
|
||||
|
|
@ -122,37 +67,6 @@ def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> di
|
|||
return yaml.safe_load(rendered)
|
||||
|
||||
|
||||
def test_watchdog_receiver_without_secret_has_no_configs_and_parses() -> None:
|
||||
"""Секрет не заведён (реальное состояние прода сейчас) — конфиг всё равно жив.
|
||||
|
||||
`watchdog-ping` остаётся без единого `*_configs` — валидный receiver,
|
||||
Alertmanager его просто пропускает. Деградация корректна: Watchdog никуда
|
||||
не пингует, но остальной алертинг (`telegram`, `telegram-clients`) цел.
|
||||
"""
|
||||
cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block="")
|
||||
receivers = {r["name"]: r for r in cfg["receivers"]}
|
||||
assert "watchdog-ping" in receivers, "receiver watchdog-ping пропал из конфига"
|
||||
watchdog = receivers["watchdog-ping"]
|
||||
assert "webhook_configs" not in watchdog, "пустой секрет не должен оставлять webhook_configs"
|
||||
assert "telegram" in receivers and "telegram-clients" in receivers, (
|
||||
"остальной алертинг не должен пострадать из-за пустого watchdog-секрета"
|
||||
)
|
||||
|
||||
|
||||
def test_watchdog_receiver_with_secret_gets_webhook() -> None:
|
||||
"""Секрет задан — Watchdog реально пингует внешний deadman-приёмник."""
|
||||
block = (
|
||||
" webhook_configs:\n"
|
||||
' - url: "https://hc-ping.com/dummy"\n'
|
||||
" send_resolved: false"
|
||||
)
|
||||
cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block=block)
|
||||
receivers = {r["name"]: r for r in cfg["receivers"]}
|
||||
hooks = receivers["watchdog-ping"].get("webhook_configs") or []
|
||||
assert hooks and hooks[0].get("url") == "https://hc-ping.com/dummy"
|
||||
assert hooks[0].get("send_resolved") is False
|
||||
|
||||
|
||||
def _telegram_configs(cfg: dict) -> list[dict]:
|
||||
out = []
|
||||
for r in cfg.get("receivers", []):
|
||||
|
|
@ -162,16 +76,16 @@ def _telegram_configs(cfg: dict) -> list[dict]:
|
|||
|
||||
|
||||
def test_topic_lands_in_every_telegram_receiver() -> None:
|
||||
"""Единственный прямой получатель адресует ИНФРАСТРУКТУРНУЮ тему, а не клиентскую (#3163).
|
||||
"""Оба прямых получателя адресуют ИНФРАСТРУКТУРНУЮ тему, а не клиентскую (#3163).
|
||||
|
||||
До разделения тем `telegram` и `telegram-heartbeat` брали топик из одной
|
||||
переменной с клиентскими инцидентами, и тема «метрики» (245) оставалась
|
||||
пустой. С 17.09 (устранение шума Watchdog) прямой Telegram-получатель
|
||||
остался один — `telegram`; Watchdog теперь пингует внешний
|
||||
deadman-приёмник вебхуком (`watchdog-ping`, без topic вовсе — не Telegram).
|
||||
Получателей два — `telegram` и `telegram-heartbeat`. До разделения тем оба
|
||||
брали топик из одной переменной с клиентскими инцидентами, и тема «метрики»
|
||||
(245) оставалась пустой. Если heartbeat уйдёт не в ту тему, «мониторинг жив»
|
||||
будет капать мимо, и это заметят не сразу — сюда же попадёт и весь
|
||||
инфраструктурный шум.
|
||||
"""
|
||||
cfgs = _telegram_configs(_render(INFRA_TOPIC_LINE))
|
||||
assert len(cfgs) >= 1, f"ожидался хотя бы один получатель telegram, найдено {len(cfgs)}"
|
||||
assert len(cfgs) >= 2, f"ожидалось минимум два получателя telegram, найдено {len(cfgs)}"
|
||||
for c in cfgs:
|
||||
assert c.get("message_thread_id") == 245, f"инфраструктурный топик не проставлен: {c}"
|
||||
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ def test_klientskiy_marshrut_idyot_v_servis_knopki() -> None:
|
|||
"""
|
||||
text = TEMPLATE.read_text(encoding="utf-8")
|
||||
block = text[text.index("- name: telegram-clients") :]
|
||||
block = block[: block.index("- name: watchdog-ping")]
|
||||
block = block[: block.index("- name: telegram-heartbeat")]
|
||||
assert "webhook_configs" in block, "клиентский приёмник не переключён на сервис"
|
||||
assert "alert-ack:8080/alertmanager" in block, "вебхук указывает не на сервис кнопки"
|
||||
# У прочих приёмников прямой путь сохранён.
|
||||
|
|
|
|||
198
backend/tests/services/cadastre/test_bulk_harvest_territorial.py
Normal file
198
backend/tests/services/cadastre/test_bulk_harvest_territorial.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"""Тесты для _save_territorial_zones (bulk_harvest.py) — mock-based.
|
||||
|
||||
Проверяет:
|
||||
- Успешный UPSERT 3 features → 3 строки вставлены
|
||||
- Повторный вызов → ON CONFLICT обновляет, не дублирует
|
||||
- Feature без geometry → строка вставлена с geom=NULL, без краша
|
||||
- Feature без zone_id → синтетический fallback zone_id используется
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.services.cadastre.bulk_harvest import _save_territorial_zones
|
||||
|
||||
|
||||
def _make_feature(
|
||||
feature_id: Any = "zone_1",
|
||||
zone_code: str = "Ж-1",
|
||||
zone_name: str = "Жилая смешанная",
|
||||
permitted_use: str = "ИЖС",
|
||||
has_geometry: bool = True,
|
||||
) -> dict:
|
||||
"""Создать raw feature dict в формате get_features_in_bbox_grid."""
|
||||
geom = (
|
||||
{
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[6090000.0, 7590000.0],
|
||||
[6090100.0, 7590000.0],
|
||||
[6090100.0, 7590100.0],
|
||||
[6090000.0, 7590100.0],
|
||||
[6090000.0, 7590000.0],
|
||||
]
|
||||
],
|
||||
}
|
||||
if has_geometry
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"id": feature_id,
|
||||
"geometry": geom,
|
||||
"properties": {
|
||||
"zone_code": zone_code,
|
||||
"zone_name": zone_name,
|
||||
"permitted_use": permitted_use,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _make_db_mock() -> MagicMock:
|
||||
"""Mock SQLAlchemy Session с begin_nested() savepoint support."""
|
||||
db = MagicMock()
|
||||
# begin_nested() используется как context manager
|
||||
savepoint_ctx = MagicMock()
|
||||
savepoint_ctx.__enter__ = MagicMock(return_value=savepoint_ctx)
|
||||
savepoint_ctx.__exit__ = MagicMock(return_value=False)
|
||||
db.begin_nested.return_value = savepoint_ctx
|
||||
return db
|
||||
|
||||
|
||||
class TestSaveTerritorialZones:
|
||||
"""Тесты для _save_territorial_zones."""
|
||||
|
||||
def test_three_features_inserted(self) -> None:
|
||||
"""3 features → returned count == 3, execute вызван 3 раза."""
|
||||
db = _make_db_mock()
|
||||
features = [
|
||||
_make_feature("z1", "Ж-1"),
|
||||
_make_feature("z2", "ОД-1"),
|
||||
_make_feature("z3", "П-1"),
|
||||
]
|
||||
|
||||
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||||
|
||||
assert result == 3
|
||||
assert db.execute.call_count == 3
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_empty_features_list(self) -> None:
|
||||
"""Пустой список → 0 inserted, commit всё равно вызван."""
|
||||
db = _make_db_mock()
|
||||
|
||||
result = _save_territorial_zones(db, "66:41:0204016", [])
|
||||
|
||||
assert result == 0
|
||||
db.execute.assert_not_called()
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_feature_without_geometry_no_crash(self) -> None:
|
||||
"""Feature без geometry → geom=NULL, строка вставлена без краша."""
|
||||
db = _make_db_mock()
|
||||
features = [_make_feature("zone_no_geom", has_geometry=False)]
|
||||
|
||||
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||||
|
||||
assert result == 1
|
||||
# Проверяем что geom параметр передан как None
|
||||
call_kwargs: dict = db.execute.call_args[0][1]
|
||||
assert call_kwargs["geom"] is None
|
||||
|
||||
def test_feature_without_zone_id_uses_fallback(self) -> None:
|
||||
"""Feature без id → md5-based fallback zone_id (stable между runs)."""
|
||||
db = _make_db_mock()
|
||||
features = [
|
||||
{
|
||||
"id": None,
|
||||
"geometry": None,
|
||||
"properties": {"zone_code": "Ж-2"},
|
||||
}
|
||||
]
|
||||
|
||||
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||||
|
||||
assert result == 1
|
||||
call_kwargs = db.execute.call_args[0][1]
|
||||
zone_id: str = call_kwargs["zone_id"]
|
||||
# fallback zone_id содержит quarter_cad и стабильный hash (12 hex chars)
|
||||
assert zone_id.startswith("66:41:0204016_")
|
||||
suffix = zone_id.split("_", 3)[-1]
|
||||
assert len(suffix) == 12
|
||||
assert all(c in "0123456789abcdef" for c in suffix)
|
||||
|
||||
# Второй вызов с теми же данными → тот же zone_id (идемпотентность)
|
||||
db2 = _make_db_mock()
|
||||
_save_territorial_zones(db2, "66:41:0204016", features)
|
||||
call_kwargs2 = db2.execute.call_args[0][1]
|
||||
assert call_kwargs2["zone_id"] == zone_id
|
||||
|
||||
def test_zone_id_from_props_id(self) -> None:
|
||||
"""Если feature.id=None, но props['id'] есть — используется props['id']."""
|
||||
db = _make_db_mock()
|
||||
features = [
|
||||
{
|
||||
"id": None,
|
||||
"geometry": None,
|
||||
"properties": {"id": "props_id_42", "zone_code": "Ж-3"},
|
||||
}
|
||||
]
|
||||
|
||||
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||||
|
||||
assert result == 1
|
||||
call_kwargs = db.execute.call_args[0][1]
|
||||
assert call_kwargs["zone_id"] == "props_id_42"
|
||||
|
||||
def test_execute_error_logged_not_raised(self) -> None:
|
||||
"""Exception в execute → строка не вставлена, warning залогирован, не re-raise."""
|
||||
db = _make_db_mock()
|
||||
db.execute.side_effect = RuntimeError("DB error")
|
||||
features = [_make_feature("z_err")]
|
||||
|
||||
# Не должен бросить исключение
|
||||
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||||
|
||||
assert result == 0
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_savepoint_used_per_row(self) -> None:
|
||||
"""begin_nested() вызывается для каждой строки (SAVEPOINT паттерн)."""
|
||||
db = _make_db_mock()
|
||||
features = [_make_feature(f"z{i}") for i in range(3)]
|
||||
|
||||
_save_territorial_zones(db, "66:41:0204016", features)
|
||||
|
||||
assert db.begin_nested.call_count == 3
|
||||
|
||||
def test_quarter_cad_param_passed(self) -> None:
|
||||
"""quarter_cad правильно передаётся в SQL параметры."""
|
||||
db = _make_db_mock()
|
||||
features = [_make_feature("zone_check")]
|
||||
|
||||
_save_territorial_zones(db, "66:41:9999999", features)
|
||||
|
||||
call_kwargs = db.execute.call_args[0][1]
|
||||
assert call_kwargs["quarter_cad"] == "66:41:9999999"
|
||||
|
||||
def test_raw_props_serialized(self) -> None:
|
||||
"""raw_props — JSON строка из properties dict."""
|
||||
import json
|
||||
|
||||
db = _make_db_mock()
|
||||
features = [
|
||||
{
|
||||
"id": "z_props",
|
||||
"geometry": None,
|
||||
"properties": {"zone_code": "ОД-2", "extra": "value"},
|
||||
}
|
||||
]
|
||||
|
||||
_save_territorial_zones(db, "66:41:0204016", features)
|
||||
|
||||
call_kwargs = db.execute.call_args[0][1]
|
||||
raw = json.loads(call_kwargs["raw_props"])
|
||||
assert raw["zone_code"] == "ОД-2"
|
||||
assert raw["extra"] == "value"
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
"""Gap-fill конкурентов берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962).
|
||||
|
||||
`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный
|
||||
`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и
|
||||
не трогает complex_id. Прод 17.09.2026: из 303 677 строк с complex_id у 236 354
|
||||
проект чужой; у 185 gap-fill конкурентов своих лотов 23 %, скорость завышена в
|
||||
медиане в 39 раз.
|
||||
|
||||
Тест герметичный и прогоняет НАСТОЯЩИЕ `_COMPETITORS_SQL` и
|
||||
`_OBJECTIVE_PRICE_FALLBACK_SQL`: временные таблицы затеняют боевые в пределах сессии.
|
||||
Нужен Postgres с PostGIS (ST_DWithin по geography). В CI он есть
|
||||
(.forgejo/workflows/ci.yml, образ postgis/postgis), и там тест не пропускается
|
||||
никогда: без PostGIS он падает с настоящей причиной. Пропуск разрешён только вне CI,
|
||||
на машине без базы, и объявлен в skip_allowlist.txt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
def _dsn() -> str:
|
||||
raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"]
|
||||
return (
|
||||
raw
|
||||
if raw.startswith("postgresql+")
|
||||
else raw.replace("postgresql://", "postgresql+psycopg://")
|
||||
)
|
||||
|
||||
|
||||
def _postgis_reachable() -> tuple[bool, str]:
|
||||
try:
|
||||
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
||||
with eng.connect() as c:
|
||||
if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first():
|
||||
return True, ""
|
||||
return False, "нет расширения postgis"
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
_DB_OK, _DB_ERR = _postgis_reachable()
|
||||
_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"))
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}"
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
"""CREATE TEMP TABLE domrf_kn_objects (
|
||||
obj_id bigint, comm_name text, dev_name text, obj_class text,
|
||||
obj_class_fallback text, latitude double precision, longitude double precision,
|
||||
flat_count int, site_status text, snapshot_date date) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE objective_complex_mapping (
|
||||
domrf_obj_id bigint, objective_complex_name text) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE complexes (
|
||||
id bigint, canonical_name text, latitude double precision,
|
||||
longitude double precision) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE complex_sources (
|
||||
complex_id bigint, source text, source_id text) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE objective_lots (
|
||||
project_name text, complex_id bigint, price_per_m2_rub numeric) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE objective_corpus_room_month (
|
||||
project_name text, report_month date, deals_total_count int) ON COMMIT DROP""",
|
||||
]
|
||||
|
||||
MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES = 1, 2, 3, 4, 5, 6, 7, 8
|
||||
|
||||
_DATA = [
|
||||
# obj_id, comm_name, …, latitude, longitude — объекты дальше 200 м друг от друга
|
||||
"""INSERT INTO domrf_kn_objects VALUES
|
||||
(1, 'ЖК Мичуринский', NULL, NULL, NULL, 56.840, 60.600, 500, 'Строящиеся', CURRENT_DATE),
|
||||
(2, 'ЖК VEER PARK', NULL, NULL, NULL, 56.850, 60.620, 500, 'Строящиеся', CURRENT_DATE),
|
||||
(3, 'Жилой комплекс "СтудияПарк"', NULL, NULL, NULL, 56.830, 60.580, 500,
|
||||
'Строящиеся', CURRENT_DATE),
|
||||
(4, 'ЖК Явный', NULL, NULL, NULL, 56.845, 60.640, 500, 'Строящиеся', CURRENT_DATE),
|
||||
(5, 'Квартал "Татлин"', NULL, NULL, NULL, 56.835, 60.610, 500, 'Строящиеся',
|
||||
CURRENT_DATE),
|
||||
(6, 'Парковый', NULL, NULL, NULL, 56.855, 60.590, 500, 'Строящиеся', CURRENT_DATE),
|
||||
(7, 'ЖК Роща Парк', NULL, NULL, NULL, 56.825, 60.630, 500, 'Строящиеся', CURRENT_DATE),
|
||||
(8, 'ЖК Сосны', NULL, NULL, NULL, 56.860, 60.650, 500, 'Строящиеся', CURRENT_DATE)""",
|
||||
"INSERT INTO objective_complex_mapping VALUES (4, 'Явный')",
|
||||
"""INSERT INTO complexes VALUES
|
||||
(10, 'ЖК Мичуринский', 56.840, 60.600),
|
||||
(20, 'ЖК VEER PARK', 56.850, 60.620),
|
||||
(30, 'СтудияПарк', 56.830, 60.580),
|
||||
(50, 'Квартал "Татлин"', 56.835, 60.610),
|
||||
(60, 'Парковый', 56.855, 60.590),
|
||||
(70, 'Роща', 56.825, 60.630),
|
||||
(71, 'Роща Парк', 56.8259, 60.630),
|
||||
(80, 'ЖК Сосны', 56.860, 60.650)""",
|
||||
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493).
|
||||
# 70 → 'Роща': проект без лотов, complex ближе «Роща Парк» (71, в 100 м).
|
||||
# 80 → два objective-проекта: UNIQUE(source, source_id) этого не запрещает.
|
||||
"""INSERT INTO complex_sources VALUES
|
||||
(10, 'objective', 'Мичуринский'),
|
||||
(20, 'objective', 'Clever Park'),
|
||||
(30, 'objective', 'Студия Парк'),
|
||||
(50, 'objective', 'Квартал Татлин'),
|
||||
(60, 'objective', 'Парковый квартал'),
|
||||
(70, 'objective', 'Роща'),
|
||||
(71, 'objective', 'Роща Парк'),
|
||||
(80, 'objective', 'Клён'),
|
||||
(80, 'objective', 'Сосны')""",
|
||||
# Под complex_id=10 лежит и свой лот, и три лота чужого «Малахита» с устаревшим
|
||||
# complex_id; два своих лота новые — complex_id у них NULL.
|
||||
"""INSERT INTO objective_lots VALUES
|
||||
('Мичуринский', 10, 100000), ('Мичуринский', NULL, 110000),
|
||||
('Мичуринский', NULL, 120000),
|
||||
('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000),
|
||||
('Clever Park', NULL, 150000),
|
||||
('Студия Парк', NULL, 90000),
|
||||
('Явный', NULL, 200000),
|
||||
('Квартал Татлин', NULL, 95000),
|
||||
('Парковый квартал', NULL, 130000),
|
||||
('Роща Парк', NULL, 105000),
|
||||
('Клён', NULL, 250000),
|
||||
('Сосны', NULL, 115000)""",
|
||||
"""INSERT INTO objective_corpus_room_month VALUES
|
||||
('Мичуринский', date_trunc('month', now())::date, 30),
|
||||
('Малахит', date_trunc('month', now())::date, 300),
|
||||
('Clever Park', date_trunc('month', now())::date, 90),
|
||||
('Студия Парк', date_trunc('month', now())::date, 15),
|
||||
('Явный', date_trunc('month', now())::date, 60),
|
||||
('Квартал Татлин', date_trunc('month', now())::date, 18),
|
||||
('Парковый квартал', date_trunc('month', now())::date, 12),
|
||||
('Роща Парк', date_trunc('month', now())::date, 21),
|
||||
('Клён', date_trunc('month', now())::date, 240),
|
||||
('Сосны', date_trunc('month', now())::date, 27)""",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def result() -> tuple[dict[int, float], dict[int, float]]:
|
||||
"""(velocity по obj_id, медианная цена objective-fallback по obj_id)."""
|
||||
from app.services.site_finder.competitors import (
|
||||
_COMPETITORS_SQL,
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||
_VELOCITY_MATCH_RADIUS_M,
|
||||
)
|
||||
|
||||
session = sessionmaker(bind=create_engine(_dsn()))()
|
||||
try:
|
||||
for stmt in _SCHEMA + _DATA:
|
||||
session.execute(text(stmt))
|
||||
vel_rows = session.execute(
|
||||
_COMPETITORS_SQL,
|
||||
{
|
||||
"center_lon": 60.6,
|
||||
"center_lat": 56.84,
|
||||
"radius_m": 5000.0,
|
||||
"time_window_months": 3.0,
|
||||
"window_interval": "3 months",
|
||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||
},
|
||||
).mappings()
|
||||
velocity = {int(r["obj_id"]): float(r["velocity_per_month"]) for r in vel_rows}
|
||||
price_rows = session.execute(
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||
{
|
||||
"obj_ids": [MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES],
|
||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||
},
|
||||
).mappings()
|
||||
price = {int(r["obj_id"]): float(r["median_price_per_m2"]) for r in price_rows}
|
||||
return velocity, price
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
def test_gapfill_velocity_counts_only_own_project(result) -> None:
|
||||
"""30 сделок своего ЖК за 3 месяца = 10/мес; с чужим «Малахитом» было бы 110."""
|
||||
velocity, _ = result
|
||||
assert velocity[MICH] == pytest.approx(10.0), velocity
|
||||
|
||||
|
||||
def test_gapfill_price_is_median_of_own_lots(result) -> None:
|
||||
"""Медиана своих лотов 100/110/120 тыс. = 110 тыс.; по complex_id было бы 300 тыс."""
|
||||
_, price = result
|
||||
assert price[MICH] == pytest.approx(110000.0), price
|
||||
|
||||
|
||||
def test_wrong_fuzzy_link_gives_no_numbers(result) -> None:
|
||||
"""«ЖК VEER PARK» связан в complex_sources с 'Clever Park' — чужие цифры не берём."""
|
||||
velocity, price = result
|
||||
assert velocity[VEER] == 0.0, velocity
|
||||
assert VEER not in price, price
|
||||
|
||||
|
||||
def test_space_difference_still_matches(result) -> None:
|
||||
"""«СтудияПарк» и «Студия Парк» — один ЖК: 15 сделок / 3 = 5, цена 90 тыс."""
|
||||
velocity, price = result
|
||||
assert velocity[STUDIO] == pytest.approx(5.0), velocity
|
||||
assert price[STUDIO] == pytest.approx(90000.0), price
|
||||
|
||||
|
||||
def test_explicit_mapping_unchanged(result) -> None:
|
||||
"""Явный маппинг правка не трогает: 60 / 3 = 20, цена 200 тыс."""
|
||||
velocity, price = result
|
||||
assert velocity[EXPLICIT] == pytest.approx(20.0), velocity
|
||||
assert price[EXPLICIT] == pytest.approx(200000.0), price
|
||||
|
||||
|
||||
def test_quotes_inside_name_still_match(result) -> None:
|
||||
"""«Квартал "Татлин"» и «Квартал Татлин»: кавычки посреди имени сверку не ломают.
|
||||
|
||||
Убрать только пробелы мало: «квартал"татлин"» и «кварталтатлин» не входят друг в
|
||||
друга. 18 сделок / 3 = 6, цена 95 тыс.
|
||||
"""
|
||||
velocity, price = result
|
||||
assert velocity[TATLIN] == pytest.approx(6.0), velocity
|
||||
assert price[TATLIN] == pytest.approx(95000.0), price
|
||||
|
||||
|
||||
def test_project_name_longer_than_object_name_matches(result) -> None:
|
||||
"""Объект «Парковый», проект «Парковый квартал»: имя сверяется в обе стороны. 12 / 3 = 4."""
|
||||
velocity, price = result
|
||||
assert velocity[PARK] == pytest.approx(4.0), velocity
|
||||
assert price[PARK] == pytest.approx(130000.0), price
|
||||
|
||||
|
||||
def test_nearest_complex_without_lots_does_not_eat_the_match(result) -> None:
|
||||
"""#968: ближайший complex «Роща» без лотов не забирает объект у «Роща Парк» в 100 м.
|
||||
|
||||
Иначе у «ЖК Роща Парк» не было бы ни скорости, ни цены. 21 / 3 = 7, цена 105 тыс.
|
||||
"""
|
||||
velocity, price = result
|
||||
assert velocity[GROVE] == pytest.approx(7.0), velocity
|
||||
assert price[GROVE] == pytest.approx(105000.0), price
|
||||
|
||||
|
||||
def test_complex_with_two_projects_takes_the_matching_one(result) -> None:
|
||||
"""У complex два objective-проекта, «Клён» и «Сосны»: берём сверенный по имени.
|
||||
|
||||
Без этого DISTINCT ON брал любой из двух (здесь — «Клён»), сверка его отвергала,
|
||||
и «Сосны» терялись. 27 / 3 = 9, цена 115 тыс.
|
||||
"""
|
||||
velocity, price = result
|
||||
assert velocity[PINES] == pytest.approx(9.0), velocity
|
||||
assert price[PINES] == pytest.approx(115000.0), price
|
||||
|
|
@ -433,43 +433,7 @@ def test_exclude_competitor_obj_ids() -> None:
|
|||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
assert resp.top_layouts == []
|
||||
# после исключения комплексов 0 (знаменатель coverage), в радиусе было 1 obj_id
|
||||
assert resp.data_quality.objects_total_in_radius == 0
|
||||
assert resp.data_quality.raw_objects_total == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filter_ids", "vel_obj_ids", "expected_total", "expected_with_data"),
|
||||
[
|
||||
([99], None, 0, 0), # фильтр не оставил никого → ранний пустой ответ
|
||||
([20], None, 1, 0), # остался 1 комплекс без velocity → пустой ответ
|
||||
([20], [20], 1, 1), # остался 1 комплекс с velocity → штатная ветка
|
||||
],
|
||||
ids=["all-filtered", "no-velocity", "normal"],
|
||||
)
|
||||
def test_objects_total_is_coverage_denominator_in_every_branch(
|
||||
filter_ids: list[int],
|
||||
vel_obj_ids: list[int] | None,
|
||||
expected_total: int,
|
||||
expected_with_data: int,
|
||||
) -> None:
|
||||
"""#2464 (best_layouts:1113): objects_total_in_radius значит одно во всех ветках.
|
||||
|
||||
UI и PDF печатают «покрытие P% (Y из N комплексов)», значит N обязан быть
|
||||
знаменателем P, а P считается по комплексам ПОСЛЕ filter. Раньше ранний пустой
|
||||
ответ отдавал число ДО фильтра (2), две другие ветки — после.
|
||||
"""
|
||||
id_rows = [_obj_id_row(20), _obj_id_row(30)]
|
||||
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=vel_obj_ids)] if vel_obj_ids else []
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
||||
resp = get_best_layouts(db, CAD_NUM, _request(filter_competitor_obj_ids=filter_ids))
|
||||
|
||||
dq = resp.data_quality
|
||||
assert dq.objects_total_in_radius == expected_total
|
||||
assert dq.objects_with_velocity_data == expected_with_data
|
||||
assert dq.raw_objects_total == 2 # число до фильтра — отдельным полем
|
||||
expected_pct = expected_with_data / expected_total * 100.0 if expected_total else 0.0
|
||||
assert dq.velocity_coverage_pct == pytest.approx(expected_pct)
|
||||
assert resp.data_quality.objects_total_in_radius == 1
|
||||
|
||||
|
||||
# ── Тест: total_sold_in_window совпадает с deals_window ──────────────────────
|
||||
|
|
|
|||
|
|
@ -489,7 +489,7 @@ async def test_harvest_quarter_does_not_early_exit_on_shared_phase_done() -> Non
|
|||
db = MagicMock()
|
||||
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter.
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk фаза корректно пропускается (тест про
|
||||
# grid-walk + territorial_zones фазы корректно пропускаются (тест про
|
||||
# snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ
|
||||
# db.execute().mappings().first() в этом тесте.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -554,7 +554,7 @@ async def test_harvest_quarter_calls_upsert_features() -> None:
|
|||
db = MagicMock()
|
||||
# phase_state = None → начинаем с нуля
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk фаза пропускается (тесты про snapshot /
|
||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||
# db.execute().mappings().first() вызовы.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -759,7 +759,7 @@ async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero()
|
|||
|
||||
db = MagicMock()
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk фаза пропускается (тесты про snapshot /
|
||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||
# db.execute().mappings().first() вызовы.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -834,7 +834,7 @@ async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None:
|
|||
|
||||
db = MagicMock()
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk фаза пропускается (тесты про snapshot /
|
||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||
# db.execute().mappings().first() вызовы.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -904,7 +904,7 @@ async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> N
|
|||
|
||||
db = MagicMock()
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk фаза пропускается (тесты про snapshot /
|
||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||
# db.execute().mappings().first() вызовы.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -1223,7 +1223,7 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None:
|
|||
|
||||
db = MagicMock()
|
||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||
# grid-walk фаза пропускается (тесты про snapshot /
|
||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||
# db.execute().mappings().first() вызовы.
|
||||
db.execute = MagicMock(
|
||||
|
|
@ -1518,6 +1518,7 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
|
|||
db = _mock_db_grid_bbox()
|
||||
client = AsyncMock()
|
||||
client.search_by_quarter = AsyncMock(return_value=snapshot)
|
||||
client.get_territorial_zones_in_bbox = AsyncMock(return_value=[])
|
||||
|
||||
progress_states: list[dict[str, Any]] = []
|
||||
|
||||
|
|
@ -1556,42 +1557,6 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
|
|||
assert progress_states[-1]["phase"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_harvest_quarter_makes_no_territorial_zones_request_2985() -> None:
|
||||
"""#2985: Phase 2.5 удалена. Квартал без overflow с валидным bbox стоит ровно один
|
||||
запрос к НСПД (search_by_quarter) — отдельного grid-walk за ПЗЗ больше нет."""
|
||||
from app.services.cadastre.bulk_harvest import harvest_quarter
|
||||
|
||||
snapshot = QuarterSnapshot(
|
||||
quarter="66:41:0303161",
|
||||
fetched_at="2026-05-15T10:00:00+00:00",
|
||||
features=[_make_parcel_feature()],
|
||||
meta_counts={},
|
||||
)
|
||||
client = AsyncMock()
|
||||
client.search_by_quarter = AsyncMock(return_value=snapshot)
|
||||
progress_states: list[dict[str, Any]] = []
|
||||
|
||||
with (
|
||||
patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert,
|
||||
patch("app.services.cadastre.bulk_harvest.quarter_bbox_3857") as mock_bbox,
|
||||
):
|
||||
mock_bbox.return_value = (6735845.0, 8329000.0, 6736595.0, 8329750.0)
|
||||
mock_upsert.return_value = dict.fromkeys(
|
||||
("parcels", "buildings", "constructions", "oncs", "enks", "zouit", "skipped"), 0
|
||||
)
|
||||
await harvest_quarter(
|
||||
db=_mock_db_grid_bbox(),
|
||||
client=client,
|
||||
quarter="66:41:0303161",
|
||||
job_id=1,
|
||||
update_progress=progress_states.append,
|
||||
)
|
||||
|
||||
assert [c[0] for c in client.mock_calls] == ["search_by_quarter"]
|
||||
assert [s["phase"] for s in progress_states] == ["snapshot_started", "snapshot_done", "done"]
|
||||
|
||||
|
||||
# ── Issue #200: geom backfill для участков с geom IS NULL ─────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -144,29 +144,6 @@ tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as
|
|||
tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket
|
||||
tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved
|
||||
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
|
||||
|
||||
# ── #2962: мост gap-fill конкурентов (complex_sources → project_name) ─────────
|
||||
# Нужен Postgres С PostGIS: тесты затеняют боевые таблицы временными и гоняют
|
||||
# НАСТОЯЩИЕ _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL, а там ST_DWithin по
|
||||
# geography. В CI ИДУТ (ci.yml поднимает postgis/postgis:16-3.4) и пропуститься
|
||||
# там НЕ МОГУТ: при CI=true skipif выключен, без PostGIS тест падает. Записи нужны
|
||||
# только для машины без базы.
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_velocity_counts_only_own_project
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_price_is_median_of_own_lots
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_wrong_fuzzy_link_gives_no_numbers
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_space_difference_still_matches
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_explicit_mapping_unchanged
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_quotes_inside_name_still_match
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project_name_longer_than_object_name_matches
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_nearest_complex_without_lots_does_not_eat_the_match
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_complex_with_two_projects_takes_the_matching_one
|
||||
|
||||
# ── #3583: гео-радиусная цена участка (complex_sources → project_name) ────────
|
||||
# Тот же случай, что #2962: гоняет НАСТОЯЩИЙ parcels._GEO_RADIUS_PRICE_SQL на
|
||||
# временных таблицах, нужен PostGIS. В CI идёт и пропуститься не может (CI=true
|
||||
# выключает skipif). Запись только для машины без базы.
|
||||
tests/api/v1/test_3583_geo_radius_price_project_bridge.py::test_median_counts_own_projects_of_nearby_complexes
|
||||
|
||||
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
|
||||
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
||||
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
# Source-of-truth copy of the Forgejo compose file.
|
||||
#
|
||||
# Forgejo is NOT part of the automated deploy pipeline (deploy.yml only
|
||||
# manages /opt/gendesign via `git reset --hard origin/main` on the main and
|
||||
# obsidian stacks). Forgejo lives separately at /home/gendesign/forgejo on
|
||||
# the VM and is a plain directory there — NOT a git checkout — so changes
|
||||
# here do not auto-apply. Sync manually:
|
||||
#
|
||||
# scp ops/forgejo/docker-compose.yml gendesign:/home/gendesign/forgejo/docker-compose.yml
|
||||
# ssh gendesign "cd /home/gendesign/forgejo && docker compose up -d --force-recreate forgejo"
|
||||
#
|
||||
# `up -d --force-recreate` (not `restart`) is required: Forgejo generates
|
||||
# app.ini from the FORGEJO__* env vars via /usr/local/bin/environment-to-ini
|
||||
# at container start, and `restart` does not re-read `environment:` from a
|
||||
# changed compose file (see docker-compose pitfall #1 in devops CLAUDE.md).
|
||||
#
|
||||
# Config keys verified 2026-09-16 against the running image
|
||||
# (codeberg.org/forgejo/forgejo:10, Forgejo 10.0.3+gitea-1.22.0) by
|
||||
# extracting Go struct tags from the binary (`strings` on
|
||||
# /app/gitea/gitea) and by a dry-run of environment-to-ini in a scratch
|
||||
# dir inside the container (no prod files touched). Do not re-derive these
|
||||
# from memory — a wrong key is silently ignored (empty section) and
|
||||
# creates a false sense of safety.
|
||||
#
|
||||
# Dotted section names ([cron.archive_cleanup]) must be encoded as
|
||||
# `_0x2E_` in the env var per the container's own
|
||||
# `environment-to-ini --help` (confirmed empirically, see PR description).
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:10
|
||||
container_name: forgejo
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
USER_UID: 1000
|
||||
USER_GID: 1000
|
||||
FORGEJO__database__DB_TYPE: postgres
|
||||
FORGEJO__database__HOST: infra-postgres:5432
|
||||
FORGEJO__database__NAME: forgejo
|
||||
FORGEJO__database__USER: forgejo
|
||||
FORGEJO__database__PASSWD: ${FORGEJO_DB_PASS}
|
||||
FORGEJO__server__DOMAIN: git.gendsgn.ru
|
||||
FORGEJO__server__ROOT_URL: https://git.gendsgn.ru/
|
||||
FORGEJO__server__SSH_PORT: 2222
|
||||
FORGEJO__server__SSH_LISTEN_PORT: 22
|
||||
FORGEJO__server__START_SSH_SERVER: "false"
|
||||
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
||||
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
|
||||
FORGEJO__actions__ENABLED: "true"
|
||||
FORGEJO__actions__DEFAULT_ACTIONS_URL: "github"
|
||||
# Actions Log/artifact retention — was unset (Forgejo defaults), which
|
||||
# let CI run logs/artifacts accumulate indefinitely. Artifact
|
||||
# retention checked against .forgejo/workflows + .github/workflows on
|
||||
# 2026-09-16: no workflow uploads/downloads artifacts today, so 14d
|
||||
# cannot break a cross-job dependency. Revisit this comment if a
|
||||
# workflow starts using actions/upload-artifact.
|
||||
FORGEJO__actions__LOG_RETENTION_DAYS: "30"
|
||||
FORGEJO__actions__ARTIFACT_RETENTION_DAYS: "14"
|
||||
# Repo-archive cache cleanup — was entirely absent (no
|
||||
# [cron.archive_cleanup] section), so it ran on Forgejo's own default
|
||||
# schedule (once every 24h, deleting archives older than 24h). That
|
||||
# let an external crawler hitting /<owner>/<repo>/archive/<ref>
|
||||
# balloon the cache to ~48GB/145GB disk before the daily sweep caught
|
||||
# up (see PR #3534, which closed the path in Caddy as the primary
|
||||
# fix). This is the second line of defense if that Caddy rule is ever
|
||||
# removed: run hourly, evict anything older than 1h.
|
||||
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__ENABLED: "true"
|
||||
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__RUN_AT_START: "true"
|
||||
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__SCHEDULE: "@every 1h"
|
||||
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__OLDER_THAN: "1h"
|
||||
FORGEJO__security__INSTALL_LOCK: "true"
|
||||
volumes:
|
||||
- ./data/forgejo:/data
|
||||
# Container log cap. Measured 2026-09-17: this container's json log had
|
||||
# grown to 3.35 GiB in 23 days (~150 MB/day) with no rotation at all,
|
||||
# the single largest log on the host by two orders of magnitude. Docker's
|
||||
# json-file driver never rotates unless told to, and there is no
|
||||
# /etc/docker/daemon.json on this VM to set a global default. 50m x 3
|
||||
# caps this container at 150 MB; recreating it also drops the old
|
||||
# unbounded file.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "2222:22"
|
||||
networks:
|
||||
- gendesign_default
|
||||
|
||||
networks:
|
||||
gendesign_default:
|
||||
external: true
|
||||
name: gendesign_default
|
||||
|
|
@ -57,7 +57,6 @@ import html
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -77,16 +76,6 @@ TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
|
|||
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
|
||||
API = "https://api.telegram.org/bot{}/{}"
|
||||
|
||||
# Значения секретных query-параметров в логе — `***` (#3576). GlitchTip шлёт
|
||||
# секрет только в `?secret=`, а строка запроса целиком уходит в access-log. То
|
||||
# же выражение, что у бэкенда МЕРЫ (tradein-mvp/backend/app/core/log_scrub.py,
|
||||
# #3154) и у Alloy (#3354); импортировать нельзя — сервис без зависимостей.
|
||||
_SENSITIVE_QUERY = re.compile(
|
||||
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
|
||||
r"[^&\s\"'<>]+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
|
||||
_PENDING: dict[str, dict] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
|
@ -330,9 +319,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib
|
||||
# Сюда сходятся и строка доступа (log_request), и ошибки разбора запроса
|
||||
# (log_error: «Bad request syntax ('POST /glitchtip?secret=…')») — маскируем здесь.
|
||||
log.info("%s %s", self.address_string(), _SENSITIVE_QUERY.sub(r"\1***", fmt % args))
|
||||
log.info("%s %s", self.address_string(), fmt % args)
|
||||
|
||||
def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None:
|
||||
self.send_response(code)
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@
|
|||
# клиентские инциденты МЕРЫ) и тему «метрики» (245, инфраструктурный шум —
|
||||
# диск, память, просевший экспортер). Инфраструктурный шум и клиентский
|
||||
# инцидент не равны по срочности, а смешанные в одной теме они обучают
|
||||
# пролистывать обе. Поэтому `telegram` ниже адресует ИНФРАСТРУКТУРНУЮ тему
|
||||
# (`METRICS_TELEGRAM_INFRA_TOPIC_LINE`). Получателя `telegram-clients` в этом
|
||||
# списке нет: он не шлёт в Telegram напрямую, а вебхуком уходит в alert-ack, и
|
||||
# тему адресует сам, своей переменной METRICS_TELEGRAM_TOPIC_ID. `watchdog-ping`
|
||||
# тоже не шлёт в Telegram вовсе — см. комментарий у его маршрута ниже.
|
||||
# пролистывать обе. Поэтому `telegram` и `telegram-heartbeat` ниже адресуют
|
||||
# ИНФРАСТРУКТУРНУЮ тему (`METRICS_TELEGRAM_INFRA_TOPIC_LINE`). Получателя
|
||||
# `telegram-clients` в этом списке нет: он не шлёт в Telegram напрямую, а
|
||||
# вебхуком уходит в alert-ack, и тему адресует сам, своей переменной
|
||||
# METRICS_TELEGRAM_TOPIC_ID.
|
||||
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
|
@ -35,35 +35,14 @@ route:
|
|||
repeat_interval: 6h
|
||||
|
||||
routes:
|
||||
# Watchdog — «сторож сторожа», горит ВСЕГДА по построению (`vector(1)`,
|
||||
# см. infra.yml). Раньше уходил в Telegram раз в 12ч — 14 сообщений в
|
||||
# неделю ни о чём, и именно они приучили пролистывать инфра-тему: 17.09
|
||||
# настоящий DiskWillFillIn24h утонул между Watchdog и вечно горящим
|
||||
# NoActiveCeleryWorkers, диск дошёл до 84% незамеченным.
|
||||
#
|
||||
# ПОЧЕМУ ВНЕШНИЙ DEADMAN-ПРИЁМНИК, А НЕ «РЕЖЕ» И НЕ ОТДЕЛЬНАЯ ТЕМА.
|
||||
# Увеличенный интервал по-прежнему кладёт человеку регулярное сообщение —
|
||||
# просто реже, и его тоже рано или поздно начнут пролистывать. Отдельная
|
||||
# техническая тема — это ещё один chat_id/topic_id и ещё один канал,
|
||||
# за которым НАДО СПЕЦИАЛЬНО следить, то есть тот же человеческий цикл,
|
||||
# сдвинутый в другое место. Внешний deadman-приёмник (healthchecks.io и
|
||||
# аналоги) устроен наоборот: Alertmanager молча шлёт HTTP-пинг на каждый
|
||||
# Watchdog, и пока пинги идут — сервис МОЛЧИТ. Он заговорит (email/свой
|
||||
# alert) только когда пинг ПЕРЕСТАНЕТ приходить, то есть ровно когда
|
||||
# канал доставки умер, — это и есть смысл «сторожа сторожа», без единого
|
||||
# штатного сообщения человеку. Полностью выключать эту проверку нельзя —
|
||||
# remove бы всей ветки Watchdog это и сделал.
|
||||
#
|
||||
# METRICS_WATCHDOG_PING_URL пока НЕ заведён на хосте (нужен аккаунт
|
||||
# healthchecks.io/аналога) — до тех пор webhook будет молча падать по
|
||||
# DNS/сети, Alertmanager это тихо ретраит; человека это не касается ни
|
||||
# раньше, ни теперь.
|
||||
- receiver: watchdog-ping
|
||||
# Watchdog не должен смешиваться с настоящими алертами и не должен молчать:
|
||||
# это «сторож сторожа», он горит всегда и подтверждает, что канал доставки жив.
|
||||
- receiver: telegram-heartbeat
|
||||
matchers:
|
||||
- alertname = "Watchdog"
|
||||
group_wait: 0s
|
||||
group_interval: 5m
|
||||
repeat_interval: 5m
|
||||
group_interval: 12h
|
||||
repeat_interval: 12h
|
||||
|
||||
# Клиентский инцидент. host="apps" — это продуктовая машина: если на ней
|
||||
# критично, значит МЕРА и Site Finder недоступны людям, а не «где-то в
|
||||
|
|
@ -84,20 +63,6 @@ route:
|
|||
group_wait: 10s
|
||||
repeat_interval: 30m
|
||||
|
||||
# Эскалация по длительности (AlertFiringTooLong, prometheus/rules/infra.yml)
|
||||
# — сигнал о том, что какую-то другую тревогу не заметили или на неё
|
||||
# забили дольше 6 часов. Она НЕ про клиентский инцидент, но обязана быть
|
||||
# заметнее обычной инфраструктуры, поэтому уходит в ту же тему, где
|
||||
# владелец бывает чаще, а не смешивается с общим потоком severity=critical
|
||||
# ниже. Матчим по имени, а не по host="apps": исходная тревога может
|
||||
# быть про любой хост, и врать в лейбле не стоит (см. инвариант host
|
||||
# у alert:app в infra.yml).
|
||||
- receiver: telegram-clients
|
||||
matchers:
|
||||
- alertname = "AlertFiringTooLong"
|
||||
group_wait: 10s
|
||||
repeat_interval: 30m
|
||||
|
||||
# Прочее критичное — инфраструктура, клиенты пока не затронуты.
|
||||
- receiver: telegram
|
||||
matchers:
|
||||
|
|
@ -152,17 +117,14 @@ ${METRICS_TELEGRAM_INFRA_TOPIC_LINE}
|
|||
- url: "http://alert-ack:8080/alertmanager"
|
||||
send_resolved: true
|
||||
|
||||
# Внешний deadman-приёмник вместо Telegram — см. комментарий у маршрута
|
||||
# Watchdog выше. Блок целиком (не значение) подставляется деплоем в
|
||||
# METRICS_WATCHDOG_PING_BLOCK — тот же приём, что у
|
||||
# METRICS_TELEGRAM_INFRA_TOPIC_LINE, и по той же причине: envsubst не умеет
|
||||
# условий. Секрет ещё не заведён на хосте — деплой в этом случае подставит
|
||||
# ПУСТУЮ строку, и receiver останется без единого `*_configs`. Это валидный
|
||||
# Alertmanager-конфиг: приёмник без конфигов просто молча отбрасывает
|
||||
# уведомление, амtool его пропускает. Одинарная подстановка ЗНАЧЕНИЯ url
|
||||
# (переменная-URL напрямую внутри готового ключа `url:`) сюда не годится:
|
||||
# непустой ключ с пустым значением или литералом плейсхолдера амtool валит
|
||||
# целиком (`unsupported scheme ""`), а с этим — весь Alertmanager, не
|
||||
# только Watchdog.
|
||||
- name: watchdog-ping
|
||||
${METRICS_WATCHDOG_PING_BLOCK}
|
||||
- name: telegram-heartbeat
|
||||
telegram_configs:
|
||||
- bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
|
||||
chat_id: ${METRICS_TELEGRAM_CHAT_ID}
|
||||
${METRICS_TELEGRAM_INFRA_TOPIC_LINE}
|
||||
api_url: "https://api.telegram.org"
|
||||
parse_mode: HTML
|
||||
send_resolved: false
|
||||
message: |
|
||||
⚪ <b>Мониторинг жив</b> — сторож отчитался, канал доставки работает.
|
||||
Если это сообщение перестало приходить дважды подряд, замолчал сам мониторинг.
|
||||
|
|
|
|||
|
|
@ -63,85 +63,40 @@ groups:
|
|||
- name: host
|
||||
interval: 60s
|
||||
rules:
|
||||
# Диск. Инцидент 16.09: кэш архивов Forgejo ел 2 ГБ/ч на диске 145 ГБ
|
||||
# (host=infra). Проценты на дисках разного размера значат разное —
|
||||
# host=apps держит /-раздел ~910 ГБ, те же 85 % там это ещё ~135 ГБ
|
||||
# запаса, а на infra (145 ГБ) — почти ничего. Порог переведён в
|
||||
# АБСОЛЮТНЫЕ ГБ свободного места: он одинаково осмыслен на любом диске,
|
||||
# потому что напрямую отвечает на вопрос «сколько времени есть до нуля
|
||||
# при текущей скорости утечки», а не «какая доля занята».
|
||||
#
|
||||
# `mountpoint="/"` — намеренное сужение с прежнего «все fstype кроме
|
||||
# tmpfs/overlay»: /boot и /boot/efi по обоим хостам меньше 1 ГБ
|
||||
# целиком, с порогом 30/15 ГБ они бы горели ПОСТОЯННО (проверено живыми
|
||||
# метриками 16.09 — infra:/boot/efi 98 МБ, apps:/boot 553 МБ). Данные
|
||||
# приложения и Postgres лежат на "/", туда и целится алерт.
|
||||
# Диск. На Beget уже был случай, когда занято 79 % и никто не смотрел;
|
||||
# порог 85 % даёт запас на реакцию, а не сообщает о свершившемся факте.
|
||||
- alert: DiskSpaceLow
|
||||
expr: |
|
||||
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}
|
||||
/ 1073741824 < 30
|
||||
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
|
||||
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Свободно на диске меньше 30 ГБ"
|
||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ."
|
||||
summary: "Диск занят больше 85 %"
|
||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}."
|
||||
|
||||
- alert: DiskSpaceCritical
|
||||
expr: |
|
||||
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}
|
||||
/ 1073741824 < 15
|
||||
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
|
||||
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.93
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Свободно на диске меньше 15 ГБ"
|
||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ. Postgres при заполнении диска останавливается."
|
||||
summary: "Диск почти кончился"
|
||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}. Postgres при заполнении диска останавливается."
|
||||
|
||||
# Скорость, а не уровень. Ловит именно ту утечку, что была 16.09: пока
|
||||
# DiskSpaceLow/Critical ещё не сработали (места вагон), но оно тает
|
||||
# быстрее ~1 ГБ/ч устойчиво — это уже течь, а не органический рост.
|
||||
# `deriv()` — линейная регрессия по 15-минутному окну (не мгновенная
|
||||
# разница двух точек, ту дёргает шум). Знак минус спереди и деление
|
||||
# переносят результат из "Б/с, отрицательное при убыли" в "ГБ/ч,
|
||||
# положительное когда тает" — чтобы $value в тексте читался нормально
|
||||
# (не "-2.1 ГБ/ч"). `for: 30m` поверх 15-минутного окна регрессии
|
||||
# требует НЕПРЕРЫВНОЙ убыли около часа, чтобы не будить на разовый
|
||||
# скачок (бэкап, ротация логов).
|
||||
- alert: DiskSpaceDepletingFast
|
||||
expr: |
|
||||
-deriv(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[15m])
|
||||
* 3600 / 1073741824 > 1
|
||||
for: 30m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Диск пустеет быстрее ГБ в час"
|
||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободное место убывает на {{ printf \"%.1f\" $value }} ГБ/час устойчиво последние ~30 минут — независимо от того, сколько места осталось сейчас."
|
||||
|
||||
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в
|
||||
# стену — но роль другая, чем у DiskSpaceDepletingFast выше. Тот ловит
|
||||
# БЫСТРУЮ (>1 ГБ/ч) утечку рано, за счёт короткого 15-минутного окна.
|
||||
# Этот ловит УМЕРЕННУЮ утечку (может быть медленнее 1 ГБ/ч), которую
|
||||
# короткое окно не поймает, но которая всё равно приведёт к нулю в
|
||||
# течение суток при текущем уровне занятости — окно 6h усредняет шум
|
||||
# ценой более позднего срабатывания. На одной и той же быстрой утечке
|
||||
# оба правила могут сработать (сначала это, следом то) — это
|
||||
# ЗАДУМАННАЯ эскалация двумя разными сигналами (скорость сейчас →
|
||||
# подтверждённый тренд на сутки), а не дублирующее письмо: тексты и
|
||||
# время срабатывания разные. DiskSpaceLow/Critical выше добавляют
|
||||
# третий, независимый от скорости сигнал — «места мало» само по себе,
|
||||
# даже если утечки нет и ничего не тает быстро.
|
||||
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в стену.
|
||||
- alert: DiskWillFillIn24h
|
||||
expr: |
|
||||
(0 - predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[6h], 24*3600))
|
||||
/ 1073741824 > 0
|
||||
predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0
|
||||
for: 30m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "По текущему темпу диск кончится за сутки"
|
||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: по экстраполяции последних 6 часов через сутки не хватит ~{{ printf \"%.1f\" $value }} ГБ."
|
||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: экстраполяция по последним 6 часам."
|
||||
|
||||
- alert: MemoryPressure
|
||||
expr: |
|
||||
|
|
@ -487,44 +442,3 @@ groups:
|
|||
annotations:
|
||||
summary: "WAL пишется быстрее 100 МБ/час"
|
||||
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|
||||
|
||||
# ── Эскалация ──────────────────────────────────────────────────────────────
|
||||
# Симптом 17.09: DiskWillFillIn24h пришёл вовремя и утонул между Watchdog
|
||||
# (10080 интервалов firing за 7 суток — горит всегда по построению) и
|
||||
# NoActiveCeleryWorkers (6229 интервалов) в общей ленте; диск дошёл до 84%
|
||||
# незамеченным. Alertmanager сам по длительности не эскалирует — это
|
||||
# правило Prometheus поверх служебной метрики ALERTS_FOR_STATE (unix-время
|
||||
# входа тревоги в pending/firing, см. Robust Perception "The
|
||||
# ALERTS_FOR_STATE metric").
|
||||
- name: escalation
|
||||
interval: 60s
|
||||
rules:
|
||||
# ИСКЛЮЧЕНИЯ В `alertname!~` ОБЯЗАТЕЛЬНЫ, а не для порядка:
|
||||
# - Watchdog горит всегда по построению (`vector(1)` выше) — без
|
||||
# исключения это правило унаследовало бы его вечный firing и стало
|
||||
# ВТОРЫМ таким сигналом, то есть тем самым шумом, который лечим.
|
||||
# - Сама AlertFiringTooLong — иначе, однажды сработав, она бы никогда
|
||||
# не погасла: собственная ALERTS_FOR_STATE тоже старше порога, и
|
||||
# правило продлевало бы себя бесконечно.
|
||||
# Что это НЕ значит: если NoActiveCeleryWorkers (её чинит параллельная
|
||||
# правка, здесь не трогаем) продолжит гореть дольше 6 часов, эта
|
||||
# тревога сработает сразу после мержа — это ожидаемо и верно: она
|
||||
# огонь реального незакрытого инцидента, а не вечная по построению.
|
||||
#
|
||||
# `label_replace(..., "stuck_alertname", "$1", "alertname", "(.+)")`
|
||||
# ОБЯЗАТЕЛЕН, а не косметика: `alertname` — зарезервированный лейбл,
|
||||
# Prometheus молча перезаписывает его именем ЭТОГО правила
|
||||
# (AlertFiringTooLong) на выходе, каким бы ни было значение в expr.
|
||||
# Без копии в `stuck_alertname` текст сообщения называл бы саму себя
|
||||
# виновником, а не исходную тревогу.
|
||||
- alert: AlertFiringTooLong
|
||||
expr: |
|
||||
label_replace(
|
||||
(time() - ALERTS_FOR_STATE{alertname!~"Watchdog|AlertFiringTooLong"}) > 6*3600,
|
||||
"stuck_alertname", "$1", "alertname", "(.+)"
|
||||
)
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Тревога держится дольше 6 часов"
|
||||
description: "{{ $labels.stuck_alertname }}{{ if $labels.host }} ({{ $labels.host }}){{ end }} непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||
|
|
|
|||
|
|
@ -170,85 +170,3 @@ tests:
|
|||
exp_annotations:
|
||||
summary: "Бэкенд «Меры» не отвечает"
|
||||
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
||||
|
||||
# Эскалация по длительности. `promtool test rules` держит одну общую шкалу
|
||||
# времени и TSDB на весь файл — к 6.5 часам к этому моменту «зависшими»
|
||||
# (input series предыдущих сценариев кончились, но absent()-условия по ним
|
||||
# продолжают гореть) оказываются и другие тестовые тревоги файла, не только
|
||||
# NoActiveCeleryWorkers из этого блока. Список ниже — ровно то, что
|
||||
# реально вернул promtool (проверено запуском, не придумано): 8 тревог,
|
||||
# держащихся дольше 6 часов. ГЛАВНАЯ ПРОВЕРКА в этом списке — то, чего в
|
||||
# нём НЕТ: ни Watchdog (горит вечно с t=0 точно так же, но исключён
|
||||
# матчером), ни сама AlertFiringTooLong (иначе была бы там на восьмое
|
||||
# место и продлевала бы себя бесконечно). В 3 часа — рано, эскалации
|
||||
# ещё быть не должно вовсе.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'up{job="celery",host="apps"}'
|
||||
values: '1x420'
|
||||
alert_rule_test:
|
||||
- eval_time: 3h
|
||||
alertname: AlertFiringTooLong
|
||||
exp_alerts: []
|
||||
- eval_time: 6h30m
|
||||
alertname: AlertFiringTooLong
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
stuck_alertname: MeraBackendDown
|
||||
host: apps
|
||||
job: app
|
||||
app: mera
|
||||
exp_annotations:
|
||||
summary: "Тревога держится дольше 6 часов"
|
||||
description: "MeraBackendDown (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
stuck_alertname: HostAgentDown
|
||||
host: apps
|
||||
job: node
|
||||
exp_annotations:
|
||||
summary: "Тревога держится дольше 6 часов"
|
||||
description: "HostAgentDown (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
stuck_alertname: RemoteWriteStalled
|
||||
host: apps
|
||||
job: node
|
||||
exp_annotations:
|
||||
summary: "Тревога держится дольше 6 часов"
|
||||
description: "RemoteWriteStalled (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
stuck_alertname: CadvisorDown
|
||||
job: cadvisor
|
||||
exp_annotations:
|
||||
summary: "Тревога держится дольше 6 часов"
|
||||
description: "CadvisorDown непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
stuck_alertname: QueueExporterDown
|
||||
job: redis
|
||||
exp_annotations:
|
||||
summary: "Тревога держится дольше 6 часов"
|
||||
description: "QueueExporterDown непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
stuck_alertname: TradeInBackgroundContainerMissing
|
||||
name: tradein-scraper
|
||||
exp_annotations:
|
||||
summary: "Тревога держится дольше 6 часов"
|
||||
description: "TradeInBackgroundContainerMissing непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
stuck_alertname: TradeInBackgroundContainerMissing
|
||||
name: tradein-tgbot
|
||||
exp_annotations:
|
||||
summary: "Тревога держится дольше 6 часов"
|
||||
description: "TradeInBackgroundContainerMissing непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
stuck_alertname: NoActiveCeleryWorkers
|
||||
exp_annotations:
|
||||
summary: "Тревога держится дольше 6 часов"
|
||||
description: "NoActiveCeleryWorkers непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ from app.schemas.trade_in import (
|
|||
CoverageProbeInput,
|
||||
CoverageProbeResponse,
|
||||
DkpCorridor,
|
||||
ExposureWindow,
|
||||
HouseAnalyticsKpi,
|
||||
HouseAnalyticsResponse,
|
||||
HouseInfoForEstimate,
|
||||
|
|
@ -391,7 +390,6 @@ async def _try_revive_dead_estimate(
|
|||
),
|
||||
{"orig": str(estimate_id), "temp": str(temp_id)},
|
||||
)
|
||||
w = result.exposure_window # #2898: окно целиком или NULL целиком
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -420,10 +418,6 @@ async def _try_revive_dead_estimate(
|
|||
ratio_basis = :ratio_basis,
|
||||
relaxations = CAST(:relaxations_json AS jsonb),
|
||||
reliability = :reliability,
|
||||
est_days_p25 = :est_days_p25,
|
||||
est_days_p50 = :est_days_p50,
|
||||
est_days_p75 = :est_days_p75,
|
||||
est_days_n = :est_days_n,
|
||||
revival_completed_at = NOW()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
|
|
@ -458,10 +452,6 @@ async def _try_revive_dead_estimate(
|
|||
"ratio_basis": result.ratio_basis,
|
||||
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
||||
"reliability": result.reliability,
|
||||
"est_days_p25": w.p25_days if w else None,
|
||||
"est_days_p50": w.p50_days if w else None,
|
||||
"est_days_p75": w.p75_days if w else None,
|
||||
"est_days_n": w.n if w else None,
|
||||
},
|
||||
)
|
||||
db.execute(
|
||||
|
|
@ -688,8 +678,7 @@ def load_estimate(
|
|||
expected_sold_price, expected_sold_range_low,
|
||||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
||||
relaxations, reliability,
|
||||
est_days_p25, est_days_p50, est_days_p75, est_days_n
|
||||
relaxations, reliability
|
||||
FROM trade_in_estimates
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
AND {ESTIMATE_READABLE_SQL}
|
||||
|
|
@ -854,19 +843,6 @@ def load_estimate(
|
|||
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
||||
# ответ AttributeError'ом.
|
||||
market_percentile=getattr(row, "market_percentile", None),
|
||||
# #2898: окно из колонок, а не пересчёт — по ссылке то же окно, что при оценке.
|
||||
# getattr: строка без колонок (in-memory double) — окна нет, а не AttributeError.
|
||||
exposure_window=(
|
||||
ExposureWindow(
|
||||
p25_days=row.est_days_p25,
|
||||
p50_days=row.est_days_p50,
|
||||
p75_days=row.est_days_p75,
|
||||
n=row.est_days_n,
|
||||
)
|
||||
if getattr(row, "est_days_n", None) is not None
|
||||
else None
|
||||
),
|
||||
est_days_on_market=getattr(row, "est_days_p50", None),
|
||||
period_months=12,
|
||||
analogs=analogs,
|
||||
actual_deals=actual_deals,
|
||||
|
|
|
|||
|
|
@ -230,21 +230,6 @@ class PriceTrendPoint(BaseModel):
|
|||
ppm2: int # медиана ₽/м² за месяц
|
||||
|
||||
|
||||
class ExposureWindow(BaseModel):
|
||||
"""#2898: сколько дней похожие квартиры рядом висели до снятия объявления.
|
||||
|
||||
Квартили `house_placement_history.exposure_days`: те же комнаты, площадь ±15%,
|
||||
дома в радиусе подбора аналогов, снятые за последние 24 мес. Это срок ДО СНЯТИЯ
|
||||
объявления, а не подтверждённой продажи: в данных снятие и продажа неразличимы.
|
||||
Окно либо целиком, либо None (выборка < `EXPOSURE_WINDOW_MIN_N`).
|
||||
"""
|
||||
|
||||
p25_days: int
|
||||
p50_days: int
|
||||
p75_days: int
|
||||
n: int # сколько снятых объявлений в выборке
|
||||
|
||||
|
||||
class AggregatedEstimate(BaseModel):
|
||||
estimate_id: UUID
|
||||
median_price_rub: int
|
||||
|
|
@ -307,11 +292,7 @@ class AggregatedEstimate(BaseModel):
|
|||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||
# абсолютный timestamp самого свежего парсинга аналогов
|
||||
last_scraped_at: datetime | None = None
|
||||
# #2898: окно срока экспозиции (p25–p75) — показывать его, а не одно число.
|
||||
exposure_window: ExposureWindow | None = None
|
||||
# Устаревшее, оставлено для старых клиентов: = exposure_window.p50_days. Раньше —
|
||||
# медиана возраста АКТИВНЫХ объявлений-аналогов (цензурированная выборка).
|
||||
est_days_on_market: int | None = None
|
||||
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
|
||||
cian_valuation: CianValuationSummary | None = None
|
||||
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
|
||||
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ from typing import Any, Literal
|
|||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES
|
||||
from scraper_kit.providers.avito.imv import (
|
||||
IMVAddressNotFoundError,
|
||||
IMVAuthError,
|
||||
|
|
@ -64,7 +63,6 @@ from app.schemas.trade_in import (
|
|||
AvitoImvSummary,
|
||||
CianValuationSummary,
|
||||
DkpCorridor,
|
||||
ExposureWindow,
|
||||
PriceTrendPoint,
|
||||
TradeInEstimateInput,
|
||||
)
|
||||
|
|
@ -2117,29 +2115,15 @@ def _sber_time_factor(series: dict[date, float], deal_month: date) -> float:
|
|||
return max(SBER_TIME_FACTOR_MIN, min(SBER_TIME_FACTOR_MAX, factor))
|
||||
|
||||
|
||||
# #2583 H1: словарь региона 66 (24 имени) не знал 23 города, по которым уже идут
|
||||
# развёртки (Реж, Карпинск, Кушва, Лесной, Тавда…). Нераспознанный город снимал с
|
||||
# ДКП-коридора городской фильтр: «Реж, Ленина, 12» получал коридор по «Ленина» всей
|
||||
# области, где доминирует ЕКБ. Добираем имена городов развёрток (те же, что пишутся в
|
||||
# listings.city), кроме городов других регионов (Москва). В общий REGIONS[66].cities
|
||||
# НЕ кладём: на нём стоят гейты геокодера и токены ключа дома (_OBLAST_CITY_TOKENS
|
||||
# вырезал бы «новая»/«лесной» из имён улиц). ё→е — вход нормализуется до поиска, а
|
||||
# deals.city хранит е-формы («Артемовский»).
|
||||
_TARGET_CITIES: frozenset[str] = SVERDLOVSK_OBLAST_CITIES | frozenset(
|
||||
name.lower().replace("ё", "е")
|
||||
for name in CITY_DISPLAY_NAMES.values()
|
||||
if (region := regions_mod.region_by_city(name)) is None or region.code == 66
|
||||
)
|
||||
|
||||
# Word/phrase-boundary regex поверх _TARGET_CITIES — resolve target-города для
|
||||
# city-scoping ДКП-коридора (см. _resolve_target_city). Длинные имена раньше коротких
|
||||
# (сортировка по len desc), чтобы «нижний тагил» матчился целиком, а не как «тагил»
|
||||
# после «нижний» отдельно (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ
|
||||
# именам городов, но сохраняем ту же дисциплину сортировки, что и
|
||||
# geocoder._OBLAST_CITY_RE).
|
||||
# Word/phrase-boundary regex поверх geocoder.SVERDLOVSK_OBLAST_CITIES (~30 городов
|
||||
# региона 66) — resolve target-города для city-scoping ДКП-коридора (см.
|
||||
# _resolve_target_city). Длинные имена раньше коротких (сортировка по len desc),
|
||||
# чтобы «нижний тагил» матчился целиком, а не как «тагил» после «нижний» отдельно
|
||||
# (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ именам городов, но
|
||||
# сохраняем ту же дисциплину сортировки, что и geocoder._OBLAST_CITY_RE).
|
||||
_TARGET_CITY_RE = re.compile(
|
||||
r"\b(?:"
|
||||
+ "|".join(re.escape(c) for c in sorted(_TARGET_CITIES, key=len, reverse=True))
|
||||
+ "|".join(re.escape(c) for c in sorted(SVERDLOVSK_OBLAST_CITIES, key=len, reverse=True))
|
||||
+ r")\b"
|
||||
)
|
||||
|
||||
|
|
@ -2147,8 +2131,8 @@ _TARGET_CITY_RE = re.compile(
|
|||
def _resolve_target_city(address_text: str | None) -> str | None:
|
||||
"""Извлекает город региона 66 из геокодированного адреса (best-effort).
|
||||
|
||||
Ищет ЛЮБОЕ известное имя города (_TARGET_CITIES: словарь региона 66 + города
|
||||
развёрток) по границе слова/фразы — работает и для forward-формата («Екатеринбург, ул.
|
||||
Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по
|
||||
границе слова/фразы — работает и для forward-формата («Екатеринбург, ул.
|
||||
Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская
|
||||
область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не
|
||||
важна. ё→е нормализация ДО поиска (симметрично _normalize_building_key).
|
||||
|
|
@ -5518,19 +5502,6 @@ async def estimate_quality(
|
|||
sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags)
|
||||
source_counts = _source_counts([lot.source for lot in analogs_lots])
|
||||
freshness_pre = _compute_freshness_minutes(metadata_lots)
|
||||
# #2898: окно считается один раз здесь и персистится — отчёт по ссылке через месяц
|
||||
# покажет то же окно, что в момент расчёта, а не пересчёт по новой истории.
|
||||
exposure_window = _exposure_window(
|
||||
await run_db_thread(
|
||||
_fetch_exposure_days,
|
||||
db,
|
||||
lat=geo.lat,
|
||||
lon=geo.lon,
|
||||
rooms=payload.rooms,
|
||||
area=payload.area_m2,
|
||||
radius_m=search_radius_m,
|
||||
)
|
||||
)
|
||||
# DaData enrichment (PR Q1) — заполняется только если service отработал.
|
||||
# При DaData = None все колонки идут в DB как NULL (graceful).
|
||||
dadata_metro_json = (
|
||||
|
|
@ -5560,7 +5531,6 @@ async def estimate_quality(
|
|||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis,
|
||||
relaxations, reliability,
|
||||
est_days_p25, est_days_p50, est_days_p75, est_days_n,
|
||||
created_by,
|
||||
expires_at,
|
||||
consent, client_ip, consent_policy_version, consent_text_snapshot
|
||||
|
|
@ -5584,7 +5554,6 @@ async def estimate_quality(
|
|||
:expected_sold_range_high, :expected_sold_per_m2,
|
||||
:asking_to_sold_ratio, :ratio_basis,
|
||||
CAST(:relaxations_json AS jsonb), :reliability,
|
||||
:est_days_p25, :est_days_p50, :est_days_p75, :est_days_n,
|
||||
:created_by,
|
||||
:expires_at,
|
||||
:consent, CAST(:client_ip AS inet), :consent_policy_version,
|
||||
|
|
@ -5645,11 +5614,6 @@ async def estimate_quality(
|
|||
# пропадал при открытии оценки по сохранённой ссылке (?id=).
|
||||
"relaxations_json": json.dumps(relaxations, ensure_ascii=False),
|
||||
"reliability": reliability,
|
||||
# #2898: окно пишем целиком или NULL целиком (n < порога).
|
||||
"est_days_p25": exposure_window.p25_days if exposure_window else None,
|
||||
"est_days_p50": exposure_window.p50_days if exposure_window else None,
|
||||
"est_days_p75": exposure_window.p75_days if exposure_window else None,
|
||||
"est_days_n": exposure_window.n if exposure_window else None,
|
||||
"created_by": created_by,
|
||||
"expires_at": expires_at,
|
||||
**_estimate_consent_persist_fields(require_consent, client_ip),
|
||||
|
|
@ -5820,8 +5784,7 @@ async def estimate_quality(
|
|||
data_freshness_minutes=freshness_min,
|
||||
last_scraped_at=last_scraped_at,
|
||||
price_trend=price_trend,
|
||||
exposure_window=exposure_window,
|
||||
est_days_on_market=exposure_window.p50_days if exposure_window else None,
|
||||
est_days_on_market=_estimate_days_on_market(metadata_lots, deals),
|
||||
cian_valuation=(
|
||||
CianValuationSummary(
|
||||
sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,
|
||||
|
|
@ -6013,88 +5976,23 @@ def _manual_review(
|
|||
return (bool(reasons), reasons)
|
||||
|
||||
|
||||
# #2898: окно срока экспозиции вместо одного числа. Раньше est_days_on_market был
|
||||
# медианой days_on_market АКТИВНЫХ аналогов — возраст висящего объявления, а не срок
|
||||
# до снятия (цензурированная выборка; deals.days_on_market пуст). Реальная экспозиция
|
||||
# снятых объявлений — house_placement_history.exposure_days.
|
||||
#
|
||||
# Порог 30 — замер 17.09.2026 на 51 прод-когорте с n>=80 (радиус 1 км): подвыборка
|
||||
# в 10 лотов даёт среднюю ошибку края окна 34-40%, 20 — 24%, 30 — 18%, 50 — 12%.
|
||||
# При 30 окно есть у 94 из 217 недавних оценок (при 20 — у 109).
|
||||
EXPOSURE_WINDOW_MIN_N = 30
|
||||
EXPOSURE_WINDOW_MONTHS = 24
|
||||
def _estimate_days_on_market(
|
||||
listings: list[dict[str, Any]], deals: list[dict[str, Any]]
|
||||
) -> int | None:
|
||||
"""Прогноз срока продажи — медиана days_on_market по аналогам/сделкам.
|
||||
|
||||
|
||||
def _exposure_window(days: list[int]) -> ExposureWindow | None:
|
||||
"""Квартили срока экспозиции; None, если выборка меньше EXPOSURE_WINDOW_MIN_N.
|
||||
|
||||
method="inclusive" — та же линейная интерполяция, что percentile_cont в
|
||||
sell-time-sensitivity, чтобы одно и то же окно не расходилось на единицу.
|
||||
Возвращает None если ни у одного аналога нет данных о сроке экспозиции
|
||||
(наши парсеры не всегда его отдают — честно показываем «нет данных»).
|
||||
"""
|
||||
if len(days) < EXPOSURE_WINDOW_MIN_N:
|
||||
values = [
|
||||
int(lot["days_on_market"])
|
||||
for lot in (*listings, *deals)
|
||||
if lot.get("days_on_market") and int(lot["days_on_market"]) > 0
|
||||
]
|
||||
if len(values) < 3:
|
||||
return None
|
||||
p25, p50, p75 = statistics.quantiles(days, n=4, method="inclusive")
|
||||
return ExposureWindow(
|
||||
p25_days=int(p25 + 0.5),
|
||||
p50_days=int(p50 + 0.5),
|
||||
p75_days=int(p75 + 0.5),
|
||||
n=len(days),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_exposure_days(
|
||||
db: Session,
|
||||
*,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
rooms: int,
|
||||
area: float,
|
||||
radius_m: int,
|
||||
) -> list[int]:
|
||||
"""exposure_days снятых объявлений той же комнатности и площади ±AREA_TOLERANCE
|
||||
в домах в радиусе подбора аналогов. Best-effort: ошибка → [] (окна нет).
|
||||
|
||||
ponytail: без дедупа avito_imv/yandex_valuation — прод 17.09: у 926 из 9482
|
||||
yandex-строк есть avito-двойник (~3% всей выборки), на квартили не влияет.
|
||||
"""
|
||||
if lat is None or lon is None or not area:
|
||||
return []
|
||||
try:
|
||||
# SAVEPOINT на месте глушения: упавший SELECT не должен отравить транзакцию,
|
||||
# в которой следом идёт INSERT оценки.
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT h.exposure_days
|
||||
FROM house_placement_history h
|
||||
JOIN houses ho ON ho.id = h.house_id
|
||||
WHERE ST_DWithin(
|
||||
ho.geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
|
||||
)
|
||||
AND h.rooms = :rooms
|
||||
AND h.area_m2 BETWEEN :area_min AND :area_max
|
||||
AND h.exposure_days IS NOT NULL
|
||||
AND h.removed_date IS NOT NULL
|
||||
AND h.removed_date <= CURRENT_DATE
|
||||
AND h.removed_date > CURRENT_DATE
|
||||
- make_interval(months => CAST(:months AS integer))
|
||||
"""
|
||||
),
|
||||
{
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius": radius_m,
|
||||
"rooms": rooms,
|
||||
"area_min": area * (1 - AREA_TOLERANCE),
|
||||
"area_max": area * (1 + AREA_TOLERANCE),
|
||||
"months": EXPOSURE_WINDOW_MONTHS,
|
||||
},
|
||||
).all()
|
||||
except Exception as exc:
|
||||
logger.warning("exposure_window lookup failed (graceful): %s", exc)
|
||||
return []
|
||||
return [int(r[0]) for r in rows]
|
||||
values.sort()
|
||||
return values[len(values) // 2]
|
||||
|
||||
|
||||
def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
|
||||
|
|
|
|||
|
|
@ -603,17 +603,11 @@ def default_city_filter_for_region(region_code: str | None) -> str | None:
|
|||
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
|
||||
# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` →
|
||||
# повторный прогон не трогает уже совпавшие строки.
|
||||
#
|
||||
# #2583 H5: фильтры выше стоят только на стороне ГАР, а UPDATE houses шёл по одному канону
|
||||
# по ВСЕМ домам всех регионов. После #3523 (матч для 77/50) прогон региона 50 проставил
|
||||
# подмосковные guid екатеринбургским домам с тем же каноном (прод 2026-09-17: 578 домов
|
||||
# региона 66 с guid региона 50, 340 — региона 77, 1371 дом 50 с guid 77, 497 домов 77 с
|
||||
# guid 50). Дом получает guid только своего региона; канон ранжируется внутри региона.
|
||||
_MATCH_SQL = text(
|
||||
"""
|
||||
WITH gar_scope AS (
|
||||
SELECT
|
||||
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count, region_code
|
||||
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
|
||||
FROM gar_house_flats
|
||||
WHERE flat_count > 0
|
||||
AND tradein_canon_addr(norm_address) <> ''
|
||||
|
|
@ -628,15 +622,15 @@ _MATCH_SQL = text(
|
|||
),
|
||||
gar_ranked AS (
|
||||
SELECT
|
||||
canon, house_guid, flat_count, region_code,
|
||||
canon, house_guid, flat_count,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid
|
||||
PARTITION BY canon ORDER BY flat_count DESC, house_guid
|
||||
) AS rn,
|
||||
COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits
|
||||
COUNT(*) OVER (PARTITION BY canon) AS canon_hits
|
||||
FROM gar_scope
|
||||
),
|
||||
gar_pick AS (
|
||||
SELECT canon, house_guid, flat_count, region_code
|
||||
SELECT canon, house_guid, flat_count
|
||||
FROM gar_ranked
|
||||
WHERE rn = 1
|
||||
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
|
||||
|
|
@ -650,33 +644,10 @@ _MATCH_SQL = text(
|
|||
WHERE tradein_canon_addr(
|
||||
COALESCE(h.short_address, h.full_address, h.address)
|
||||
) = gp.canon
|
||||
AND CAST(h.region_code AS text) = gp.region_code
|
||||
AND h.gar_flat_count IS DISTINCT FROM gp.flat_count
|
||||
"""
|
||||
)
|
||||
|
||||
# #2583 H5: снять уже проставленные guid чужого региона (см. коммент у _MATCH_SQL). Сам
|
||||
# _MATCH_SQL их не перезапишет, если у дома нет пары в своём регионе, — а по чужому guid
|
||||
# ЖКХ/ФРТ/капремонт-загрузчики продолжат тянуть данные другого дома. Только canon_addr и
|
||||
# только доказуемо чужие (регион дома известен и не совпадает) — дом без региона не трогаем.
|
||||
_CLEAR_FOREIGN_REGION_SQL = text(
|
||||
"""
|
||||
UPDATE houses h
|
||||
SET gar_house_guid = NULL,
|
||||
gar_flat_count = NULL,
|
||||
gar_matched_at = NULL,
|
||||
gar_match_method = NULL
|
||||
FROM gar_house_flats g
|
||||
WHERE g.house_guid = h.gar_house_guid
|
||||
AND h.gar_match_method = 'canon_addr'
|
||||
AND CAST(h.region_code AS text) <> g.region_code
|
||||
AND (
|
||||
CAST(:region AS text) IS NULL
|
||||
OR CAST(h.region_code AS text) = CAST(:region AS text)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def match_houses_to_gar(
|
||||
db: Session,
|
||||
|
|
@ -695,14 +666,11 @@ def match_houses_to_gar(
|
|||
"""
|
||||
if isinstance(city_filter, CityFilterAutoType):
|
||||
city_filter = default_city_filter_for_region(region_code)
|
||||
cleared = db.execute(_CLEAR_FOREIGN_REGION_SQL, {"region": region_code}).rowcount
|
||||
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
|
||||
matched = result.rowcount
|
||||
logger.info(
|
||||
"gar match: проставлено gar_flat_count домам=%d, снято guid чужого региона=%d"
|
||||
" (region=%s, city=%s)",
|
||||
"gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)",
|
||||
matched,
|
||||
cleared,
|
||||
region_code,
|
||||
city_filter,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -204,10 +204,9 @@ def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
|
|||
Словарь — `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские
|
||||
гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город,
|
||||
отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый
|
||||
(`_names_non_ekb_city` его не увидит) — т.е. новый город области в любом
|
||||
случае добавляется СЮДА, и гейт хинта не создаёт новой связности.
|
||||
(`estimator._resolve_target_city` дополнительно знает города развёрток,
|
||||
#2583 H1 — ему город нужен для фильтра сделок, а не для гейта.)
|
||||
(`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не
|
||||
резолвит) — т.е. новый город области в любом случае добавляется СЮДА, и
|
||||
гейт хинта не создаёт новой связности.
|
||||
|
||||
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
|
||||
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по
|
||||
|
|
|
|||
|
|
@ -25,31 +25,30 @@ WHAT this is:
|
|||
«улица Вайнера, 66» share one cluster_key. Rows whose canon is NULL/blank are never clustered
|
||||
(cluster_key NULL → ignored). Only canons shared by >1 house_id form a cluster.
|
||||
|
||||
GEO GUARD (anti over-merge, BOTH PASSES — #2690): because the canon strips город/район,
|
||||
GEO GUARD (anti over-merge, CANON PASS ONLY — #2187): because the canon strips город/район,
|
||||
two different buildings with the same street+number in different region-66 towns (e.g. «Ленина
|
||||
5») would share a canon. To avoid merging them, within a cluster a house is a LOSER only
|
||||
if it is within 250 m of the keeper (ST_DistanceSphere on the WGS84 geom). Same-key houses
|
||||
>250 m away — or with NULL geom on either side — are left as separate rows (conservative).
|
||||
The FIAS pass used to skip this guard (#2187, «a shared ФИАС UUID IS the building identity»).
|
||||
That premise is false: house_fias_id is DaData's answer to OUR address string (KEY section
|
||||
below), so a street+number without a town gets the same UUID as the same street+number in
|
||||
another town. Prod run 2026-09-05 (house_merge_log, merge_pass='fias'): 408 merges, 129 of
|
||||
them >250 m apart, 53 >3 km, worst 362 km («р-н Синарский, улица Кирова, 4» of
|
||||
Каменск-Уральский into Екатеринбург), plus 67 with no coordinates on a side.
|
||||
5») would share a canon. To avoid merging them, within a canon-cluster a house is a LOSER only
|
||||
if it is within 250 m of the keeper (ST_DistanceSphere on the WGS84 geom). Same-canon houses
|
||||
>250 m away — or with NULL geom on either side — are left as separate rows (conservative). The
|
||||
FIAS pass deliberately SKIPS this guard: a shared ФИАС/ГАР UUID IS the building identity and
|
||||
strictly outranks geo-proximity, so same-fias rows merge even with NULL geom on a side or
|
||||
>250 m apart (the geom-first keeper rule simultaneously repairs the broken coordinate).
|
||||
|
||||
TWO PASSES (2026-07-02 follow-up): the SAME cluster→keeper→re-point→carry-identity→delete
|
||||
pipeline now runs TWICE inside one transaction, parametrised by the cluster-key expression
|
||||
(`_mapping_sql`) — no 300-line copy-paste:
|
||||
1. FIAS pass — clusters by lower(NULLIF(house_fias_id, '')) (the ФИАС/ГАР building UUID,
|
||||
DaData-backfilled 2026-07-02 for 5 122 houses). Only non-null fias shared by >1 house_id
|
||||
cluster. Catches duplicates the canon MISSES: посёлок truncation (Кедровка/Шувакиш
|
||||
colliding with a same-named ЕКБ street) and, until migration 322 (#2189), slash-collapse
|
||||
(«Сулимова, 32» vs «Сулимова, 3/2» → same canon «сулимова32»).
|
||||
cluster. Catches duplicates the canon MISSES: slash-collapse («Сулимова, 32» vs
|
||||
«Сулимова, 3/2» → same canon «сулимова32») and посёлок truncation (Кедровка/Шувакиш
|
||||
colliding with a same-named ЕКБ street).
|
||||
2. CANON pass — the canonical-address clustering above, now with a CROSS-FIAS GUARD: within a
|
||||
canon cluster a loser is NOT merged when it AND the keeper both carry a non-null but
|
||||
DIFFERENT house_fias_id — provably different buildings the canon collapsed (the
|
||||
slash-collapse class). Critical anti-over-merge fix. The 250 m geo guard applies to
|
||||
BOTH passes (#2690; the fias-pass exemption of #2187 is reverted — see GEO GUARD).
|
||||
slash-collapse class). Critical anti-over-merge fix. The 250 m geo guard applies to the
|
||||
CANON pass ONLY (#2187): the FIAS pass merges on UUID identity regardless of geom — a
|
||||
боевой прогон left 130 same-fias groups (335 houses, 1 529 listings) split because the
|
||||
guard blocked them (NULL geom on a side, or >250 m from a broken coordinate).
|
||||
|
||||
IDENTITY CARRY-OVER (each pass, BEFORE deleting losers): the keeper's NULL identity / geo-QC
|
||||
fields are filled from its losers with COALESCE semantics (keeper value wins; donor = the
|
||||
|
|
@ -115,9 +114,9 @@ MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230):
|
|||
keeper rule and no guard. It only makes whatever the pass decides reversible — which is the
|
||||
precondition for revisiting those decisions at all (#2690, #1772).
|
||||
|
||||
distance_m is recorded on BOTH passes. It is what exposed the fias pass merging without a
|
||||
proximity check (#2690, 2026-09-05): «how many merges happened beyond N metres, on which key»
|
||||
is one query.
|
||||
distance_m is recorded on BOTH passes, including the fias pass whose geo guard is off. That
|
||||
asymmetry — merge allowed without a proximity check — was invisible in data before; now
|
||||
«how many merges happened beyond N metres, on which key» is one query.
|
||||
|
||||
KEY — there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690):
|
||||
#2690 asked for a cluster key that does not come from the normalized address, so that two
|
||||
|
|
@ -225,8 +224,8 @@ _KEEPER_ORDER = f"""
|
|||
# cluster key differs, parametrised into `_mapping_sql`, so there is NO copy-paste of the pipeline.
|
||||
#
|
||||
# FIAS key (#1772 follow-up, 2026-07-02): the ФИАС/ГАР building UUID (DaData backfill). Catches
|
||||
# duplicates the canon misses — посёлок truncation (Кедровка/Шувакиш) and, until migration 322
|
||||
# (#2189), slash-collapse («Сулимова, 32» vs «Сулимова, 3/2»). Only non-null fias clusters.
|
||||
# duplicates the canon misses — slash-collapse («Сулимова, 32» vs «Сулимова, 3/2») and посёлок
|
||||
# truncation (Кедровка/Шувакиш). Only non-null fias clusters.
|
||||
_FIAS_KEY_EXPR = """
|
||||
CASE WHEN NULLIF(house_fias_id, '') IS NOT NULL
|
||||
THEN 'fias:' || lower(house_fias_id)
|
||||
|
|
@ -312,15 +311,35 @@ def _ranked_cte(cluster_key_case: str) -> str:
|
|||
)"""
|
||||
|
||||
|
||||
def _mapping_sql(cluster_key_case: str) -> str:
|
||||
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
||||
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
|
||||
|
||||
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
|
||||
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
|
||||
row shares one fias by construction). So does the 250 m GEO GUARD — on BOTH passes (#2690):
|
||||
neither key is independent of our address string, so neither may merge what proximity rejects.
|
||||
row shares one fias by construction).
|
||||
|
||||
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
|
||||
- CANON pass → True: the canon strips город/район, so same-street-number buildings in
|
||||
different region-66 towns share a canon; the guard stops the cross-town over-merge.
|
||||
- FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
|
||||
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
|
||||
geom-first keeper rule simultaneously repairs the broken coordinate).
|
||||
`cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
|
||||
"""
|
||||
geo_guard = (
|
||||
"""
|
||||
-- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
|
||||
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
|
||||
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
|
||||
-- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
|
||||
-- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
|
||||
-- geom on either side, → left as separate rows (conservative — never over-merges).
|
||||
AND keeper_geom IS NOT NULL
|
||||
AND loser_geom IS NOT NULL
|
||||
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
|
||||
if apply_geo_guard
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
|
||||
{_ranked_cte(cluster_key_case)}
|
||||
|
|
@ -331,22 +350,16 @@ def _mapping_sql(cluster_key_case: str) -> str:
|
|||
--
|
||||
-- cluster_key / distance_m are carried out of the mapping for the MERGE JOURNAL (#2690):
|
||||
-- cluster_key records WHICH key value fired, distance_m how far apart the two rows were.
|
||||
-- distance_m is what showed the fias pass merging 362 km apart while it had no guard (#2690).
|
||||
-- distance_m is computed even when the geo guard is OFF for this pass — that is precisely
|
||||
-- the case where nothing else records the distance, and #2690 had no way to ask
|
||||
-- «how many merges happened at distances the guard would have blocked» from data.
|
||||
SELECT id AS loser_id, keeper_id, norm_address, cluster_key,
|
||||
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
|
||||
THEN ST_DistanceSphere(loser_geom, keeper_geom)
|
||||
END AS distance_m
|
||||
FROM ranked
|
||||
WHERE rn > 1
|
||||
AND id <> keeper_id
|
||||
-- GEO GUARD (both passes — #2690). Neither cluster key tells towns apart: the canon strips
|
||||
-- город/район, and house_fias_id is DaData's answer to that same town-less address. A loser
|
||||
-- merges only when geographically next to the keeper (<=250 m — covers one building's
|
||||
-- geocode spread, prod: Мраморская 34к4 dupes at 222 m; region-66 towns are km+ apart).
|
||||
-- >250 m, or NULL geom on either side, → left as separate rows (conservative).
|
||||
AND keeper_geom IS NOT NULL
|
||||
AND loser_geom IS NOT NULL
|
||||
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250
|
||||
AND id <> keeper_id{geo_guard}
|
||||
AND NOT (
|
||||
NULLIF(loser_fias, '') IS NOT NULL
|
||||
AND NULLIF(keeper_fias, '') IS NOT NULL
|
||||
|
|
@ -355,10 +368,12 @@ def _mapping_sql(cluster_key_case: str) -> str:
|
|||
"""
|
||||
|
||||
|
||||
# Canon-pass mapping.
|
||||
# Canon-pass mapping — geo guard ON (cross-town over-merge protection for street+number canons).
|
||||
_BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
|
||||
# Fias-pass mapping — same pipeline and same guards, clustered by the ФИАС UUID (runs first).
|
||||
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR))
|
||||
# Fias-pass mapping — same pipeline, clustered by the ФИАС building UUID (runs first). Geo guard
|
||||
# OFF (#2187): a shared ГАР UUID IS the building identity and outranks proximity — same-fias rows
|
||||
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
|
||||
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
|
||||
|
||||
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
|
||||
#
|
||||
|
|
@ -855,6 +870,7 @@ def _run_merge_pass(
|
|||
*,
|
||||
build_sql: Any,
|
||||
pass_label: str,
|
||||
geo_guard: bool,
|
||||
batch_id: str,
|
||||
run_id: int | None,
|
||||
initiator: str,
|
||||
|
|
@ -895,7 +911,7 @@ def _run_merge_pass(
|
|||
"run_id": run_id,
|
||||
"initiator": initiator,
|
||||
"merge_pass": pass_label,
|
||||
"geo_guard": True, # both passes are guarded since #2690
|
||||
"geo_guard": geo_guard,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -1016,8 +1032,8 @@ def merge_duplicate_houses(
|
|||
Re-implements migration 108's proven collision-safe pipeline as a RECURRING TWO-PASS job:
|
||||
1. FIAS pass — cluster by lower(NULLIF(house_fias_id, '')) (catches slash-collapse /
|
||||
посёлок canon bugs the address canon misses).
|
||||
2. CANON pass — cluster by canonical address.
|
||||
Both passes carry the 250 m geo guard and the cross-fias anti-over-merge guard.
|
||||
2. CANON pass — cluster by canonical address (250 m geo guard + cross-fias anti-over-merge
|
||||
guard).
|
||||
Each pass: pick keeper → re-point children (UNIQUE-collision-safe) → carry identity onto the
|
||||
keeper → delete losers → backfill sources/aliases. BOTH passes run in ONE transaction.
|
||||
dry_run=True computes counts then ROLLS BACK (no writes). Idempotent: a clean table yields an
|
||||
|
|
@ -1041,6 +1057,7 @@ def merge_duplicate_houses(
|
|||
db,
|
||||
build_sql=_BUILD_MAPPING_SQL_FIAS,
|
||||
pass_label="fias",
|
||||
geo_guard=False,
|
||||
batch_id=batch_id,
|
||||
run_id=run_id,
|
||||
initiator=initiator,
|
||||
|
|
@ -1051,6 +1068,7 @@ def merge_duplicate_houses(
|
|||
db,
|
||||
build_sql=_BUILD_MAPPING_SQL,
|
||||
pass_label="canon",
|
||||
geo_guard=True,
|
||||
batch_id=batch_id,
|
||||
run_id=run_id,
|
||||
initiator=initiator,
|
||||
|
|
|
|||
|
|
@ -544,9 +544,6 @@ async def _job_domclick_city_sweep(
|
|||
region_code=kit_resolve_region_code(params),
|
||||
resume_run_id=kit_pick_resume(db, run_id),
|
||||
cookies=cookies,
|
||||
watchdog_sec=(
|
||||
int(params["watchdog_sec"]) if params.get("watchdog_sec") is not None else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"""Daily per-source snapshot writer (#570).
|
||||
|
||||
Берёт текущее состояние listing_sources (последний снимок на canonical listing × source)
|
||||
и раз в сутки пишет в listing_source_snapshots строку на КАЖДОЕ ИЗМЕНЕНИЕ (#2993, см.
|
||||
_SNAPSHOT_SQL; раньше — полная копия каждые сутки) + change-log в
|
||||
и пишет ежедневный ряд в listing_source_snapshots + change-log price_change в
|
||||
listing_source_events. Так история per-source цены копится СРАЗУ, независимо от
|
||||
(сейчас DORMANT) скраперов — см. шапку data/sql/079_listing_source_history.sql.
|
||||
|
||||
|
|
@ -73,28 +72,9 @@ def _clamp_budget_sec(raw: Any) -> float:
|
|||
return max(_MIN_BUDGET_SEC, min(val, _MAX_BUDGET_SEC))
|
||||
|
||||
|
||||
# ── Snapshot upsert: строка только на изменение (#2993) ───────────────────────
|
||||
# Снимок на (listing_source_id, CURRENT_DATE) пишется, только если у источника ещё нет
|
||||
# ни одного снимка ИЛИ хоть одно из четырёх значений (price_rub, is_active, last_seen_at,
|
||||
# payload_hash) отличается от его ПОСЛЕДНЕГО снимка. Было: полная копия listing_sources
|
||||
# каждые сутки, прод 14-17.09 — 290-293 тыс. строк в сутки, из них отличались от
|
||||
# предыдущего снимка 7.7-11.4 тыс. (3-4 %). Решение владельца 2026-08-23.
|
||||
#
|
||||
# Состояние источника на дату D = его последний снимок с snapshot_date <= D. Для всех
|
||||
# четырёх колонок это то же значение, что записала бы суточная модель: сутки, в которые
|
||||
# ни одна из них не менялась, и есть пропущенные строки. Поэтому last_seen_at обязан
|
||||
# быть в сравнении: пол переобхода (deactivate_stale_avito._revisit_floor_from_where_sql,
|
||||
# PR-A #3056) берёт last_seen_at последнего снимка не позже якоря — без него пары
|
||||
# «прошлое наблюдение → текущее» считались бы от устаревшей свежести. is_active там же:
|
||||
# он выводится из now(), и переход «свежий → протух» при неизменном last_seen_at — тоже
|
||||
# изменение состояния на дату.
|
||||
#
|
||||
# p — последний снимок ВКЛЮЧАЯ сегодняшний: повторный прогон в те же сутки сравнивает
|
||||
# с тем, что уже записано сегодня, и перезаписывает строку только если значение снова
|
||||
# сдвинулось (ON CONFLICT → last-write-wins, как раньше). Пер-строчный LATERAL
|
||||
# point-lookup по PK, как в event-diff (#2607); прод 17.09: 293 602 lookup'а — 1.0 с.
|
||||
# Старые суточные снимки не трогаются — история до перехода остаётся как есть.
|
||||
#
|
||||
# ── Daily snapshot upsert ─────────────────────────────────────────────────────
|
||||
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
|
||||
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями).
|
||||
# is_active derived: last_seen_at в пределах окна свежести на момент снимка.
|
||||
# payload_hash = md5(raw_payload::text) — ::text на колонке допустим (это не bind-param).
|
||||
# run_id через CAST(:run_id AS bigint) — psycopg v3 (никогда :run_id::bigint).
|
||||
|
|
@ -105,33 +85,15 @@ _SNAPSHOT_SQL = text(
|
|||
last_seen_at, payload_hash, observed_at, run_id
|
||||
)
|
||||
SELECT
|
||||
cur.id,
|
||||
id,
|
||||
CURRENT_DATE,
|
||||
cur.price_rub,
|
||||
cur.is_active,
|
||||
cur.last_seen_at,
|
||||
cur.payload_hash,
|
||||
price_rub,
|
||||
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
|
||||
last_seen_at,
|
||||
md5(raw_payload::text),
|
||||
now(),
|
||||
CAST(:run_id AS bigint)
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
price_rub,
|
||||
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
|
||||
last_seen_at,
|
||||
md5(raw_payload::text) AS payload_hash
|
||||
FROM listing_sources
|
||||
) cur
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.snapshot_date, s.price_rub, s.is_active, s.last_seen_at, s.payload_hash
|
||||
FROM listing_source_snapshots s
|
||||
WHERE s.listing_source_id = cur.id
|
||||
ORDER BY s.snapshot_date DESC
|
||||
LIMIT 1
|
||||
) p ON true
|
||||
WHERE p.snapshot_date IS NULL
|
||||
OR (cur.price_rub, cur.is_active, cur.last_seen_at, cur.payload_hash)
|
||||
IS DISTINCT FROM (p.price_rub, p.is_active, p.last_seen_at, p.payload_hash)
|
||||
FROM listing_sources
|
||||
ON CONFLICT (listing_source_id, snapshot_date) DO UPDATE SET
|
||||
price_rub = EXCLUDED.price_rub,
|
||||
is_active = EXCLUDED.is_active,
|
||||
|
|
@ -146,9 +108,6 @@ _SNAPSHOT_SQL = text(
|
|||
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
|
||||
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
|
||||
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
|
||||
# С #2993 здесь только изменившиеся и новые источники: у остальных все четыре
|
||||
# значения равны последнему снимку, а значит ни одно событие ниже сработать
|
||||
# не могло бы — набор событий тот же, что при суточной копии.
|
||||
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
|
||||
#
|
||||
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
|
||||
|
|
@ -290,8 +249,7 @@ def snapshot_listing_sources(
|
|||
|
||||
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
||||
Два set-based statement'а в одной транзакции:
|
||||
1. upsert снимка на (listing_source_id, CURRENT_DATE) только для источников,
|
||||
изменившихся с последнего снимка (#2993) — last-write-wins.
|
||||
1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins.
|
||||
2. diff сегодняшнего снимка против последнего предыдущего → три события,
|
||||
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
|
||||
они НЕ выводимы при покрытии обхода 10-35% — см. _EVENT_DIFF_SQL.
|
||||
|
|
|
|||
|
|
@ -52,13 +52,6 @@ non-200 ответ считается блоком, а его диагноз б
|
|||
размерный порог settings.yandex_detail_min_html_bytes) и считается исходом
|
||||
incomplete ⊆ failed — обогащения нет, значит следующий снапшот
|
||||
(detail_enriched_at IS NULL) возьмёт её снова.
|
||||
|
||||
Вечный недогруз (#3191, 13.09–16.09): карточка, которая не догружается НИКОГДА, без
|
||||
счётчика попыток возвращалась в голову каждого снапшота. Пять таких объявлений подряд
|
||||
выбивали брейкер, и 16 прогонов закончились ровно attempted=5 incomplete=5. Теперь
|
||||
недогруз пишется на объявление (миграция 321): сутки его не берём, после
|
||||
INCOMPLETE_MAX_ATTEMPTS не берём совсем, а повторный недогруз уже известной карточки
|
||||
не двигает брейкер — это сведения о карточке, а не о площадке.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -144,13 +137,6 @@ OFFER_URL_PATTERN = "/offer/[0-9]+"
|
|||
OFFER_ID_PATTERN = "^[0-9]+$"
|
||||
CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
|
||||
|
||||
# Недогруз на объявлении (#3191). Разовый недогруз (1,8 МБ вместо 3,9 в замере 28.08)
|
||||
# с другого захода отдаётся целиком, поэтому пауза, а не приговор. Вечный — пустая
|
||||
# SPA-оболочка ~381 КБ без INITIAL_STATE, одинаковая с разных IP (проба 17.09) — после
|
||||
# трёх суточных попыток из очереди выходит и считается в incomplete_given_up.
|
||||
INCOMPLETE_RETRY_AFTER_HOURS = 24
|
||||
INCOMPLETE_MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class YandexDetailBackfillResult:
|
||||
|
|
@ -175,8 +161,6 @@ class YandexDetailBackfillResult:
|
|||
url_from_offer_id: int = 0
|
||||
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
|
||||
unenrichable_pending: int = 0
|
||||
# Ждут обогащения, но вышли из очереди после INCOMPLETE_MAX_ATTEMPTS недогрузов.
|
||||
incomplete_given_up: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
|
|
@ -188,7 +172,6 @@ class YandexDetailBackfillResult:
|
|||
"failed": self.failed,
|
||||
"url_from_offer_id": self.url_from_offer_id,
|
||||
"unenrichable_pending": self.unenrichable_pending,
|
||||
"incomplete_given_up": self.incomplete_given_up,
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
||||
|
|
@ -245,8 +228,7 @@ async def run_yandex_detail_backfill(
|
|||
WHEN source_url ~ CAST(:offer_url_pattern AS text)
|
||||
THEN source_url
|
||||
ELSE {CANONICAL_URL_SQL}
|
||||
END AS source_url,
|
||||
detail_incomplete_count
|
||||
END AS source_url
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND detail_enriched_at IS NULL
|
||||
|
|
@ -257,15 +239,6 @@ async def run_yandex_detail_backfill(
|
|||
)
|
||||
OR source_id ~ CAST(:offer_id_pattern AS text)
|
||||
)
|
||||
-- #3191: недогруженную карточку не берём сутки и не берём
|
||||
-- совсем после INCOMPLETE_MAX_ATTEMPTS.
|
||||
AND detail_incomplete_count < CAST(:incomplete_max_attempts AS int)
|
||||
AND (
|
||||
detail_incomplete_at IS NULL
|
||||
OR detail_incomplete_at < now() - make_interval(
|
||||
hours => CAST(:incomplete_retry_hours AS int)
|
||||
)
|
||||
)
|
||||
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
|
||||
LIMIT CAST(:batch_size AS int)
|
||||
"""
|
||||
|
|
@ -276,8 +249,6 @@ async def run_yandex_detail_backfill(
|
|||
"batch_size": batch_size,
|
||||
"offer_url_pattern": OFFER_URL_PATTERN,
|
||||
"offer_id_pattern": OFFER_ID_PATTERN,
|
||||
"incomplete_max_attempts": INCOMPLETE_MAX_ATTEMPTS,
|
||||
"incomplete_retry_hours": INCOMPLETE_RETRY_AFTER_HOURS,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -312,19 +283,6 @@ async def run_yandex_detail_backfill(
|
|||
).one()
|
||||
counters.url_from_offer_id = int(pending.url_from_offer_id)
|
||||
counters.unenrichable_pending = int(pending.unenrichable_pending)
|
||||
counters.incomplete_given_up = int(
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(*) FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND detail_incomplete_count >= CAST(:incomplete_max_attempts AS int)
|
||||
"""
|
||||
),
|
||||
{"incomplete_max_attempts": INCOMPLETE_MAX_ATTEMPTS},
|
||||
).scalar_one()
|
||||
)
|
||||
if counters.url_from_offer_id:
|
||||
logger.info(
|
||||
"yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый "
|
||||
|
|
@ -485,59 +443,26 @@ async def run_yandex_detail_backfill(
|
|||
# объявление уедет в БД с detail_enriched_at, выбыв из очереди
|
||||
# навсегда. Здесь оно исхода 'enriched' не получает, значит в
|
||||
# следующем прогоне снова попадёт в снапшот (detail_enriched_at
|
||||
# IS NULL) — но не раньше чем через сутки: недогруз пишется на
|
||||
# объявление, и после INCOMPLETE_MAX_ATTEMPTS снапшот его не берёт.
|
||||
# IS NULL). Серия таких страниц двигает consecutive_none — тот же
|
||||
# брейкер, что у parse→None: вечно недогружаемая карточка упрётся
|
||||
# в max_consecutive_blocks и оборвёт прогон, а не будет молотиться
|
||||
# (per-listing счётчика попыток в схеме нет, см. отчёт #3191).
|
||||
incomplete_reason = detail_incomplete_reason(
|
||||
resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes
|
||||
)
|
||||
if incomplete_reason is not None:
|
||||
counters.incomplete += 1
|
||||
counters.failed += 1
|
||||
# Брейкер ловит СИСТЕМНЫЙ недогруз (площадка отдаёт оболочки
|
||||
# всем). Повторный недогруз карточки, уже недогружавшейся
|
||||
# раньше, — сведения о ней, а не о площадке: он брейкер не
|
||||
# двигает, иначе вечные карточки, стоящие в очереди подряд,
|
||||
# рвали бы прогон при каждом повторе (#3191, 16 прогонов
|
||||
# attempted=5 incomplete=5). Первый недогруз двигает, как раньше.
|
||||
prior_incomplete = int(row.get("detail_incomplete_count") or 0)
|
||||
if prior_incomplete == 0:
|
||||
consecutive_none += 1
|
||||
consecutive_none += 1
|
||||
# Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196).
|
||||
consecutive_blocks = 0
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE listings
|
||||
SET detail_incomplete_count = detail_incomplete_count + 1,
|
||||
detail_incomplete_at = now()
|
||||
WHERE id = CAST(:listing_id AS bigint)
|
||||
"""
|
||||
),
|
||||
{"listing_id": listing_id},
|
||||
)
|
||||
db.commit()
|
||||
except Exception as mark_exc:
|
||||
# Не записали — карточка просто вернётся в следующий
|
||||
# снапшот без паузы (поведение до #3191-счётчика).
|
||||
db.rollback()
|
||||
logger.warning(
|
||||
"yandex_detail_backfill: run_id=%d listing_id=%d "
|
||||
"недогруз не записан: %s",
|
||||
run_id,
|
||||
listing_id,
|
||||
mark_exc,
|
||||
)
|
||||
logger.warning(
|
||||
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
|
||||
"-> недогруженная карточка, отказ: %s (попытка %d/%d, "
|
||||
"consecutive=%d)",
|
||||
"-> недогруженная карточка, отказ: %s (consecutive=%d)",
|
||||
run_id,
|
||||
listing_id,
|
||||
source_url,
|
||||
incomplete_reason,
|
||||
prior_incomplete + 1,
|
||||
INCOMPLETE_MAX_ATTEMPTS,
|
||||
consecutive_none,
|
||||
)
|
||||
if consecutive_none >= max_consecutive_blocks:
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
-- 311_canon_strip_district_glued_to_house_number.sql
|
||||
--
|
||||
-- CONTEXT (#1772): один дом живёт в нескольких записях houses, аналоги из того же дома
|
||||
-- разъезжаются. Схлопывание house_dedup_merge кластеризует по tradein_canon_addr, а канон
|
||||
-- промахивается на адресах, где район ПРИКЛЕЕН к номеру дома без запятой (наследие слипа
|
||||
-- «29р-н» из #1773 — источник починен, но записанные дома сохранили склеенный адрес):
|
||||
-- «Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический» → «евгениясавкова17арнакадемический»
|
||||
-- «Екатеринбург, ул. Евгения Савкова, 44Бр-н Академический» → «евгениясавкова44накадемический»
|
||||
-- Во втором случае хуже, чем шум: токен-срез S5 принимает «бр» (бульвар) и съедает литеру Б.
|
||||
-- Срез S4 (мигр. 147) снимает «р-н ...» только как отдельный сегмент перед запятой, поэтому
|
||||
-- двойник «ул. Евгения Савкова,17А» (канон «евгениясавкова17а») с этим домом не кластеризуется
|
||||
-- никогда, и перепись остатка (residual_*) такие пары не видит — у них РАЗНЫЙ канон.
|
||||
--
|
||||
-- WHAT: CREATE OR REPLACE FUNCTION tradein_canon_addr(text) — тело мигр. 147 плюс один шаг
|
||||
-- S0b сразу после lower+ё→е: «<цифра>[литера]р-н <название>» до запятой/конца → «<цифра>[литера]».
|
||||
-- Якорь на цифре обязателен: без него «мкр-н Кутузовский, 2» теряет «р-н ...» и превращается
|
||||
-- в «мк2» (прод 17.09: срез «р-н [^,]*» без якоря менял канон у 1024 домов вместо 44; в выборке
|
||||
-- 30 изменений вне шаблона 29 — «мкр-н …», одно — хвост «· р-н Верх-Исетский»).
|
||||
-- Литера сохраняется: «17Ар-н» → «17а», «17А» не схлопывается с «17».
|
||||
--
|
||||
-- Замер на проде 2026-09-17 (read-only, выражение шага inline в SELECT):
|
||||
-- houses: канон меняется у 44 из 49 159 — ровно у 44 домов с шаблоном «[0-9][а-я]?р-н »;
|
||||
-- у 20 из них появляется двойник с тем же каноном в пределах 250 м (0–222 м, все — тот же
|
||||
-- адрес в другом написании, напр. «Академика Вавилова, 9р-н Академический» ↔ «улица
|
||||
-- Академика Вавилова, 9», 0 м, 705 и 105 объявлений); разных ФИАС ни в одной паре нет.
|
||||
-- gar_house_flats: 0 из 3,27 млн строк содержат шаблон → канон ГАР-стороны не меняется,
|
||||
-- значения функционального индекса остаются верными.
|
||||
--
|
||||
-- DEPENDENCIES (existing prod objects):
|
||||
-- - tradein_canon_addr(text) — мигр. 144/147 (заменяем тело, IMMUTABLE, сигнатура та же).
|
||||
-- - gar_house_flats_canon_idx — функциональный индекс на tradein_canon_addr(norm_address)
|
||||
-- WHERE flat_count > 0 (мигр. 144). Postgres не перестраивает функциональный индекс при
|
||||
-- смене тела функции: СТАРЫЕ ключи остаются, новые строки получают ключ нового тела.
|
||||
-- Старый ключ неверен ровно у строк, где срабатывает S0b (lower+ё→е ~ '[0-9][а-я]?р-н ').
|
||||
-- Прод 17.09: 0 из 201 513 индексируемых строк → сегодня REINDEX ничего не меняет.
|
||||
-- Оставлен в ТОЙ ЖЕ транзакции, что и замена тела: замер не покрывает строки, загруженные
|
||||
-- между 17.09 и деплоем, а атомарная пара «тело + индекс» не оставляет окна со старыми ключами.
|
||||
--
|
||||
-- ЦЕНА REINDEX (прод 17.09, только чтение): таблица 3,27 млн строк / 942 МБ heap, индекс 8,8 МБ.
|
||||
-- Эквивалент перестройки — seq scan + tradein_canon_addr по flat_count > 0 в один поток — 5,6 с
|
||||
-- (скан 0,96 с, вычисление ключей ~4,6 с). Всё это время REINDEX держит SHARE на таблице и
|
||||
-- ACCESS EXCLUSIVE на индексе: планировщик любого запроса к gar_house_flats ждёт. Читают и
|
||||
-- пишут таблицу только ручной ГАР-загрузчик и ре-матч (app/services/gar_flats_loader.py; в
|
||||
-- scrape_schedules их нет; в pg_stat_statements с 27.08 — только INSERT загрузчика и ручные
|
||||
-- замеры) — путь запроса пользователя не задет.
|
||||
-- lock_timeout 5s ограничивает ОЖИДАНИЕ лока (#2752): если в момент деплоя идёт загрузка ГАР,
|
||||
-- миграция падает целиком (тело откатывается вместе с REINDEX), деплой красный, повторить позже.
|
||||
--
|
||||
-- Почему не REINDEX CONCURRENTLY (раннер гонит файл psql'ем в autocommit, так что вне
|
||||
-- BEGIN/COMMIT он возможен, образец — 270): CIC ждёт ВСЕ транзакции базы старше своего
|
||||
-- снимка, а сборщики держат «idle in transaction» минутами (17.09: 3 мин) — деплой висит без
|
||||
-- lock_timeout, который CIC не терпит; оборванный CIC оставляет невалидный *_ccnew и красный
|
||||
-- деплой с ручной чисткой на проде; тело функции коммитится раньше индекса. Ради ~5 с лока на
|
||||
-- таблице пакетного загрузчика это хуже.
|
||||
--
|
||||
-- POST-DEPLOY: ничего запускать не нужно. Следующий прогон расписания house_dedup_merge
|
||||
-- (еженедельно) сольёт пары, прошедшие страж 250 м, с журналом в house_merge_log.
|
||||
--
|
||||
-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE + REINDEX INDEX — повторный прогон no-op. Раннер деплоя
|
||||
-- гонит файл через psql ON_ERROR_STOP=on без --single-transaction → BEGIN/COMMIT в файле.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$
|
||||
SELECT regexp_replace( -- S6: оставить только [а-я0-9]
|
||||
regexp_replace( -- S5: срез типов улиц (токены на границе не-кириллицы)
|
||||
regexp_replace( -- S5b: срез маркера дома «д[.]» перед номером
|
||||
regexp_replace( -- S4b: срез «мкр/пос/поселок ...,» только если следом улица
|
||||
regexp_replace( -- S4: срез префикс-сегментов (каскад через lookahead)
|
||||
regexp_replace( -- S3: срез суффикс-сегмента «<Имя> м-н,»
|
||||
regexp_replace( -- S2: срез суффикс-сегмента «<...> обл/область,»
|
||||
regexp_replace( -- S1b: «б-р» → «бульвар»
|
||||
regexp_replace( -- S1a: «пр-т/пр-кт/пр кт/пркт» → «проспект»
|
||||
regexp_replace( -- S0b: «17Ар-н Академический» → «17а» (#1772)
|
||||
translate(lower(coalesce(s, '')), 'ё', 'е'), -- S0: lower + ё→е
|
||||
'([0-9][а-я]?)р-н [^,]*', '\1', 'g'),
|
||||
'(^|[^а-я])пр[-. ]?к?т([^а-я]|$)', '\1проспект\2', 'g'),
|
||||
'(^|[^а-я])б-р([^а-я]|$)', '\1бульвар\2', 'g'),
|
||||
'(^|,)[^,]* обл[а-я]*\.?(?=,)', '\1', 'g'),
|
||||
'(^|,)[^,]* м-н(?=,)', '\1', 'g'),
|
||||
'(^|,)\s*(россия|екатеринбург|город|жилой район|жилрайон|пгт|снт|р-н|м-н|г)([. ][^,]*)?(?=,)', '\1', 'g'),
|
||||
'(^|,)\s*(мкр|поселок|пос)[. ][^,]*(?=,[^,]*[а-я])', '\1', 'g'),
|
||||
'(^|[^а-я])д\.?(?= *[0-9])', '\1', 'g'),
|
||||
'(^|[^а-я])(улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр)([^а-я]|$)', '\1\3', 'g'),
|
||||
'[^а-я0-9]', '', 'g');
|
||||
$func$ LANGUAGE sql IMMUTABLE;
|
||||
|
||||
COMMENT ON FUNCTION tradein_canon_addr(text) IS
|
||||
'Канонический ключ адреса для ГАР↔houses матча и схлопывания дублей домов (мигр. 144 + 147 + 311): '
|
||||
'lower+ё→е, срез района, приклеенного к номеру дома («17Ар-н Академический» → «17а»), '
|
||||
'нормализация проспект/бульвар (пр-т/пр-кт/б-р), срез гео-префиксов '
|
||||
'(Россия/обл/Екатеринбург/г/мкр/м-н/пос/жилой район/р-н/снт/пгт) и маркера дома «д.», '
|
||||
'затем срез типов улиц как токенов на границе не-кириллицы → оставляет только [а-я0-9].';
|
||||
|
||||
REINDEX INDEX gar_house_flats_canon_idx;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
-- 321_listings_detail_incomplete_attempts.sql
|
||||
-- Счётчик недогруженных карточек Яндекса на объявлении (#3191, вечный недогруз).
|
||||
--
|
||||
-- Apply after: 310_yandex_seed_decimal_slips.sql
|
||||
-- Deploy order: схема первой (деплой применяет миграции до пересоздания контейнеров);
|
||||
-- старый код новые колонки не читает, поэтому окно между шагами безопасно.
|
||||
--
|
||||
-- ── ЧТО НЕ ТАК ────────────────────────────────────────────────────────────────
|
||||
-- PR #3364 сделал недогруженную карточку отказом: detail_enriched_at не ставится,
|
||||
-- объявление остаётся в очереди yandex_detail_backfill. Попыток на объявлении при
|
||||
-- этом никто не считал, и карточка, которая не догружается НИКОГДА, возвращалась в
|
||||
-- голову каждого снапшота. Прод 13.09 21:14 – 16.09 18:52: пять объявлений
|
||||
-- (10776456, 10775964, 10775945, 10775747, 10775594; scraped_at 12.09 15:02, подряд
|
||||
-- в порядке очереди) давали 381 КБ без encryptedPhones/redirectPhones в каждом
|
||||
-- прогоне, пять недогрузов подряд выбивали брейкер consecutive_none=5, и 16 прогонов
|
||||
-- закончились ровно attempted=5 incomplete=5, а остальные обрывались, дойдя до них.
|
||||
-- Страница — пустая SPA-оболочка без INITIAL_STATE (id оффера в ней не встречается),
|
||||
-- та же с другого IP (проба 17.09): это свойство объявления, а не сети.
|
||||
--
|
||||
-- ── ЧТО ДЕЛАЕТ ФАЙЛ ───────────────────────────────────────────────────────────
|
||||
-- detail_incomplete_count — сколько раз карточка пришла недогруженной;
|
||||
-- detail_incomplete_at — когда последний раз. Читает и пишет только
|
||||
-- app/tasks/yandex_detail_backfill.py: снапшот не берёт карточку в течение суток
|
||||
-- после недогруза и не берёт совсем после трёх.
|
||||
--
|
||||
-- ── СТОИМОСТЬ ─────────────────────────────────────────────────────────────────
|
||||
-- ADD COLUMN с константным DEFAULT в PostgreSQL 11+ не переписывает heap (значение
|
||||
-- лежит в каталоге), поэтому 19 ГБ listings не копируются. ACCESS EXCLUSIVE берётся
|
||||
-- на миллисекунды; ожидание лока ограничено lock_timeout (.claude/rules/sql.md).
|
||||
--
|
||||
-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS. Данные существующих строк не трогаются.
|
||||
--
|
||||
-- Критерий приёмки (записан ДО применения):
|
||||
-- 1. Запись в _schema_migrations по имени этого файла.
|
||||
-- 2. information_schema.columns: обе колонки у listings.
|
||||
-- 3. После первого прогона yandex_detail_backfill, дошедшего до пяти объявлений:
|
||||
-- у них detail_incomplete_count = 1, и следующий прогон их не запрашивает.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE listings
|
||||
ADD COLUMN IF NOT EXISTS detail_incomplete_count smallint NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS detail_incomplete_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN listings.detail_incomplete_count IS
|
||||
'Сколько раз detail-страница приходила недогруженной (#3191). Пишет yandex_detail_backfill; после 3 объявление выходит из очереди добора.';
|
||||
COMMENT ON COLUMN listings.detail_incomplete_at IS
|
||||
'Время последнего недогруза detail-страницы (#3191). Суточная пауза перед повтором в yandex_detail_backfill.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
-- 322_canon_keep_slash_in_house_number.sql
|
||||
--
|
||||
-- CONTEXT (#2189): канон-ключ tradein_canon_addr последним шагом оставлял только [а-я0-9],
|
||||
-- и дробь в номере дома исчезала: «Сулимова, 3/2» ≡ «Сулимова, 32», «Краснолесья, 10/7» ≡
|
||||
-- «107», «Машиностроителей, 4/1» ≡ «41». В ГАР это разные дома с разными house_guid
|
||||
-- («ул. Сулимова, 3/2» — 414 квартир, «ул. Сулимова, 32» — 120), а матчер ГАР↔houses
|
||||
-- (gar_flats_loader._MATCH_SQL) при фильтре по городу берёт из такого ключа строку с
|
||||
-- max(flat_count): так «Сулимова 32» получил 414 квартир дома 3/2. Ревизия DaData 02.07
|
||||
-- подтвердила класс (23 из 25 расхождений — в пользу DaData, метка dadata_fias_revision).
|
||||
--
|
||||
-- WHAT: CREATE OR REPLACE FUNCTION tradein_canon_addr(text) — тело мигр. 311 без изменений,
|
||||
-- кроме последнего шага: S6 оставляет [а-я0-9/], новый S7 убирает «/», если хотя бы с одной
|
||||
-- стороны от неё не цифра. «3/2» → «3/2», «3 / 2» → «3/2» (пробелы S6 уже снял), а угловые
|
||||
-- адреса «Гагарина, 27 / Первомайская, 81» и «204Г/1» дают прежний ключ.
|
||||
--
|
||||
-- Новый ключ — ТОЛЬКО уточнение старого: убери из нового все «/» — получишь старый. Значит,
|
||||
-- два адреса с одинаковым новым ключом имели одинаковый и старый; склеить дома, которые
|
||||
-- раньше не склеивались, правка не может, только развести.
|
||||
--
|
||||
-- Замер на проде 2026-09-17 (read-only, выражение нового тела inline в SELECT):
|
||||
-- houses: ключ меняется у 2549 из 49 204; групп «один новый ключ — несколько старых» 0.
|
||||
-- Распалось 47 прежних канон-кластеров — 113 пар домов, и ни одна не ближе 250 м
|
||||
-- (минимум 256 м), общего ФИАС нет ни в одной, разный ФИАС у 5. Страж 250 м
|
||||
-- house_dedup_merge эти пары и так не сливал, число слияний не меняется.
|
||||
-- gar_house_flats: «/» между цифрами в 7237 строках с квартирами (66: 938, 77: 2711,
|
||||
-- 50: 3588); групп «один новый ключ — несколько старых» 0.
|
||||
-- Симуляция _MATCH_SQL по затронутым ключам (регион: сопоставлений было → стало,
|
||||
-- неоднозначных ключей было → стало): 66 с фильтром ЕКБ 399 → 364, 17 → 9;
|
||||
-- 77: 259 → 242, 38 → 12; 50: 400 → 448, 348 → 249. Каждая потерянная пара —
|
||||
-- дробь против числа («Высоцкого, 4/2» ↔ ГАР «42», 725 квартир).
|
||||
--
|
||||
-- DEPENDENCIES (existing prod objects):
|
||||
-- - tradein_canon_addr(text) — мигр. 144/147/311 (заменяем тело, IMMUTABLE, сигнатура та же).
|
||||
-- ДЕПЛОИТЬ ПОСЛЕ 311 (PR #3558): тело включает её шаг S0b, иначе порядок файлов неважен.
|
||||
-- - gar_house_flats_canon_idx — функциональный индекс на tradein_canon_addr(norm_address)
|
||||
-- (мигр. 144). Значения у 7237 строк меняются → REINDEX обязателен: Postgres не
|
||||
-- перестраивает функциональный индекс при смене тела функции (урок мигр. 147).
|
||||
--
|
||||
-- ЧИСТКА СОПОСТАВЛЕНИЙ, СДЕЛАННЫХ СКЛЕЕННЫМ КЛЮЧОМ. Матчер (_MATCH_SQL) только проставляет
|
||||
-- gar_house_guid и никогда не снимает: дом «Высоцкого, 4/2», пришитый к ГАР «42» (725 квартир),
|
||||
-- при перематче новым ключом не найдёт пары и так и останется с чужими квартирами. Поэтому
|
||||
-- здесь сбрасываются ровно те canon_addr-сопоставления, у которых ключи дома и его ГАР-строки
|
||||
-- совпадают без «/», но различаются с ней, — то есть держались только на потерянной дроби.
|
||||
-- Сопоставления, разошедшиеся с ключом по другим причинам (прод: 4 дома, адрес сменился после
|
||||
-- матча), и методы dadata_fias/dadata_fias_revision не трогаются.
|
||||
-- Замер 17.09: 47 домов (регион ГАР 66 — 7, 77 — 21, 50 — 19), 3836 чужих квартир, 180 активных
|
||||
-- объявлений; все 47 — дробь против числа («Удельная, 1/1» → «11», «Ландау, 5/1» → «51»).
|
||||
-- Сомнительны 2 из 47: «Первомайская, 04А/04Б» ↔ ГАР «0/4а»/«0/4б» (регион 50) — возможно,
|
||||
-- тот же дом, у которого источник потерял дробь; без ГАР-числа они честнее, чем с угаданным.
|
||||
-- Порог остановки 100: сбросили больше — критерий ловит не то, миграция падает целиком.
|
||||
-- Перематч (новые и исправленные пары) — отдельный шаг, см. PR.
|
||||
--
|
||||
-- POST-DEPLOY: новые ключи houses сразу действуют в house_dedup_merge. Перематч ГАР — по желанию
|
||||
-- владельца: python -m app.tasks.gar_flats_load --match-only --region 66 (и 77, 50).
|
||||
--
|
||||
-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE + REINDEX INDEX — повторный прогон no-op; сброшенные дома
|
||||
-- второй раз не находятся (gar_match_method уже NULL). Раннер деплоя гонит файл через psql
|
||||
-- ON_ERROR_STOP=on без --single-transaction → BEGIN/COMMIT в файле.
|
||||
|
||||
BEGIN;
|
||||
-- Конвенция проекта (#2752): REINDEX и UPDATE houses берут блокировки — не вставать в очередь.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$
|
||||
SELECT regexp_replace( -- S7: «/» остаётся только между цифрами (#2189)
|
||||
regexp_replace( -- S6: оставить только [а-я0-9/]
|
||||
regexp_replace( -- S5: срез типов улиц (токены на границе не-кириллицы)
|
||||
regexp_replace( -- S5b: срез маркера дома «д[.]» перед номером
|
||||
regexp_replace( -- S4b: срез «мкр/пос/поселок ...,» только если следом улица
|
||||
regexp_replace( -- S4: срез префикс-сегментов (каскад через lookahead)
|
||||
regexp_replace( -- S3: срез суффикс-сегмента «<Имя> м-н,»
|
||||
regexp_replace( -- S2: срез суффикс-сегмента «<...> обл/область,»
|
||||
regexp_replace( -- S1b: «б-р» → «бульвар»
|
||||
regexp_replace( -- S1a: «пр-т/пр-кт/пр кт/пркт» → «проспект»
|
||||
regexp_replace( -- S0b: «17Ар-н Академический» → «17а» (#1772)
|
||||
translate(lower(coalesce(s, '')), 'ё', 'е'), -- S0: lower + ё→е
|
||||
'([0-9][а-я]?)р-н [^,]*', '\1', 'g'),
|
||||
'(^|[^а-я])пр[-. ]?к?т([^а-я]|$)', '\1проспект\2', 'g'),
|
||||
'(^|[^а-я])б-р([^а-я]|$)', '\1бульвар\2', 'g'),
|
||||
'(^|,)[^,]* обл[а-я]*\.?(?=,)', '\1', 'g'),
|
||||
'(^|,)[^,]* м-н(?=,)', '\1', 'g'),
|
||||
'(^|,)\s*(россия|екатеринбург|город|жилой район|жилрайон|пгт|снт|р-н|м-н|г)([. ][^,]*)?(?=,)', '\1', 'g'),
|
||||
'(^|,)\s*(мкр|поселок|пос)[. ][^,]*(?=,[^,]*[а-я])', '\1', 'g'),
|
||||
'(^|[^а-я])д\.?(?= *[0-9])', '\1', 'g'),
|
||||
'(^|[^а-я])(улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр)([^а-я]|$)', '\1\3', 'g'),
|
||||
'[^а-я0-9/]', '', 'g'),
|
||||
'(?<![0-9])/|/(?![0-9])', '', 'g');
|
||||
$func$ LANGUAGE sql IMMUTABLE;
|
||||
|
||||
COMMENT ON FUNCTION tradein_canon_addr(text) IS
|
||||
'Канонический ключ адреса для ГАР↔houses матча и схлопывания дублей домов (мигр. 144 + 147 + 311 + 322): '
|
||||
'lower+ё→е, срез района, приклеенного к номеру дома («17Ар-н Академический» → «17а»), '
|
||||
'нормализация проспект/бульвар (пр-т/пр-кт/б-р), срез гео-префиксов '
|
||||
'(Россия/обл/Екатеринбург/г/мкр/м-н/пос/жилой район/р-н/снт/пгт) и маркера дома «д.», '
|
||||
'затем срез типов улиц как токенов на границе не-кириллицы → оставляет только [а-я0-9] '
|
||||
'и «/» между цифрами («3/2» ≠ «32»).';
|
||||
|
||||
REINDEX INDEX gar_house_flats_canon_idx;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
reset_rows bigint;
|
||||
BEGIN
|
||||
UPDATE houses h
|
||||
SET gar_house_guid = NULL, gar_flat_count = NULL, gar_matched_at = NULL, gar_match_method = NULL
|
||||
FROM gar_house_flats g
|
||||
WHERE g.house_guid = h.gar_house_guid
|
||||
AND h.gar_match_method = 'canon_addr'
|
||||
AND tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address))
|
||||
<> tradein_canon_addr(g.norm_address)
|
||||
AND replace(tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address)), '/', '')
|
||||
= replace(tradein_canon_addr(g.norm_address), '/', '');
|
||||
GET DIAGNOSTICS reset_rows = ROW_COUNT;
|
||||
RAISE NOTICE 'houses: сброшено ГАР-сопоставлений, державшихся на потерянной дроби = %', reset_rows;
|
||||
|
||||
-- Исключение откатывает всю транзакцию, включая замену функции.
|
||||
IF reset_rows > 100 THEN
|
||||
RAISE EXCEPTION
|
||||
'houses: к сбросу % canon_addr-сопоставлений при замере 17.09.2026 = 47 — '
|
||||
'критерий ловит не то, миграция остановлена', reset_rows;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
-- #2898: окно срока экспозиции (p25/p50/p75 и размер выборки) вместо одного числа.
|
||||
--
|
||||
-- ЗАЧЕМ КОЛОНКИ. Окно считается по house_placement_history на момент оценки, а
|
||||
-- история пополняется каждый день: пересчёт на чтении показал бы по ссылке через
|
||||
-- месяц другое окно, чем в момент покупки отчёта. Поэтому значение хранится.
|
||||
--
|
||||
-- Все четыре либо заполнены, либо NULL: NULL — выборка меньше
|
||||
-- EXPOSURE_WINDOW_MIN_N (30) или оценка посчитана до этой миграции. Бэкфилла нет:
|
||||
-- историю размещений на дату старой оценки не восстановить.
|
||||
--
|
||||
-- Старое поле ответа est_days_on_market не колонка и не удаляется: GET отдаёт в нём
|
||||
-- est_days_p50, старые строки дают null, как и раньше.
|
||||
--
|
||||
-- ПРО ЛОКИ (#2752): ADD COLUMN без DEFAULT не переписывает таблицу, но ждать
|
||||
-- ACCESS EXCLUSIVE может долго и держать очередь запросов — lock_timeout.
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS est_days_p25 integer,
|
||||
ADD COLUMN IF NOT EXISTS est_days_p50 integer,
|
||||
ADD COLUMN IF NOT EXISTS est_days_p75 integer,
|
||||
ADD COLUMN IF NOT EXISTS est_days_n integer;
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.est_days_p50 IS
|
||||
'#2898: медиана дней до СНЯТИЯ объявления (не продажи) у похожих квартир рядом, '
|
||||
'house_placement_history.exposure_days. p25/p75 — края окна, n — размер выборки. '
|
||||
'NULL — выборка < 30.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
-- 325_listing_source_snapshots_change_only_comment.sql
|
||||
-- #2993 — listing_source_snapshots переходит на «строку на изменение»
|
||||
-- (app/tasks/listing_source_snapshot.py, _SNAPSHOT_SQL; решение владельца 2026-08-23).
|
||||
--
|
||||
-- Схема не меняется, меняется смысл строк — поэтому только комментарии. Прежние
|
||||
-- («Ежедневный снимок…», «на дату снимка») после перехода стали бы неправдой:
|
||||
-- `WHERE snapshot_date = D` отдаёт теперь не состояние всех источников на D, а только
|
||||
-- изменившиеся в D. Состояние на дату — последний снимок с snapshot_date <= D.
|
||||
-- Суточные строки до перехода остаются в таблице как есть.
|
||||
--
|
||||
-- COMMENT берёт ShareUpdateExclusiveLock (конфликтует с autovacuum/ANALYZE, не с
|
||||
-- чтением и записью) — lock_timeout, чтобы деплой не повис за VACUUM 8-млн таблицы.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
COMMENT ON TABLE listing_source_snapshots IS
|
||||
'Снимок listing_sources на источник (#570), строка только при изменении (#2993): '
|
||||
'пишется, если price_rub/is_active/last_seen_at/payload_hash отличаются от последнего '
|
||||
'снимка источника или снимка ещё нет. Состояние на дату D — последняя строка с '
|
||||
'snapshot_date <= D. До перехода (#2993) — полная суточная копия. '
|
||||
'PK (listing_source_id, snapshot_date), last-write-wins за день.';
|
||||
|
||||
COMMENT ON VIEW v_listing_source_price_on_date IS
|
||||
'Цена/активность источника с даты снимка (#570): listing_source_snapshots ⋈ listing_sources. '
|
||||
'С #2993 строки только на даты изменений — цена на дату D = последняя строка с snapshot_date <= D.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
-- 326_domclick_msk_reenable_after_incremental_save.sql
|
||||
-- Вернуть домклик-свипы Москвы (77) и области (50) — причина выключения устранена.
|
||||
--
|
||||
-- Apply after: 325_listing_source_snapshots_change_only_comment.sql
|
||||
--
|
||||
-- WHY:
|
||||
-- Миграция 308 выключила эти строки: свип копил лоты в памяти и сохранял их ОДНИМ
|
||||
-- save_listings после всех шести корзин, поэтому снятие по watchdog теряло всё
|
||||
-- собранное, а чекпоинт при этом помечал корзины пройденными. На выдаче размером с
|
||||
-- Москву (≈23 690 лотов вторички против ≈6 300 у ЕКБ) снятие было гарантировано, и
|
||||
-- итоговый сбор равнялся нулю навсегда.
|
||||
--
|
||||
-- PR #3592 это снял: save_listings зовётся из колбэка on_bucket сразу после КАЖДОЙ
|
||||
-- успешной корзины, туда же переехал чекпоинт — done_buckets теперь означает
|
||||
-- «собрано И сохранено». Снятие по watchdog больше не теряет собранное, а корзина
|
||||
-- без сохранённых строк в чекпоинт не попадает. Тем же колбэком добавлена
|
||||
-- кооперативная отмена по корзинам (раньше is_cancelled проверялся только перед
|
||||
-- SERP-фазой, и повисший свип нельзя было снять три часа).
|
||||
--
|
||||
-- interval_days = 1, а не 3 как было: полный проход Москвы в одно окно watchdog'а
|
||||
-- по-прежнему НЕ помещается (замер run 7344: 2 корзины из 6 за два часа). Механизм
|
||||
-- добора — ротация стартовой корзины (start_bucket_index = run_id % 6) плюс
|
||||
-- skip_buckets из чекпоинта: каждый прогон берёт корзины, которых ещё нет в
|
||||
-- done_buckets. При суточном такте шесть корзин закрываются примерно за трое суток,
|
||||
-- при трёхсуточном — за девять, а корпус живёт 14 суток (LISTINGS_FRESH_DAYS).
|
||||
-- Девять суток на полный оборот не оставляли бы запаса на пропуски из-за
|
||||
-- QRATOR-банов.
|
||||
--
|
||||
-- watchdog_sec НЕ задаётся намеренно. Override в коде есть (PR #3592, читается из
|
||||
-- default_params обоими хендлерами), но поднимать таймаут до замера нечем
|
||||
-- обосновать: с инкрементальным сохранением ранний снос перестал быть потерей, а
|
||||
-- более длинный прогон дольше держит один из ДВУХ узлов provider_affinity='any'
|
||||
-- (id 13 и 14), за которые конкурирует cian. Сначала смотрим реальный выход за
|
||||
-- прогон, потом решаем про таймаут.
|
||||
--
|
||||
-- Окна не меняются: Москва 0-3, область 9-12 (разведены миграцией 307), ЕКБ 3-6.
|
||||
-- Ни одно окно не содержит двух домклик-строк — это условие, за нарушение которого
|
||||
-- 17.09 ЕКБ-свип run 7339 отбился 'banned' за 0 секунд.
|
||||
--
|
||||
-- Чекпоинт прогонов 7333/7344 сбрасывать не нужно: у обоих buckets_completed пуст,
|
||||
-- они были задрейнены деплоем ещё до первой завершённой корзины.
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ: UPDATE ... WHERE source IN (...) — повторный прогон пишет те же
|
||||
-- значения. next_run_at не трогаем: планировщик посчитает его сам по окну, а явная
|
||||
-- простановка здесь разъехалась бы с реальным временем применения миграции.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
UPDATE scrape_schedules
|
||||
SET enabled = true,
|
||||
default_params = jsonb_set(default_params, '{interval_days}', '1'::jsonb, true)
|
||||
WHERE source IN ('domclick_city_sweep_moskva', 'domclick_city_sweep_moskovskaya_oblast');
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1674,16 +1674,12 @@ class _HouseIdResolution:
|
|||
total: int = 0 # deals processed in the full-engine path under the flag
|
||||
resolved: int = 0 # deals where match_house_readonly returned an id
|
||||
imv_reachable: int = 0 # of resolved, those whose house_imv anchor fired
|
||||
# #2862: сделкам, у которых тип дома подставлен из `houses` (deals.house_type пуст
|
||||
# у всех строк). Ноль здесь = штраф за тип дома в прогоне не участвовал вовсе.
|
||||
house_type_from_houses: int = 0
|
||||
|
||||
def as_json(self) -> dict[str, int]:
|
||||
return {
|
||||
"resolved": self.resolved,
|
||||
"total": self.total,
|
||||
"imv_reachable": self.imv_reachable,
|
||||
"house_type_from_houses": self.house_type_from_houses,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2554,18 +2550,6 @@ def run_backtest_full(
|
|||
target_house_id = _resolve_house_id_for_deal(db, deal)
|
||||
if target_house_id is not None:
|
||||
resolution.resolved += 1
|
||||
# #2862: боевой estimate_quality (#3234) берёт тип дома из `houses`, когда
|
||||
# его нет в форме — по резолвленному house_id, иначе ближайший дом в 60 м.
|
||||
# deals.house_type пуст у всех сделок, так что без этого штраф за тип дома
|
||||
# в прогоне не срабатывал ни разу. Только тип: год и этажность из `houses`
|
||||
# сдвинули бы метрики по другим признакам и смешали бы замер.
|
||||
if deal.house_type is None:
|
||||
facts = est.m._lookup_house_facts(
|
||||
db, target_house_id=target_house_id, lat=deal.lat, lon=deal.lon
|
||||
)
|
||||
if facts is not None and facts.house_type is not None:
|
||||
deal = dataclasses.replace(deal, house_type=facts.house_type)
|
||||
resolution.house_type_from_houses += 1
|
||||
try:
|
||||
pr = _predict_full_spine(
|
||||
db,
|
||||
|
|
@ -2632,11 +2616,10 @@ def run_backtest_full(
|
|||
if resolution is not None:
|
||||
metrics["house_id_resolution"] = resolution.as_json()
|
||||
logger.info(
|
||||
"house_id resolution: resolved=%d/%d imv_reachable=%d house_type_from_houses=%d",
|
||||
"house_id resolution: resolved=%d/%d imv_reachable=%d",
|
||||
resolution.resolved,
|
||||
resolution.total,
|
||||
resolution.imv_reachable,
|
||||
resolution.house_type_from_houses,
|
||||
)
|
||||
|
||||
if dump_fixture is not None and capture is not None:
|
||||
|
|
|
|||
|
|
@ -31,19 +31,12 @@ tests/test_pdf_real_render.py
|
|||
tests/tasks/test_backfill_house_coords_from_listings.py::test_real_transfers_agreeing_coords_skips_scattered_and_is_idempotent
|
||||
tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked
|
||||
tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows
|
||||
tests/test_2189_canon_keeps_slash.py::test_real_canon_keeps_slash_between_digits
|
||||
tests/test_2189_canon_keeps_slash.py::test_real_dedup_does_not_merge_slash_and_plain_number
|
||||
tests/test_2189_canon_keeps_slash.py::test_real_gar_match_picks_the_house_with_the_same_number
|
||||
tests/test_2189_canon_keeps_slash.py::test_migration_322_resets_only_matches_held_by_the_lost_slash
|
||||
tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count
|
||||
tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched
|
||||
tests/test_gar_flats_loader.py::test_house_gets_gar_guid_only_from_its_own_region
|
||||
tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
|
||||
tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number
|
||||
tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover
|
||||
tests/test_house_dedup_merge.py::test_real_fias_pass_keeps_geo_guard
|
||||
tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
|
||||
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
|
||||
tests/test_house_dedup_merge.py::test_real_migration_311_gives_up_on_busy_gar_table_instead_of_queueing
|
||||
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
|
||||
tests/test_user_events.py::test_real_record_event_inserts_row
|
||||
tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
|
||||
|
|
@ -187,15 +180,3 @@ tests/test_3404_egress_run_attribution.py::test_attribution_does_not_commit_call
|
|||
# снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09.
|
||||
tests/test_3385_migration_310_yandex_seed_slips.py::test_deletes_only_seed_off_by_order_and_is_idempotent
|
||||
tests/test_3385_migration_310_yandex_seed_slips.py::test_stops_and_rolls_back_when_too_many
|
||||
|
||||
# Вечный недогруз Яндекса (#3191, миграция 321): снапшот-SELECT очереди добора с
|
||||
# суточной паузой и выбыванием после трёх недогрузов судит только Postgres — на
|
||||
# мок-лэйне БД нет. В ci-tradein.yml бежит по-настоящему (postgres-сервис, #2745).
|
||||
tests/test_3191_yandex_perpetual_underloaded.py::test_perpetual_underloaded_cards_leave_queue_head
|
||||
|
||||
# Снапшоты «строка на изменение» (#2993, миграция 325): сравнение с последним
|
||||
# снимком источника и суточная идемпотентность исполняет только Postgres — на
|
||||
# мок-лэйне деплоя БД нет. В ci-tradein.yml бегут по-настоящему (postgres-сервис).
|
||||
tests/test_2993_snapshot_change_only.py::test_events_survive_change_only_writes
|
||||
tests/test_2993_snapshot_change_only.py::test_only_changed_or_new_sources_get_a_row_today
|
||||
tests/test_2993_snapshot_change_only.py::test_rerun_same_day_compares_with_todays_row
|
||||
|
|
|
|||
|
|
@ -1,215 +0,0 @@
|
|||
"""#2189 — канон-ключ адреса не теряет дробь в номере дома («Сулимова, 3/2» ≠ «Сулимова, 32»).
|
||||
|
||||
Проверяется функция, которую поставили миграции (322), а не её Python-копия, и оба её
|
||||
потребителя: матч ГАР↔houses (gar_flats_loader) и схлопывание дублей (house_dedup_merge).
|
||||
Без БД тесты пропускаются (записи в tests/skip_allowlist.txt), в CI идут на схеме из data/sql.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.services import gar_flats_loader as gfl
|
||||
from app.services import house_dedup_merge as hdm
|
||||
|
||||
|
||||
def _live_session() -> Any | None:
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return None
|
||||
engine = create_engine(dsn, future=True)
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return sessionmaker(bind=engine, future=True)()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
needs_db = pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
|
||||
|
||||
|
||||
@needs_db
|
||||
def test_real_canon_keeps_slash_between_digits() -> None:
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
|
||||
def canon(s: str) -> str:
|
||||
return db.execute(text("SELECT tradein_canon_addr(:s)"), {"s": s}).scalar()
|
||||
|
||||
try:
|
||||
# живые пары с прода (ревизия DaData 02.07): это разные дома
|
||||
assert canon("Екатеринбург, ул. Сулимова, 3/2") == "сулимова3/2"
|
||||
assert canon("Екатеринбург, ул. Сулимова, 32") == "сулимова32"
|
||||
assert canon("улица Краснолесья, 10/7") == "краснолесья10/7"
|
||||
assert canon("ул. Краснолесья,107") == "краснолесья107"
|
||||
assert canon("улица Машиностроителей, 4/1") != canon("ул. Машиностроителей, 41")
|
||||
# пробелы вокруг дроби и формат ГАР дают тот же ключ, что и слитная запись
|
||||
assert canon("ул. Сулимова, 3 / 2") == canon(
|
||||
"р-н Кировский, мкр. Пионерский, ул. Сулимова,3/2"
|
||||
)
|
||||
# «/» не между цифрами — как раньше: угловой адрес и литера перед дробью
|
||||
assert canon(
|
||||
"Екатеринбург, Втузгородок м-н, улица Гагарина, 27 / Первомайская улица, 81"
|
||||
) == ("гагарина27первомайская81")
|
||||
assert canon("ул. 8 Марта,204Г/1") == "8марта204г1"
|
||||
# остальная нормализация не задета
|
||||
assert canon("ул. Шаумяна,20") == canon("ул. Шаумяна, 20") == "шаумяна20"
|
||||
assert (
|
||||
canon("Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический") == "евгениясавкова17а"
|
||||
)
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
@needs_db
|
||||
def test_real_gar_match_picks_the_house_with_the_same_number() -> None:
|
||||
"""Прод: «Сулимова 32» получал ГАР-дом «3/2» (414 квартир) — max(flat_count) в общем ключе."""
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO gar_house_flats "
|
||||
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
|
||||
"('g2189-3-2', '66', 414, 'г. Екатеринбург, ул. Сулимова2189, 3/2', "
|
||||
" 'ул. Сулимова2189, 3/2'),"
|
||||
"('g2189-32', '66', 120, 'г. Екатеринбург, ул. Сулимова2189, 32', "
|
||||
" 'ул. Сулимова2189, 32')"
|
||||
)
|
||||
)
|
||||
# region_code обязателен: с #2583 H5 матчер даёт guid только ГАР-строке региона дома,
|
||||
# дом без региона не матчится вовсе.
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO houses (id, source, ext_house_id, url, address, region_code) VALUES "
|
||||
"(900189, 'avito', 'EXT-2189-32', 'u', 'Екатеринбург, ул. Сулимова2189, 32', 66),"
|
||||
"(900190, 'cian', 'EXT-2189-3-2', 'u', 'ул. Сулимова2189, 3 / 2', 66)"
|
||||
)
|
||||
)
|
||||
|
||||
gfl.match_houses_to_gar(db, region_code="66")
|
||||
|
||||
got = db.execute(
|
||||
text(
|
||||
"SELECT id, gar_house_guid, gar_flat_count, gar_match_method FROM houses "
|
||||
"WHERE id IN (900189, 900190) ORDER BY id"
|
||||
)
|
||||
).all()
|
||||
assert [tuple(r) for r in got] == [
|
||||
(900189, "g2189-32", 120, "canon_addr"),
|
||||
(900190, "g2189-3-2", 414, "canon_addr"),
|
||||
]
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
@needs_db
|
||||
def test_real_dedup_does_not_merge_slash_and_plain_number() -> None:
|
||||
"""Дома «3/2» и «32» без ФИАС в одной точке — разные здания, в один кластер не попадают."""
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES "
|
||||
"(900189, 'avito', 'EXT-2189-32', 'u', 'ул. Сулимова2189, 32', 56.84, 60.6),"
|
||||
"(900190, 'cian', 'EXT-2189-3-2', 'u', 'ул. Сулимова2189, 3/2', 56.84009, 60.6)"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
hdm.merge_duplicate_houses(db, dry_run=False)
|
||||
|
||||
ids = {r.id for r in db.execute(text("SELECT id FROM houses WHERE id IN (900189, 900190)"))}
|
||||
assert ids == {900189, 900190}
|
||||
finally:
|
||||
db.rollback()
|
||||
for sql in (
|
||||
"DELETE FROM house_sources WHERE house_id IN (900189, 900190)",
|
||||
"DELETE FROM house_address_aliases WHERE house_id IN (900189, 900190)",
|
||||
"DELETE FROM house_merge_log WHERE loser_id IN (900189, 900190)",
|
||||
"DELETE FROM houses WHERE id IN (900189, 900190)",
|
||||
):
|
||||
db.execute(text(sql))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
_MIGRATION = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "data"
|
||||
/ "sql"
|
||||
/ "322_canon_keep_slash_in_house_number.sql"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_322_resets_only_matches_held_by_the_lost_slash() -> None:
|
||||
"""Матчер пары не снимает: «4/1», пришитый к ГАР «41», без миграции так и остался бы с ним.
|
||||
|
||||
Прогон настоящего файла миграции; houses и gar_house_flats затенены временными таблицами
|
||||
(pg_temp первым в search_path), реальные таблицы не трогаются.
|
||||
"""
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
pytest.skip("нет доступной Postgres test-БД")
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(
|
||||
dsn.replace("postgresql+psycopg://", "postgresql://"), autocommit=True
|
||||
) as c:
|
||||
c.execute(
|
||||
"CREATE TEMP TABLE gar_house_flats (house_guid text PRIMARY KEY, region_code text,"
|
||||
" flat_count int, norm_address text)"
|
||||
)
|
||||
c.execute(
|
||||
"CREATE TEMP TABLE houses (id int PRIMARY KEY, short_address text, full_address text,"
|
||||
" address text, gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
|
||||
" gar_match_method text)"
|
||||
)
|
||||
c.execute(
|
||||
"INSERT INTO gar_house_flats VALUES "
|
||||
"('g-3-2', '66', 414, 'ул. Сулимова, 3/2'),"
|
||||
"('g-41', '66', 92, 'ул. Машиностроителей, 41'),"
|
||||
"('g-58', '66', 249, 'ул. Абрикосовая, 58')"
|
||||
)
|
||||
c.execute(
|
||||
"INSERT INTO houses (id, short_address, gar_house_guid, gar_flat_count, gar_matched_at,"
|
||||
" gar_match_method) VALUES "
|
||||
# держится только на потерянной дроби → сброс
|
||||
"(1, 'ул. Сулимова, 32', 'g-3-2', 414, now(), 'canon_addr'),"
|
||||
"(2, 'улица Машиностроителей, 4/1', 'g-41', 92, now(), 'canon_addr'),"
|
||||
# верная пара с дробью
|
||||
"(3, 'Сулимова ул.,3/2', 'g-3-2', 414, now(), 'canon_addr'),"
|
||||
# не канон-метод
|
||||
"(4, 'улица Машиностроителей, 4/1', 'g-41', 92, now(), 'dadata_fias_revision'),"
|
||||
# ключ разошёлся не из-за дроби (адрес сменился после матча)
|
||||
"(5, 'жилой комплекс Совушки, Абрикосовая ул.,58', 'g-58', 249, now(), 'canon_addr')"
|
||||
)
|
||||
|
||||
c.execute(_MIGRATION.read_text(encoding="utf-8"))
|
||||
|
||||
rows = c.execute(
|
||||
"SELECT id, gar_house_guid, gar_flat_count, gar_matched_at IS NOT NULL,"
|
||||
" gar_match_method FROM houses ORDER BY id"
|
||||
).fetchall()
|
||||
assert rows == [
|
||||
(1, None, None, False, None),
|
||||
(2, None, None, False, None),
|
||||
(3, "g-3-2", 414, True, "canon_addr"),
|
||||
(4, "g-41", 92, True, "dadata_fias_revision"),
|
||||
(5, "g-58", 249, True, "canon_addr"),
|
||||
]
|
||||
|
|
@ -173,14 +173,10 @@ async def test_anchor_timeout_skips_anchor_and_continues(kind: str) -> None:
|
|||
async def test_domclick_unsaved_buckets_are_not_checkpointed(broken: str) -> None:
|
||||
"""Корзина без сохранённых строк не попадает в чекпоинт.
|
||||
|
||||
С инкрементальным сохранением (fix/domclick-incremental-save) save_listings зовётся из
|
||||
колбэка on_bucket ПОСЛЕ каждой корзины, а чекпоинт пишется там же и только
|
||||
ПОСЛЕ успешного save. Поэтому корзина, чей save упал, в done_buckets не попадает,
|
||||
хотя скрейпер её фетч прошёл (_s.completed_buckets её содержит). Снятая
|
||||
watchdog'ом фаза — тот же инвариант: до on_bucket она не дошла.
|
||||
|
||||
Отметить такую корзину пройденной значило бы, что следующий прогон пропустит её
|
||||
через skip_buckets навсегда (миграция 308).
|
||||
Лоты Домклика копятся в памяти и пишутся ОДНИМ save_listings после всех корзин.
|
||||
Фаза, снятая watchdog'ом (или упавшая на save), не сохранила ничего — даже из
|
||||
корзины 'st', которую скрейпер успел пройти. Отметить её пройденной значило бы,
|
||||
что следующий прогон пропустит её через skip_buckets навсегда (миграция 308).
|
||||
"""
|
||||
|
||||
class _Dc(_Scraper):
|
||||
|
|
@ -189,17 +185,10 @@ async def test_domclick_unsaved_buckets_are_not_checkpointed(broken: str) -> Non
|
|||
buckets_completed, buckets_total = 1, 6
|
||||
completed_buckets = ["st"] # noqa: RUF012
|
||||
|
||||
async def fetch_city(self, **kw: Any) -> list[Any]:
|
||||
async def fetch_city(self, **_kw: Any) -> list[Any]:
|
||||
if broken == "fetch_timeout":
|
||||
raise TimeoutError
|
||||
# Заглушка обязана ВЫЗВАТЬ колбэк: путь сохранения переехал внутрь цикла
|
||||
# по корзинам (serp.py fetch_city), и стаб, который просто возвращает лоты,
|
||||
# проверял бы мёртвую ветку — save_listings не был бы вызван вовсе.
|
||||
on_bucket = kw.get("on_bucket")
|
||||
lots = [object()]
|
||||
if on_bucket is not None:
|
||||
on_bucket("st", lots)
|
||||
return lots
|
||||
return [object()]
|
||||
|
||||
saved: list[int] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -186,12 +186,6 @@ class _FakeFetcher:
|
|||
def report_ban(self, reason: str) -> None:
|
||||
return None
|
||||
|
||||
def request_context_reset(self) -> None:
|
||||
# #3118: свип зовёт сброс тёплого контекста на каждой упавшей корзине —
|
||||
# двойник обязан повторять сигнатуру настоящего фетчера, иначе он проверяет
|
||||
# не поведение свипа, а собственную неполноту.
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
"""#2898: окно срока экспозиции (p25–p75) вместо одного числа и его персист.
|
||||
|
||||
Раньше est_days_on_market был медианой возраста АКТИВНЫХ объявлений-аналогов и не
|
||||
сохранялся: отчёт по ссылке отдавал null. Теперь квартили exposure_days снятых
|
||||
объявлений считаются на POST, пишутся в trade_in_estimates и поднимаются на GET.
|
||||
|
||||
Ожидаемые квартили посчитаны руками по формуле percentile_cont (позиция
|
||||
(n-1)·q, линейная интерполяция, ::int округляет .5 вверх), а не той же функцией.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
from app.services.estimator import (
|
||||
EXPOSURE_WINDOW_MIN_N,
|
||||
_exposure_window,
|
||||
_fetch_exposure_days,
|
||||
)
|
||||
|
||||
# 2..31 (n=30): p25 на позиции 7.25 → 9.25 → 9; p50 на 14.5 → 16.5 → 17 (банковское
|
||||
# округление дало бы 16); p75 на 21.75 → 23.75 → 24.
|
||||
_DAYS_30 = list(range(2, 32))
|
||||
|
||||
|
||||
def test_quartiles_match_percentile_cont() -> None:
|
||||
w = _exposure_window(_DAYS_30)
|
||||
assert w is not None
|
||||
assert (w.p25_days, w.p50_days, w.p75_days, w.n) == (9, 17, 24, 30)
|
||||
|
||||
|
||||
def test_order_of_input_does_not_matter() -> None:
|
||||
w = _exposure_window(list(reversed(_DAYS_30)))
|
||||
assert w is not None
|
||||
assert (w.p25_days, w.p50_days, w.p75_days) == (9, 17, 24)
|
||||
|
||||
|
||||
def test_skewed_sample_window() -> None:
|
||||
"""Прод-подобная выборка: пачка ровно по 31 дню (срок размещения на Авито) и хвост."""
|
||||
days = [31] * 20 + [62] * 5 + [120, 180, 240, 300, 400]
|
||||
w = _exposure_window(days)
|
||||
assert w is not None
|
||||
# позиции 7.25 / 14.5 / 21.75 — внутри пачки 31 и пачки 62
|
||||
assert (w.p25_days, w.p50_days, w.p75_days) == (31, 31, 62)
|
||||
|
||||
|
||||
def test_below_threshold_no_window() -> None:
|
||||
assert EXPOSURE_WINDOW_MIN_N == 30
|
||||
assert _exposure_window(_DAYS_30[:-1]) is None
|
||||
assert _exposure_window([]) is None
|
||||
|
||||
|
||||
def test_fetch_failure_is_graceful() -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
assert _fetch_exposure_days(db, lat=56.8, lon=60.6, rooms=1, area=40.0, radius_m=1000) == []
|
||||
|
||||
|
||||
def test_fetch_without_coords_does_not_query() -> None:
|
||||
db = MagicMock()
|
||||
assert _fetch_exposure_days(db, lat=None, lon=60.6, rooms=1, area=40.0, radius_m=1000) == []
|
||||
assert not db.execute.called
|
||||
|
||||
|
||||
# ── POST: окно уходит и в ответ, и в INSERT ───────────────────────────────────
|
||||
|
||||
|
||||
def _run_estimate(exposure_days: list[int]) -> tuple[Any, dict[str, Any]]:
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
from app.services.estimator import estimate_quality
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
db = MagicMock()
|
||||
payload = TradeInEstimateInput(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1)
|
||||
geo = GeocodeResult(
|
||||
lat=56.838, lon=60.595, full_address="Екатеринбург, ул. Тестовая, 1", provider="nominatim"
|
||||
)
|
||||
fetch = MagicMock(return_value=exposure_days)
|
||||
patches = (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch("app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation", new=AsyncMock(return_value=None)
|
||||
),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
patch("app.services.estimator._fetch_exposure_days", new=fetch),
|
||||
)
|
||||
|
||||
async def _run() -> Any:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in patches:
|
||||
stack.enter_context(cm)
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
result = anyio.run(_run)
|
||||
# Выборка берётся по координатам и параметрам оцениваемой квартиры.
|
||||
kwargs = fetch.call_args.kwargs
|
||||
assert (kwargs["lat"], kwargs["lon"], kwargs["rooms"], kwargs["area"]) == (
|
||||
56.838,
|
||||
60.595,
|
||||
1,
|
||||
40.0,
|
||||
)
|
||||
for call in db.execute.call_args_list:
|
||||
sql = str(getattr(call.args[0], "text", call.args[0]))
|
||||
if "INSERT INTO trade_in_estimates" in sql:
|
||||
return result, call.args[1]
|
||||
raise AssertionError("INSERT INTO trade_in_estimates не вызван")
|
||||
|
||||
|
||||
def test_post_persists_window_and_returns_it() -> None:
|
||||
result, params = _run_estimate(_DAYS_30)
|
||||
assert (
|
||||
params["est_days_p25"],
|
||||
params["est_days_p50"],
|
||||
params["est_days_p75"],
|
||||
params["est_days_n"],
|
||||
) == (9, 17, 24, 30)
|
||||
assert result.exposure_window is not None
|
||||
assert (result.exposure_window.p25_days, result.exposure_window.p75_days) == (9, 24)
|
||||
# Старое поле для старых клиентов — медиана того же окна, а не возраст объявлений.
|
||||
assert result.est_days_on_market == 17
|
||||
|
||||
|
||||
def test_post_small_sample_persists_nulls() -> None:
|
||||
result, params = _run_estimate(_DAYS_30[:-1])
|
||||
assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
]
|
||||
assert result.exposure_window is None
|
||||
assert result.est_days_on_market is None
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
"""#2993: listing_source_snapshots получает строку только при изменении источника.
|
||||
|
||||
Проверки идут по ЗНАЧЕНИЯМ в живом Postgres: какие источники получили строку за
|
||||
сегодня, с какими значениями, и какие события записал event-diff поверх них.
|
||||
Без реальной БД файл self-skip'ается, как соседние live-тесты (в CI есть
|
||||
postgres, см. ci-tradein.yml). Вся синтетика живёт в одной транзакции, очистка —
|
||||
rollback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.tasks import listing_source_snapshot as snap_mod
|
||||
|
||||
|
||||
def _live_session() -> Any | None:
|
||||
"""Тот же контракт, что у test_revisit_floor_lateral_lookup.py."""
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return None
|
||||
engine = create_engine(dsn, future=True)
|
||||
conn = engine.connect()
|
||||
conn.execute(text("SELECT 1"))
|
||||
conn.close()
|
||||
return sessionmaker(bind=engine, future=True)()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
|
||||
_PRICE = 5_000_000
|
||||
|
||||
|
||||
def _source(db: Any, tag: str, *, price: int, payload: str, seen_days_ago: int) -> int:
|
||||
ext_id = f"zzz2993-{tag}-{uuid.uuid4().hex[:8]}"
|
||||
listing_id = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO listings
|
||||
(source, source_url, source_id, dedup_hash, price_rub,
|
||||
is_active, scraped_at, last_seen_at)
|
||||
VALUES ('cian', :url, :ext_id, :ext_id, :price, true, now(), now())
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"url": f"https://example.test/2993/{ext_id}", "ext_id": ext_id, "price": price},
|
||||
).scalar_one()
|
||||
return int(
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO listing_sources
|
||||
(listing_id, ext_source, ext_id, confidence, matched_method,
|
||||
price_rub, raw_payload, last_seen_at)
|
||||
VALUES (:listing_id, 'cian', :ext_id, 1.0, 'test',
|
||||
:price, CAST(:payload AS jsonb),
|
||||
now() - make_interval(days => :seen))
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"listing_id": listing_id,
|
||||
"ext_id": ext_id,
|
||||
"price": price,
|
||||
"payload": payload,
|
||||
"seen": seen_days_ago,
|
||||
},
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(
|
||||
db: Any,
|
||||
lsid: int,
|
||||
*,
|
||||
days_ago: int,
|
||||
price: int,
|
||||
payload: str,
|
||||
seen_days_ago: int,
|
||||
is_active: bool = True,
|
||||
) -> None:
|
||||
# now() постоянен внутри транзакции — last_seen_at снимка совпадает с
|
||||
# listing_sources.last_seen_at до микросекунды, если seen_days_ago тот же.
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO listing_source_snapshots
|
||||
(listing_source_id, snapshot_date, price_rub, is_active,
|
||||
last_seen_at, payload_hash)
|
||||
VALUES (:lsid, CURRENT_DATE - :days_ago, :price, :is_active,
|
||||
now() - make_interval(days => :seen),
|
||||
md5(CAST(:payload AS jsonb)::text))
|
||||
"""
|
||||
),
|
||||
{
|
||||
"lsid": lsid,
|
||||
"days_ago": days_ago,
|
||||
"price": price,
|
||||
"is_active": is_active,
|
||||
"seen": seen_days_ago,
|
||||
"payload": payload,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _run_writer(db: Any) -> None:
|
||||
db.execute(
|
||||
snap_mod._SNAPSHOT_SQL,
|
||||
{"freshness_days": snap_mod.FRESHNESS_WINDOW_DAYS, "run_id": None},
|
||||
)
|
||||
|
||||
|
||||
def _today(db: Any, ids: list[int]) -> dict[int, tuple[int, bool]]:
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT listing_source_id, price_rub, is_active
|
||||
FROM listing_source_snapshots
|
||||
WHERE snapshot_date = CURRENT_DATE AND listing_source_id = ANY(:ids)
|
||||
"""
|
||||
),
|
||||
{"ids": ids},
|
||||
).fetchall()
|
||||
return {int(r[0]): (int(r[1]), bool(r[2])) for r in rows}
|
||||
|
||||
|
||||
def _seed(db: Any) -> dict[str, int]:
|
||||
"""Семь источников: по одному на каждую ветку условия записи."""
|
||||
p = '{"v": 1}'
|
||||
ids = {
|
||||
"same": _source(db, "same", price=_PRICE, payload=p, seen_days_ago=1),
|
||||
"same_gap": _source(db, "same_gap", price=_PRICE, payload=p, seen_days_ago=1),
|
||||
"price": _source(db, "price", price=_PRICE, payload=p, seen_days_ago=1),
|
||||
"payload": _source(db, "payload", price=_PRICE, payload=p, seen_days_ago=1),
|
||||
"seen": _source(db, "seen", price=_PRICE, payload=p, seen_days_ago=1),
|
||||
"active": _source(db, "active", price=_PRICE, payload=p, seen_days_ago=10),
|
||||
"new": _source(db, "new", price=_PRICE, payload=p, seen_days_ago=1),
|
||||
}
|
||||
_snapshot(db, ids["same"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=1)
|
||||
# Последний снимок пятидневной давности совпадает, а более старый — нет:
|
||||
# сравнивать обязаны с ПОСЛЕДНИМ, а не с любым из прошлых.
|
||||
_snapshot(db, ids["same_gap"], days_ago=9, price=_PRICE - 1, payload=p, seen_days_ago=9)
|
||||
_snapshot(db, ids["same_gap"], days_ago=5, price=_PRICE, payload=p, seen_days_ago=1)
|
||||
_snapshot(db, ids["price"], days_ago=1, price=4_900_000, payload=p, seen_days_ago=1)
|
||||
_snapshot(db, ids["payload"], days_ago=1, price=_PRICE, payload='{"v": 0}', seen_days_ago=1)
|
||||
_snapshot(db, ids["seen"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=3)
|
||||
# last_seen_at тот же, но 10 суток > окна свежести: вчера снимок был активным.
|
||||
_snapshot(db, ids["active"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=10)
|
||||
return ids
|
||||
|
||||
|
||||
def test_only_changed_or_new_sources_get_a_row_today() -> None:
|
||||
db = _live_session()
|
||||
try:
|
||||
ids = _seed(db)
|
||||
_run_writer(db)
|
||||
today = _today(db, list(ids.values()))
|
||||
by_tag = {tag: today.get(lsid) for tag, lsid in ids.items()}
|
||||
|
||||
assert by_tag == {
|
||||
"same": None,
|
||||
"same_gap": None,
|
||||
"price": (_PRICE, True),
|
||||
"payload": (_PRICE, True),
|
||||
"seen": (_PRICE, True),
|
||||
"active": (_PRICE, False),
|
||||
"new": (_PRICE, True),
|
||||
}
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_events_survive_change_only_writes() -> None:
|
||||
"""event-diff видит только записанные сегодня строки — и не теряет ни одного события."""
|
||||
db = _live_session()
|
||||
try:
|
||||
ids = _seed(db)
|
||||
_run_writer(db)
|
||||
db.execute(snap_mod._EVENT_DIFF_SQL).fetchall()
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT listing_source_id, event_type, diff_percent
|
||||
FROM listing_source_events
|
||||
WHERE listing_source_id = ANY(:ids)
|
||||
"""
|
||||
),
|
||||
{"ids": list(ids.values())},
|
||||
).fetchall()
|
||||
tag_of = {lsid: tag for tag, lsid in ids.items()}
|
||||
events = {(tag_of[int(r[0])], r[1], None if r[2] is None else float(r[2])) for r in rows}
|
||||
|
||||
assert events == {
|
||||
("price", "price_change", 2.0408), # round((5.0-4.9)/4.9*100, 4)
|
||||
("payload", "edited", None),
|
||||
("new", "first_seen", None),
|
||||
}
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_rerun_same_day_compares_with_todays_row() -> None:
|
||||
"""Второй прогон в те же сутки сравнивает с уже записанной сегодня строкой.
|
||||
|
||||
price вернулся к вчерашней цене — сегодняшняя строка обязана перезаписаться
|
||||
(иначе на сегодня осталась бы цена, которой у источника больше нет); same
|
||||
изменился после первого прогона — строка появляется; seen не менялся после
|
||||
первого прогона — строка остаётся как была.
|
||||
"""
|
||||
db = _live_session()
|
||||
try:
|
||||
ids = _seed(db)
|
||||
_run_writer(db)
|
||||
db.execute(
|
||||
text("UPDATE listing_sources SET price_rub = 4900000 WHERE id = :id"),
|
||||
{"id": ids["price"]},
|
||||
)
|
||||
db.execute(
|
||||
text("UPDATE listing_sources SET price_rub = 5100000 WHERE id = :id"),
|
||||
{"id": ids["same"]},
|
||||
)
|
||||
_run_writer(db)
|
||||
today = _today(db, list(ids.values()))
|
||||
|
||||
assert today.get(ids["price"]) == (4_900_000, True)
|
||||
assert today.get(ids["same"]) == (5_100_000, True)
|
||||
assert today.get(ids["seen"]) == (_PRICE, True)
|
||||
assert ids["same_gap"] not in today
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
"""Свип ДомКлика ходит в тёплый переиспользуемый браузер-контекст сайдкара (#3118).
|
||||
|
||||
Замер (см. `backend/app/tasks/domclick_detail_backfill.py:394-401`): 26 подряд
|
||||
холодных фетчей = 100% QRATOR-блок, те же карточки в тёплом контексте — 5/5
|
||||
примерно по 2с. Причина — `browser.new_page()` создаёт НОВЫЙ изолированный
|
||||
context сайдкара на каждый `/fetch`, из-за чего живой `qrator_jsid2` (куки,
|
||||
которые сайт ротирует через Set-Cookie) никогда не доживает до следующего
|
||||
запроса, а якорная вкладка не выживает между фетчами (`goto(origin)` валился
|
||||
таймаутом 60с, убивая всю корзину).
|
||||
|
||||
Три проверки:
|
||||
1. `build_browser_fetcher(..., reuse_context=True)` включает флаг на фетчере,
|
||||
дефолт (без параметра) — выключен (не ломаем прочие call-site'ы).
|
||||
2. `DomClickScraper.fetch_city` строит фетчер именно с `reuse_context=True`.
|
||||
3. На QRATOR-блоке `fetcher.request_context_reset()` вызывается ДО
|
||||
`fetcher.report_ban()` — иначе сожжённый блоком тёплый контекст травит
|
||||
остаток прогона тем же `qrator_jsid2`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import types
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _fetcher_config() -> types.SimpleNamespace:
|
||||
return types.SimpleNamespace(
|
||||
browser_http_endpoint="http://sidecar:8080",
|
||||
use_proxy_pool_browser=False,
|
||||
environment="test",
|
||||
)
|
||||
|
||||
|
||||
def test_build_browser_fetcher_reuse_context_default_is_off() -> None:
|
||||
"""Без явного параметра поведение прежнее — прочие call-site'ы не меняются."""
|
||||
from scraper_kit.providers._base import build_browser_fetcher
|
||||
|
||||
fetcher = build_browser_fetcher(_fetcher_config(), "domclick")
|
||||
|
||||
assert fetcher._reuse_context is False
|
||||
|
||||
|
||||
def test_build_browser_fetcher_reuse_context_true_sets_flag() -> None:
|
||||
from scraper_kit.providers._base import build_browser_fetcher
|
||||
|
||||
fetcher = build_browser_fetcher(_fetcher_config(), "domclick", reuse_context=True)
|
||||
|
||||
assert fetcher._reuse_context is True
|
||||
|
||||
|
||||
class _FakeFetcher:
|
||||
"""Двойник BrowserFetcher: фиксирует порядок reset/ban."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[Any] = []
|
||||
|
||||
def request_context_reset(self) -> None:
|
||||
self.calls.append("reset")
|
||||
|
||||
def report_ban(self, reason: str) -> None:
|
||||
self.calls.append(("ban", reason))
|
||||
|
||||
|
||||
class _FakeFetcherCM:
|
||||
def __init__(self, fetcher: _FakeFetcher) -> None:
|
||||
self._fetcher = fetcher
|
||||
|
||||
async def __aenter__(self) -> _FakeFetcher:
|
||||
return self._fetcher
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_domclick_sweep_builds_fetcher_with_reuse_context() -> None:
|
||||
"""`fetch_city` зовёт фабрику фетчера с `reuse_context=True` (не дефолтом)."""
|
||||
from scraper_kit.domclick_exceptions import DomClickBlockedError
|
||||
from scraper_kit.providers.domclick.serp import DomClickScraper
|
||||
|
||||
fetcher = _FakeFetcher()
|
||||
mock_build = MagicMock(return_value=_FakeFetcherCM(fetcher))
|
||||
|
||||
scraper = DomClickScraper(_fetcher_config())
|
||||
# Обрываем на первом же бакете — детали сбора корзины здесь не проверяются.
|
||||
with (
|
||||
patch("scraper_kit.providers._base.build_browser_fetcher", mock_build),
|
||||
patch.object(
|
||||
scraper, "_sweep_bucket", AsyncMock(side_effect=DomClickBlockedError("qrator"))
|
||||
),
|
||||
):
|
||||
await scraper.fetch_city(city_id=66, pages=1)
|
||||
|
||||
assert mock_build.call_args.kwargs.get("reuse_context") is True, (
|
||||
f"свип построил фетчер без reuse_context=True: {mock_build.call_args}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_domclick_sweep_resets_context_after_failed_bucket() -> None:
|
||||
"""Упавшая корзина сбрасывает тёплый контекст, чтобы он не переполз в следующую.
|
||||
|
||||
Прод 17.09, прогон 7389: после падения 'rooms=3' три корзины подряд легли за две
|
||||
минуты каждая на таймауте goto(origin) — якорная вкладка не поднималась в том же
|
||||
сгоревшем контексте. Без сброса reuse_context=True превращает одну неудачу в
|
||||
цепочку.
|
||||
"""
|
||||
from scraper_kit.providers.domclick.serp import DomClickScraper
|
||||
|
||||
fetcher = _FakeFetcher()
|
||||
mock_build = MagicMock(return_value=_FakeFetcherCM(fetcher))
|
||||
|
||||
scraper = DomClickScraper(_fetcher_config())
|
||||
with (
|
||||
patch("scraper_kit.providers._base.build_browser_fetcher", mock_build),
|
||||
patch.object(scraper, "_sweep_bucket", AsyncMock(side_effect=RuntimeError("boom"))),
|
||||
):
|
||||
await scraper.fetch_city(city_id=66, pages=1)
|
||||
|
||||
assert fetcher.calls.count("reset") == 6, (
|
||||
f"сброс контекста ожидался на каждой из 6 упавших корзин: {fetcher.calls!r}"
|
||||
)
|
||||
assert not any(c != "reset" for c in fetcher.calls), (
|
||||
f"report_ban не должен вызываться на не-QRATOR ошибке: {fetcher.calls!r}"
|
||||
)
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
"""Вечно недогружаемые карточки Яндекса не держат очередь добора (#3191).
|
||||
|
||||
Прод 13.09 21:14 – 16.09 18:52: пять объявлений (10776456, 10775964, 10775945,
|
||||
10775747, 10775594), стоящих в очереди подряд, в каждом прогоне приходили пустой
|
||||
SPA-оболочкой ~381 КБ без блока контактов. Пять недогрузов подряд выбивали брейкер
|
||||
consecutive_none=5: 16 прогонов закончились ровно attempted=5 incomplete=5, а
|
||||
«успешные» обрывались, едва дойдя до них. За ними — 20 тыс. необогащённых объявлений.
|
||||
|
||||
Проверки ПО ЗНАЧЕНИЮ:
|
||||
* мок-часть — какие карточки запрошены и какие счётчики вышли;
|
||||
* живой Postgres (в CI есть, локально skip) — что НАСТОЯЩИЙ снапшот-SELECT после
|
||||
недогруза не берёт карточку сутки, берёт её после паузы и выбрасывает после трёх.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from sqlalchemy import text # noqa: E402
|
||||
|
||||
from app.tasks.yandex_detail_backfill import run_yandex_detail_backfill # noqa: E402
|
||||
|
||||
_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
|
||||
_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
|
||||
_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment"
|
||||
_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
|
||||
_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
|
||||
_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
|
||||
|
||||
# Оболочка как на проде: 381 КБ, контактов нет. Полная — 3,9 МБ с маркером.
|
||||
SHELL_HTML = "<html>" + "x" * 381_157 + "</html>"
|
||||
FULL_HTML = "<html>" + "x" * 3_920_119 + '"encryptedPhones":["a"]</html>'
|
||||
|
||||
PERPETUAL = 5
|
||||
NORMAL = 3
|
||||
|
||||
|
||||
def _resp(html: str) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.text = html
|
||||
return resp
|
||||
|
||||
|
||||
async def _run(db: Any, perpetual_urls: set[str]) -> tuple[Any, list[str]]:
|
||||
"""Один прогон; оболочку отдаём по URL, всё остальное — полная карточка."""
|
||||
requested: list[str] = []
|
||||
|
||||
async def get(url: str, **_kw: Any) -> MagicMock:
|
||||
requested.append(url)
|
||||
return _resp(SHELL_HTML if url in perpetual_urls else FULL_HTML)
|
||||
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(side_effect=get)
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=session)
|
||||
ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(_ASYNC_SESSION, MagicMock(return_value=ctx)),
|
||||
patch(_PARSE, return_value=MagicMock()),
|
||||
patch(_SAVE, MagicMock(return_value=True)),
|
||||
patch(_RUNS, MagicMock()),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
|
||||
):
|
||||
counters = await run_yandex_detail_backfill(
|
||||
db, run_id=3191, params={"batch_size": PERPETUAL + NORMAL, "budget_sec": 3600}
|
||||
)
|
||||
return counters, requested
|
||||
|
||||
|
||||
# ── 1. Брейкер: повтор известной карточки не рвёт прогон, первый недогруз — рвёт ──
|
||||
|
||||
|
||||
def _mock_db(prior_incomplete: int) -> tuple[MagicMock, set[str], list[str]]:
|
||||
rows = [
|
||||
{
|
||||
"id": i + 1,
|
||||
"source_url": f"https://realty.yandex.ru/offer/{i + 1}/",
|
||||
"detail_incomplete_count": prior_incomplete if i < PERPETUAL else 0,
|
||||
}
|
||||
for i in range(PERPETUAL + NORMAL)
|
||||
]
|
||||
db = MagicMock()
|
||||
sel = MagicMock()
|
||||
sel.mappings.return_value.all.return_value = rows
|
||||
sel.one.return_value = MagicMock(url_from_offer_id=0, unenrichable_pending=0)
|
||||
sel.scalar_one.return_value = 0
|
||||
db.execute.return_value = sel
|
||||
urls = [r["source_url"] for r in rows]
|
||||
return db, set(urls[:PERPETUAL]), urls[PERPETUAL:]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_of_known_underloaded_cards_does_not_abort_run() -> None:
|
||||
"""Пять карточек, уже недогружавшихся раньше, в голове снапшота — прогон идёт дальше."""
|
||||
db, perpetual, normal = _mock_db(prior_incomplete=1)
|
||||
|
||||
counters, requested = await _run(db, perpetual)
|
||||
|
||||
assert set(normal) <= set(requested), (
|
||||
f"запрошено {len(requested)} из {PERPETUAL + NORMAL}: повторный недогруз известных "
|
||||
"карточек оборвал прогон раньше, чем очередь дошла до следующих"
|
||||
)
|
||||
assert (counters.attempted, counters.enriched, counters.incomplete) == (8, 3, 5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_underload_series_still_aborts_run() -> None:
|
||||
"""Защита от системного недогруза жива: пять ПЕРВЫХ недогрузов подряд — обрыв."""
|
||||
db, perpetual, normal = _mock_db(prior_incomplete=0)
|
||||
|
||||
counters, requested = await _run(db, perpetual)
|
||||
|
||||
assert not set(normal) & set(requested), (
|
||||
"пять первых недогрузов подряд не оборвали прогон — брейкер системного "
|
||||
"недогруза больше не срабатывает"
|
||||
)
|
||||
assert (counters.attempted, counters.enriched, counters.incomplete) == (5, 0, 5)
|
||||
|
||||
|
||||
# ── 2. Очередь на живом Postgres ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _live_session() -> Any | None:
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return None
|
||||
engine = create_engine(dsn, future=True)
|
||||
conn = engine.connect()
|
||||
conn.execute(text("SELECT 1"))
|
||||
conn.close()
|
||||
return sessionmaker(bind=engine, future=True)()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
@pytest.mark.asyncio
|
||||
async def test_perpetual_underloaded_cards_leave_queue_head() -> None:
|
||||
db = _live_session()
|
||||
tag = f"t3191-{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
ids: list[int] = []
|
||||
urls: list[str] = []
|
||||
for i in range(PERPETUAL + NORMAL):
|
||||
url = f"https://realty.yandex.ru/offer/3191{uuid.uuid4().int % 10**12}/"
|
||||
# Вечные — самые свежие (голова очереди), как 12.09 15:02 на проде.
|
||||
# Дата в будущем: чужие необогащённые строки тестовой БД не встанут впереди.
|
||||
row_id = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO listings (source, source_url, source_id, dedup_hash,
|
||||
price_rub, is_active, scraped_at)
|
||||
VALUES ('yandex', :url, :sid, :sid, 5000000, true,
|
||||
TIMESTAMPTZ '2100-01-01' - make_interval(mins => :i))
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"url": url, "sid": f"{tag}-{i}", "i": i},
|
||||
).scalar_one()
|
||||
ids.append(row_id)
|
||||
urls.append(url)
|
||||
db.commit()
|
||||
perpetual = set(urls[:PERPETUAL])
|
||||
normal = urls[PERPETUAL:]
|
||||
|
||||
# Прогон 1: карточки ещё не недогружались — первый недогруз, обрыв на пятой.
|
||||
_c1, req1 = await _run(db, perpetual)
|
||||
assert set(req1) == perpetual, req1
|
||||
|
||||
# Прогон 2 сразу следом: голова очереди больше не держит — берутся следующие.
|
||||
c2, req2 = await _run(db, perpetual)
|
||||
assert not set(req2) & perpetual, (
|
||||
"недогруженные час назад карточки снова в снапшоте — прогон опять "
|
||||
"упрётся в них, как 16 прогонов 13.09–16.09"
|
||||
)
|
||||
assert set(normal) <= set(req2)
|
||||
assert c2.enriched == NORMAL
|
||||
|
||||
# Сутки спустя: повтор (разовый недогруз мог пройти) и прогон не обрывается.
|
||||
def age(hours: int) -> None:
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE listings SET detail_incomplete_at = now() - make_interval(hours => :h) "
|
||||
"WHERE id = ANY(:ids)"
|
||||
),
|
||||
{"h": hours, "ids": ids[:PERPETUAL]},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
age(25)
|
||||
c3, req3 = await _run(db, perpetual)
|
||||
assert perpetual <= set(req3), "после суток паузы карточки обязаны вернуться в очередь"
|
||||
assert set(normal) <= set(req3), "повтор известных карточек оборвал прогон"
|
||||
assert c3.incomplete == PERPETUAL
|
||||
|
||||
age(25)
|
||||
await _run(db, perpetual) # третья попытка
|
||||
age(25)
|
||||
c5, req5 = await _run(db, perpetual)
|
||||
assert not set(req5) & perpetual, "после трёх недогрузов карточка всё ещё в очереди"
|
||||
assert c5.incomplete_given_up >= PERPETUAL, (
|
||||
f"incomplete_given_up={c5.incomplete_given_up}: выбывшие из очереди не посчитаны"
|
||||
)
|
||||
counts = db.execute(
|
||||
text("SELECT detail_incomplete_count FROM listings WHERE id = ANY(:ids) ORDER BY id"),
|
||||
{"ids": ids},
|
||||
).scalars()
|
||||
assert list(counts) == [3] * PERPETUAL + [0] * NORMAL
|
||||
finally:
|
||||
db.rollback()
|
||||
db.execute(text("DELETE FROM listings WHERE source_id LIKE :p"), {"p": f"{tag}-%"})
|
||||
db.commit()
|
||||
db.close()
|
||||
|
|
@ -153,15 +153,7 @@ class _CleanSweepScraper:
|
|||
async def __aexit__(self, *_e: Any) -> None:
|
||||
return None
|
||||
|
||||
async def fetch_city(self, **kw: Any) -> list[Any]:
|
||||
# Чекпоинт с fix/domclick-incremental-save набирается ИЗ колбэка on_bucket, а не
|
||||
# мержится в конце из completed_buckets — стаб обязан его вызвать, иначе
|
||||
# проверялась бы мёртвая ветка. Лотов нет: корзина пройдена, но пустая —
|
||||
# это законный случай, save_listings для неё не зовётся, чекпоинт пишется.
|
||||
on_bucket = kw.get("on_bucket")
|
||||
if on_bucket is not None:
|
||||
for bucket in self.completed_buckets:
|
||||
on_bucket(bucket, [])
|
||||
async def fetch_city(self, **_kw: Any) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
"""#3412: у tradein-browser PID 1 — init, который собирает осиротевших детей camoufox.
|
||||
|
||||
Без `init: true` PID 1 контейнера — `python server.py`; дочерние процессы закрытого
|
||||
браузера переходят к нему и остаются зомби (прод 17.09.2026: 2296 процессов в Z за 14 ч).
|
||||
Проверка по значению: разобранный compose, сервис с образом tradein-browser.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
_COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.prod.yml"
|
||||
|
||||
|
||||
def test_browser_service_runs_under_init() -> None:
|
||||
services = yaml.safe_load(_COMPOSE.read_text())["services"]
|
||||
browser = {
|
||||
name: svc
|
||||
for name, svc in services.items()
|
||||
if "gendesign-tradein-browser" in svc.get("image", "")
|
||||
}
|
||||
# Контроль разбора: пустой словарь дал бы зелёный тест ни о чём.
|
||||
assert set(browser) == {"browser"}, sorted(browser)
|
||||
assert browser["browser"].get("init") is True
|
||||
|
|
@ -519,23 +519,13 @@ def test_argparse_resolve_house_id_override() -> None:
|
|||
|
||||
|
||||
def test_house_id_resolution_as_json_shape() -> None:
|
||||
res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2, house_type_from_houses=3)
|
||||
assert res.as_json() == {
|
||||
"resolved": 4,
|
||||
"total": 10,
|
||||
"imv_reachable": 2,
|
||||
"house_type_from_houses": 3,
|
||||
}
|
||||
res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2)
|
||||
assert res.as_json() == {"resolved": 4, "total": 10, "imv_reachable": 2}
|
||||
|
||||
|
||||
def test_house_id_resolution_defaults_zero() -> None:
|
||||
res = bt._HouseIdResolution()
|
||||
assert res.as_json() == {
|
||||
"resolved": 0,
|
||||
"total": 0,
|
||||
"imv_reachable": 0,
|
||||
"house_type_from_houses": 0,
|
||||
}
|
||||
assert res.as_json() == {"resolved": 0, "total": 0, "imv_reachable": 0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -1016,108 +1006,3 @@ def test_predict_full_spine_passes_resolved_city_to_corridor(
|
|||
assert captured.get("called") is True
|
||||
assert captured.get("city") == "нижний тагил"
|
||||
assert pred is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# #2862: тип дома из `houses` доезжает до отбора аналогов, как в estimate_quality.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _run_full_with_house_facts(
|
||||
monkeypatch: pytest.MonkeyPatch, *, resolve_house_id: bool
|
||||
) -> tuple[dict[int, set[object]], list[dict[str, object]], dict[str, object]]:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services import estimator as est_mod
|
||||
|
||||
def _deal(deal_id: int, house_type: str | None) -> object:
|
||||
return bt.DealSample(
|
||||
id=deal_id,
|
||||
lon=60.6,
|
||||
lat=56.8,
|
||||
rooms=2,
|
||||
sold_ppm2=100_000.0,
|
||||
deal_date=None,
|
||||
area_m2=50.0,
|
||||
address="Екатеринбург, ул. Ленина, 5",
|
||||
floor=3,
|
||||
year_built=None,
|
||||
house_type=house_type,
|
||||
)
|
||||
|
||||
# Сделка 1 — как все сделки на проде (house_type пуст); сделка 2 — тип уже есть.
|
||||
monkeypatch.setattr(bt, "_load_sample", lambda *a, **kw: [_deal(1, None), _deal(2, "brick")])
|
||||
monkeypatch.setattr(bt, "_resolve_house_id_for_deal", lambda _db, _deal: 77)
|
||||
|
||||
lookups: list[dict[str, object]] = []
|
||||
|
||||
def _fake_lookup(_db: object, **kw: object) -> object:
|
||||
lookups.append(kw)
|
||||
return est_mod._HouseFacts(
|
||||
house_id=77, total_floors=16, year_built=1985, house_type="panel", material_walls=None
|
||||
)
|
||||
|
||||
seen: dict[int, set[object]] = {}
|
||||
current: dict[str, int] = {}
|
||||
|
||||
def _fake_fetch_analogs(_db: object, **kw: object) -> tuple[list[object], bool, str]:
|
||||
seen.setdefault(current["id"], set()).add(kw["house_type"])
|
||||
return [], False, "W"
|
||||
|
||||
def _fake_price_from_inputs(**_kw: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
median_price=5_000_000.0,
|
||||
median_ppm2=100_000.0,
|
||||
confidence="low",
|
||||
anchor_tier=None,
|
||||
expected_sold_per_m2=95_000.0,
|
||||
expected_sold_price=4_750_000.0,
|
||||
expected_sold_range_low=4_000_000.0,
|
||||
expected_sold_range_high=5_500_000.0,
|
||||
)
|
||||
|
||||
real_predict = bt._predict_full_spine
|
||||
|
||||
def _tracking_predict(db: object, deal: object, est: object, **kw: object) -> object:
|
||||
current["id"] = deal.id # type: ignore[attr-defined]
|
||||
return real_predict(db, deal, est, **kw) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(est_mod, "_lookup_house_facts", _fake_lookup)
|
||||
monkeypatch.setattr(est_mod, "_fetch_analogs", _fake_fetch_analogs)
|
||||
monkeypatch.setattr(est_mod, "_resolve_target_city", lambda _addr: None)
|
||||
monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None))
|
||||
monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs)
|
||||
monkeypatch.setattr(bt, "_predict_full_spine", _tracking_predict)
|
||||
|
||||
metrics = bt.run_backtest_full(
|
||||
None, # type: ignore[arg-type]
|
||||
sample=2,
|
||||
since="2025-06-01",
|
||||
resolve_house_id=resolve_house_id,
|
||||
)
|
||||
return seen, lookups, metrics
|
||||
|
||||
|
||||
def test_full_backtest_takes_house_type_from_houses_like_prod(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=True)
|
||||
|
||||
# Пустой тип сделки заменён типом дома — именно он уходит в каждый отбор аналогов.
|
||||
assert seen[1] == {"panel"}
|
||||
# Свой тип сделки главнее справочника, как форма главнее houses на проде.
|
||||
assert seen[2] == {"brick"}
|
||||
assert lookups == [{"target_house_id": 77, "lat": 56.8, "lon": 60.6}]
|
||||
assert metrics["house_id_resolution"]["house_type_from_houses"] == 1 # type: ignore[index]
|
||||
|
||||
|
||||
def test_full_backtest_without_flag_keeps_house_type_untouched(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=False)
|
||||
|
||||
assert seen[1] == {None}
|
||||
assert lookups == []
|
||||
assert "house_id_resolution" not in metrics
|
||||
|
|
|
|||
|
|
@ -1,234 +0,0 @@
|
|||
"""fix/domclick-incremental-save: большая выдача (Москва ≈23 690 лотов против ЕКБ
|
||||
≈6 300) теряла ВСЁ собранное при снятии свипа по watchdog — сохранение было ОДНО,
|
||||
в самом конце `run_domclick_city_sweep`. Прод run 7344 (`domclick_city_sweep_moskva`):
|
||||
за 2ч watchdog'а (11 100с) пройдено 2 бакета из 6.
|
||||
|
||||
Три дефекта, три слоя тестов ниже:
|
||||
1. Инкрементальное сохранение по бакетам (`fetch_city.on_bucket`) — секция 1.
|
||||
2. Чекпоинт врал ("пройдено" без "сохранено") — секция 1, тест
|
||||
`test_on_bucket_exception_interrupts_bucket_loop` документирует нюанс, на
|
||||
котором строится фикс в pipeline.py: scraper.completed_buckets отмечает бакет
|
||||
как "фетч прошёл" ДО вызова on_bucket, поэтому пайплайн больше не берёт
|
||||
чекпоинт оттуда — только из факта успешного on_bucket (см. комментарий у
|
||||
"Перенести счётчики" в run_domclick_city_sweep).
|
||||
3. Watchdog не знал о размере выдачи — секция 2, `watchdog_sec` override.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
import pytest
|
||||
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
|
||||
from scraper_kit.providers.domclick.serp import (
|
||||
ROOM_BUCKETS,
|
||||
DomClickBlockedError,
|
||||
DomClickScraper,
|
||||
)
|
||||
|
||||
PFX = "scraper_kit.orchestration.pipeline"
|
||||
|
||||
|
||||
# ── Секция 1: DomClickScraper.fetch_city(on_bucket=...) ──────────────────────
|
||||
|
||||
|
||||
def _scraper() -> DomClickScraper:
|
||||
return DomClickScraper(SimpleNamespace(scraper_proxy_url=None))
|
||||
|
||||
|
||||
class _FakeFetcherCtx:
|
||||
async def __aenter__(self) -> SimpleNamespace:
|
||||
# request_context_reset (#3118): вызывается ДО report_ban на QRATOR-блоке —
|
||||
# двойник фетчера обязан его иметь, иначе AttributeError на первом же блоке.
|
||||
return SimpleNamespace(
|
||||
report_ban=lambda *_a, **_k: None,
|
||||
request_context_reset=lambda *_a, **_k: None,
|
||||
)
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def _run_fetch_city(
|
||||
scraper: DomClickScraper,
|
||||
*,
|
||||
sweep_bucket: Any,
|
||||
on_bucket: Any = None,
|
||||
start: int = 0,
|
||||
skip: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
with (
|
||||
patch.object(scraper, "_sweep_bucket", sweep_bucket),
|
||||
patch(
|
||||
"scraper_kit.providers._base.build_browser_fetcher",
|
||||
lambda *_a, **_k: _FakeFetcherCtx(),
|
||||
),
|
||||
):
|
||||
return await scraper.fetch_city(
|
||||
city_id=1,
|
||||
pages=1,
|
||||
start_bucket_index=start,
|
||||
skip_buckets=skip,
|
||||
on_bucket=on_bucket,
|
||||
)
|
||||
|
||||
|
||||
async def test_on_bucket_called_once_per_bucket_with_only_its_own_lots() -> None:
|
||||
"""on_bucket зовётся по разу на каждый успешный бакет с лотами ИМЕННО его,
|
||||
а не накопленным out_lots (главная регрессия #1 issue — было "одно сохранение
|
||||
в конце")."""
|
||||
|
||||
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
|
||||
out_lots.append(f"{rooms}-a")
|
||||
out_lots.append(f"{rooms}-b")
|
||||
|
||||
calls: list[tuple[str, list[str]]] = []
|
||||
s = _scraper()
|
||||
result = await _run_fetch_city(
|
||||
s, sweep_bucket=_fake_sweep, on_bucket=lambda n, lots: calls.append((n, list(lots)))
|
||||
)
|
||||
|
||||
assert [c[0] for c in calls] == list(ROOM_BUCKETS)
|
||||
for bucket, lots in calls:
|
||||
assert lots == [f"{bucket}-a", f"{bucket}-b"], (bucket, lots, "получил чужие лоты")
|
||||
assert len(result) == len(ROOM_BUCKETS) * 2
|
||||
|
||||
|
||||
async def test_on_bucket_skips_failed_and_blocked_buckets() -> None:
|
||||
"""Битый бакет (generic Exception) и бакет с QRATOR-блоком не отдают лоты в
|
||||
on_bucket — там ничего не собрано/не гарантированно собрано."""
|
||||
|
||||
async def _fake_sweep_fail_middle(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
|
||||
out_lots.append(f"{rooms}-x")
|
||||
if rooms == "1":
|
||||
raise ValueError("boom")
|
||||
|
||||
calls_a: list[str] = []
|
||||
s_a = _scraper()
|
||||
await _run_fetch_city(
|
||||
s_a, sweep_bucket=_fake_sweep_fail_middle, on_bucket=lambda n, _lots: calls_a.append(n)
|
||||
)
|
||||
assert "1" not in calls_a
|
||||
# continue идёт дальше — остальные бакеты всё равно получают on_bucket.
|
||||
assert calls_a == [b for b in ROOM_BUCKETS if b != "1"], calls_a
|
||||
|
||||
async def _fake_sweep_block(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
|
||||
out_lots.append(f"{rooms}-x")
|
||||
if rooms == "1":
|
||||
raise DomClickBlockedError("QRATOR")
|
||||
|
||||
calls_b: list[str] = []
|
||||
s_b = _scraper()
|
||||
await _run_fetch_city(
|
||||
s_b, sweep_bucket=_fake_sweep_block, on_bucket=lambda n, _lots: calls_b.append(n)
|
||||
)
|
||||
# break останавливает обход целиком — после блока ни один бакет не пробуется.
|
||||
assert calls_b == ["st"], calls_b
|
||||
|
||||
|
||||
async def test_on_bucket_exception_interrupts_bucket_loop() -> None:
|
||||
"""Исключение из on_bucket (канал кооперативной отмены) прерывает цикл по
|
||||
ROOM_BUCKETS целиком — остальные бакеты не идут."""
|
||||
|
||||
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
|
||||
out_lots.append(f"{rooms}-x")
|
||||
|
||||
visited: list[str] = []
|
||||
|
||||
def _on_bucket(name: str, lots: list[str]) -> None:
|
||||
visited.append(name)
|
||||
if name == "1":
|
||||
raise RuntimeError("cancelled")
|
||||
|
||||
s = _scraper()
|
||||
with pytest.raises(RuntimeError, match="cancelled"):
|
||||
await _run_fetch_city(s, sweep_bucket=_fake_sweep, on_bucket=_on_bucket)
|
||||
|
||||
assert visited == ["st", "1"], visited
|
||||
# Скрейпер-уровень успел зафетчить оба бакета ДО того, как on_bucket поднял
|
||||
# исключение — на этом нюансе строится фикс чекпоинта в pipeline.py: pipeline
|
||||
# больше не берёт done_buckets из scraper.completed_buckets, только из факта
|
||||
# успешного on_bucket (см. run_domclick_city_sweep._on_bucket/_checkpoint).
|
||||
assert s.completed_buckets == ["st", "1"], s.completed_buckets
|
||||
|
||||
|
||||
async def test_on_bucket_none_preserves_return_value() -> None:
|
||||
"""on_bucket=None → поведение прежнее, байт-в-байт: лоты возвращаются из
|
||||
fetch_city как раньше, никаких промежуточных вызовов."""
|
||||
|
||||
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
|
||||
out_lots.append(f"{rooms}-only")
|
||||
|
||||
s = _scraper()
|
||||
result = await _run_fetch_city(s, sweep_bucket=_fake_sweep, on_bucket=None)
|
||||
|
||||
assert result == [f"{b}-only" for b in ROOM_BUCKETS]
|
||||
assert s.completed_buckets == list(ROOM_BUCKETS)
|
||||
|
||||
|
||||
# ── Секция 2: run_domclick_city_sweep(watchdog_sec=...) ──────────────────────
|
||||
|
||||
|
||||
class _NoOpRuns:
|
||||
"""Достаточно методов, чтобы SERP-фаза дошла до asyncio.wait_for и честно
|
||||
финализировалась после симулированного TimeoutError — значения не важны,
|
||||
важен ТОЛЬКО timeout, с которым позвали wait_for."""
|
||||
|
||||
def is_cancelled(self, db: Any, run_id: int) -> bool:
|
||||
return False
|
||||
|
||||
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
def mark_banned(
|
||||
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def _drive_and_capture_timeout(**kwargs: Any) -> float:
|
||||
captured: dict[str, float] = {}
|
||||
|
||||
async def _fake_wait_for(coro: Any, timeout: float) -> None:
|
||||
captured["timeout"] = timeout
|
||||
coro.close()
|
||||
raise TimeoutError()
|
||||
|
||||
with (
|
||||
patch(f"{PFX}.asyncio.wait_for", _fake_wait_for),
|
||||
patch(f"{PFX}.runs", _NoOpRuns()),
|
||||
):
|
||||
await run_domclick_city_sweep(
|
||||
object(), # type: ignore[arg-type]
|
||||
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
|
||||
matcher=object(),
|
||||
run_id=1,
|
||||
city_id=4,
|
||||
**kwargs,
|
||||
)
|
||||
return captured["timeout"]
|
||||
|
||||
|
||||
async def test_watchdog_sec_default_formula_matches_prod_11100() -> None:
|
||||
"""watchdog_sec=None (дефолт) → прежняя формула байт-в-байт. pages=100,
|
||||
delay=6.0 — те же параметры, что дали 11 100с в run 7344 (Москва)."""
|
||||
timeout = await _drive_and_capture_timeout(pages=100, request_delay_sec=6.0)
|
||||
assert timeout == 11100
|
||||
|
||||
|
||||
async def test_watchdog_sec_override_bypasses_formula() -> None:
|
||||
"""watchdog_sec задан явно → формула не считается вовсе, идёт ровно override
|
||||
— даже с теми же pages/delay, что в тесте формулы выше."""
|
||||
timeout = await _drive_and_capture_timeout(pages=100, request_delay_sec=6.0, watchdog_sec=777)
|
||||
assert timeout == 777
|
||||
|
|
@ -80,11 +80,6 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
|
|||
# #2899: колонка есть у всех строк после миграции 267; NULL у старых
|
||||
# (бэкфилла нет — позицию по сохранённому top-10 не восстановить).
|
||||
market_percentile=63,
|
||||
# #2898: окно срока экспозиции (миграция 324); NULL у строк до неё.
|
||||
est_days_p25=31,
|
||||
est_days_p50=58,
|
||||
est_days_p75=121,
|
||||
est_days_n=44,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
sources_used=["avito"],
|
||||
|
|
@ -806,36 +801,3 @@ def test_get_estimate_market_percentile_nullable(trade_in_app: FastAPI) -> None:
|
|||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["market_percentile"] is None
|
||||
|
||||
|
||||
def test_get_estimate_surfaces_exposure_window(trade_in_app: FastAPI) -> None:
|
||||
"""#2898: окно срока поднимается из колонок — по ссылке то же, что при оценке.
|
||||
|
||||
До #2898 GET отдавал est_days_on_market=null всегда: значение жило только в POST.
|
||||
"""
|
||||
row = _make_estimate_row(created_by="kopylov")
|
||||
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
|
||||
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["exposure_window"] == {"p25_days": 31, "p50_days": 58, "p75_days": 121, "n": 44}
|
||||
assert body["est_days_on_market"] == 58
|
||||
|
||||
|
||||
def test_get_estimate_old_row_without_window(trade_in_app: FastAPI) -> None:
|
||||
"""Контроль: строка до миграции 324 (все четыре NULL) — окна нет, старое поле null."""
|
||||
row = _make_estimate_row(created_by="kopylov")
|
||||
row.est_days_p25 = row.est_days_p50 = row.est_days_p75 = row.est_days_n = None
|
||||
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
|
||||
|
||||
resp = client.get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["exposure_window"] is None
|
||||
assert resp.json()["est_days_on_market"] is None
|
||||
|
|
|
|||
|
|
@ -602,32 +602,3 @@ def test_empty_estimate_persists_relaxations_reliability() -> None:
|
|||
src = inspect.getsource(estimator._empty_estimate)
|
||||
assert "relaxations, reliability" in src
|
||||
assert "'very_low'" in src
|
||||
|
||||
|
||||
def test_revival_persists_exposure_window(
|
||||
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
|
||||
) -> None:
|
||||
"""#2898: пересчитанное окно срока пишется в исходную строку — иначе следующий
|
||||
GET по той же ссылке отдал бы окно null, хотя ответ ревайвла его показал."""
|
||||
from app.schemas.trade_in import ExposureWindow
|
||||
|
||||
window = ExposureWindow(p25_days=31, p50_days=58, p75_days=121, n=44)
|
||||
|
||||
async def _fake_estimate_quality(payload, db, **kwargs):
|
||||
return _fake_revived_result(exposure_window=window, est_days_on_market=58)
|
||||
|
||||
_estimator_stub.estimate_quality = _fake_estimate_quality
|
||||
|
||||
db = _dispatch_db(_make_dead_row(), claim_result=SimpleNamespace(id=_ESTIMATE_ID))
|
||||
resp = _client_with(trade_in_app, db).get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "kopylov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
params = _calls_containing(db, "UPDATE trade_in_estimates SET")[0].args[1]
|
||||
assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [
|
||||
31,
|
||||
58,
|
||||
121,
|
||||
44,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
"""#2583 H1 — город развёртки обязан попадать в городской фильтр ДКП-коридора.
|
||||
|
||||
Словарь региона 66 (24 имени) не знал 23 города, по которым уже идут развёртки
|
||||
(Реж, Карпинск, Кушва, Лесной, Тавда…). `_resolve_target_city` возвращал None,
|
||||
`_fetch_dkp_corridor` собирал сделки по одной улице во всей области — «Ленина» там
|
||||
в основном екатеринбургская, и коридор Режа получался ценой Екатеринбурга.
|
||||
|
||||
Замер прода 2026-09-17 (tradein-postgres, deals source='rosreestr', region 66): по
|
||||
всем 23 городам сделки есть под теми же е-формами (Реж 745, из них 189 за 12 мес.;
|
||||
Карпинск 521/161; Лесной 1215/402), а среди сделок Екатеринбурга ни один адрес не
|
||||
содержит этих имён отдельным словом — ложного городского фильтра на ЕКБ-адресах нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES
|
||||
|
||||
from app.services.estimator import _fetch_dkp_corridor, _resolve_target_city
|
||||
|
||||
_REGION_66_SWEEP_CITIES = sorted(
|
||||
name for slug, name in CITY_DISPLAY_NAMES.items() if slug != "moskva"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("address", "city"),
|
||||
[
|
||||
("Реж, Ленина, 12", "реж"),
|
||||
("Карпинск, Ленина, 5", "карпинск"),
|
||||
("Россия, Свердловская область, Артёмовский, улица Ленина, 5", "артемовский"),
|
||||
("Свердловская обл., Нижнетуринский г.о., Нижняя Тура, ул. Ленина, 3", "нижняя тура"),
|
||||
],
|
||||
)
|
||||
def test_sweep_city_from_the_audit_is_resolved(address: str, city: str) -> None:
|
||||
assert _resolve_target_city(address) == city
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", _REGION_66_SWEEP_CITIES)
|
||||
def test_every_region_66_sweep_city_is_resolved(name: str) -> None:
|
||||
"""Новый город развёртки не должен снова выпасть в «коридор по всей области»."""
|
||||
assert _resolve_target_city(f"{name}, ул. Ленина, 12") == name.lower().replace("ё", "е")
|
||||
|
||||
|
||||
def test_moscow_is_not_pulled_into_the_oblast_resolver() -> None:
|
||||
"""Москва — город развёртки, но другого региона: для неё прежний None."""
|
||||
assert _resolve_target_city("Москва, ул. Тверская, 1") is None
|
||||
|
||||
|
||||
def test_ekb_district_named_like_a_city_still_not_a_city() -> None:
|
||||
"""Прежний гард «мкр Заречный» не сломан расширением словаря."""
|
||||
assert _resolve_target_city("р-н Верх-Исетский, мкр. Заречный, улица Опалихинская, 20А") is None
|
||||
|
||||
|
||||
# ── коридор: значение, а не параметр ─────────────────────────────────────────
|
||||
|
||||
_EKB_PPM2 = 150_000
|
||||
_REZH_PPM2 = 55_000
|
||||
|
||||
|
||||
def _deals_db() -> Any:
|
||||
"""Двойник сессии, исполняющий городской фильтр так же, как SQL коридора.
|
||||
|
||||
Улица «Ленина» есть и в Екатеринбурге (10 сделок), и в Реже (4 сделки). Без
|
||||
`LOWER(d.city) = :target_city` запрос отдаёт обе, с ним — только свой город.
|
||||
"""
|
||||
rows = [{"city": "екатеринбург", "ppm2": _EKB_PPM2}] * 10 + [
|
||||
{"city": "реж", "ppm2": _REZH_PPM2}
|
||||
] * 4
|
||||
|
||||
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
result = MagicMock()
|
||||
sql = str(query)
|
||||
picked: list[dict[str, Any]] = []
|
||||
if "FROM deals d" in sql:
|
||||
city = (params or {}).get("target_city")
|
||||
filtered = "LOWER(d.city) = CAST(:target_city AS text)" in sql
|
||||
picked = [
|
||||
{"price_per_m2": r["ppm2"], "deal_date": date(2026, 1, 1)}
|
||||
for r in rows
|
||||
if not filtered or r["city"] == city
|
||||
]
|
||||
result.mappings.return_value.all.return_value = picked
|
||||
return result
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = _execute
|
||||
return db
|
||||
|
||||
|
||||
def test_rezh_corridor_is_built_from_rezh_deals_not_the_whole_oblast() -> None:
|
||||
"""«Реж, Ленина, 12»: медиана коридора — цена Режа, а не Екатеринбурга.
|
||||
|
||||
На main город не распознан → фильтра нет → 10 екатеринбургских сделок из 14
|
||||
и медиана 150 000 ₽/м².
|
||||
"""
|
||||
address = "Реж, Ленина, 12"
|
||||
raw = _fetch_dkp_corridor(
|
||||
_deals_db(),
|
||||
address=address,
|
||||
rooms=2,
|
||||
area=45.0,
|
||||
city=_resolve_target_city(address),
|
||||
)
|
||||
assert raw is not None
|
||||
assert raw["count"] == 4
|
||||
assert raw["median_ppm2"] == _REZH_PPM2
|
||||
|
|
@ -236,8 +236,7 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None:
|
|||
# Канонический ключ (мигр. 144), а не точное равенство norm_address.
|
||||
assert "tradein_canon_addr(norm_address) AS canon" in flat
|
||||
# Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid.
|
||||
# #2583 H5: ранжирование внутри региона — канон «ленина5» 66-го и 50-го не соревнуются.
|
||||
assert "PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid" in flat
|
||||
assert "PARTITION BY canon ORDER BY flat_count DESC, house_guid" in flat
|
||||
# houses-сторона: тот же канон поверх COALESCE(short/full/address).
|
||||
houses_side = (
|
||||
"tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon"
|
||||
|
|
@ -342,8 +341,6 @@ def test_canon_addr_street_type_and_punct_collapse() -> None:
|
|||
|
||||
def test_canon_addr_strips_rayon_and_korpus_slash() -> None:
|
||||
# «р-н Ленинский, улица Цвиллинга, 7/6» → «цвиллинга76» (район срезан, слэш выброшен).
|
||||
# Так было в зеркалируемой 144; с мигр. 322 дробь в ключе остаётся (#2189,
|
||||
# tests/test_2189_canon_keeps_slash.py проверяет саму функцию).
|
||||
assert _canon_addr_py("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга76"
|
||||
|
||||
|
||||
|
|
@ -515,7 +512,6 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
|
|||
text(
|
||||
"CREATE TEMP TABLE houses ("
|
||||
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
|
||||
" region_code smallint,"
|
||||
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
|
||||
" gar_match_method text"
|
||||
") ON COMMIT DROP"
|
||||
|
|
@ -526,8 +522,8 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
|
|||
# (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже).
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO houses (short_address, region_code) VALUES "
|
||||
"('ул. Шаумяна,10', 66), ('ул Ленина, 7', 66), ('ул. Мира,5', 66)"
|
||||
"INSERT INTO houses (short_address) VALUES "
|
||||
"('ул. Шаумяна,10'), ('ул Ленина, 7'), ('ул. Мира,5')"
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -617,7 +613,6 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
|
|||
text(
|
||||
"CREATE TEMP TABLE houses ("
|
||||
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
|
||||
" region_code smallint,"
|
||||
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
|
||||
" gar_match_method text"
|
||||
") ON COMMIT DROP"
|
||||
|
|
@ -638,10 +633,7 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
|
|||
)
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO houses (short_address, region_code) VALUES "
|
||||
"('ул. Ленина,5', 50), ('ул. Мира,1', 50)"
|
||||
)
|
||||
text("INSERT INTO houses (short_address) VALUES ('ул. Ленина,5'), ('ул. Мира,1')")
|
||||
)
|
||||
|
||||
matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None)
|
||||
|
|
@ -660,90 +652,3 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
|
|||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
|
||||
def test_house_gets_gar_guid_only_from_its_own_region() -> None:
|
||||
"""#2583 H5: UPDATE houses ограничен регионом дома, чужие guid снимаются.
|
||||
|
||||
Прод 2026-09-17, после матча регионов 77/50 (#3523): 578 домов региона 66 несли guid
|
||||
региона 50 и 340 — региона 77. Канон не несёт ни город, ни регион, поэтому
|
||||
екатеринбургская «Ленина, 5» и подмосковная «Ленина, 5» — один ключ.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
try:
|
||||
raw = db.connection()
|
||||
raw.exec_driver_sql(_NORMALIZER_FN)
|
||||
raw.exec_driver_sql(_CANON_FN)
|
||||
db.execute(
|
||||
text(
|
||||
"CREATE TEMP TABLE gar_house_flats ("
|
||||
" house_guid text PRIMARY KEY, object_id bigint, region_code text NOT NULL,"
|
||||
" flat_count int, full_address text, norm_address text, street_name text,"
|
||||
" house_num text, loaded_at timestamptz NOT NULL DEFAULT now(), gar_version text"
|
||||
") ON COMMIT DROP"
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"CREATE TEMP TABLE houses ("
|
||||
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
|
||||
" region_code smallint,"
|
||||
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
|
||||
" gar_match_method text"
|
||||
") ON COMMIT DROP"
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO gar_house_flats "
|
||||
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
|
||||
"('g66-lenina', '66', 60, 'обл Свердловская, г Екатеринбург, ул Ленина, 5', "
|
||||
"'ул Ленина, 5'), "
|
||||
"('g50-lenina', '50', 400, 'обл Московская, г Химки, ул Ленина, 5', "
|
||||
"'ул Ленина, 5'), "
|
||||
"('g50-mira', '50', 250, 'обл Московская, г Химки, ул Мира, 7', 'ул Мира, 7')"
|
||||
)
|
||||
)
|
||||
# ЕКБ «Ленина, 5» и «Мира, 7» уже несут подмосковные guid (как на проде после
|
||||
# прогона региона 50); подмосковная «Ленина, 5» и дом без региона — не матчены.
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO houses (short_address, region_code, gar_house_guid, gar_flat_count, "
|
||||
"gar_match_method) VALUES "
|
||||
"('ул. Ленина,5', 66, 'g50-lenina', 400, 'canon_addr'), "
|
||||
"('ул. Мира,7', 66, 'g50-mira', 250, 'canon_addr'), "
|
||||
"('ул. Ленина,5', 50, NULL, NULL, NULL), "
|
||||
"('ул. Мира,7', NULL, 'g50-mira', 250, 'canon_addr')"
|
||||
)
|
||||
)
|
||||
|
||||
def _state() -> set[tuple[Any, ...]]:
|
||||
rows = db.execute(
|
||||
text(
|
||||
"SELECT short_address, region_code, gar_house_guid, gar_flat_count FROM houses"
|
||||
)
|
||||
).all()
|
||||
return {tuple(r) for r in rows}
|
||||
|
||||
assert gfl.match_houses_to_gar(db, region_code="66") == 1
|
||||
assert _state() == {
|
||||
("ул. Ленина,5", 66, "g66-lenina", 60), # свой ЕКБ-дом вместо подмосковного
|
||||
("ул. Мира,7", 66, None, None), # пары в 66 нет — чужой guid снят, не оставлен
|
||||
("ул. Ленина,5", 50, None, None), # прогон 66 не трогает дома 50
|
||||
("ул. Мира,7", None, "g50-mira", 250), # регион дома неизвестен — не доказуемо
|
||||
}
|
||||
|
||||
assert gfl.match_houses_to_gar(db, region_code="50", city_filter=None) == 1
|
||||
assert _state() == {
|
||||
("ул. Ленина,5", 66, "g66-lenina", 60), # прогон 50 не перетирает ЕКБ
|
||||
("ул. Мира,7", 66, None, None),
|
||||
("ул. Ленина,5", 50, "g50-lenina", 400),
|
||||
("ул. Мира,7", None, "g50-mira", 250),
|
||||
}
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ def test_keeper_listing_count_puts_nulls_last() -> None:
|
|||
assert "listing_cnt DESC NULLS LAST" in order
|
||||
# Оба места, где применяется порядок (ROW_NUMBER-ранг и first_value-выбор keeper'а),
|
||||
# берут одну и ту же константу — иначе ранг и keeper разъедутся построчно.
|
||||
mapping = _flat(hdm._mapping_sql(hdm._FIAS_KEY_EXPR))
|
||||
mapping = _flat(hdm._mapping_sql(hdm._FIAS_KEY_EXPR, apply_geo_guard=False))
|
||||
assert mapping.count("listing_cnt DESC NULLS LAST") >= 2
|
||||
|
||||
|
||||
|
|
@ -274,31 +274,56 @@ def test_fias_pass_clusters_by_house_fias_id() -> None:
|
|||
assert "'addr:'" not in flat
|
||||
|
||||
|
||||
def test_both_passes_carry_the_geo_guard() -> None:
|
||||
"""#2690: the fias pass carries the same 250 m guard as the canon pass (#2187 is reverted).
|
||||
|
||||
house_fias_id is DaData's answer to our own address string, so it does not tell towns apart
|
||||
any better than the canon does — prod 2026-09-05 the unguarded fias pass merged 53 pairs
|
||||
more than 3 km apart.
|
||||
The value-level proof is test_real_fias_pass_keeps_geo_guard; this pins the rendered SQL.
|
||||
"""
|
||||
def test_fias_pass_drops_geo_guard_canon_pass_keeps_it() -> None:
|
||||
"""#2187: ФИАС identity strictly outranks geo-proximity, so the fias-pass mapping must NOT
|
||||
carry the 250 m distance guard (same-fias rows merge even with NULL geom on a side or >250 m
|
||||
apart), while the canon-pass mapping MUST keep it (cross-town over-merge protection)."""
|
||||
fias = _flat(_FIAS_MAPPING_SQL)
|
||||
canon = _flat(_MAPPING_SQL)
|
||||
# canon pass keeps the full geo guard (distance + NULL-geom safety on both sides).
|
||||
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" in canon
|
||||
assert "keeper_geom IS NOT NULL" in canon
|
||||
assert "loser_geom IS NOT NULL" in canon
|
||||
# fias pass drops the distance guard AND the NULL-geom exclusions entirely.
|
||||
#
|
||||
# Asserted on the guard PREDICATE, not on the bare function name: since #2690 the mapping also
|
||||
# MEASURES the keeper↔loser distance into `distance_m` for the merge journal, on both passes.
|
||||
# Measuring is the opposite of guarding — the fias pass is precisely where nothing else records
|
||||
# how far apart the merged rows were — so the name alone can no longer stand in for the guard.
|
||||
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias
|
||||
guard = (
|
||||
"AND keeper_geom IS NOT NULL AND loser_geom IS NOT NULL "
|
||||
"AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"
|
||||
)
|
||||
assert guard in _flat(_MAPPING_SQL)
|
||||
assert guard in _flat(_FIAS_MAPPING_SQL)
|
||||
for sql in (_MAPPING_SQL, _FIAS_MAPPING_SQL):
|
||||
# the journal's distance MEASUREMENT and the cross-fias guard stay on both passes.
|
||||
assert "AS distance_m" in sql
|
||||
assert "lower(loser_fias) <> lower(keeper_fias)" in _flat(sql)
|
||||
assert guard in canon
|
||||
assert guard not in fias
|
||||
# the cross-fias anti-over-merge guard is untouched in the canon pass.
|
||||
assert "lower(loser_fias) <> lower(keeper_fias)" in canon
|
||||
|
||||
|
||||
def test_mapping_sql_geo_guard_param_toggles_only_distance_filter() -> None:
|
||||
"""_mapping_sql(apply_geo_guard=...) toggles ONLY the 250 m distance filter; the cross-fias
|
||||
guard is emitted regardless, and the default is True (canon-safe)."""
|
||||
guard = "ST_DistanceSphere(loser_geom, keeper_geom) <= 250"
|
||||
with_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=True))
|
||||
without_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=False))
|
||||
assert guard in with_guard
|
||||
assert guard not in without_guard
|
||||
# default = True (the canon pass must never lose its guard by omission).
|
||||
assert guard in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR))
|
||||
# ...while the journal's distance MEASUREMENT is emitted either way (#2690).
|
||||
assert "AS distance_m" in with_guard and "AS distance_m" in without_guard
|
||||
# cross-fias guard present in BOTH renderings (independent of the geo guard).
|
||||
assert "lower(loser_fias) <> lower(keeper_fias)" in with_guard
|
||||
assert "lower(loser_fias) <> lower(keeper_fias)" in without_guard
|
||||
|
||||
|
||||
def test_both_passes_share_one_pipeline_no_copy_paste() -> None:
|
||||
"""The two mappings differ ONLY in the cluster key — no copy-paste.
|
||||
"""The two mappings differ ONLY in the cluster key + the (canon-only) geo guard — no copy-paste.
|
||||
|
||||
The CTE skeleton, keeper rule, per-partition geom/fias exposure, the cross-fias guard and (since
|
||||
#2690) the 250 m geo guard are identical.
|
||||
#2187: the 250 m distance guard is emitted for the canon pass ONLY; the fias pass drops it (a
|
||||
shared ГАР UUID IS the building identity and outranks proximity). Everything else — the CTE
|
||||
skeleton, keeper rule, per-partition geom/fias exposure and the cross-fias guard — is identical.
|
||||
"""
|
||||
canon = _flat(_MAPPING_SQL)
|
||||
fias = _flat(_FIAS_MAPPING_SQL)
|
||||
|
|
@ -313,16 +338,19 @@ def test_both_passes_share_one_pipeline_no_copy_paste() -> None:
|
|||
"lower(loser_fias) <> lower(keeper_fias)",
|
||||
):
|
||||
assert token in canon and token in fias
|
||||
# the rendered SQL is the same text once the cluster key is swapped back.
|
||||
assert fias.replace(_flat(hdm._FIAS_KEY_EXPR), _flat(hdm._CANON_KEY_EXPR)) == canon
|
||||
# the 250 m distance guard is CANON-ONLY (#2187) — fias identity outranks proximity.
|
||||
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" in canon
|
||||
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias
|
||||
# ...but the journal's distance MEASUREMENT is on both — measuring is not guarding.
|
||||
assert "AS distance_m" in canon and "AS distance_m" in fias
|
||||
|
||||
|
||||
def test_cross_fias_guard_blocks_slash_collapse_over_merge() -> None:
|
||||
"""Canon pass must NOT merge two rows that BOTH carry a non-null but DIFFERENT house_fias_id.
|
||||
|
||||
This is the critical anti-over-merge fix for the canon slash-collapse class: «Сулимова, 32»
|
||||
and «Сулимова, 3/2» shared the canon «сулимова32» (until migration 322, #2189) but are different
|
||||
buildings — their distinct fias UUIDs veto the merge.
|
||||
and «Сулимова, 3/2» share the canon «сулимова32» but are different buildings — their distinct
|
||||
fias UUIDs veto the merge.
|
||||
"""
|
||||
flat = _flat(_MAPPING_SQL)
|
||||
assert "h.house_fias_id AS loser_fias" in flat
|
||||
|
|
@ -994,133 +1022,14 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None:
|
|||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
def test_real_canon_strips_district_glued_to_house_number() -> None:
|
||||
"""#1772: a district glued to the house number («17Ар-н Академический») is canon noise.
|
||||
|
||||
Prod 2026-09-17: 44 houses carry such an address (the «29р-н» slip of #1773); the canon kept
|
||||
the tail («евгениясавкова17арнакадемический») or even ate the letter («44Бр-н» → «44н…»),
|
||||
so the twin «ул. Евгения Савкова,17А» never shared a cluster and the merge could not see it.
|
||||
Asserted on the function the migrations installed (311), not on a Python mirror of it.
|
||||
"""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
|
||||
def canon(s: str) -> str:
|
||||
return db.execute(_t("SELECT tradein_canon_addr(:s)"), {"s": s}).scalar()
|
||||
|
||||
try:
|
||||
assert canon("Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический") == (
|
||||
"евгениясавкова17а"
|
||||
)
|
||||
assert canon("ул. Евгения Савкова,17А") == "евгениясавкова17а"
|
||||
assert canon("Екатеринбург, ул. Евгения Савкова, 44Бр-н Академический") == (
|
||||
"евгениясавкова44б"
|
||||
)
|
||||
assert canon("Екатеринбург, Рассветная ул., 8/1р-н Кировский") == "рассветная8/1"
|
||||
# the letter is a different building: «17А» never collapses into «17»
|
||||
assert canon("ул. Евгения Савкова, 17") == "евгениясавкова17"
|
||||
# the digit anchor keeps «мкр-н» intact (an unanchored «р-н …» strip gives «мк2»)
|
||||
assert canon("Мкр-н Кутузовский, 2") == "нкутузовский2"
|
||||
# #2189: дробь в номере дома не теряется
|
||||
assert canon("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга7/6"
|
||||
|
||||
# End to end: the glued row and its clean twin (same point) merge; «17» without the
|
||||
# letter, at the same point, stays a separate building.
|
||||
db.execute(
|
||||
_t(
|
||||
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES "
|
||||
"(900040,'avito','EXT-1772-G','u',"
|
||||
" 'Екатеринбург, ул. Савкова1772, 17Ар-н Академический',56.84,60.6),"
|
||||
"(900041,'cian','EXT-1772-T','u','ул. Савкова1772,17А',56.84,60.6),"
|
||||
"(900042,'cian','EXT-1772-N','u','ул. Савкова1772, 17',56.84,60.6)"
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
_t(
|
||||
"INSERT INTO listings "
|
||||
"(id, source, source_url, source_id, dedup_hash, price_rub, house_id_fk) VALUES "
|
||||
"(910040,'avito','http://t/1772/g','L-1772-G','dh-1772-g',7000000,900040)"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
out = hdm.merge_duplicate_houses(db, dry_run=False)
|
||||
|
||||
ids = {
|
||||
r.id for r in db.execute(_t("SELECT id FROM houses WHERE id BETWEEN 900040 AND 900042"))
|
||||
}
|
||||
assert ids == {900040, 900042}, sorted(ids)
|
||||
assert out["losers_deleted"] == 1
|
||||
journal = db.execute(
|
||||
_t(
|
||||
"SELECT merge_pass, cluster_key, loser_id, keeper_id FROM house_merge_log "
|
||||
"WHERE loser_id BETWEEN 900040 AND 900042"
|
||||
)
|
||||
).all()
|
||||
assert [tuple(j) for j in journal] == [("canon", "addr:савкова177217а", 900041, 900040)]
|
||||
finally:
|
||||
db.rollback()
|
||||
db.execute(_t("DELETE FROM listings WHERE id = 910040"))
|
||||
db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900040 AND 900042"))
|
||||
db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900040 AND 900042"))
|
||||
db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299"))
|
||||
db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900040 AND 900042"))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
def test_real_migration_311_gives_up_on_busy_gar_table_instead_of_queueing() -> None:
|
||||
"""Migration 311 under a running GAR load fails in ~5 s and rolls back whole (#2752).
|
||||
|
||||
Its REINDEX needs SHARE on gar_house_flats; the loader's INSERT holds ROW EXCLUSIVE. Without
|
||||
lock_timeout the deploy waits for the load and every query on the table queues behind it.
|
||||
The file is run the way the deploy runner runs it (autocommit, BEGIN/COMMIT inside); the
|
||||
blocker lets go after 12 s, so a missing timeout shows up as a migration that succeeds.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
import psycopg
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
dsn = db.get_bind().url.set(drivername="postgresql").render_as_string(hide_password=False)
|
||||
db.close()
|
||||
sql = (_SQL_DIR / "311_canon_strip_district_glued_to_house_number.sql").read_text("utf-8")
|
||||
body_version = "SELECT xmin::text FROM pg_proc WHERE proname = 'tradein_canon_addr'"
|
||||
|
||||
with psycopg.connect(dsn, autocommit=True) as deploy, psycopg.connect(dsn) as loader:
|
||||
before = deploy.execute(body_version).fetchone()
|
||||
loader.execute("LOCK TABLE gar_house_flats IN ROW EXCLUSIVE MODE")
|
||||
release = threading.Timer(12, loader.rollback)
|
||||
release.start()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with pytest.raises(psycopg.errors.LockNotAvailable):
|
||||
deploy.execute(sql)
|
||||
finally:
|
||||
release.cancel()
|
||||
release.join()
|
||||
loader.rollback()
|
||||
assert time.monotonic() - started < 10
|
||||
deploy.execute("ROLLBACK")
|
||||
# CREATE OR REPLACE rewrites the pg_proc row: an unchanged xmin = the body was rolled back
|
||||
assert deploy.execute(body_version).fetchone() == before
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
|
||||
"""End-to-end on a real DB for the #1772 follow-up (fias pass + cross-fias guard + carry-over):
|
||||
|
||||
A. same house_fias_id, DIFFERENT canon (different streets) → MERGED by the FIAS pass;
|
||||
the loser's listing re-points onto the single survivor.
|
||||
B. same canon («ул.»/«улица», same number), BOTH fias filled and DIFFERENT → NOT merged
|
||||
(cross-fias guard vetoes — provably different buildings), both survive. Until #2189 this
|
||||
pair was «32»/«3/2»; migration 322 gave them different canons, so the guard went unused.
|
||||
B. same canon (slash-collapse «32»/«3/2»), BOTH fias filled and DIFFERENT → NOT merged
|
||||
(cross-fias guard vetoes — provably different buildings), both survive.
|
||||
C. same canon, fias only on the LOSER → MERGED by the canon pass; the loser's
|
||||
house_fias_id + gar_house_guid CARRY OVER onto the fias-less keeper.
|
||||
D. a second full run is idempotent (nothing left to merge).
|
||||
|
|
@ -1142,10 +1051,10 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
|
|||
" 'F-SAME-1772',NULL,NULL),"
|
||||
"(900021,'cian', 'EXT-F-L','u','СовсемДругая1772, 77',56.84009,60.60000,"
|
||||
" 'F-SAME-1772',NULL,NULL),"
|
||||
# B — same canon, DIFFERENT fias → cross-fias guard BLOCKS
|
||||
"(900022,'avito','EXT-B-1','u','ул. Клара1772, 32',56.84000,60.60000,"
|
||||
# B — same canon (slash-collapse), DIFFERENT fias → cross-fias guard BLOCKS
|
||||
"(900022,'avito','EXT-B-1','u','Клара1772, 32',56.84000,60.60000,"
|
||||
" 'F-B1-1772',NULL,NULL),"
|
||||
"(900023,'cian', 'EXT-B-2','u','улица Клара1772, 32',56.84009,60.60000,"
|
||||
"(900023,'cian', 'EXT-B-2','u','Клара1772, 3/2',56.84009,60.60000,"
|
||||
" 'F-B2-1772',NULL,NULL),"
|
||||
# C — same canon, fias only on the loser → canon-pass merge + carry-over
|
||||
"(900024,'avito','EXT-C-K','u','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL),"
|
||||
|
|
@ -1208,8 +1117,7 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
|
|||
db.execute(
|
||||
_t(
|
||||
"DELETE FROM house_address_aliases WHERE normalized_address IN "
|
||||
"('фиасодин1772, 10','совсемдругая1772, 77','ул. клара1772, 32',"
|
||||
"'улица клара1772, 32',"
|
||||
"('фиасодин1772, 10','совсемдругая1772, 77','клара1772, 32','клара1772, 3/2',"
|
||||
"'донбасс1772, 8')"
|
||||
)
|
||||
)
|
||||
|
|
@ -1222,17 +1130,19 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
|
|||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
def test_real_fias_pass_keeps_geo_guard() -> None:
|
||||
"""End-to-end on a real DB for #2690 — the FIAS pass obeys the same 250 m geo guard:
|
||||
def test_real_fias_pass_ignores_geo_guard() -> None:
|
||||
"""End-to-end on a real DB for #2187 — the FIAS pass drops the 250 m geo guard:
|
||||
|
||||
A. same house_fias_id, loser has NULL geom (no lat/lon) → NOT merged (guard cannot judge).
|
||||
B. same house_fias_id, ~5 km apart (>250 m) → NOT merged (prod 2026-09-05: the
|
||||
unguarded pass merged «р-н Синарский, улица Кирова, 4» into Екатеринбург, 362 km).
|
||||
C. same house_fias_id, DIFFERENT canon, ~10 m apart → MERGED (the pass still works —
|
||||
without C, «nothing merged» would pass even if the fias pass were dead).
|
||||
A. same house_fias_id, loser has NULL geom (no lat/lon) → MERGED (fias identity outranks
|
||||
the missing coordinate; the geom-first keeper survives + the loser's listing re-points).
|
||||
B. same house_fias_id, ~5 km apart (>250 m) → MERGED (fias identity outranks
|
||||
proximity — a scattered/broken coordinate no longer blocks the merge).
|
||||
C. same canon, NO fias, ~5 km apart (>250 m) → NOT merged (the canon pass STILL
|
||||
enforces the geo guard — the fix is fias-only, canon behaviour is unchanged).
|
||||
|
||||
Distinct synthetic streets «*2690» so ONLY the shared fias groups each pair (never the canon).
|
||||
Latitude delta 0.045° at ~56.84° ≈ 5 km; 0.00009° ≈ 10 m.
|
||||
A/B use distinct synthetic streets so ONLY the shared fias groups them (the canon never does);
|
||||
C shares one address so only the canon pass can group it. Streets «*2187» avoid prod aliases.
|
||||
Latitude delta 0.045° at ~56.84° ≈ 5 km (>250 m).
|
||||
"""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
|
|
@ -1243,53 +1153,49 @@ def test_real_fias_pass_keeps_geo_guard() -> None:
|
|||
_t(
|
||||
"INSERT INTO houses "
|
||||
"(id, source, ext_house_id, url, address, lat, lon, house_fias_id) VALUES "
|
||||
"(900030,'avito','EXT-2690-A-K','u','ФиасГеоA2690, 1',56.84,60.6,'F-A-2690'),"
|
||||
"(900031,'cian', 'EXT-2690-A-L','u','ФиасГеоAL2690, 2',NULL,NULL,'F-A-2690'),"
|
||||
"(900032,'avito','EXT-2690-B-K','u','ФиасГеоB2690, 3',56.84,60.6,'F-B-2690'),"
|
||||
"(900033,'cian', 'EXT-2690-B-L','u','ФиасГеоBL2690, 4',56.885,60.6,'F-B-2690'),"
|
||||
"(900034,'avito','EXT-2690-C-K','u','ФиасГеоC2690, 5',56.84,60.6,'F-C-2690'),"
|
||||
"(900035,'cian', 'EXT-2690-C-L','u','ФиасГеоCL2690, 6',56.84009,60.6,'F-C-2690')"
|
||||
# A — same fias, loser NULL geom → fias pass merges despite the missing coordinate
|
||||
"(900030,'avito','EXT-2187-A-K','u','ФиасГеоA2187, 1',56.84,60.6,'F-A-2187'),"
|
||||
"(900031,'cian', 'EXT-2187-A-L','u','ФиасГеоAL2187, 2',NULL,NULL,'F-A-2187'),"
|
||||
# B — same fias, ~5 km apart (>250 m) → fias pass merges despite the distance
|
||||
"(900032,'avito','EXT-2187-B-K','u','ФиасГеоB2187, 3',56.84,60.6,'F-B-2187'),"
|
||||
"(900033,'cian', 'EXT-2187-B-L','u','ФиасГеоBL2187, 4',56.885,60.6,'F-B-2187'),"
|
||||
# C — same canon, NO fias, ~5 km apart → canon pass STILL blocks (guard unchanged)
|
||||
"(900034,'avito','EXT-2187-C-1','u','КанонГео2187, 5', 56.84000,60.60000,NULL),"
|
||||
"(900035,'cian', 'EXT-2187-C-2','u','КанонГео2187, 5', 56.88500,60.60000,NULL)"
|
||||
)
|
||||
)
|
||||
# A loser gets a listing so we prove the re-point still fires with a NULL-geom loser.
|
||||
db.execute(
|
||||
_t(
|
||||
"INSERT INTO listings "
|
||||
"(id, source, source_url, source_id, dedup_hash, price_rub, house_id_fk) VALUES "
|
||||
"(910031,'cian','http://t/2690/al','L-2690-AL','dh-2690-al',6000000,900031),"
|
||||
"(910035,'cian','http://t/2690/cl','L-2690-CL','dh-2690-cl',6000000,900035)"
|
||||
"(910031,'cian','http://t/2187/al','L-2187-AL','dh-2187-al',6000000,900031)"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
out = hdm.merge_duplicate_houses(db, dry_run=False)
|
||||
assert out["losers_deleted"] >= 2 # A + B both merged by the fias pass
|
||||
|
||||
ids = {
|
||||
ids = [
|
||||
r.id
|
||||
for r in db.execute(
|
||||
_t("SELECT id FROM houses WHERE id BETWEEN 900030 AND 900035")
|
||||
_t("SELECT id FROM houses WHERE id BETWEEN 900030 AND 900035 ORDER BY id")
|
||||
).all()
|
||||
}
|
||||
assert ids == {900030, 900031, 900032, 900033, 900035}, sorted(ids)
|
||||
assert out["losers_deleted"] == 1
|
||||
# A: the NULL-geom row keeps its own listing; C: the row WITH the listing is the keeper.
|
||||
lst = dict(
|
||||
db.execute(
|
||||
_t("SELECT id, house_id_fk FROM listings WHERE id IN (910031, 910035)")
|
||||
).all()
|
||||
)
|
||||
assert lst == {910031: 900031, 910035: 900035}
|
||||
journal = db.execute(
|
||||
_t(
|
||||
"SELECT merge_pass, geo_guard, loser_id, keeper_id, round(distance_m) AS d "
|
||||
"FROM house_merge_log WHERE loser_id BETWEEN 900030 AND 900035"
|
||||
)
|
||||
).all()
|
||||
assert [(j.merge_pass, j.geo_guard, j.loser_id, j.keeper_id, j.d) for j in journal] == [
|
||||
("fias", True, 900034, 900035, 10)
|
||||
]
|
||||
# A: same-fias, loser NULL geom → MERGED (the geo guard would have blocked on NULL geom).
|
||||
assert 900030 in ids and 900031 not in ids, "same-fias NULL-geom loser must merge"
|
||||
# B: same-fias, >250 m apart → MERGED (the geo guard would have blocked on distance).
|
||||
assert 900032 in ids and 900033 not in ids, "same-fias >250 m apart must merge"
|
||||
# C: same-canon, no fias, >250 m apart → NOT merged (canon geo guard unchanged by #2187).
|
||||
assert 900034 in ids and 900035 in ids, "canon-only pair >250 m apart must NOT merge"
|
||||
|
||||
# A: the NULL-geom loser's listing re-pointed onto the surviving geom-first keeper.
|
||||
repointed = db.execute(_t("SELECT house_id_fk FROM listings WHERE id = 910031")).scalar()
|
||||
assert repointed == 900030
|
||||
finally:
|
||||
db.rollback()
|
||||
db.execute(_t("DELETE FROM listings WHERE id IN (910031, 910035)"))
|
||||
db.execute(_t("DELETE FROM listings WHERE id = 910031"))
|
||||
db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900030 AND 900035"))
|
||||
db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900030 AND 900035"))
|
||||
# journal rows have no FK and are never cascaded away — sweep them explicitly,
|
||||
|
|
|
|||
|
|
@ -73,11 +73,7 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
|
|||
proxy_provider: object | None = None,
|
||||
use_pool: bool = False,
|
||||
environment: str = "dev",
|
||||
reuse_context: bool = False,
|
||||
) -> None:
|
||||
# reuse_context (#3118) обязан быть в сигнатуре двойника: фабрика
|
||||
# build_browser_fetcher передаёт его ВСЕГДА, и двойник без него падал бы
|
||||
# TypeError на каждом вызывающем, а не проверял то, ради чего написан.
|
||||
calls.append(
|
||||
{
|
||||
"source": source,
|
||||
|
|
@ -85,7 +81,6 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
|
|||
"proxy_provider": proxy_provider,
|
||||
"use_pool": use_pool,
|
||||
"environment": environment,
|
||||
"reuse_context": reuse_context,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -261,7 +261,6 @@ _PAID_FIELDS = {
|
|||
"actual_deals",
|
||||
"expected_sold_price_rub",
|
||||
"est_days_on_market",
|
||||
"exposure_window",
|
||||
"price_trend",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -411,19 +411,8 @@ async def _drive_domclick(
|
|||
recorder = _RunsRecorder()
|
||||
db = MagicMock()
|
||||
lots = [MagicMock() for _ in range(lots_n)]
|
||||
|
||||
async def _fetch_city(**kw: Any) -> list[Any]:
|
||||
# fix/domclick-incremental-save: save_listings переехал внутрь цикла по корзинам и
|
||||
# зовётся из колбэка on_bucket. Стаб, который просто возвращает лоты, не
|
||||
# вызвал бы сохранение вовсе — фикстура проверяла бы мёртвую ветку.
|
||||
# Одна корзина со всеми лотами: ровно один save_listings, как и было.
|
||||
on_bucket = kw.get("on_bucket")
|
||||
if on_bucket is not None and lots:
|
||||
on_bucket(ROOM_BUCKETS[0], lots)
|
||||
return lots
|
||||
|
||||
scraper = _ctx_scraper(
|
||||
fetch_city=_fetch_city,
|
||||
fetch_city=AsyncMock(return_value=lots),
|
||||
blocked=blocked,
|
||||
geo_filtered=0,
|
||||
fetch_errors=fetch_errors,
|
||||
|
|
|
|||
|
|
@ -34,9 +34,6 @@ Per-provider модель (#1793):
|
|||
|
||||
Переменные окружения:
|
||||
BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000)
|
||||
BROWSER_MAX_INSTANCES — потолок одновременно живых camoufox на весь сайдкар
|
||||
(default: 3, #3412). Перед запуском нового инстанса
|
||||
закрывается простаивающий чужой; занятый — никогда.
|
||||
BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска, глобальный
|
||||
дефолт для провайдеров без код-дефолта (default: 15).
|
||||
per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}.
|
||||
|
|
@ -144,7 +141,6 @@ Per-provider модель (#1793):
|
|||
import asyncio
|
||||
import base64
|
||||
import gzip
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
|
|
@ -168,14 +164,6 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev")
|
|||
IS_PROD: bool = ENVIRONMENT == "production"
|
||||
|
||||
BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000"))
|
||||
# #3412: до потолка инстанс поднимался лениво на поставщика и не закрывался никогда —
|
||||
# пять поставщиков давали пять вечных camoufox в контейнере с mem_limit 2560m.
|
||||
# Откуда 3: cadvisor working_set tradein-browser за 41 ч (15.09 16:55 → 17.09 09:53),
|
||||
# сопоставленный с числом живых инстансов по логу запусков/закрытий: медиана 1311 МиБ
|
||||
# при 2 живых, 1494 при 3, 1956 при 4, 2108 при 5; 5 живых простояли 2.4 ч прямо перед
|
||||
# резким рестартом контейнера 16.09 19:08. Потолок не тормозит работу: занятый инстанс
|
||||
# не закрывается, лишний запуск идёт сверх потолка (см. _trim_instances).
|
||||
BROWSER_MAX_INSTANCES: int = max(1, int(os.environ.get("BROWSER_MAX_INSTANCES", "3")))
|
||||
# Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и
|
||||
# живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается —
|
||||
# внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали
|
||||
|
|
@ -861,14 +849,6 @@ _last_nav_ms: dict[str, int | None] = {}
|
|||
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
|
||||
# если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое).
|
||||
_launched_proxy: dict[str, str | None] = {}
|
||||
# #3412: поставщики, чей camoufox поднимается прямо сейчас. Считаются живыми для
|
||||
# потолка: запуск длится секунды, и два параллельных запуска иначе оба увидели бы
|
||||
# свободное место.
|
||||
_launching: set[str] = set()
|
||||
# #3412: provider → номер последнего использования (LRU выбора жертвы). Счётчик, а не
|
||||
# часы: два обращения в один тик часов неразличимы.
|
||||
_last_used: dict[str, int] = {}
|
||||
_use_seq = itertools.count(1)
|
||||
|
||||
# Guard на ленивое создание per-provider локов: setdefault на обычном dict из
|
||||
# разных корутин гонок не даёт (нет await между read-modify-write), но держим
|
||||
|
|
@ -947,20 +927,12 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
|
|||
proxy is not None,
|
||||
_RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK),
|
||||
)
|
||||
# #3412: потолок живых — здесь, а не в _ensure_browser: сюда сходятся ВСЕ пути
|
||||
# запуска (первый запрос, смена прокси, recycle/краш, фоновый retry).
|
||||
await _trim_instances(provider)
|
||||
_launching.add(provider)
|
||||
try:
|
||||
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
|
||||
browser = await cm.__aenter__()
|
||||
finally:
|
||||
_launching.discard(provider)
|
||||
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
|
||||
browser = await cm.__aenter__()
|
||||
_browser_cms[provider] = cm
|
||||
_browsers[provider] = browser
|
||||
_page_counters[provider] = 0
|
||||
_launched_proxy[provider] = proxy_url
|
||||
_last_used[provider] = next(_use_seq) # свежий инстанс — не первая жертва LRU
|
||||
logger.info(
|
||||
"tradein-browser[%s]: браузер запущен (proxy_override=%s)",
|
||||
provider,
|
||||
|
|
@ -1078,22 +1050,9 @@ async def _close_reusable_context(provider: str) -> None:
|
|||
|
||||
|
||||
async def _close_browser(provider: str) -> None:
|
||||
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).
|
||||
|
||||
Инстанс снимается со словарей ДО первого await (#3412): закрыть его может и чужой
|
||||
запуск по потолку (_trim_instances), не держащий _locks[provider]. Запрос этого
|
||||
поставщика, вошедший под лок во время close(), должен увидеть «инстанса нет» и
|
||||
поднять свой, а не получить полузакрытый браузер.
|
||||
|
||||
_last_goto_at НЕ трогает: пейсинг — ритм обращений к площадке, он переживает
|
||||
инстанс. Сбрасывают его те, кто перезапускает инстанс сам (_relaunch_browser,
|
||||
смена прокси в _ensure_browser), — ровно как было до #3412.
|
||||
"""
|
||||
cm = _browser_cms.pop(provider, None)
|
||||
_browsers.pop(provider, None)
|
||||
_page_counters[provider] = 0
|
||||
_launched_proxy.pop(provider, None)
|
||||
await _close_reusable_context(provider) # context/якорь снимает до своего await
|
||||
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118)."""
|
||||
await _close_reusable_context(provider)
|
||||
cm = _browser_cms.get(provider)
|
||||
if cm is not None:
|
||||
try:
|
||||
await cm.__aexit__(None, None, None) # type: ignore[attr-defined]
|
||||
|
|
@ -1104,56 +1063,11 @@ async def _close_browser(provider: str) -> None:
|
|||
provider,
|
||||
type(exc).__name__,
|
||||
)
|
||||
|
||||
|
||||
def _pick_victim(provider: str) -> str | None:
|
||||
"""Чей простаивающий инстанс закрыть под новый инстанс ``provider`` (#3412).
|
||||
|
||||
Кандидаты — живые инстансы ЧУЖИХ поставщиков со свободным _locks: вся работа
|
||||
поставщика идёт под его локом, занятый лок = страница в работе. Первыми идут
|
||||
инстансы без context'а и якорной вкладки: их перезапуск стоит только процесса.
|
||||
С context'ом (avito/domclick) теряются прогретые куки площадки и вкладка выдачи —
|
||||
4-5 с на карточку против 17-52 с без неё (замер 29.08, см. _anchor_pages). Внутри
|
||||
класса — давнее всех использованный. None — закрыть некого.
|
||||
"""
|
||||
idle = [
|
||||
p
|
||||
for p in _browsers
|
||||
if p != provider and not (p in _locks and _locks[p].locked())
|
||||
]
|
||||
if not idle:
|
||||
return None
|
||||
return min(idle, key=lambda p: (p in _contexts or p in _anchor_pages, _last_used.get(p, 0)))
|
||||
|
||||
|
||||
async def _trim_instances(provider: str) -> None:
|
||||
"""Освобождает место под новый инстанс ``provider``, пока живых >= потолка (#3412).
|
||||
|
||||
ponytail: потолок мягкий — если все прочие инстансы заняты, поднимаемся сверх него
|
||||
с предупреждением, а не отказываем запросу и не рвём чужую навигацию. Очередь на
|
||||
слот — если предупреждение станет регулярным. Цена мягкости и места проверки: запуск,
|
||||
который потом упадёт (InvalidIP), успевает закрыть один простаивающий инстанс.
|
||||
"""
|
||||
while len(_browsers.keys() | _launching) >= BROWSER_MAX_INSTANCES:
|
||||
victim = _pick_victim(provider)
|
||||
if victim is None:
|
||||
logger.warning(
|
||||
"tradein-browser[%s]: живых инстансов %d при потолке %d, все заняты — "
|
||||
"запуск сверх потолка",
|
||||
provider,
|
||||
len(_browsers.keys() | _launching),
|
||||
BROWSER_MAX_INSTANCES,
|
||||
)
|
||||
return
|
||||
logger.info(
|
||||
"tradein-browser[%s]: живых инстансов %d при потолке %d — закрываем "
|
||||
"простаивающий %s",
|
||||
provider,
|
||||
len(_browsers.keys() | _launching),
|
||||
BROWSER_MAX_INSTANCES,
|
||||
victim,
|
||||
)
|
||||
await _close_browser(victim)
|
||||
_browsers.pop(provider, None)
|
||||
_browser_cms.pop(provider, None)
|
||||
_page_counters[provider] = 0
|
||||
_last_goto_at.pop(provider, None)
|
||||
_launched_proxy.pop(provider, None)
|
||||
|
||||
|
||||
async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None:
|
||||
|
|
@ -1169,7 +1083,6 @@ async def _relaunch_browser(provider: str, proxy_override: str | None = None) ->
|
|||
effective = proxy_override if proxy_override is not None else _launched_proxy.get(provider)
|
||||
logger.info("tradein-browser[%s]: перезапуск браузера", provider)
|
||||
await _close_browser(provider)
|
||||
_last_goto_at.pop(provider, None)
|
||||
await _launch_browser(provider, proxy_override=effective)
|
||||
|
||||
|
||||
|
|
@ -1224,7 +1137,6 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
|
|||
desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider)
|
||||
if _browsers.get(provider) is not None:
|
||||
if _launched_proxy.get(provider) == desired_proxy:
|
||||
_last_used[provider] = next(_use_seq)
|
||||
return True
|
||||
# Реальная смена прокси → пересобрать инстанс с новым прокси.
|
||||
logger.info(
|
||||
|
|
@ -1233,7 +1145,6 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
|
|||
proxy_override is not None,
|
||||
)
|
||||
await _close_browser(provider)
|
||||
_last_goto_at.pop(provider, None)
|
||||
|
||||
await _try_launch_browser(provider, proxy_override=proxy_override)
|
||||
if _browsers.get(provider) is not None:
|
||||
|
|
@ -1300,7 +1211,6 @@ async def _on_startup(app: web.Application) -> None:
|
|||
logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER)
|
||||
logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER)
|
||||
logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER)
|
||||
logger.info("tradein-browser: потолок живых инстансов %d", BROWSER_MAX_INSTANCES)
|
||||
_warn_retired_env()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,209 +0,0 @@
|
|||
"""test_server_instance_limit.py — потолок живых camoufox и выбор жертвы (#3412).
|
||||
|
||||
До #3412 инстанс поставщика поднимался лениво и не закрывался никогда: пять
|
||||
поставщиков = пять вечных браузеров в контейнере с mem_limit 2560m. Каждый тест
|
||||
ниже закрывает один дефект ревью прошлой попытки (ветка fix/3412-cian-instance-
|
||||
relaunch) или сам потолок; проверка — по тому, какие инстансы ОСТАЛИСЬ живыми.
|
||||
|
||||
camoufox не запускается: модуль camoufox.async_api подменён заглушкой.
|
||||
|
||||
Запуск (из tradein-mvp/browser/)::
|
||||
|
||||
python -m pytest test_server_instance_limit.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||||
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
server = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(server)
|
||||
|
||||
|
||||
class _FakeCM:
|
||||
"""AsyncCamoufox-заглушка: запуск по желанию медленный, закрытие пишет журнал."""
|
||||
|
||||
launch_delay = 0.0
|
||||
closed: list[str] = []
|
||||
# provider → был ли он ещё в server._browsers в момент СВОЕГО закрытия
|
||||
visible_while_closing: dict[str, bool] = {}
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
await asyncio.sleep(_FakeCM.launch_delay)
|
||||
return object()
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
provider = next(p for p, cm in _cms_by_provider.items() if cm is self)
|
||||
_FakeCM.visible_while_closing[provider] = provider in server._browsers
|
||||
await asyncio.sleep(0)
|
||||
_FakeCM.closed.append(provider)
|
||||
|
||||
|
||||
_cms_by_provider: dict[str, _FakeCM] = {}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in ("_browsers", "_browser_cms", "_contexts", "_anchor_pages", "_anchor_origins",
|
||||
"_page_counters", "_locks", "_retry_tasks", "_last_goto_at",
|
||||
"_launched_proxy", "_last_used"):
|
||||
monkeypatch.setattr(server, name, {})
|
||||
monkeypatch.setattr(server, "_launching", set())
|
||||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||
monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2)
|
||||
_FakeCM.launch_delay = 0.0
|
||||
_FakeCM.closed = []
|
||||
_FakeCM.visible_while_closing = {}
|
||||
_cms_by_provider.clear()
|
||||
|
||||
fake_mod = types.ModuleType("camoufox.async_api")
|
||||
fake_mod.AsyncCamoufox = _FakeCM # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox"))
|
||||
monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod)
|
||||
|
||||
|
||||
async def _launch(provider: str) -> None:
|
||||
await server._launch_browser(provider, proxy_override="http://u:p@pool:8080")
|
||||
_cms_by_provider[provider] = server._browser_cms[provider]
|
||||
|
||||
|
||||
def _live() -> set[str]:
|
||||
return set(server._browsers)
|
||||
|
||||
|
||||
def test_launch_at_cap_closes_idle_instance() -> None:
|
||||
async def _run() -> None:
|
||||
await _launch("cian")
|
||||
await _launch("yandex")
|
||||
await _launch("generic")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert _live() == {"yandex", "generic"}
|
||||
assert _FakeCM.closed == ["cian"]
|
||||
|
||||
|
||||
def test_victim_is_cheap_instance_not_avito_with_anchor() -> None:
|
||||
"""Дефект ревью 1: LRU закрывал avito с якорной вкладкой, оставляя cian без context'а."""
|
||||
|
||||
async def _run() -> None:
|
||||
await _launch("avito") # использован ДАВНЕЕ всех, но с прогретым context'ом
|
||||
server._contexts["avito"] = types.SimpleNamespace(close=_noop)
|
||||
server._anchor_pages["avito"] = object()
|
||||
await _launch("cian")
|
||||
await _launch("yandex")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert _live() == {"avito", "yandex"}
|
||||
assert _FakeCM.closed == ["cian"]
|
||||
|
||||
|
||||
async def _noop() -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_eviction_keeps_victim_pacing() -> None:
|
||||
"""Дефект ревью 2: вытеснение сбрасывало _last_goto_at чужого поставщика."""
|
||||
|
||||
async def _run() -> None:
|
||||
await _launch("cian")
|
||||
server._last_goto_at["cian"] = 12345.0
|
||||
await _launch("yandex")
|
||||
await _launch("generic")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert "cian" not in _live()
|
||||
assert server._last_goto_at.get("cian") == 12345.0
|
||||
|
||||
|
||||
def test_background_retry_path_respects_cap() -> None:
|
||||
"""Дефект ревью 3: retry-путь _try_launch_browser поднимал инстанс мимо потолка."""
|
||||
|
||||
async def _run() -> bool:
|
||||
await _launch("cian")
|
||||
await _launch("yandex")
|
||||
return await server._try_launch_browser("avito")
|
||||
|
||||
assert asyncio.run(_run()) is True
|
||||
assert len(_live()) == 2
|
||||
assert "avito" in _live()
|
||||
|
||||
|
||||
def test_fresh_instance_is_not_the_next_victim() -> None:
|
||||
"""Дефект ревью 4: свежий инстанс без отметки использования уходил первым."""
|
||||
|
||||
async def _run() -> None:
|
||||
await _launch("cian")
|
||||
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
|
||||
await _launch("yandex") # свежий, после последнего обращения к cian
|
||||
await _launch("generic") # должен закрыть давний cian, а не свежий yandex
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert _FakeCM.closed == ["cian"]
|
||||
|
||||
|
||||
def test_use_refreshes_lru_order() -> None:
|
||||
async def _run() -> None:
|
||||
await _launch("cian")
|
||||
await _launch("yandex")
|
||||
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
|
||||
await _launch("generic")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert _FakeCM.closed == ["yandex"]
|
||||
|
||||
|
||||
def test_busy_instance_is_never_closed() -> None:
|
||||
async def _run() -> None:
|
||||
await _launch("cian")
|
||||
await _launch("yandex")
|
||||
for provider in ("cian", "yandex"):
|
||||
await (await server._lock_for(provider)).acquire()
|
||||
await _launch("generic")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert _FakeCM.closed == []
|
||||
assert _live() == {"cian", "yandex", "generic"}
|
||||
|
||||
|
||||
def test_parallel_launches_reserve_their_slot() -> None:
|
||||
"""Два запуска разных поставщиков одновременно не должны оба увидеть свободное место."""
|
||||
|
||||
async def _run() -> None:
|
||||
await _launch("cian")
|
||||
_FakeCM.launch_delay = 0.01
|
||||
await asyncio.gather(_launch("avito"), _launch("yandex"))
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert _live() == {"avito", "yandex"}
|
||||
|
||||
|
||||
def test_evicted_instance_disappears_before_close_awaits() -> None:
|
||||
"""Жертву закрывает чужая корутина без её лока: во время close() браузер уже снят."""
|
||||
|
||||
async def _run() -> None:
|
||||
await _launch("cian")
|
||||
await _launch("yandex")
|
||||
await _launch("generic")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert _FakeCM.visible_while_closing == {"cian": False}
|
||||
|
|
@ -82,12 +82,6 @@ services:
|
|||
# (грубо ~2.5g на каждую доп. параллельную страницу).
|
||||
mem_limit: 2560m
|
||||
memswap_limit: 3g
|
||||
# init (#3412): tini как PID 1. Без него PID 1 — `python server.py`, который не
|
||||
# собирает осиротевших детей: дочерние процессы закрытого camoufox (forkserver,
|
||||
# Utility, Socket, RDD) переходят к PID 1 и висят зомби навсегда. Прод 17.09.2026
|
||||
# за 14 ч с рестарта: 2296 процессов в состоянии Z (по 574 каждого вида),
|
||||
# pids.current 2722 при трёх живых браузерах.
|
||||
init: true
|
||||
# stop_grace_period: browser/server.py — bare aiohttp web.run_app(), которое
|
||||
# само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний
|
||||
# shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие
|
||||
|
|
|
|||
|
|
@ -178,14 +178,10 @@ LIMIT 20;
|
|||
одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С
|
||||
city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже
|
||||
закрыта сужением по городу, поведение byte-for-byte прежнее.
|
||||
- **Регион дома (#2583 H5).** Дом получает guid только ГАР-строки своего региона
|
||||
(`houses.region_code`), канон ранжируется внутри региона. Перед матчем снимаются уже
|
||||
проставленные `canon_addr`-guid чужого региона (дом без `region_code` не трогаем) — лог
|
||||
пишет «снято guid чужого региона=N». После выкатки прогнать `--match-only` для 66, 77 и 50.
|
||||
- **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon
|
||||
однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве —
|
||||
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY region_code, canon
|
||||
ORDER BY flat_count DESC, house_guid)`.
|
||||
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER
|
||||
BY flat_count DESC, house_guid)`.
|
||||
- **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`).
|
||||
Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс
|
||||
`gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ const TARIFFS: readonly {
|
|||
},
|
||||
{
|
||||
name: "Полный отчёт",
|
||||
note: "Цена, диапазон, срок экспозиции похожих объявлений, аналоги, факторы. Разовый платёж",
|
||||
note: "Цена, прогноз срока, диапазон, аналоги, факторы. Разовый платёж",
|
||||
price: `${SERVICE_PRICE_RUB} ₽`,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -139,7 +139,6 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = {
|
|||
sources_used: ["avito", "cian", "rosreestr"],
|
||||
data_freshness_minutes: 35,
|
||||
last_scraped_at: "2026-05-30T12:00:00Z",
|
||||
exposure_window: { p25_days: 31, p50_days: 48, p75_days: 104, n: 57 },
|
||||
est_days_on_market: 48,
|
||||
address_precision: "house",
|
||||
area_m2: 55.3,
|
||||
|
|
|
|||
|
|
@ -246,9 +246,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
const houseType = estimate.house_type ?? input.house_type;
|
||||
const repairState = estimate.repair_state ?? input.repair_state;
|
||||
const hasBalcony = estimate.has_balcony ?? input.has_balcony;
|
||||
// #2898: окно p25–p75, а не одно число. est_days_on_market не показываем:
|
||||
// у старых оценок это возраст висящих объявлений, а не срок до снятия.
|
||||
const exposure = estimate.exposure_window ?? null;
|
||||
const estDaysOnMarket = estimate.est_days_on_market;
|
||||
|
||||
function handleEnrichSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
|
@ -405,14 +403,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
<span className="v mono">{estimate.n_analogs}</span>
|
||||
</div>
|
||||
<div className="meta-row">
|
||||
<span className="k">До снятия объявления</span>
|
||||
{exposure ? (
|
||||
<span
|
||||
className="v mono"
|
||||
title={`Средняя половина из ${exposure.n} похожих объявлений рядом висела столько дней до снятия. Снятие объявления не значит продажу.`}
|
||||
>
|
||||
{exposure.p25_days}–{exposure.p75_days} дн.
|
||||
</span>
|
||||
<span className="k">Срок продажи</span>
|
||||
{estDaysOnMarket !== null ? (
|
||||
<span className="v mono">{estDaysOnMarket} дн.</span>
|
||||
) : (
|
||||
<span className="v" style={{ color: "var(--muted)" }}>нет данных</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) {
|
|||
<HouseAnalyticsKpiRow kpi={data.kpi} />
|
||||
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
|
||||
{data.price_history.length >= 2 && (
|
||||
<PriceHistoryChart points={data.price_history} radiusM={data.radius_m} />
|
||||
<PriceHistoryChart points={data.price_history} />
|
||||
)}
|
||||
{data.recent_sold.length > 0 && (
|
||||
<RecentSoldList items={data.recent_sold} />
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ import {
|
|||
import type { PriceHistoryYearPoint } from "@/types/trade-in";
|
||||
import { sourcePublicLabel } from "@/lib/source-registry";
|
||||
|
||||
// radiusM — radius_m из /house-analytics: 0 = выборка ровно по дому, >0 = дом и соседние
|
||||
// в этом радиусе (M2 audit #2583).
|
||||
type Props = { points: PriceHistoryYearPoint[]; radiusM: number };
|
||||
type Props = { points: PriceHistoryYearPoint[] };
|
||||
|
||||
const AVITO_LABEL = sourcePublicLabel("avito_imv");
|
||||
const YANDEX_LABEL = sourcePublicLabel("yandex_valuation");
|
||||
|
|
@ -30,7 +28,7 @@ interface PivotRow {
|
|||
n_yandex?: number;
|
||||
}
|
||||
|
||||
export function PriceHistoryChart({ points, radiusM }: Props) {
|
||||
export function PriceHistoryChart({ points }: Props) {
|
||||
const pivoted = useMemo<PivotRow[]>(() => {
|
||||
const byYear: Record<number, PivotRow> = {};
|
||||
for (const p of points) {
|
||||
|
|
@ -52,12 +50,10 @@ export function PriceHistoryChart({ points, radiusM }: Props) {
|
|||
<article className="card" style={{ marginTop: 12, padding: 16 }}>
|
||||
<header style={{ marginBottom: 8 }}>
|
||||
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
||||
{radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"}
|
||||
История цен в этом доме
|
||||
</h4>
|
||||
<small style={{ color: "var(--muted, #6b7280)" }}>
|
||||
Медиана ₽/м² по годам ·{" "}
|
||||
{radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"} ·{" "}
|
||||
{AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
|
||||
Медиана ₽/м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
|
||||
</small>
|
||||
</header>
|
||||
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
/**
|
||||
* #2898 — в hero окно срока до снятия объявления (p25–p75), а не одно число.
|
||||
* Старое est_days_on_market без окна не показываем: у оценок до #2898 это возраст
|
||||
* висящих объявлений, и «48 дн.» читалось бы как срок продажи.
|
||||
*/
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HeroSummary } from "../HeroSummary";
|
||||
import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "@/app/ui-preview/estimate/fixture";
|
||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||
|
||||
vi.mock("@/lib/useBrand", () => ({
|
||||
useActiveBrandSlug: () => null,
|
||||
useBrand: () => ({ data: null }),
|
||||
}));
|
||||
vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null }));
|
||||
|
||||
function heroText(estimate: AggregatedEstimate): string {
|
||||
const { container } = render(
|
||||
<HeroSummary estimate={estimate} input={FIXTURE_INPUT} onResubmit={() => {}} />,
|
||||
);
|
||||
return container.textContent ?? "";
|
||||
}
|
||||
|
||||
describe("#2898 окно срока в v1 HeroSummary", () => {
|
||||
it("показывает края окна", () => {
|
||||
const text = heroText({
|
||||
...FIXTURE_ESTIMATE,
|
||||
exposure_window: { p25_days: 29, p50_days: 61, p75_days: 146, n: 44 },
|
||||
est_days_on_market: 61,
|
||||
});
|
||||
expect(text).toContain("До снятия объявления29–146 дн.");
|
||||
expect(text).not.toContain("Срок продажи");
|
||||
});
|
||||
|
||||
it("без окна — «нет данных», старое число не всплывает", () => {
|
||||
const text = heroText({
|
||||
...FIXTURE_ESTIMATE,
|
||||
exposure_window: null,
|
||||
est_days_on_market: 48,
|
||||
});
|
||||
expect(text).toContain("До снятия объявлениянет данных");
|
||||
expect(text).not.toContain("48 дн.");
|
||||
});
|
||||
});
|
||||
|
|
@ -357,7 +357,7 @@ export default function AnalyticsView({
|
|||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
||||
{data.priceHistory.title}
|
||||
История цен в этом доме
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||
{historyNote}
|
||||
|
|
@ -401,9 +401,9 @@ export default function AnalyticsView({
|
|||
style={{ width: "100%", height: 260, marginTop: 12 }}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={`${data.priceHistory.title}: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`}
|
||||
aria-label={`График истории цен в этом доме: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`}
|
||||
>
|
||||
<title>{`${data.priceHistory.title} — медиана ₽/м² по годам`}</title>
|
||||
<title>История цен в этом доме — медиана ₽/м² по годам</title>
|
||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||
{PH_GRID_Y.map((y) => (
|
||||
<line key={y} x1={40} y1={y} x2={900} y2={y} />
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
// M2 audit #2583 — подпись «История цен в этом доме · только этот дом» была
|
||||
// безусловной, хотя /house-analytics при <8 архивных лотах расширяет выборку до
|
||||
// соседних домов в радиусе 300 м (radius_m в ответе). На том же экране рядом
|
||||
// стояло «в радиусе 300 м» — две взаимоисключающие подписи.
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PriceHistoryChart } from "../../PriceHistoryChart";
|
||||
import AnalyticsView from "../AnalyticsView";
|
||||
import { mapAnalytics } from "../mappers";
|
||||
import type {
|
||||
HouseAnalyticsResponse,
|
||||
PriceHistoryYearPoint,
|
||||
} from "@/types/trade-in";
|
||||
|
||||
const points: PriceHistoryYearPoint[] = [
|
||||
{ year: 2024, source: "avito_imv", median_price_per_m2: 120_000, n_lots: 3, median_price_rub: 6_000_000 },
|
||||
{ year: 2025, source: "avito_imv", median_price_per_m2: 130_000, n_lots: 4, median_price_rub: 6_500_000 },
|
||||
];
|
||||
|
||||
function analytics(radius_m: number): HouseAnalyticsResponse {
|
||||
return {
|
||||
house_ids: [1, 2, 3],
|
||||
radius_m,
|
||||
price_history: points,
|
||||
recent_sold: [],
|
||||
kpi: {
|
||||
total_lots: 7,
|
||||
sold_count: 0,
|
||||
sold_rate_pct: 0,
|
||||
median_exposure_days: null,
|
||||
median_bargain_pct: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("v2 история цен: подпись следует radius_m", () => {
|
||||
it("выборка расширена соседями — не «только этот дом»", () => {
|
||||
const { priceHistory } = mapAnalytics(analytics(300), null, null);
|
||||
expect(priceHistory.title).toBe("История цен: дом и соседние");
|
||||
expect(priceHistory.note).toContain("дом и соседние в радиусе 300 м");
|
||||
expect(priceHistory.note).not.toContain("только этот дом");
|
||||
});
|
||||
|
||||
it("экран v2 печатает подпись из данных, а не константу", () => {
|
||||
render(<AnalyticsView data={mapAnalytics(analytics(300), null, null)} />);
|
||||
expect(screen.getByText("История цен: дом и соседние")).toBeTruthy();
|
||||
expect(screen.queryByText(/в этом доме/)).toBeNull();
|
||||
});
|
||||
|
||||
it("выборка ровно по дому — прежняя подпись", () => {
|
||||
const { priceHistory } = mapAnalytics(analytics(0), null, null);
|
||||
expect(priceHistory.title).toBe("История цен в этом доме");
|
||||
expect(priceHistory.note).toContain("только этот дом");
|
||||
});
|
||||
});
|
||||
|
||||
describe("старый экран: PriceHistoryChart", () => {
|
||||
it("radiusM>0 — заголовок и подпись про соседей", () => {
|
||||
// recharts ResponsiveContainer требует ResizeObserver, которого нет в jsdom.
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
render(<PriceHistoryChart points={points} radiusM={300} />);
|
||||
expect(screen.getByRole("heading").textContent).toBe("История цен: дом и соседние");
|
||||
expect(screen.queryByText(/только этот дом/)).toBeNull();
|
||||
expect(screen.getByText(/дом и соседние в радиусе 300 м/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -1820,16 +1820,7 @@ export function phYearX(i: number, n: number): number {
|
|||
}
|
||||
|
||||
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
||||
// M2 audit #2583: при <8 архивных лотах /house-analytics подмешивает до 30 соседних
|
||||
// домов в радиусе radius_m — «только этот дом» тогда неправда (рядом на экране
|
||||
// стоит «в радиусе 300 м»). radius_m=0 — выборка ровно по дому.
|
||||
const radiusM = a?.radius_m ?? 0;
|
||||
const title =
|
||||
radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме";
|
||||
const scope =
|
||||
radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом";
|
||||
const empty: PriceHistory = {
|
||||
title,
|
||||
note: "Нет данных по истории цен",
|
||||
years: [],
|
||||
avito: "",
|
||||
|
|
@ -1888,7 +1879,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
|||
.join(" ");
|
||||
|
||||
const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
|
||||
const note = `Медиана ₽/м² по годам · ${scope} · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu(
|
||||
const note = `Медиана ₽/м² по годам · только этот дом · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu(
|
||||
totalLots,
|
||||
["лот", "лота", "лотов"],
|
||||
)})`;
|
||||
|
|
@ -1899,7 +1890,6 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
|
|||
});
|
||||
|
||||
return {
|
||||
title,
|
||||
note,
|
||||
years: yearsNum.map(String),
|
||||
avito: series("avito_imv"),
|
||||
|
|
|
|||
|
|
@ -264,8 +264,6 @@ export interface SellTimeTier {
|
|||
}
|
||||
|
||||
export interface PriceHistory {
|
||||
/** Заголовок карточки: «в этом доме» только когда выборка не расширялась соседями. */
|
||||
title: string;
|
||||
note: string;
|
||||
years: string[];
|
||||
/** SVG polyline points string (viewBox 0 0 900 220). */
|
||||
|
|
|
|||
|
|
@ -128,15 +128,6 @@ export interface PriceTrendPoint {
|
|||
ppm2: number; // ₽/м²
|
||||
}
|
||||
|
||||
// #2898: сколько дней похожие квартиры рядом висели до СНЯТИЯ объявления
|
||||
// (не подтверждённой продажи) — квартили house_placement_history.exposure_days.
|
||||
export interface ExposureWindow {
|
||||
p25_days: number;
|
||||
p50_days: number;
|
||||
p75_days: number;
|
||||
n: number; // снятых объявлений в выборке
|
||||
}
|
||||
|
||||
export interface CianValuationSummary {
|
||||
sale_price_rub: number | null;
|
||||
rent_price_rub: number | null;
|
||||
|
|
@ -243,10 +234,7 @@ export interface AggregatedEstimate {
|
|||
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
|
||||
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
||||
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
||||
// #2898: окно p25–p75; null — выборка < 30 или оценка старее бэкенда.
|
||||
exposure_window?: ExposureWindow | null;
|
||||
// Устарело: = exposure_window.p50_days. Показывать окно, не это число.
|
||||
est_days_on_market: number | null;
|
||||
est_days_on_market: number | null; // прогноз срока продажи
|
||||
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
|
||||
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
|
||||
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
|
||||
|
|
|
|||
|
|
@ -4868,7 +4868,6 @@ async def run_domclick_city_sweep(
|
|||
region_code: int = DEFAULT_REGION_CODE,
|
||||
resume_run_id: int | None = None,
|
||||
cookies: dict[str, str] | None = None,
|
||||
watchdog_sec: int | None = None,
|
||||
) -> DomClickCitySweepCounters:
|
||||
"""DomClick citywide sweep через BFF JSON API.
|
||||
|
||||
|
|
@ -4897,15 +4896,6 @@ async def run_domclick_city_sweep(
|
|||
зависли на challenge). None (дефолт, сессии в БД нет/протухла) — прежнее
|
||||
поведение, без инъекции.
|
||||
|
||||
watchdog_sec (incremental-save): явный override расчётной формулы watchdog'а. Формула
|
||||
ниже (buckets × pages × per_fetch + budget) не знает про бисекцию по цене
|
||||
(ДомКлик режет offset на 2000, каждый лист пагинируется отдельно отдельным
|
||||
деревом сплитов) и на больших городах (Москва ≈23 690 лотов против ЕКБ
|
||||
≈6 300) занижена в разы — прод run 7344: за 2ч watchdog'а (11 100с) пройдено
|
||||
2 бакета из 6. С инкрементальным сохранением (on_bucket, см. ниже) ранний
|
||||
снос по watchdog больше не теряет собранное, поэтому вместо более точной
|
||||
оценки — простой override: None (дефолт) — прежняя формула байт-в-байт.
|
||||
|
||||
Возвращает DomClickCitySweepCounters.
|
||||
"""
|
||||
# Гео-скоуп свипа больше не зашит в ЕКБ: его задаёт профиль региона (address
|
||||
|
|
@ -4919,18 +4909,12 @@ async def run_domclick_city_sweep(
|
|||
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
|
||||
counters = DomClickCitySweepCounters()
|
||||
|
||||
# Watchdog: 6 buckets × pages × per_fetch + budget. watchdog_sec (incremental-save) — явный
|
||||
# override для больших городов, где формула занижена (см. докстринг выше).
|
||||
# Watchdog: 6 buckets × pages × per_fetch + budget.
|
||||
_num_fetches = _DOMCLICK_NUM_BUCKETS * max(1, pages)
|
||||
if watchdog_sec is not None:
|
||||
_sweep_timeout = int(watchdog_sec)
|
||||
else:
|
||||
_sweep_timeout = max(
|
||||
ANCHOR_TIMEOUT_SEC,
|
||||
int(
|
||||
_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S
|
||||
),
|
||||
)
|
||||
_sweep_timeout = max(
|
||||
ANCHOR_TIMEOUT_SEC,
|
||||
int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S),
|
||||
)
|
||||
|
||||
# Мутируемый контейнер для захвата scraper-ссылки из замыкания.
|
||||
_scraper_ref: list[DomClickScraper] = []
|
||||
|
|
@ -5007,68 +4991,14 @@ async def run_domclick_city_sweep(
|
|||
_sweep_timeout,
|
||||
)
|
||||
|
||||
# incremental-save: сохранение инкрементальное — save_listings зовётся из _on_bucket ПОСЛЕ
|
||||
# КАЖДОГО room-бакета, а не одним save_listings в самом конце. Раньше снятие фазы
|
||||
# по watchdog'у (asyncio.wait_for TimeoutError) теряло ВСЁ собранное — на большой
|
||||
# выдаче (Москва ≈23 690 лотов vs ЕКБ ≈6 300) формула watchdog'а систематически
|
||||
# не укладывалась в отведённое время (прод run 7344: 2 бакета из 6 за 2ч).
|
||||
_cancel_reason: str | None = None
|
||||
|
||||
def _on_bucket(bucket_key: str, bucket_lots: list[ScrapedLot]) -> None:
|
||||
"""Инкрементальный save сразу после того, как room-бакет отфетчился.
|
||||
|
||||
fetch_city (serp.py) зовёт колбэк ВНЕ try/except конкретного бакета —
|
||||
исключение отсюда прерывает обход ROOM_BUCKETS целиком (канал кооперативной
|
||||
отмены/SIGTERM-дрейна), а не проглатывается generic except'ом бакета.
|
||||
Sentinel-приём (RuntimeError("cancelled")/("shutdown")) — тот же, что в
|
||||
run_cian_full_load._on_bucket (#1182 Phase 3a).
|
||||
"""
|
||||
nonlocal _checkpoint
|
||||
if runs.is_cancelled(db, run_id):
|
||||
logger.info(
|
||||
"domclick-sweep run_id=%d: cancel detected in on_bucket (%s)",
|
||||
run_id,
|
||||
bucket_key,
|
||||
)
|
||||
raise RuntimeError("cancelled")
|
||||
elif shutdown_requested():
|
||||
logger.info(
|
||||
"domclick-sweep run_id=%d: SIGTERM-drain — stopping at bucket %s",
|
||||
run_id,
|
||||
bucket_key,
|
||||
)
|
||||
raise RuntimeError("shutdown")
|
||||
if bucket_lots:
|
||||
# Имя города — из профиля региона, а не из сравнения с vestigial
|
||||
# city_id: гео-скоп задаёт регион, он же знает, какой город штамповать.
|
||||
# У области (50) city_name=None — одного города нет, угадывать нечего.
|
||||
inserted, updated = save_listings(
|
||||
db,
|
||||
bucket_lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
city=_geo_profile.city_name,
|
||||
)
|
||||
counters.lots_fetched += len(bucket_lots)
|
||||
counters.lots_inserted += inserted
|
||||
counters.lots_updated += updated
|
||||
# #3118, теперь на уровне бакета: done_buckets означает "собрано И
|
||||
# сохранено" — чекпоинт пишем ТОЛЬКО пройдя cancel/shutdown-гейт выше и
|
||||
# save_listings этого бакета, не из scraper.completed_buckets (см.
|
||||
# комментарий у "Перенести счётчики" ниже — там раньше был баг #2 issue).
|
||||
_checkpoint = sorted(set(_checkpoint) | {bucket_key})
|
||||
runs.update_heartbeat(db, run_id, _payload())
|
||||
logger.info(
|
||||
"domclick-sweep run_id=%d: bucket %s saved lots=%d total=%d",
|
||||
run_id,
|
||||
bucket_key,
|
||||
len(bucket_lots),
|
||||
counters.lots_fetched,
|
||||
)
|
||||
lots: list[ScrapedLot] = []
|
||||
# #2406: собранное сохраняется ОДНИМ save_listings после всех корзин, поэтому
|
||||
# корзина считается пройденной только если фаза дошла до конца (см. чекпоинт ниже).
|
||||
_saved = False
|
||||
|
||||
async def _domclick_phase() -> None:
|
||||
"""Единственная citywide-фаза: fetch_city с инкрементальным save по бакетам."""
|
||||
"""Единственная citywide-фаза: fetch_city + save."""
|
||||
nonlocal lots, _saved
|
||||
async with DomClickScraper(
|
||||
config,
|
||||
proxy_provider=proxy_provider,
|
||||
|
|
@ -5091,21 +5021,36 @@ async def run_domclick_city_sweep(
|
|||
# источники и растёт неравномерно, — но за 30 суток каждая корзина
|
||||
# получает порядка пяти стартов, чего достаточно для критерия приёмки
|
||||
# «объявления с rooms >= 2 появились».
|
||||
await _scraper.fetch_city(
|
||||
lots = await _scraper.fetch_city(
|
||||
city_id=city_id,
|
||||
rooms=rooms,
|
||||
pages=pages,
|
||||
start_bucket_index=run_id % len(ROOM_BUCKETS),
|
||||
skip_buckets=skip_buckets or None,
|
||||
on_bucket=_on_bucket,
|
||||
)
|
||||
counters.lots_fetched += len(lots)
|
||||
if lots:
|
||||
# Имя города — из профиля региона, а не из сравнения с vestigial
|
||||
# city_id: гео-скоп задаёт регион, он же знает, какой город штамповать.
|
||||
# У области (50) city_name=None — одного города нет, угадывать нечего.
|
||||
_dc_city = _geo_profile.city_name
|
||||
inserted, updated = save_listings(
|
||||
db,
|
||||
lots,
|
||||
matcher=matcher,
|
||||
region_code=region_code,
|
||||
run_id=run_id,
|
||||
city=_dc_city,
|
||||
)
|
||||
counters.lots_inserted += inserted
|
||||
counters.lots_updated += updated
|
||||
_saved = True
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_domclick_phase(), timeout=_sweep_timeout)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"domclick-sweep run_id=%d: SERP phase timed out after %ds — partial results "
|
||||
"(incremental-save: уже сохранены инкрементально, бакет за бакетом)",
|
||||
"domclick-sweep run_id=%d: SERP phase timed out after %ds — partial results",
|
||||
run_id,
|
||||
_sweep_timeout,
|
||||
)
|
||||
|
|
@ -5135,16 +5080,6 @@ async def run_domclick_city_sweep(
|
|||
ban_kind=ban_kind_of_exception(exc),
|
||||
)
|
||||
return counters
|
||||
except RuntimeError as exc:
|
||||
# on_bucket кидает RuntimeError("cancelled") при кооперативной отмене,
|
||||
# RuntimeError("shutdown") при SIGTERM-дрейне — тот же sentinel-приём, что в
|
||||
# run_cian_full_load._on_bucket (#1182 Phase 3a). Прочие RuntimeError —
|
||||
# обычная поломка фазы, ведём себя как под generic except ниже.
|
||||
if str(exc) in ("cancelled", "shutdown"):
|
||||
_cancel_reason = str(exc)
|
||||
else:
|
||||
logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
|
||||
counters.errors_count += 1
|
||||
except Exception:
|
||||
logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
|
||||
counters.errors_count += 1
|
||||
|
|
@ -5160,13 +5095,15 @@ async def run_domclick_city_sweep(
|
|||
# скрейпер живая, а его счётчик показывает, докуда прогон дошёл.
|
||||
counters.buckets_completed = _s.buckets_completed
|
||||
counters.buckets_total = _s.buckets_total
|
||||
# incremental-save: _checkpoint СЮДА больше не мержится из _s.completed_buckets — он
|
||||
# пишется инкрементально внутри _on_bucket, СРАЗУ после save_listings этого
|
||||
# бакета. _s.completed_buckets на уровне скрейпера отмечает бакет как "фетч
|
||||
# прошёл" ДО вызова on_bucket (см. serp.py): если on_bucket поймал
|
||||
# cancel/shutdown ДО save для этого самого бакета, _s.completed_buckets
|
||||
# включил бы его, а _checkpoint — честно нет (лоты не сохранены). Мердж
|
||||
# отсюда воспроизвёл бы старый баг — чекпоинт врёт про несохранённые бакеты.
|
||||
# #3118: чекпоинт = унаследованное ∪ завершённое в этом прогоне.
|
||||
# Пишем heartbeat'ом СЕЙЧАС (мерж jsonb) — финализаторы ключ не
|
||||
# затирают, и оборванный болезнью финализации прогон его не теряет.
|
||||
# #2406: только если лоты сохранены. Снятая watchdog'ом или упавшая фаза
|
||||
# не дошла до save_listings — у её корзин в БД ноль строк, а отметка
|
||||
# «пройдена» заставила бы следующий прогон пропустить их навсегда
|
||||
# (механизм разобран в миграции 308, из-за него выключены свипы 77/50).
|
||||
if _saved:
|
||||
_checkpoint = sorted(skip_buckets | set(_s.completed_buckets))
|
||||
runs.update_heartbeat(db, run_id, _payload())
|
||||
counters.bucket_start_index = _s.bucket_start_index
|
||||
|
||||
|
|
@ -5174,32 +5111,6 @@ async def run_domclick_city_sweep(
|
|||
counters.pages_fetched = _num_fetches
|
||||
runs.update_heartbeat(db, run_id, _payload())
|
||||
|
||||
# incremental-save: кооперативная отмена/SIGTERM-дрейн, пойманные в on_bucket — партиал уже
|
||||
# сохранён инкрементально, финализируем как run_cian_full_load (mark_done partial,
|
||||
# не honest-status ниже: обрыв тут known-signal, а не "прогон не доделал сам").
|
||||
if _cancel_reason == "cancelled":
|
||||
logger.info(
|
||||
"domclick-sweep run_id=%d: cancelled — partial results lots=%d (ins=%d/upd=%d)",
|
||||
run_id,
|
||||
counters.lots_fetched,
|
||||
counters.lots_inserted,
|
||||
counters.lots_updated,
|
||||
)
|
||||
runs.mark_done(db, run_id, _payload())
|
||||
return counters
|
||||
if _cancel_reason == "shutdown":
|
||||
logger.info(
|
||||
"domclick-sweep run_id=%d: SIGTERM-drain — partial results lots=%d (ins=%d/upd=%d)",
|
||||
run_id,
|
||||
counters.lots_fetched,
|
||||
counters.lots_inserted,
|
||||
counters.lots_updated,
|
||||
)
|
||||
_drain = {**_payload(), "interrupted": 1}
|
||||
runs.update_heartbeat(db, run_id, _drain)
|
||||
runs.mark_done(db, run_id, _drain)
|
||||
return counters
|
||||
|
||||
# ── ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657) ───────────────────────────
|
||||
# Распознанный QRATOR-блок НИКОГДА не даёт done. Домклик тут структурно
|
||||
# отличается от cian/yandex (#2625/#2642): там независимые anchor'ы, и
|
||||
|
|
|
|||
|
|
@ -1270,9 +1270,6 @@ async def _job_domclick_city_sweep(
|
|||
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
|
||||
region_code=_resolve_region_code(params),
|
||||
resume_run_id=_pick_resume(db, run_id),
|
||||
watchdog_sec=(
|
||||
int(params["watchdog_sec"]) if params.get("watchdog_sec") is not None else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -192,7 +192,6 @@ def build_browser_fetcher(
|
|||
*,
|
||||
proxy_provider: ProxyProvider | None = None,
|
||||
fetch_timeout_s: float | None = None,
|
||||
reuse_context: bool = False,
|
||||
) -> BrowserFetcher:
|
||||
"""Собрать `BrowserFetcher` с `config: ScraperConfig` **mandatory**.
|
||||
|
||||
|
|
@ -222,11 +221,6 @@ def build_browser_fetcher(
|
|||
(120s). Явный таймаут передаёт ровно один call-site — `yandex/serp.py` (30s);
|
||||
`yandex/newbuilding.py` идёт на дефолтных 120s.
|
||||
|
||||
`reuse_context=False` (дефолт) — сохраняет прежнее поведение всех вызывающих:
|
||||
холодный контекст на каждый /fetch. `reuse_context=True` (#3118, свип домклика)
|
||||
держит один сайдкар-контекст на весь прогон вместо нового камуфокса на каждый
|
||||
фетч.
|
||||
|
||||
`environment=getattr(config, "environment", "dev")` (#2616 шаг 1) — прокидывается в
|
||||
`BrowserFetcher._pool_proxy`: пул пуст/сломан + прод → отказ вместо мёртвого
|
||||
env-прокси. `getattr` с дефолтом "dev" — минимальные ScraperConfig-заглушки без поля
|
||||
|
|
@ -240,7 +234,6 @@ def build_browser_fetcher(
|
|||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
environment=environment,
|
||||
reuse_context=reuse_context,
|
||||
)
|
||||
return BrowserFetcher(
|
||||
source=source,
|
||||
|
|
@ -249,7 +242,6 @@ def build_browser_fetcher(
|
|||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
environment=environment,
|
||||
reuse_context=reuse_context,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -459,7 +459,6 @@ class DomClickScraper(BaseScraper):
|
|||
pages: int = 100,
|
||||
start_bucket_index: int = 0,
|
||||
skip_buckets: set[str] | None = None,
|
||||
on_bucket: Callable[[str, list[ScrapedLot]], None] | None = None,
|
||||
) -> list[ScrapedLot]:
|
||||
"""Citywide sweep через BFF JSON API.
|
||||
|
||||
|
|
@ -493,17 +492,6 @@ class DomClickScraper(BaseScraper):
|
|||
buckets_total при этом = числу корзин В ЭТОМ прогоне (без
|
||||
скипнутых) — иначе honest-status читал бы возобновлённый прогон
|
||||
как вечно-частичный.
|
||||
on_bucket: колбэк инкрементального сохранения — большая выдача теряла
|
||||
ВСЁ собранное при снятии по watchdog, единственный save был в самом
|
||||
конце (fix/domclick-incremental-save). Зовётся СИНХРОННО сразу после того, как
|
||||
бакет отработал успешно (после DomClickBlockedError/generic
|
||||
Exception — НЕ зовётся), аргументы: имя бакета + лоты ИМЕННО
|
||||
этого бакета (не накопленный out_lots). Вызов стоит ВНЕ
|
||||
try/except этого бакета: исключение из колбэка (кооперативная
|
||||
отмена/SIGTERM-дрейн — см. run_domclick_city_sweep) обязано
|
||||
прервать цикл по ROOM_BUCKETS, а не быть проглоченным generic
|
||||
except'ом. on_bucket=None (дефолт) — поведение прежнее,
|
||||
байт-в-байт.
|
||||
|
||||
Returns:
|
||||
Дедуплицированный по source_id список ScrapedLot.
|
||||
|
|
@ -522,17 +510,8 @@ class DomClickScraper(BaseScraper):
|
|||
# вживую 09.08: через него 500, через мобильный узел пула — 200 и
|
||||
# snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
|
||||
# proxy_provider=None (тесты/dev) по-прежнему валиден — env-fallback.
|
||||
# reuse_context=True (#3118): без него сайдкар поднимает новый камуфокс на
|
||||
# КАЖДЫЙ /fetch (browser.new_page() создаёт свежий изолированный контекст) —
|
||||
# разовая инъекция cookie выше никогда не видит живой qrator_jsid2, который
|
||||
# сайт ротирует через Set-Cookie (TTL ~2.5ч). Замер: 26 подряд холодных
|
||||
# фетчей = 100% блок, те же карточки в тёплом контексте — 5/5 примерно по 2с
|
||||
# (см. backend/app/tasks/domclick_detail_backfill.py:394-401). Заодно
|
||||
# холодный путь не давал якорной вкладке выжить между фетчами — goto(origin)
|
||||
# валился таймаутом 60с, убивая всю корзину. Тёплый контекст держит один
|
||||
# сайдкар-контекст на весь прогон вместо этого.
|
||||
async with build_browser_fetcher(
|
||||
self._config, "domclick", proxy_provider=self._proxy_provider, reuse_context=True
|
||||
self._config, "domclick", proxy_provider=self._proxy_provider
|
||||
) as fetcher:
|
||||
# Циклический сдвиг: состав корзин прежний, меняется только точка входа.
|
||||
# Отрицательный/большой индекс нормализуем — вызывающий передаёт остаток от
|
||||
|
|
@ -569,7 +548,6 @@ class DomClickScraper(BaseScraper):
|
|||
city_id,
|
||||
pages,
|
||||
)
|
||||
_bucket_start_len = len(out_lots)
|
||||
try:
|
||||
await self._sweep_bucket(
|
||||
fetcher=fetcher,
|
||||
|
|
@ -591,16 +569,6 @@ class DomClickScraper(BaseScraper):
|
|||
# прокинут выше) это уже не no-op: узел уходит в
|
||||
# scrape_proxy_source_bans и следующий acquire("domclick") его не
|
||||
# выдаст.
|
||||
# NB: request_context_reset() здесь СОЗНАТЕЛЬНО не зовём. Он лишь
|
||||
# взводит `_context_reset_pending`, а тот уезжает в сайдкар только
|
||||
# ключом `reset_context` СЛЕДУЮЩЕГО fetch() (browser_fetcher.py:565);
|
||||
# `__aexit__` его не сливает. Ниже сразу break — фетчей больше не
|
||||
# будет, флаг умрёт вместе с объектом. Вызов был бы no-op'ом, который
|
||||
# читается как защита.
|
||||
# Дыра остаётся: `_contexts[provider]` в сайдкаре — dict без TTL, так
|
||||
# что сожжённый блоком контекст достанется СЛЕДУЮЩЕМУ прогону. Закрыть
|
||||
# можно только отдельной ручкой сброса в сайдкаре (сейчас там только
|
||||
# /fetch, /fetch-json, /login, /health, /pacing) — отдельной задачей.
|
||||
fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
|
||||
break
|
||||
except Exception as exc:
|
||||
|
|
@ -614,14 +582,6 @@ class DomClickScraper(BaseScraper):
|
|||
# Exception, а не BaseException — CancelledError (SIGTERM-drain,
|
||||
# watchdog asyncio.wait_for) обязан пройти насквозь.
|
||||
self.fetch_errors += 1
|
||||
# #3118: корзина упала — тёплый контекст (reuse_context=True) мог
|
||||
# сгореть вместе с ней, и тогда он переползёт в следующую корзину.
|
||||
# Прод 17.09, прогон 7389: после падения 'rooms=3' три корзины подряд
|
||||
# ('5+', 'st', '1') легли за две минуты каждая на таймауте
|
||||
# goto(origin) — якорная вкладка не поднималась в том же контексте.
|
||||
# Сброс стоит РОВНО здесь, а не в ветке DomClickBlockedError: там
|
||||
# сразу break и прогон заканчивается, сбрасывать уже нечего.
|
||||
fetcher.request_context_reset()
|
||||
logger.warning(
|
||||
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
||||
bucket,
|
||||
|
|
@ -631,11 +591,6 @@ class DomClickScraper(BaseScraper):
|
|||
continue
|
||||
self.buckets_completed += 1
|
||||
self.completed_buckets.append(bucket)
|
||||
# incremental-save: колбэк ВНЕ try/except этого бакета — исключение (канал
|
||||
# кооперативной отмены/SIGTERM-дрейна в pipeline.run_domclick_city_sweep)
|
||||
# обязано прервать цикл, а не попасть в generic except выше.
|
||||
if on_bucket is not None:
|
||||
on_bucket(bucket, out_lots[_bucket_start_len:])
|
||||
|
||||
logger.info(
|
||||
"domklik: fetch_city done city_id=%d total=%d buckets=%d/%d "
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue