From dc1cd943e69bc440a652ce8af8366b90f8bc4db5 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 24 May 2026 15:56:31 +0300 Subject: [PATCH 1/2] fix(beat): distinguish DB-unreachable from intentional-empty schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UPDATE job_settings SET enabled=false для всех cron-able jobs приводил к пустому schedule из DB — caller (build_beat_schedule) интерпретировал 'not schedule' как 'DB error' и срывался на env-based fallback, который повторно добавлял scrape_kn + objective_sync из settings.scrape_kn_cron / objective_sync_cron. Disable через DB был **бесполезен**. Incident 2026-05-24: WAF hard-ban на VPS IP от DOM.РФ → попытка отключить scrape_kn + objective_sync через UPDATE job_settings — fallback вернул их обратно. Беспорядок виден в beat logs: 'beat_schedule: строим fallback из env vars' при наличии rows в БД. Fix: - `_build_beat_schedule_from_db` теперь использует `get_all` (strict, raises) вместо `get_all_safe` (silently возвращает _DEFAULTS) - Возвращает (schedule, rows_seen) tuple - Caller fallback'ает ТОЛЬКО при `not db_reachable` (exception) ИЛИ `rows_seen == 0` (fresh install, безопасный fallback для setup) - При rows_seen > 0 + schedule={} — RESPECT user intent (все disabled) - Hardcoded entries (OSM POI/noise, nspd cleanup, refresh_analytics) добавляются ВСЕГДА сверху — они не управляются job_settings --- backend/app/workers/beat_schedule.py | 54 +++++++++++++++++++--------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index e7ce5146..fb7a197b 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -50,19 +50,30 @@ 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: +def _build_beat_schedule_from_db() -> tuple[dict, int]: """Строит beat_schedule из job_settings БД. - Возвращает пустой словарь при любой ошибке — caller добавит fallback. - """ - try: - from app.core.db import SessionLocal - from app.services.job_settings import get_all_safe + Returns: (schedule, rows_seen). + - rows_seen > 0 → DB reachable, есть конфигурация job_settings (даже если все disabled) + - rows_seen == 0 → DB reachable, но таблица пустая (fresh install до seed'а) + - **RAISES** при недоступности БД (table missing, connection refused и т.п.) — + caller должен поймать и использовать env-based fallback как safety net. - rows = get_all_safe(SessionLocal) - except Exception as e: - logger.warning("build_beat_schedule: не удалось прочитать job_settings: %s", e) - return {} + Используем `get_all` (strict, raises on failure), НЕ `get_all_safe` — нам важно + отличить "DB unreachable" от "DB сказал ничего не schedule'ить". + Без этого пара UPDATE job_settings SET enabled=false бессильна: + `_build_beat_schedule_from_db` вернул бы {} как при DB error → caller fallback'ает + на env-cron → отключённые jobs снова в schedule. См. инцидент 2026-05-24 + (WAF cooldown disable scrape_kn → fallback вернул его обратно). + """ + 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: @@ -117,7 +128,7 @@ def _build_beat_schedule_from_db() -> dict: } logger.info("build_beat_schedule: %s cron=%r queue=%s", jt, cron_str, queue) - return schedule + return schedule, len(rows) def _build_beat_schedule_fallback() -> dict: @@ -168,18 +179,27 @@ def _build_beat_schedule_fallback() -> dict: def build_beat_schedule() -> dict: - """Строит beat_schedule: сначала из DB, при неудаче — fallback на env. + """Строит beat_schedule: + - Из DB (job_settings) если DB reachable И rows > 0 + - Env-based fallback ТОЛЬКО когда DB unreachable ИЛИ table пустая (fresh install) + - Пустой schedule с rows>0 (все jobs disabled намеренно) — RESPECT, без fallback - Всегда добавляет refresh-ekb-districts-medians (нет в job_settings). + Всегда добавляет hardcoded entries поверх (refresh-analytics, OSM POI/noise, + nspd cleanup) — они не управляются через job_settings. """ schedule: dict = {} + rows_seen = 0 + db_reachable = True try: - schedule = _build_beat_schedule_from_db() + schedule, rows_seen = _build_beat_schedule_from_db() except Exception as e: - logger.error("build_beat_schedule: неожиданная ошибка: %s — fallback", e) + logger.error( + "build_beat_schedule: DB unreachable: %s — fallback на env vars (safety net)", e + ) + db_reachable = False - if not schedule: - # БД пустая или недоступна — полный fallback + if not db_reachable or rows_seen == 0: + # DB недоступна ИЛИ fresh install (job_settings пустая) → env-based safety net return _build_beat_schedule_fallback() # Всегда добавляем refresh_analytics (этот job нет в job_settings, -- 2.45.3 From cca7dcd029ab72489f1f9fc6c63f049b1e885852 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 24 May 2026 16:10:48 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(beat):=20cleanup=20dead-code=20from=20p?= =?UTF-8?q?rior=20fallback=20distinguishing=20=E2=80=94=20align=20with=20r?= =?UTF-8?q?eality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-bot PR #520 SHA dc1cd94 нашёл medium discrepancy: `get_all` НЕ raises на DB unreachable — оно catches Exception и возвращает _DEFAULTS (4 synthetic rows, scrape_kn + objective_sync enabled). Значит `except` блок в build_beat_schedule и `rows_seen == 0` branch — dead code, никогда не fires. Primary fix всё равно работает (operator-disable создаёт реальные rows c enabled=false → loop их skip'ает → schedule={} → respect). Но контракт в docstring ("strict, raises on failure") был fiction. Cleanup (Option A из review): - Убрана try/except в build_beat_schedule (dead — get_all не raises) - Убрана rows_seen == 0 branch (dead — get_all всегда returns >=4 rows из _DEFAULTS) - Сигнатура `_build_beat_schedule_from_db` обратно `-> dict` (не tuple) - Docstring переписан под реальное поведение: schedule пуст ТОЛЬКО при operator-initiated disable; DB-unreachable case замаскирован под _DEFAULTS (синонимично env-fallback по итогу: scrape_kn + objective_sync с дефолтным cron) Net behavior identical для всех 4 сценариев (operator-disable, normal-config, DB-unreachable, fresh-install) — но код теперь честный. --- backend/app/workers/beat_schedule.py | 57 ++++++++++++---------------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index fb7a197b..e45ec29e 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -50,21 +50,22 @@ 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() -> tuple[dict, int]: +def _build_beat_schedule_from_db() -> dict: """Строит beat_schedule из job_settings БД. - Returns: (schedule, rows_seen). - - rows_seen > 0 → DB reachable, есть конфигурация job_settings (даже если все disabled) - - rows_seen == 0 → DB reachable, но таблица пустая (fresh install до seed'а) - - **RAISES** при недоступности БД (table missing, connection refused и т.п.) — - caller должен поймать и использовать env-based fallback как safety net. + Returns: schedule (может быть пустой если все enabled rows без cron или disabled). - Используем `get_all` (strict, raises on failure), НЕ `get_all_safe` — нам важно - отличить "DB unreachable" от "DB сказал ничего не schedule'ить". - Без этого пара UPDATE job_settings SET enabled=false бессильна: - `_build_beat_schedule_from_db` вернул бы {} как при DB error → caller fallback'ает - на env-cron → отключённые jobs снова в schedule. См. инцидент 2026-05-24 - (WAF cooldown disable scrape_kn → fallback вернул его обратно). + `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 @@ -128,7 +129,7 @@ def _build_beat_schedule_from_db() -> tuple[dict, int]: } logger.info("build_beat_schedule: %s cron=%r queue=%s", jt, cron_str, queue) - return schedule, len(rows) + return schedule def _build_beat_schedule_fallback() -> dict: @@ -179,28 +180,18 @@ def _build_beat_schedule_fallback() -> dict: def build_beat_schedule() -> dict: - """Строит beat_schedule: - - Из DB (job_settings) если DB reachable И rows > 0 - - Env-based fallback ТОЛЬКО когда DB unreachable ИЛИ table пустая (fresh install) - - Пустой schedule с rows>0 (все jobs disabled намеренно) — RESPECT, без fallback + """Строит beat_schedule из DB (job_settings) + добавляет hardcoded entries. - Всегда добавляет hardcoded entries поверх (refresh-analytics, OSM POI/noise, - nspd cleanup) — они не управляются через job_settings. + `_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: dict = {} - rows_seen = 0 - db_reachable = True - try: - schedule, rows_seen = _build_beat_schedule_from_db() - except Exception as e: - logger.error( - "build_beat_schedule: DB unreachable: %s — fallback на env vars (safety net)", e - ) - db_reachable = False - - if not db_reachable or rows_seen == 0: - # DB недоступна ИЛИ fresh install (job_settings пустая) → env-based safety net - return _build_beat_schedule_fallback() + schedule = _build_beat_schedule_from_db() # Всегда добавляем refresh_analytics (этот job нет в job_settings, # он технический и не требует конфигурации через UI). -- 2.45.3