"""Shared scheduler helpers (#2397 Part C — legacy scheduler_loop removed). История: до #2192 этот модуль нёс полный in-app asyncio scheduler (`scheduler_loop`, 27-веточный `if/elif` dispatch, `trigger_*_run` per-source launchers, `get_due_schedules`, `reap_zombies`, `_claim_run` advisory-lock claim, `_spawn_tracked`/`_drain_inflight` graceful-drain machinery). #2192 представил kit-путь (`scraper_kit.orchestration.scheduler` + `app.services.product_handlers`) как ship-dark alternative; прод переключился на него (`USE_KIT_SCHEDULER=true` в tradein-scraper, см. docker-compose.prod.yml). #2397 Part C убрал legacy `scheduler_loop` и все `trigger_*`/dispatch-функции — kit теперь единственный scheduling-путь (`app/scheduler_main.py` безусловно запускает `_run_kit_scheduler()`). Что осталось в этом модуле — НЕ scheduler-loop, а функции с живыми потребителями вне удалённой machinery: - `compute_next_run_at` — читается admin.py (операторский предпросмотр "next run"). - `has_running_run` — читается admin.py (UI-индикатор "уже бежит"). - `import_rosreestr_dkp` — job-тело, вызываемое kit-handler'ом product_handlers._job_rosreestr_dkp (lazy import). - `_execute_cian_backfill` — job-тело, вызываемое kit-handler'ом product_handlers._job_cian_history_backfill (lazy import). Zombie-reap, advisory-lock claim и tick-loop теперь целиком в `scraper_kit/orchestration/scheduler.py` (см. test_scraper_kit_scheduler_parity.py). """ from __future__ import annotations import logging import random from datetime import UTC, datetime, time, timedelta from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session from app.core.shutdown import shutdown_requested from app.services import scrape_runs as runs_mod logger = logging.getLogger(__name__) # import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше # которой прогон помечается FAILED, а не silent-green (Fix C). Единичные битые строки # (редкий bad row) не валят импорт; систематический сбой (≈100% ошибок) — валит. DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05 def compute_next_run_at( window_start_hour: int, window_end_hour: int, *, now: datetime | None = None, interval_days: int = 1, ) -> datetime: """Pick random datetime в window [start, end) UTC, через interval_days суток после now. interval_days задаёт каденс источника: 1 (default) = daily (back-compat), 7 = weekly. Берётся из schedule.default_params["interval_days"] вызывающим кодом; отсутствие ключа → 1 → прежнее ежедневное поведение. Если window_end_hour <= window_start_hour → cross-midnight window (например 22→3 → окно 22:00-23:59 ИЛИ 00:00-02:59). """ now = now or datetime.now(tz=UTC) interval_days = max(1, int(interval_days)) # Целевая дата = now + interval_days суток (interval_days=1 → завтра, как раньше). target = (now + timedelta(days=interval_days)).date() if window_end_hour > window_start_hour: # Обычное окно (например 2..5 → 02:00-04:59) start_seconds = window_start_hour * 3600 end_seconds = window_end_hour * 3600 rand_seconds = random.randint(start_seconds, end_seconds - 1) return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=rand_seconds) else: # Cross-midnight (22..3 → 22:00-23:59 + 00:00-02:59) # Длина окна = (24-start) + end часов total_seconds = ((24 - window_start_hour) + window_end_hour) * 3600 rand_seconds = random.randint(0, total_seconds - 1) # Если rand попадает в первую часть (start..24) first_half = (24 - window_start_hour) * 3600 if rand_seconds < first_half: # interval_days=1: текущая дата (если окно ещё не наступило сегодня) или next day. # interval_days>1: всегда целевая дата (стаггер на N суток вперёд). today_ok = interval_days == 1 and now.hour < window_start_hour base_date = now.date() if today_ok else target return datetime.combine(base_date, time(0, 0), tzinfo=UTC) + timedelta( seconds=window_start_hour * 3600 + rand_seconds ) else: # Во второй части (0..end), целевого дня offset = rand_seconds - first_half return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=offset) def has_running_run(db: Session, source: str) -> bool: """Есть ли активный run для source (status='running').""" row = db.execute( text( """ SELECT 1 FROM scrape_runs WHERE source = :source AND status = 'running' LIMIT 1 """ ), {"source": source}, ).fetchone() return row is not None async def _execute_cian_backfill( db: Session, *, run_id: int, params: dict[str, Any], ) -> None: """Orchestrate Cian history backfill with heartbeat + checkpoint. Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat) before and after the batch call for zombie-detection visibility. Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS NULL via LEFT JOIN — so re-running after a partial completion naturally skips already-processed rows (idempotent by design). Params (from default_params jsonb): batch_size: int — rows per run (listings + houses counted separately). """ from app.tasks.cian_history_backfill import backfill_cian_history batch_size = int(params.get("batch_size", 100)) counters: dict[str, int] = { "listings_processed": 0, "listings_succeeded": 0, "listings_failed": 0, "houses_processed": 0, "houses_succeeded": 0, "houses_failed": 0, } try: runs_mod.update_heartbeat(db, run_id, counters) result = await backfill_cian_history( db, batch_size=batch_size, do_listings=True, do_houses=True, do_valuations=False, ) counters = { "listings_processed": result.listings_processed, "listings_succeeded": result.listings_succeeded, "listings_failed": result.listings_failed_fetch + result.listings_failed_save, "houses_processed": result.houses_processed, "houses_succeeded": result.houses_succeeded, "houses_failed": result.houses_failed_fetch + result.houses_failed_save, "duration_sec": int(result.duration_sec), } runs_mod.mark_done(db, run_id, counters) logger.info( "scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs", run_id, result.listings_succeeded, result.listings_total, result.houses_succeeded, result.houses_total, result.duration_sec, ) except Exception as exc: logger.exception("scheduler: cian_history_backfill run_id=%d failed", run_id) runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) raise def import_rosreestr_dkp( db: Session, run_id: int, params: dict[str, Any], ) -> None: """Import ДКП-сделок из gendesign rosreestr_deals через postgres_fdw. Python-порт import-rosreestr.sh (Variant C из #563). Источник: foreign table gendesign_rosreestr_deals (создана в migration 072). SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql. USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader). Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург — прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом) снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже сидящих в source foreign table). address и deals.city строятся из реального city источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки источника (= 66 при текущем фильтре). Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24): - region_code = 66 (вся Свердловская область, все города) - city IS NOT NULL AND trim(city) != '' (непустой город → корректный address) - realestate_type_code = '002001003000' (квартира) - area BETWEEN 18 AND 200 - deal_price BETWEEN 1000000 AND 100000000 - street IS NOT NULL AND trim(street) != '' - doc_type = 'ДКП' (только вторичка — #549 / Fix_Rosreestr_Dkp_Filter_May24) - period_start_date >= since (default '2024-01-01') dedup_hash: 'ros:dkp:' || id — плоский натуральный ключ (инъективный, без коллизий, human-readable). До #576 здесь был md5('ros:dkp:' || id); миграция 077 конвертировала существующие строки. source_id хранит исходный rosreestr id (дедуп переустанавливаем). Rooms: выводятся из площади (Росреестр не отдаёт кол-во комнат). Batch-процессинг: читаем из FDW батчами по batch_size через cursor-based пагинацию (WHERE id > last_id ORDER BY id). Heartbeat обновляется каждый батч (= checkpoint). SAVEPOINT per row — один сбойный row не откатывает батч. Координаты: NULL после импорта — геокодинг остаётся follow-up (geocode-deals). TODO (follow-up): запустить geocode backfill после import. Cleanup: удаляет legacy строки address='Екатеринбург, реальная сделка' (pre-#549). """ since: str = str(params.get("since", "2024-01-01")) batch_size: int = int(params.get("batch_size", 2000)) counters: dict[str, int] = { "rows_fetched": 0, "rows_inserted": 0, # rows_updated: ON CONFLICT DO UPDATE обновил существующую строку # (исправленный/переопубликованный квартал — Fix D). "rows_updated": 0, # rows_skipped: ТОЛЬКО легитимный dedup-пропуск (строка уже есть, факты # идентичны — DO UPDATE ... WHERE distinct не сработал). "rows_skipped": 0, # rows_errored: реальные per-row INSERT-ошибки, отделены от dedup-skip (Fix C), # раньше обе категории клались в rows_skipped → систематический сбой выглядел # как обычный дедуп и прогон рапортовал success. "rows_errored": 0, "batches_done": 0, } # Cleanup legacy synthetic rows (pre-#549, idempotent) try: deleted = db.execute( text("DELETE FROM deals WHERE address = CAST(:addr AS text) RETURNING id"), {"addr": "Екатеринбург, реальная сделка"}, ).fetchall() db.commit() if deleted: logger.info( "rosreestr_dkp_import run_id=%d: removed %d legacy synthetic rows", run_id, len(deleted), ) except Exception as exc: logger.warning( "rosreestr_dkp_import run_id=%d: cleanup failed (non-fatal): %s", run_id, exc ) db.rollback() last_id: int = 0 total_batches = 0 try: while True: cancelled = runs_mod.is_cancelled(db, run_id) if cancelled or shutdown_requested(): if cancelled: # User-cancel: семантика без изменений — mark_cancelled. logger.info("rosreestr_dkp_import run_id=%d: cancelled by user", run_id) runs_mod.mark_cancelled(db, run_id) return # #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper). # Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор # уже зафиксирован update_heartbeat'ом каждый батч; следующий run # пере-сканирует с id=0 — INSERT ... ON CONFLICT DO UPDATE идемпотентен # для неизменных строк (WHERE ... IS DISTINCT FROM пропускает совпадающие # факты), поэтому повторный проход не плодит дубликаты и не трогает данные. # mark_done выводит run из 'running' → reap_zombies его не тронет. logger.info( "rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial " "(last_id=%d, batches=%d) and exiting", run_id, last_id, total_batches, ) runs_mod.update_heartbeat(db, run_id, counters) runs_mod.mark_done(db, run_id, counters) return # Cursor-based pagination via foreign table gendesign_rosreestr_deals. # FDW pushes WHERE + ORDER BY + LIMIT to gendesign-postgres automatically # (postgres_fdw 'use_remote_estimate' is off by default but clause push-down # still happens for simple predicates on the remote side). batch_rows = ( db.execute( text(""" SELECT id, id AS source_id_src, 'ros:dkp:' || CAST(id AS text) AS dedup_hash, trim(city) || ', ' || trim(street) AS address, region_code, trim(city) AS city, CASE WHEN area < 30 THEN 0 WHEN area < 44 THEN 1 WHEN area < 62 THEN 2 WHEN area < 85 THEN 3 ELSE 4 END AS rooms, round(area, 2) AS area_m2, LEAST( NULLIF( -- #1525: '-?[0-9]+' сохраняет знак минус, иначе цоколь/подвал -- '-1' импортируется как 1. Для '5/9' по-прежнему берётся 5. substring(floor FROM '-?[0-9]+'), '' )::int, 100 ) AS floor_num, year_build AS year_built, round(deal_price)::bigint AS price_rub, round(price_per_sqm)::int AS price_per_m2, period_start_date AS deal_date FROM gendesign_rosreestr_deals WHERE region_code = 66 AND city IS NOT NULL AND trim(city) <> '' AND realestate_type_code = '002001003000' AND area BETWEEN 18 AND 200 AND deal_price BETWEEN 1000000 AND 100000000 AND street IS NOT NULL AND trim(street) <> '' AND doc_type = 'ДКП' AND period_start_date >= CAST(:since AS date) AND id > CAST(:last_id AS bigint) ORDER BY id LIMIT CAST(:batch_size AS int) """), {"since": since, "last_id": last_id, "batch_size": batch_size}, ) .mappings() .all() ) if not batch_rows: break total_batches += 1 batch_inserted = 0 batch_updated = 0 batch_skipped = 0 batch_errored = 0 batch_max_id = last_id for row in batch_rows: row_id: int = int(row["id"]) if row_id > batch_max_id: batch_max_id = row_id try: with db.begin_nested(): # SAVEPOINT per row # Fix D: ON CONFLICT DO UPDATE вместо прежнего no-op-дедупа — при # повторном импорте ИСПРАВЛЕННОГО/переопубликованного квартала обновляем # сырые факты Росреестра. WHERE ... IS DISTINCT FROM оставляет # неизменные строки нетронутыми (идемпотентность resume: строка # без изменений → 0 returned → legit dedup-skip). source/source_id # (identity) и dedup_hash (ключ конфликта) стабильны, не трогаем. # Обогащение (lat/lon/geom/geocode_tried_at, cadastral_number, # total_floors, house_type ...) НЕ в EXCLUDED-списке → сохраняется. # RETURNING (xmax = 0): freshly-inserted → xmax=0 (was_inserted), # обновлённая по ON CONFLICT → xmax<>0; отличаем insert от update. result = db.execute( text(""" INSERT INTO deals ( source, dedup_hash, source_id, address, region_code, city, rooms, area_m2, floor, year_built, price_rub, price_per_m2, deal_date ) VALUES ( 'rosreestr', CAST(:dedup_hash AS text), CAST(:source_id AS text), CAST(:address AS text), CAST(:region_code AS int), CAST(:city AS text), CAST(:rooms AS int), CAST(:area_m2 AS numeric), CAST(:floor_num AS int), CAST(:year_built AS int), CAST(:price_rub AS bigint), CAST(:price_per_m2 AS int), CAST(:deal_date AS date) ) ON CONFLICT (dedup_hash) DO UPDATE SET address = EXCLUDED.address, region_code = EXCLUDED.region_code, city = EXCLUDED.city, rooms = EXCLUDED.rooms, area_m2 = EXCLUDED.area_m2, floor = EXCLUDED.floor, year_built = EXCLUDED.year_built, price_rub = EXCLUDED.price_rub, price_per_m2 = EXCLUDED.price_per_m2, deal_date = EXCLUDED.deal_date WHERE deals.address IS DISTINCT FROM EXCLUDED.address OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code OR deals.city IS DISTINCT FROM EXCLUDED.city OR deals.rooms IS DISTINCT FROM EXCLUDED.rooms OR deals.area_m2 IS DISTINCT FROM EXCLUDED.area_m2 OR deals.floor IS DISTINCT FROM EXCLUDED.floor OR deals.year_built IS DISTINCT FROM EXCLUDED.year_built OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2 OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date RETURNING (xmax = 0) AS was_inserted """), { "dedup_hash": row["dedup_hash"], "source_id": str(row["source_id_src"]), "address": row["address"], "region_code": row["region_code"], "city": row["city"], "rooms": row["rooms"], "area_m2": row["area_m2"], "floor_num": row["floor_num"], "year_built": row["year_built"], "price_rub": row["price_rub"], "price_per_m2": row["price_per_m2"], "deal_date": row["deal_date"], }, ).fetchone() if result is None: # DO UPDATE ... WHERE distinct не сработал → строка есть и # факты идентичны = легитимный dedup-skip (Fix C: только это # теперь считается skip, INSERT-ошибки — отдельно ниже). batch_skipped += 1 elif result[0]: batch_inserted += 1 else: batch_updated += 1 except Exception as exc: # Fix C: реальная per-row INSERT-ошибка — ОТДЕЛЬНЫЙ счётчик, не skip. logger.warning( "rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s", run_id, row_id, exc, ) batch_errored += 1 last_id = batch_max_id db.commit() counters["rows_fetched"] += len(batch_rows) counters["rows_inserted"] += batch_inserted counters["rows_updated"] += batch_updated counters["rows_skipped"] += batch_skipped counters["rows_errored"] += batch_errored counters["batches_done"] = total_batches counters["last_id"] = last_id # type: ignore[assignment] # Heartbeat = checkpoint: allows zombie detection + resume visibility runs_mod.update_heartbeat(db, run_id, counters) logger.info( "rosreestr_dkp_import run_id=%d: batch=%d fetched=%d " "inserted=%d updated=%d skipped=%d errored=%d last_id=%d", run_id, total_batches, len(batch_rows), batch_inserted, batch_updated, batch_skipped, batch_errored, last_id, ) if len(batch_rows) < batch_size: break # Last partial batch — no more rows # Fix C: систематический per-row INSERT-сбой больше не рапортует success. # Отделив rows_errored от rows_skipped, проверяем долю ошибок: выше порога — # прогон FAILED (raise → внешний except → mark_failed), а не silent-green. fetched = counters["rows_fetched"] errored = counters["rows_errored"] if fetched > 0 and errored / fetched > DKP_IMPORT_ERROR_RATE_THRESHOLD: raise RuntimeError( f"rosreestr_dkp_import: per-row INSERT error rate " f"{errored}/{fetched} ({errored / fetched:.1%}) exceeds " f"{DKP_IMPORT_ERROR_RATE_THRESHOLD:.0%} threshold — marking run failed" ) runs_mod.mark_done(db, run_id, counters) logger.info( "rosreestr_dkp_import run_id=%d done: " "total_fetched=%d inserted=%d updated=%d skipped=%d errored=%d batches=%d", run_id, counters["rows_fetched"], counters["rows_inserted"], counters["rows_updated"], counters["rows_skipped"], counters["rows_errored"], total_batches, ) except Exception as exc: logger.exception("rosreestr_dkp_import run_id=%d failed at last_id=%d", run_id, last_id) runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) raise