#647-2 (P0, инфра — клиентскую цену НЕ меняет). Делает mv_quarter_price_index (#760) читаемым estimator'ом за O(1) + периодический refresh. - tradein 085_quarter_price_index_fdw.sql: FOREIGN TABLE quarter_price_index над main public.mv_quarter_price_index через существующий server gendesign_remote (паттерн 060_postgres_fdw_extension.sql, per-table). Колонки 1:1 с MV (varchar(30)/float8/bigint/text/timestamptz). DROP IF EXISTS идемпот. - main 99b_grant_quarter_price_index_fdw.sql: GRANT SELECT на MV роли tradein_fdw_reader (иначе foreign table падает; зеркало 122_grant_rosreestr). - main refresh: Celery beat (pg_cron нет). quarter_price_index_refresh.py — chained REFRESH mv_quarter_price_per_m2 → mv_quarter_price_index CONCURRENTLY; task refresh_quarter_price_index.py; beat-entry 'refresh-quarter-price-index' cron '0 2 5 * *' (05:00 MSK 5-го, после ekb-districts-medians). Lookup-индекс: MV уже несёт UNIQUE mv_quarter_price_index_uq (O(1) remote); foreign table не нуждается в локальном. Валидация (postgres-gendesign): MV 1972 строки, 0 NULL, UNIQUE indisunique=true, REFRESH CONCURRENTLY валиден. Разблокирует #647-3 (estimator integration, за гейтом #763).
294 lines
14 KiB
Python
294 lines
14 KiB
Python
"""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"},
|
||
}
|
||
|
||
return schedule
|