"""Beat schedule builder for Celery. Priority order: 1. DB-driven — читает таблицу job_settings при старте beat-контейнера. 2. Fallback — env-based расписание (SCRAPE_KN_CRON, OBJECTIVE_SYNC_CRON), используется если БД недоступна или таблица пустая. 3. Hardcoded entries — добавляются поверх DB-расписания (не в job_settings): - refresh-ekb-districts-medians (аналитика, ежемесячно 5-го в 04:00) - nspd-geo-zombie-cleanup (каждую минуту, замена нестабильного worker_ready) - poi-sync-weekly (OSM POI, понедельник 03:00) - noise-sync-weekly (OSM шум, понедельник 03:30) Публичный API: build_beat_schedule() → dict. """ import logging from celery.schedules import crontab from app.core.config import settings logger = logging.getLogger(__name__) # Словарь job_type → celery task name (полное dotted name). # Используется при построении beat_schedule из job_settings. TASK_MAP: dict[str, str] = { "scrape_kn": "tasks.scrape_kn.scrape_kn_region", "objective_sync": "tasks.scrape_objective.sync_all_groups", # nspd_geo и objective_etl не имеют cron — запускаются вручную. # При необходимости можно добавить сюда. } def _parse_cron(spec: str) -> crontab: """Parse 'M H DoM Mon DoW' crontab string into Celery crontab. Empty fields default to '*'.""" parts = spec.strip().split() if len(parts) != 5: raise ValueError(f"crontab spec must have 5 fields, got: {spec!r}") minute, hour, dom, month, dow = parts return crontab( minute=minute, hour=hour, day_of_month=dom, month_of_year=month, day_of_week=dow, ) def _default_regions() -> list[int]: return [int(x.strip()) for x in settings.scrape_kn_default_regions.split(",") if x.strip()] def _build_beat_schedule_from_db() -> dict: """Строит beat_schedule из job_settings БД. Returns: schedule (может быть пустой если все enabled rows без cron или disabled). `get_all` маскирует DB-unreachable case через возврат _DEFAULTS (4 synthetic rows, все enabled=True с дефолтными cron'ами) — это значит "DB unreachable" и "fresh install (table empty)" неотличимы от "normal с _DEFAULTS". В обоих случаях schedule НЕ будет пустым — _DEFAULTS дадут scrape_kn + objective_sync. КРИТИЧНО для operator-disable scenario: когда DB reachable и оператор сделал `UPDATE job_settings SET enabled=false WHERE job_type IN (...)` — get_all вернёт реальные rows (НЕ _DEFAULTS), loop их пропустит → schedule={}. Caller (build_beat_schedule) НЕ должен срывался на env fallback в этом случае, иначе disable бесполезен. См. инцидент 2026-05-24 (WAF cooldown disable scrape_kn → old code fallback'ал scrape_kn обратно из env). """ from app.core.db import SessionLocal from app.services.job_settings import get_all db = SessionLocal() try: rows = get_all(db) finally: db.close() schedule: dict = {} for row in rows: jt = row["job_type"] if not row.get("enabled"): continue cron_str = row.get("cron_schedule") if not cron_str: # NULL cron = только ручной запуск, не добавляем в beat continue if jt not in TASK_MAP: logger.warning( "build_beat_schedule: job_type '%s' не имеет TASK_MAP-записи — пропускаем", jt ) continue try: cron = _parse_cron(cron_str) except Exception as e: logger.warning( "build_beat_schedule: невалидный cron '%s' для '%s': %s", cron_str, jt, e ) continue queue = row.get("queue_name", "celery") or "celery" if jt == "scrape_kn": # scrape_kn запускается отдельной задачей на каждый регион extra = row.get("extra_config") or {} regions: list[int] = extra.get("default_regions") or _default_regions() for rc in regions: schedule[f"kn-region-{rc}"] = { "task": TASK_MAP[jt], "schedule": cron, "args": [rc, None], "options": {"queue": queue}, } logger.info( "build_beat_schedule: scrape_kn cron=%r regions=%s queue=%s", cron_str, regions, queue, ) else: kwargs: dict = {} if jt == "objective_sync": kwargs = {"triggered_by": "beat"} schedule[jt] = { "task": TASK_MAP[jt], "schedule": cron, "kwargs": kwargs, "options": {"queue": queue}, } logger.info("build_beat_schedule: %s cron=%r queue=%s", jt, cron_str, queue) return schedule def _build_beat_schedule_fallback() -> dict: """Fallback-расписание из env vars — используется если БД недоступна на старте.""" logger.warning("beat_schedule: строим fallback из env vars") schedule: dict = {} # scrape_kn try: for rc in _default_regions(): schedule[f"kn-region-{rc}"] = { "task": "tasks.scrape_kn.scrape_kn_region", "schedule": _parse_cron(settings.scrape_kn_cron), "args": [rc, None], } except Exception as e: logger.warning("beat_schedule fallback: scrape_kn failed: %s", e) # objective_sync try: from app.core.db import SessionLocal from app.services.objective_sync_config import get_cron_schedule_safe cron_str = get_cron_schedule_safe(SessionLocal) except Exception: cron_str = settings.objective_sync_cron try: schedule["objective-sync"] = { "task": "tasks.scrape_objective.sync_all_groups", "schedule": _parse_cron(cron_str), "kwargs": {"triggered_by": "beat"}, } except Exception as e: logger.warning("beat_schedule fallback: objective_sync failed: %s", e) # refresh ekb-districts medians — ежемесячно 5-го числа в 04:00 МСК try: schedule["refresh-ekb-districts-medians"] = { "task": "tasks.refresh_analytics.refresh_ekb_districts_medians", "schedule": _parse_cron("0 4 5 * *"), "kwargs": {"window_months": 24, "min_deals": 50}, } except Exception as e: logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e) return schedule def build_beat_schedule() -> dict: """Строит beat_schedule из DB (job_settings) + добавляет hardcoded entries. `_build_beat_schedule_from_db` использует `get_all` который сам fallback'ает на _DEFAULTS при DB unreachable / fresh install → возвращает 4 synthetic rows (scrape_kn + objective_sync enabled). То есть schedule из DB-builder ПУСТ только когда оператор явно disabled все cron-able jobs через UPDATE job_settings — это **намерение**, респектим без env-fallback. Hardcoded entries (refresh-analytics, OSM POI/noise, nspd cleanup) добавляются ВСЕГДА — они не управляются через job_settings. """ schedule = _build_beat_schedule_from_db() # Всегда добавляем refresh_analytics (этот job нет в job_settings, # он технический и не требует конфигурации через UI). if "refresh-ekb-districts-medians" not in schedule: try: schedule["refresh-ekb-districts-medians"] = { "task": "tasks.refresh_analytics.refresh_ekb_districts_medians", "schedule": _parse_cron("0 4 5 * *"), "kwargs": {"window_months": 24, "min_deals": 50}, "options": {"queue": "celery"}, } except Exception as e: logger.warning("beat_schedule: refresh_analytics failed: %s", e) # Zombie cleanup: каждую минуту проверяет nspd_geo_jobs с heartbeat > 2 мин # и re-enqueue'ит их. Замена worker_ready signal handler'а — он не fires # стабильно в нашем setup, beat-task надёжнее. schedule["nspd-geo-zombie-cleanup"] = { "task": "tasks.nspd_geo.cleanup_zombies", "schedule": _parse_cron("* * * * *"), "options": {"queue": "celery"}, } # OSM POI sync — еженедельно в понедельник в 03:00 МСК schedule["poi-sync-weekly"] = { "task": "tasks.poi_sync.sync_osm_poi_ekb", "schedule": _parse_cron("0 3 * * mon"), "options": {"queue": "celery"}, } # OSM noise sources sync — еженедельно в понедельник в 03:30 МСК # (через 30 мин после poi-sync, чтобы не нагружать Overpass одновременно) schedule["noise-sync-weekly"] = { "task": "tasks.noise_sync.sync_osm_noise_sources_ekb", "schedule": _parse_cron("30 3 * * mon"), "options": {"queue": "celery"}, } # #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК (02:00 UTC) schedule["ekburg-permits-monthly"] = { "task": "tasks.ekburg_permits_sync.refresh_all", "schedule": _parse_cron("0 2 1 * *"), "options": {"queue": "celery"}, } # ПЗЗ территориальных зон ЕКБ из PKK6 ArcGIS — ежемесячно 1-го числа в 03:00 МСК (00:00 UTC). # PKK6 нестабилен под нагрузкой, но данные ПЗЗ меняются редко — раз в месяц достаточно. # Task: tasks/pzz_sync.py → sync_pzz_zones_ekb. # Admin trigger: POST /api/v1/admin/scrape/pzz-sync. # Ref: issue #233 (pzz_zones_ekb = 0 rows, задача никогда не запускалась автоматически). schedule["pzz-sync-monthly"] = { "task": "tasks.pzz_sync.sync_pzz_zones_ekb", "schedule": _parse_cron("0 0 1 * *"), "options": {"queue": "celery"}, } # Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц. # kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д. # Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления. # # DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed # extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет # 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после # cooldown 24-48h (проверить через targeted test). # schedule["scrape-kn-catalog-objects-weekly"] = { # "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects", # "schedule": _parse_cron("0 4 * * 2"), # Tuesday 04:00 UTC # "kwargs": {"region_code": 66, "max_objects": 300}, # "options": {"queue": "celery"}, # } # Quarter price index refresh (#762) — monthly on the 5th at 05:00 MSK (02:00 UTC). # Chains: mv_quarter_price_per_m2 → mv_quarter_price_index (in order, both CONCURRENTLY). # Runs 1 h after refresh-ekb-districts-medians (04:00 MSK) so deals data is settled. # Not in job_settings (technical infra task, no UI config needed). schedule["refresh-quarter-price-index"] = { "task": "tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain", "schedule": _parse_cron("0 2 5 * *"), # 02:00 UTC = 05:00 MSK, 5th of month "options": {"queue": "celery"}, } # NSPD quarter dump refresh — re-enabled 2026-05-17 после Sub-PR B (#260) # переключения search_by_quarter на grid-walk. Foundation (#247) + integration # (#260) теперь возвращают полноценные dumps (territorial_zones, ЗОУИТ, risk # zones, engineering structures) вместо 0-3 features из single-pixel WMS. # # Schedule: Mon 04:00 МСК (01:00 UTC). batch_size=50 ограничивает fanout per # tick — при 11k+ кварталов в cad_quarters_geom полный backfill займёт # несколько недель, но защищает от WAF rate-limit burst. # max_age_days=90 — refresh свежее квартала; новые / отсутствующие dumps # тоже попадают через harvest_stale_quarters (берёт NULL fetched_at). schedule["nspd-harvest-stale-quarters"] = { "task": "tasks.nspd_sync.harvest_stale_quarters", "schedule": _parse_cron("0 1 * * mon"), # 01:00 UTC = 04:00 МСК "kwargs": {"region_code": 66, "max_age_days": 90, "batch_size": 50}, "options": {"queue": "celery"}, } # CBR макро-показатели (ключевая ставка) → macro_indicator (#945 PR B, # GG-форсайт). Ставка меняется редко (8 заседаний ЦБ в год) — еженедельного # прогона с запасом достаточно. ON CONFLICT делает прогон идемпотентным # (полный бэкфилл с 2019-01-01 каждый раз; дёшево — ~1 SOAP-запрос + ~1500 # upsert'ов). Понедельник 05:30 МСК (Celery conf.timezone=Europe/Moscow, т.е. # crontab трактуется в МСК) — после publish итогов пятничного заседания. Задача # техническая, не управляется через job_settings — поэтому добавлена сюда (как # poi-sync / refresh-analytics). Оператор может перенести расписание правкой crontab. schedule["cbr-macro-sync-weekly"] = { "task": "tasks.cbr_macro_sync.cbr_macro_sync", "schedule": _parse_cron("30 5 * * mon"), "options": {"queue": "celery"}, } # Supply-layers пересчёт (#950 EPIC 6 Step5, GG-форсайт) → supply_layers (м.125). # Детерминированно UPSERT-ит L1(open)/L2(hidden)/L3(future) по всем районам Свердл. # обл. через supply_layers.compute_all_layers. ON CONFLICT идемпотентен в рамках # одного snapshot_date (same-day re-run обновляет мутабельные колонки). # # Понедельник 06:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК). # ORDERING ASSUMPTION: scrape_kn (наполняет domrf_kn_objects.free_flats, м.124 — # источник L2 hidden) идёт Mon 04:15 МСК (settings.scrape_kn_cron='15 4 * * mon'). # 06:00 даёт ~1.75 ч запаса, чтобы supply-layers считались по СВЕЖИМ free_flats. # Если scrape_kn перенесут позже/он затянется — сдвинуть этот cron соответственно # (или сделать chain). Задача техническая, не в job_settings (как cbr/poi/refresh). schedule["supply-layers-refresh-weekly"] = { "task": "tasks.supply_layers_refresh.supply_layers_refresh", "schedule": _parse_cron("0 6 * * mon"), "options": {"queue": "celery"}, } return schedule