gendesign/backend/app/workers/beat_schedule.py
bot-backend 86b9a5a197
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Successful in 1m21s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m26s
Deploy / deploy (push) Successful in 1m13s
perf(sf): КРТ-геометрия в БД, _krt_at из БД вместо живого WFS (#1139)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 12:14:35 +00:00

417 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
schedule["ekb-krt-sync-weekly"] = {
"task": "tasks.ekb_krt_sync.sync_krt_sites",
"schedule": _parse_cron("0 5 * * wed"),
"options": {"queue": "celery"},
}
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)
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
schedule["ekb-krt-sync-weekly"] = {
"task": "tasks.ekb_krt_sync.sync_krt_sites",
"schedule": _parse_cron("0 5 * * wed"),
"options": {"queue": "celery"},
}
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"},
}
# ИРД-overlay harvest (#1067 B5): grid-walk готовых НСПД ИРД-слоёв по кварталам ЕКБ →
# ird_overlays (м.132). Понедельник 05:00 МСК — до supply-layers (06:00), не конкурируя
# с scrape_kn (04:15). Геометрия зон стабильна, еженедельно достаточно. Техническая,
# не в job_settings (как cbr/poi/supply-layers).
schedule["ird-harvest-weekly"] = {
"task": "tasks.ird_harvest.harvest_ird_overlays",
"schedule": _parse_cron("0 5 * * mon"),
"options": {"queue": "celery"},
}
# ЗОУИТ-harvest ЕКБ-геопортала (#1063/#1064/#1065/#1066, #1067 GG-форсайт): WFS-фетч
# gknspecial_zone по full_name-префиксам → ird_overlays (layer_kind=servitude/water_*/
# airfield/okn_protection_zone/zrz). Понедельник 05:30 МСК — после ird-harvest (05:00),
# до supply-layers (06:00). Геометрия зон стабильна, еженедельно достаточно.
schedule["gknspecial-harvest-weekly"] = {
"task": "tasks.gknspecial_harvest.harvest_gknspecial_zones",
"schedule": _parse_cron("30 5 * * mon"),
"options": {"queue": "celery"},
}
# Opportunity-ЗУ harvest (#1086): grid-walk НСПД opportunity-слоёв по кварталам ЕКБ →
# ird_overlays (layer_kind='opportunity_*'). 1-е число месяца 05:30 МСК — реже ИРД (weekly),
# opportunity-земля меняется медленнее ограничений. Тот же движок, что ird-harvest.
schedule["opportunity-harvest-monthly"] = {
"task": "tasks.opportunity_harvest.harvest_opportunity_overlays",
"schedule": _parse_cron("30 5 1 * *"),
"options": {"queue": "celery"},
}
# ППТ/ПМТ harvest (#1085): WFS-фетч геопортала ЕКБ по bbox агломерации → planning_projects.
# 1-е число месяца 06:00 МСК — после opportunity (05:30). Документы планировки стабильны,
# ежемесячно достаточно. WFS BBOX (не grid-walk) — один запрос на слой.
schedule["planning-harvest-monthly"] = {
"task": "tasks.planning_harvest.harvest_planning_projects",
"schedule": _parse_cron("0 6 1 * *"),
"options": {"queue": "celery"},
}
# Прогрев кэша ПЗЗ-регламента зон ЕКБ (#1059/C8b): WFS-перечисление терзон → urbanCard →
# zone_regulation_cache. 1-е число месяца 06:30 МСК — после planning (06:00). Регламент
# меняется только при правке ПЗЗ, ежемесячно достаточно. Наполняет кэш C8b проактивно.
schedule["zone-regulation-refresh-monthly"] = {
"task": "tasks.zone_regulation_refresh.refresh_zone_regulations",
"schedule": _parse_cron("30 6 1 * *"),
"options": {"queue": "celery"},
}
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
schedule["ekb-krt-sync-weekly"] = {
"task": "tasks.ekb_krt_sync.sync_krt_sites",
"schedule": _parse_cron("0 5 * * wed"),
"options": {"queue": "celery"},
}
# КРТ-геометрии ЕКБ (#1130): WFS-фичи portal_geo_oks_krt → ekb_krt_geometry.
# Геометрия КРТ-территорий стабильна (новые площадки — квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:30 МСК — сразу после ekb-krt-sync-weekly (05:00),
# одна сессия геопортала, не дублируем нагрузку.
schedule["ekb-krt-geometry-sync-weekly"] = {
"task": "tasks.krt_geometry_sync.sync_krt_geometry",
"schedule": _parse_cron("30 5 * * wed"),
"options": {"queue": "celery"},
}
# Функциональные зоны генплана ЕКБ-2045 (#1058): ГИСОГД-СО WFS →
# ekb_genplan_functional_zone (м.139). ~6436 полигонов, EPSG:4326. Генплан
# меняется редко (раз в год+), еженедельно избыточно но дёшево — WFS-фетч +
# upsert по globalid идемпотентен. Понедельник 06:30 МСК — после supply-layers
# (06:00). Техническая, не в job_settings.
schedule["genplan-zones-sync-weekly"] = {
"task": "tasks.genplan_zones_sync.sync_functional_zones",
"schedule": _parse_cron("30 6 * * mon"),
"options": {"queue": "celery"},
}
return schedule