gendesign/backend/app/workers/beat_schedule.py
bot-backend f6fb79522a
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m57s
CI / backend-tests (pull_request) Successful in 14m44s
feat(scrapers): wire per-flat DOM.РФ catalog scraper + propagate catalog_url_hash (#2442)
Task 1: _norm_flat теперь читает catalog_url_hash из уже доступного kn-API
elemId вместо жёсткого None (domrf_kn.py:479) — разблокирует источник
хэшей для 983k квартир при следующем kn-sweep. elemId or None корректно
нормализует и отсутствие, и пустую строку.

Task 2: новый scrape_kn_catalog_flats Celery task оборачивает уже
смерженный scrape_catalog_batch (#2441) — batch-выборка по последнему
snapshot WHERE catalog_url_hash IS NOT NULL + 30d freshness/force.
Зарегистрирован в celery_app, ручной триггер POST /kn-catalog-flats.

Beat-запись оставлена закомментированной — по образцу уже отключённого
object-level catalog scraper: DOM.РФ WAF hard-ban на VPS IP с 2026-05-24
(см. #2443), cooldown не подтверждён. Task ships полностью dormant:
beat не назначен в рантайме, admin-endpoint требует авторизации, browser-
stack импортируется лениво внутри функции (не на старте воркера), живой
fetch недостижим без непустого batch. code-reviewer отдельно проверил
WAF-safety независимо — подтверждено.

Batch наполнится непустыми строками только после того, как Task 1
задеплоится И пройдёт свежий kn-sweep — ожидаемо 0 строк первое время.

Refs #2442, #2441, #2443
2026-07-05 21:39:39 +05:00

681 lines
42 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"},
}
# РИАСУРТ Свердл (#108): региональные градостроительные зоны агломерации ЕКБ меняются
# медленно → ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК). NB: harvest_all_*
# пока на ПЛЕЙСХОЛДЕР-bbox 5 МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO) — фактический
# прод-прогон только после уточнения границ.
schedule["riasurt-sverdl-harvest-quarterly"] = {
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
"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"},
}
# РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК).
# NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO).
schedule["riasurt-sverdl-harvest-quarterly"] = {
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
"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"},
}
# Bug #1236: парный cleanup для cadastre_jobs. Переводит застрявшие
# 'running' jobs (heartbeat > 5 мин) в 'paused', чтобы оператор мог
# resume через admin API. Раз в 2 минуты — cadastre fanout меньше nspd_geo,
# per-minute опрос DB не оправдан.
schedule["cadastre-zombie-cleanup"] = {
"task": "tasks.cadastre.cleanup_zombies",
"schedule": _parse_cron("*/2 * * * *"),
"options": {"queue": "geo"},
}
# 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"},
}
# OSM инженерные сети (#1746) sync — еженедельно в понедельник в 04:00 МСК
# (через 30 мин после noise-sync, чтобы не нагружать Overpass одновременно).
# No-B2B open-data источник (Overpass): ЛЭП/подстанции/трубопроводы/ЦТП/вышки.
schedule["utility-infrastructure-sync-weekly"] = {
"task": "tasks.utility_infrastructure_sync.sync_osm_utility_infrastructure_ekb",
"schedule": _parse_cron("0 4 * * mon"),
"options": {"queue": "celery"},
}
# Точки подключения со свободной мощностью (Forgejo #2119 Phase A):
# электро — Россети WFS (~488 ЦП) + xlsx-резервы (ЦП + 10.7k ТП/РП);
# вода — Водоканал ЕКБ docx (ЦСВ/ЦСК, city-level).
# Источники ГЕО-БЛОКИРУЮТ non-RU IP → RUN-ON-PROD (Celery worker на VPS).
# Меняются медленно (раскрытие ФАС квартальное) — еженедельно с запасом.
# Вторник 04:00 МСКНАМЕРЕННО вне monday-кластера тяжёлых Overpass/site_finder
# рефрешей (не конкурируем за БД/сеть). Электро (WFS+xlsx) и вода (docx) —
# разные хосты, ставим с интервалом 30 мин, чтобы не бить оба одновременно.
schedule["power-capacity-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_power_supply_centers",
"schedule": _parse_cron("0 4 * * tue"), # 04:00 МСК, вторник
"options": {"queue": "celery"},
}
schedule["water-capacity-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_water_reserves",
"schedule": _parse_cron("30 4 * * tue"), # 04:30 МСК, вторник
"options": {"queue": "celery"},
}
# Газовая вертикаль (Forgejo #2119 Фаза B1): свободная мощность ГРС «Газпром
# трансгаз Екатеринбург» из инлайн HTML-таблицы раскрытия. Источник ГЕО-БЛОКИРУЕТ
# non-RU IP → RUN-ON-PROD. 05:00 МСК вторник — через 30 мин после воды (разные
# хосты, не бьём одновременно), в том же вторник-кластере вне monday-нагрузки.
schedule["gas-grs-capacity-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_gas_grs_capacity",
"schedule": _parse_cron("0 5 * * tue"), # 05:00 МСК, вторник
"options": {"queue": "celery"},
}
# Точки выхода ГРС АО «Екатеринбурггаз» (форма 6 ФАС №960 → gas_grs_outlet_points +
# догеокод адресов → geom). Forgejo #2119 Фаза B2, PR-4. Источник ГЕО-БЛОКИРУЕТ
# non-RU IP + DaData-ключи на проде → RUN-ON-PROD. Источник публикуется ~5-го числа
# месяца, НО beat не поддерживает «раз в месяц по факту публикации» надёжно (jitter
# даты) → еженедельно вторник, идемпотентно: помесячный UPSERT + geom сохраняется,
# не-новый период просто пере-UPSERT-ится без эффекта. 05:15 МСК — в газовом суб-
# кластере сразу после gas-grs (05:00, тот же apex-родственник ekgas/ГТЕ), до ЕЭСК
# (05:30); намеренно вне monday-нагрузки. Целимся ~05:30-окно спеки, но 05:30 занят
# ЕЭСК → 05:15 (не бьём оба газовых источника ekgas одновременно).
schedule["gas-outlet-points-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_gas_outlet_points",
"schedule": _parse_cron("15 5 * * tue"), # 05:15 МСК, вторник
"options": {"queue": "celery"},
}
# ЕЭСК-резервы (Forgejo #2119 Фаза B2): ПС 35-220 (МВт) + ТП/РП «ниже 35» из
# xlsx-раскрытия eesk.ru. Обычный SSL БЕЗ гео-блока, но держим в том же вторник-
# кластере рядом с rosseti/gas/vodokanal. 05:30 МСК — через 30 мин после газа
# (разные хосты, не бьём одновременно), вне monday-нагрузки.
schedule["eesk-reserves-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_eesk_reserves",
"schedule": _parse_cron("30 5 * * tue"), # 05:30 МСК, вторник
"options": {"queue": "celery"},
}
# OSM-геокод городских ТП/РП ЕКБ → geom (Forgejo #2119 Фаза B2, финал). Матч
# name_norm (чистый номер) ↔ OSM substation ref/name через Overpass →
# power_tp_rp_reserves.geom. Источник (Overpass) БЕЗ гео-блока, но держим в том же
# вторник-кластере рядом с ЕЭСК. 05:45 МСК — после газа/ЕЭСК (05:30), до тепла
# (06:00); идемпотентно (geom IS NULL-гейт), вне monday-Overpass-нагрузки.
schedule["osm-tp-geocode-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_osm_tp_geocode",
"schedule": _parse_cron("45 5 * * tue"), # 05:45 МСК, вторник
"options": {"queue": "celery"},
}
# Тепло-резервы (Forgejo #2119 Фаза B2): резерв мощности источников тепла из
# реестра раскрытия ФАС (ri.eias.ru, ЕТК + Екатеринбургэнерго). Источник ГЕО-
# БЛОКИРУЕТ non-RU IP + российский УЦ → RUN-ON-PROD. 06:00 МСК — через 30 мин
# после ЕЭСК (разные хосты), в том же вторник-кластере вне monday-нагрузки.
schedule["heat-reserves-sync-weekly"] = {
"task": "tasks.connection_capacity_sync.sync_heat_reserves",
"schedule": _parse_cron("0 6 * * tue"), # 06:00 МСК, вторник
"options": {"queue": "celery"},
}
# #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК.
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
schedule["ekburg-permits-monthly"] = {
"task": "tasks.ekburg_permits_sync.refresh_all",
"schedule": _parse_cron("0 5 1 * *"),
"options": {"queue": "celery"},
}
# #2367: реестр РНС/РВЭ Свердловской области из открытого API ГИСОГД-СО
# (gisogd66.midural.ru, раздел 13). Источник обновляется ежедневно, но лоадер
# инкрементален (карточки тянутся только для новых/изменившихся) → еженедельно с
# запасом. Источник БЕЗ гео-блока и БЕЗ авторизации — не привязан к прод-IP. 06:30
# МСК вторник — в том же вторник-кластере, что и capacity-sync (последний — heat в
# 06:00), сразу после него, вне monday-нагрузки. Celery conf.timezone=Europe/Moscow.
schedule["gisogd-permits-weekly"] = {
"task": "tasks.gisogd_permits_sync.sync_gisogd_permits",
"schedule": _parse_cron("30 6 * * tue"), # 06:30 МСК, вторник
"options": {"queue": "celery"},
}
# Developer registry refresh (#1088 «GG-форсайт») — ежемесячно 1-го числа в 05:30 МСК,
# через 30 мин ПОСЛЕ ekburg-permits-monthly (05:00), чтобы permits-сторона реестра
# обновлялась на свежих РНС/РВЭ. fn_refresh_developer_registry() → REFRESH MATERIALIZED
# VIEW CONCURRENTLY developer_registry (миграция 149; не блокирует читателей analyze).
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233). Техническая
# infra-задача (не в job_settings, как refresh-quarter-price-index). domrf-сторона
# обновляется ежедневно (scrape_kn), но ИНН/track-record меняются редко — monthly хватает.
schedule["developer-registry-refresh-monthly"] = {
"task": "tasks.developer_registry_refresh.refresh_developer_registry",
"schedule": _parse_cron("30 5 1 * *"), # 05:30 MSK, 1st of month
"options": {"queue": "celery"},
}
# ПЗЗ территориальных зон ЕКБ из PKK6 ArcGIS — DISABLED 2026-06-13 (issue #259).
#
# ПОЧЕМУ ОТКЛЮЧЕНО: источник pkk.rosreestr.ru/.../PKK6/ZONES deprecated (Росреестр
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
# zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD
# territorial_zones dumps (Phase 2.5 bulk_harvest, layer 875838). Активный beat
# каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал
# ошибку как error → рекуррентил GlitchTip BACKEND-1B.
#
# Ручной перезапуск (если появится рабочий источник) остаётся через admin-эндпоинт
# POST /api/v1/admin/scrape/pzz-sync. Ref: #233 (исходно «0 rows»), #259 (disable).
# schedule["pzz-sync-monthly"] = {
# "task": "tasks.pzz_sync.sync_pzz_zones_ekb",
# "schedule": _parse_cron("0 3 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"},
# }
# Catalog-FLAT scrape (#2442 Task 2) — наполняет per-flat catalog-поля
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
#
# DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
# каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
# напр. четверг 04:00 UTC.
# schedule["scrape-kn-catalog-flats-weekly"] = {
# "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
# "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC
# "kwargs": {"region_code": 66, "max_flats": 300},
# "options": {"queue": "celery"},
# }
# Quarter price index refresh (#762) — monthly on the 5th at 05:00 MSK.
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
# 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 5 5 * *"), # 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 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
# 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 4 * * mon"), # 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"},
}
# Росстат макро-показатели (демография региона 65 / ЕКБ) → macro_indicator
# (#946 EPIC2). Ряды Росстата (open-data) обновляются редко (годовые) — ежемесячного
# прогона с большим запасом достаточно. ON CONFLICT делает прогон идемпотентным
# (полный ре-fetch каждый раз; дёшево — пара CSV-файлов). 1-е число месяца 05:45 МСК
# (Celery conf.timezone=Europe/Moscow → crontab в МСК); по нагрузке не пересекается с
# cbr-macro (пн 05:30). ИПЦ/доходы/СМР-цены — EMISS-only за WAF, пока не тянутся
# (см. rosstat_emiss docstring). Задача техническая, не управляется через job_settings —
# добавлена сюда (как cbr/poi/refresh). Оператор может перенести расписание правкой crontab.
schedule["rosstat-macro-sync-monthly"] = {
"task": "tasks.rosstat_macro_sync.rosstat_macro_sync",
"schedule": _parse_cron("45 5 1 * *"),
"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"},
}
# Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings.
# Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК).
# Не в job_settings (технический ETL, не требует конфигурации UI).
# Идемпотентен через ON CONFLICT (source, ext_house_id).
# Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения.
schedule["newbuilding-crossload-nightly"] = {
"task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload",
"schedule": _parse_cron("30 0 * * *"), # 00:30 UTC = 03:30 МСК
"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"},
}
# ОКН-объекты ЕКБ/Свердл.обл из АИС ЕГРКН (#1066): POST searchMap →
# okn_objects (м.141). ~800+ фич (54 фед + 213 рег + remainder по ЕКБ;
# область добавляет дополнительные). Воскресенье 04:30 МСК — не конкурирует
# с ird-harvest (понедельник 05:00) и gknspecial (05:30). Реестр ОКН меняется
# медленно — еженедельно достаточно. Техническая, не в job_settings.
schedule["okn-objects-sync-weekly"] = {
"task": "tasks.okn_objects_sync.sync_okn_objects",
"schedule": _parse_cron("30 4 * * sun"),
"options": {"queue": "celery"},
}
# ОКН-анализ ЕКБ (#1062): OCR PDF-сканов изъятия с екатеринбург.рф → land_reservation.
# Постановления об изъятии выходят редко (2-4 раза в год) → еженедельного прогона хватает.
# Пятница 07:00 МСК — не пересекается с monday-кластером (ird/gkn/supply/cbr).
# Tesseract rus: каждый PDF ~30 стр., OCR ~1-2 мин/PDF → итого <10 мин/прогон.
schedule["izyatie-ocr-ingest-weekly"] = {
"task": "tasks.izyatie_ocr_ingest.ingest_izyatie_ocr",
"schedule": _parse_cron("0 7 * * fri"),
"options": {"queue": "celery"},
}
# District-level индексы локаций (#948 Part B, §8.2) → location (м.146): reuse
# per-district forecast-функций (market_metrics/future_supply/POI), нормализация
# 0..1, upsert по 8 районам ЕКБ. Понедельник 07:00 МСК — ПОСЛЕ supply-layers
# (06:00) и genplan (06:30), чтобы future_supply-индекс считался по СВЕЖЕМУ складу
# предложения. Индексы меняются медленно (рынок поглощает сток неделями) —
# еженедельно достаточно. ON CONFLICT идемпотентен. Техническая, не в job_settings
# (как cbr/supply-layers). Оператор может перенести расписание правкой crontab.
schedule["location-refresh-weekly"] = {
"task": "tasks.location_refresh.location_refresh",
"schedule": _parse_cron("0 7 * * mon"),
"options": {"queue": "celery"},
}
# Refresh mv_layout_velocity (#1666) — питает «лучшие планировки» best_layouts (#113).
# До этого MV рефрешился ТОЛЬКО ручным вызовом refresh_layout_velocity() → в проде
# данные молча устаревали. REFRESH ... CONCURRENTLY (non-blocking) по mv_layout_velocity.
#
# Воскресенье 03:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
# Намеренно ВНЕ monday-кластера тяжёлых site_finder-рефрешей (scrape_kn 04:15,
# ird 05:00, gknspecial 05:30, supply-layers 06:00, genplan 06:30, location 07:00) и
# вне воскресного okn-objects (04:30) — час запаса, не конкурирует за БД/CPU.
# MV агрегирует темпы вымывания планировок (меняются медленно) → еженедельно хватает.
# Техническая infra-задача, не в job_settings (как refresh-quarter-price-index / supply-layers).
schedule["refresh-layout-velocity"] = {
"task": "tasks.refresh_layout_velocity.refresh_layout_velocity",
"schedule": _parse_cron("0 3 * * sun"), # 03:00 MSK, воскресенье
"options": {"queue": "celery"},
}
# Sales-tracker MVs (#61) — питают Site Finder Velocity Score (4-й критерий) +
# recommend_mix / sellout-forecast. Оба MV (mv_sales_tracker_velocity_by_district,
# mv_sales_tracker_absorption_curves) рефрешатся CONCURRENTLY (non-blocking, требуют
# unique-индексы из миграции 161). Источник — objective_lots / objective_lots_history
# (Объектив-шахматки), наполняются objective_sync (Mon 04:15 МСК по умолчанию).
#
# Понедельник 04:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233) —
# ПОСЛЕ objective_sync (04:15), чтобы агрегаты считались по свежему снапшоту; в
# окне до тяжёлого monday-кластера site_finder-рефрешей (ird 05:00, gknspecial 05:30,
# supply-layers 06:00). Refresh лёгкий (~6с на 1.1M lots). Техническая infra-задача,
# не в job_settings (как refresh-quarter-price-index / refresh-layout-velocity).
schedule["mv-sales-tracker-refresh-weekly"] = {
"task": "tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs",
"schedule": _parse_cron("30 4 * * mon"), # 04:30 MSK, понедельник
"options": {"queue": "celery"},
}
# Data-freshness monitor: ежедневно 09:00 МСК (Celery conf.timezone=Europe/Moscow →
# crontab в МСК, #1233). Запускает compute_freshness по 5 run-таблицам и шлёт
# GlitchTip-алёрт, если критичный источник устарел/упал. 09:00 — после ночного
# кластера скрейперов (03:0007:00) и в начале рабочего дня, чтобы оператор увидел
# деградацию свежести данных утром. Только читает + алертит (не пишет), дёшево.
# Техническая, не в job_settings (как refresh-analytics / mv-sales-tracker).
schedule["scrape-freshness-check-daily"] = {
"task": "tasks.scrape_freshness_check.scrape_freshness_check",
"schedule": _parse_cron("0 9 * * *"), # 09:00 MSK ежедневно
"options": {"queue": "celery"},
}
return schedule