fix(beat): distinguish DB-unreachable from intentional-empty schedule #520

Merged
lekss361 merged 2 commits from fix/beat-fallback-distinguish-empty into main 2026-05-24 13:14:12 +00:00
Owner

Summary

  • backend/app/workers/beat_schedule.py: differentiate "DB unreachable" from "DB сказал ничего не schedule'ить", чтобы UPDATE job_settings SET enabled=false реально работал

Root cause / incident

WAF cooldown disable процедура 2026-05-24 включала:

UPDATE job_settings SET enabled=false WHERE job_type IN ('scrape_kn','objective_sync');

Идея: остановить понедельник 04:15 UTC scrape_kn cron и вторник 06:00 UTC objective_sync cron на 24-48h пока DOM.РФ WAF reputation penalty на VPS IP не отстоится.

Эта команда не работалаgendesign-beat-1 logs показывали:

beat_schedule: строим fallback из env vars

Причина: _build_beat_schedule_from_db() возвращал {} (все cron-able jobs отключены), build_beat_schedule() интерпретировал not schedule как DB error → срывался на _build_beat_schedule_fallback() который повторно добавлял scrape_kn + objective_sync из settings.scrape_kn_cron / settings.objective_sync_cron env vars. Disable бесполезен.

# OLD
schedule = _build_beat_schedule_from_db()  # returns {} on success+empty OR exception
if not schedule:
    return _build_beat_schedule_fallback()  # ← can't distinguish

Fix

Три состояния вместо двух:

Сигнал Значение Поведение
Exception в _build_beat_schedule_from_db DB unreachable / table missing Fallback (safety net)
rows_seen == 0 Fresh install, job_settings пустая Fallback (safety net)
rows_seen > 0 + schedule == {} Пользователь намеренно всё disabled Respect — без fallback
rows_seen > 0 + schedule != {} Нормальная конфигурация Use DB schedule

Implementation:

  • _build_beat_schedule_from_db теперь возвращает tuple[dict, int] (schedule, rows_seen)
  • Использует get_all (strict, raises) вместо get_all_safe (silently returns _DEFAULTS) — нам важно отличать ошибку DB от пустого ответа
  • build_beat_schedule fallback'ает ТОЛЬКО на not db_reachable or rows_seen == 0
  • Hardcoded entries (OSM POI/noise, NSPD cleanup, refresh_analytics) добавляются ВСЕГДА сверху — они не управляются через job_settings, остаются работать даже при empty DB schedule

Verification

После merge + beat reload:

  1. Beat logs больше НЕ должны показывать beat_schedule: строим fallback из env vars (DB reachable, rows > 0)
  2. Beat schedule не должен включать kn-region-66 (scrape_kn=disabled в DB) — проверить через celery -A app inspect scheduled или beat-логи
  3. Понедельник 04:15 UTC — никакого task tasks.scrape_kn.scrape_kn_region не должно появиться в /api/v1/admin/scrape/queue

Test plan

  • Pre-commit hooks passed (ruff/ruff-format)
  • python -c "from app.workers.beat_schedule import build_beat_schedule" — imports OK
  • После merge: beat reload → проверить логи на отсутствие строим fallback из env vars
  • Понедельник 26.05 04:15 UTC — нет scrape_kn_region task в очереди
  • Вторник 26.05 06:00 UTC — нет sync_all_groups task в очереди

Risk: LOW

  • Логика более restrictive в fallback path — было "fallback на любую пустоту", стало "fallback только при unreachable DB или fresh install"
  • Если DB корректно настроена (rows есть) — поведение точно такое же как раньше для НЕ-пустого schedule
  • Hardcoded entries (OSM, NSPD cleanup, refresh) сохранены без изменений
  • Если кто-то реально хочет вернуть fallback — UPDATE job_settings SET enabled=true WHERE ... восстановит

Cross-file impact

  • backend/app/services/job_settings.py:124get_all() (strict) used directly, get_all_safe() остаётся доступным для остальных callers (admin endpoints etc), не трогаем
  • Все hardcoded entries в build_beat_schedule() после if not db_reachable... — без изменений
  • Если есть тесты на build_beat_schedule() (не нашёл grep'ом) — должны pass без изменений
## Summary - `backend/app/workers/beat_schedule.py`: differentiate "DB unreachable" from "DB сказал ничего не schedule'ить", чтобы `UPDATE job_settings SET enabled=false` реально работал ## Root cause / incident WAF cooldown disable процедура 2026-05-24 включала: ```sql UPDATE job_settings SET enabled=false WHERE job_type IN ('scrape_kn','objective_sync'); ``` Идея: остановить понедельник 04:15 UTC `scrape_kn` cron и вторник 06:00 UTC `objective_sync` cron на 24-48h пока DOM.РФ WAF reputation penalty на VPS IP не отстоится. **Эта команда не работала** — `gendesign-beat-1` logs показывали: ``` beat_schedule: строим fallback из env vars ``` Причина: `_build_beat_schedule_from_db()` возвращал `{}` (все cron-able jobs отключены), `build_beat_schedule()` интерпретировал `not schedule` как DB error → срывался на `_build_beat_schedule_fallback()` который **повторно добавлял scrape_kn + objective_sync** из `settings.scrape_kn_cron` / `settings.objective_sync_cron` env vars. Disable бесполезен. ```python # OLD schedule = _build_beat_schedule_from_db() # returns {} on success+empty OR exception if not schedule: return _build_beat_schedule_fallback() # ← can't distinguish ``` ## Fix Три состояния вместо двух: | Сигнал | Значение | Поведение | |---|---|---| | Exception в `_build_beat_schedule_from_db` | DB unreachable / table missing | Fallback (safety net) | | `rows_seen == 0` | Fresh install, job_settings пустая | Fallback (safety net) | | `rows_seen > 0` + `schedule == {}` | Пользователь намеренно всё disabled | **Respect** — без fallback | | `rows_seen > 0` + `schedule != {}` | Нормальная конфигурация | Use DB schedule | Implementation: - `_build_beat_schedule_from_db` теперь возвращает `tuple[dict, int]` (schedule, rows_seen) - Использует `get_all` (strict, raises) вместо `get_all_safe` (silently returns _DEFAULTS) — нам важно отличать ошибку DB от пустого ответа - `build_beat_schedule` fallback'ает ТОЛЬКО на `not db_reachable or rows_seen == 0` - Hardcoded entries (OSM POI/noise, NSPD cleanup, refresh_analytics) добавляются ВСЕГДА сверху — они не управляются через job_settings, остаются работать даже при empty DB schedule ## Verification После merge + beat reload: 1. Beat logs больше НЕ должны показывать `beat_schedule: строим fallback из env vars` (DB reachable, rows > 0) 2. Beat schedule не должен включать `kn-region-66` (scrape_kn=disabled в DB) — проверить через `celery -A app inspect scheduled` или beat-логи 3. Понедельник 04:15 UTC — никакого task `tasks.scrape_kn.scrape_kn_region` не должно появиться в `/api/v1/admin/scrape/queue` ## Test plan - [x] Pre-commit hooks passed (ruff/ruff-format) - [x] `python -c "from app.workers.beat_schedule import build_beat_schedule"` — imports OK - [ ] После merge: beat reload → проверить логи на отсутствие `строим fallback из env vars` - [ ] Понедельник 26.05 04:15 UTC — нет `scrape_kn_region` task в очереди - [ ] Вторник 26.05 06:00 UTC — нет `sync_all_groups` task в очереди ## Risk: LOW - Логика более restrictive в fallback path — было "fallback на любую пустоту", стало "fallback только при unreachable DB или fresh install" - Если DB корректно настроена (rows есть) — поведение точно такое же как раньше для НЕ-пустого schedule - Hardcoded entries (OSM, NSPD cleanup, refresh) сохранены без изменений - Если кто-то реально хочет вернуть fallback — `UPDATE job_settings SET enabled=true WHERE ...` восстановит ## Cross-file impact - `backend/app/services/job_settings.py:124` — `get_all()` (strict) used directly, `get_all_safe()` остаётся доступным для остальных callers (admin endpoints etc), не трогаем - Все hardcoded entries в `build_beat_schedule()` после `if not db_reachable...` — без изменений - Если есть тесты на `build_beat_schedule()` (не нашёл grep'ом) — должны pass без изменений
lekss361 added 1 commit 2026-05-24 12:57:59 +00:00
fix(beat): distinguish DB-unreachable from intentional-empty schedule
Some checks failed
CI / backend (pull_request) Failing after 41s
CI / frontend (pull_request) Successful in 1m42s
dc1cd943e6
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
Author
Owner

Deep code review — PR #520

Status: COMMENT (not blocking; flag medium discrepancy before merge)

Verified at head SHA dc1cd94. Single-file change backend/app/workers/beat_schedule.py (+37/-17). No tests, no SQL/migration changes, no API/auth surface touched.

Bottom line

The operator-disable scenario IS fixed by this PR — real DB rows with enabled=false now produce schedule={}, rows_seen>0, and the env-fallback is bypassed. After merge + beat reload the Monday 04:15 UTC scrape_kn_region and Tuesday 06:00 UTC sync_all_groups will NOT be scheduled.

However the docstring sells a stronger contract than the code delivers — see below.

🟡 Medium — Docstring claims a contract the dependency doesn't honor

File: backend/app/workers/beat_schedule.py:53-72 (new _build_beat_schedule_from_db docstring) и :182-186 (except Exception setting db_reachable=False).

Docstring claims:

Используем get_all (strict, raises on failure), НЕ get_all_safe — нам важно отличить "DB unreachable" от "DB сказал ничего не schedule'ить".

Actual app/services/job_settings.py:124-149:

def get_all(db) -> list[dict[str, Any]]:
    """Вернуть все строки job_settings. При ошибке БД — fallback на _DEFAULTS."""
    try:
        rows = (db.execute(text("...")).mappings().all())
    except Exception as e:
        logger.warning("get_all job_settings: БД недоступна — fallback. %s", e)
        return [_fallback(jt) for jt in _DEFAULTS]   # 4 synthetic rows, all enabled=True
    if not rows:
        logger.warning("get_all job_settings: таблица пустая — fallback")
        return [_fallback(jt) for jt in _DEFAULTS]
    return [_row_to_dict(r) for r in rows]

get_all is NOT strict — it catches Exception internally and returns synthetic _DEFAULTS (scrape_kn enabled=True, objective_sync enabled=True). Consequences:

Scenario What actually happens in new code
DB unreachable get_all swallows → returns 4 _DEFAULTS rows (all enabled=True) → rows_seen=4, schedule populated from synth → db_reachable=True, rows_seen>0 → no env-fallback. Same scrape_kn + objective_sync re-introduced via synth defaults instead of env. Net behavior equivalent to old code's env-fallback path (both re-enable both jobs), but docstring promises something different.
Truly empty job_settings table Same — get_all returns 4 _DEFAULTS rows. rows_seen=4, never ==0. The rows_seen == 0 branch is dead code.
except Exception as e: db_reachable = False in build_beat_schedule Dead code — get_all doesn't raise, so this branch never fires.
Operator UPDATE SET enabled=false (the targeted incident) Works: real rows returned, loop skips all, schedule={}, rows_seen=4 → no fallback. Disable respected.

The primary fix works because it doesn't rely on the broken contract — the rows_seen>0 guard is what makes operator-disable land. But the documented "DB unreachable detection via raise" is fiction.

Two options:

A. Make the docstring match reality (cheapest, narrow PR scope):

  • Remove the try/except around _build_beat_schedule_from_db() in build_beat_schedule (dead code).
  • Remove the rows_seen == 0 branch from the if (dead code).
  • Update docstring: "get_all returns synthetic _DEFAULTS on DB error, so DB-unreachable case is silently masked as enabled-by-default. This is acceptable because the failure mode degrades to env-equivalent behavior. The fix here only addresses operator-initiated disable via enabled=false on real rows."

B. Make get_all actually raise (cleaner but wider blast radius):

  • Inline the db.execute(text(...)) directly in _build_beat_schedule_from_db and let OperationalError / ProgrammingError propagate.
  • OR add a get_all_strict(db) variant that doesn't swallow.
  • Then the except + rows_seen == 0 checks become real.

Given the operational urgency (WAF cooldown needs Monday 04:15 UTC to be quiet) — Option A as a follow-up is fine. Land this PR if the goal is just to unblock the disable; come back for the dead-code cleanup.

🟢 Low — PR description overstates "hardcoded entries добавляются ВСЕГДА сверху"

The early return _build_beat_schedule_fallback() on line ~201 means when DB-unreachable branch is hit (in theory — see above), nspd-geo-zombie-cleanup, poi-sync-weekly, noise-sync-weekly are silently dropped. refresh-ekb-districts-medians is in the fallback function and survives. Pre-existing behavior, not introduced here, but the PR body asserts the opposite. Cosmetic, no fix needed in this PR.

🟢 Low — No regression test for the fix

No test under backend/tests/workers/ for build_beat_schedule. The original bug (env-fallback ignoring DB disable) could regress silently. A minimal test_build_beat_schedule_respects_all_disabled mocking app.services.job_settings.get_all to return [{...enabled=False...}] * 4 and asserting kn-region-66 ∉ schedule and objective_sync ∉ schedule would lock this in. Out of scope for the hot-fix, recommended for follow-up.

Vault cross-check

  • decisions/Decision_Job_Settings_Centralized.md — DB-driven beat with env fallback. PR sharpens the boundary (disable-respect), consistent direction.
  • runbooks/Beat_Schedule_Module.md + code/modules/Module_Beat_Schedule.md — describe 3-tier priority. After merge, update vault to mention the new "explicit-disable respected, no fallback re-add" semantic.

Security / perf / conventions

  • No SQL injection vectors (SQLAlchemy text() parameterization in get_all).
  • No credential / secret exposure in diff.
  • psycopg v3 compatible (no :x::type syntax, no psycopg2 imports).
  • Session lifecycle correct: db = SessionLocal() before try/finally db.close()SessionLocal() in SQLAlchemy is lazy, doesn't raise on construction.
  • _build_beat_schedule_from_db is private; signature change -> dict-> tuple[dict, int] only affects build_beat_schedule (only caller, updated).
  • Logger escalated warning → error on the (theoretical) fallback path — appropriate for visibility.
  • Ruff line-100 respected (per PR test plan: pre-commit passed).
  1. Merge this PR — primary fix is correct and operational urgency is real (24-48h WAF cooldown).
  2. Same session or immediate follow-up: pick Option A from above — strip the dead except + rows_seen==0 branch, align docstring with reality. ~10 lines, narrow PR.
  3. Vault: update runbooks/Beat_Schedule_Module.md to document the disable-respect semantic.
  4. Optional: regression test under backend/tests/workers/test_beat_schedule.py.

Complexity / blast radius

  • Risk: LOW — operator-disable case works correctly; DB-unreachable case has identical net behavior to pre-PR code.
  • Reversibility: HIGH — single file, revertable in one commit, UPDATE SET enabled=true restores prior schedule.
  • Merge window: sooner is better (Monday 04:15 UTC scrape_kn).

Marker: <!-- gendesign-review-bot: sha=dc1cd94 verdict=comment --> — verdict=comment (not approve) because of the medium docstring/dead-code discrepancy. Auto-merge withheld; human can choose to land now and follow up with hardening, or harden first.

## Deep code review — PR #520 <!-- gendesign-review-bot: sha=dc1cd94 verdict=comment --> ### Status: COMMENT (not blocking; flag medium discrepancy before merge) Verified at head SHA `dc1cd94`. Single-file change `backend/app/workers/beat_schedule.py` (+37/-17). No tests, no SQL/migration changes, no API/auth surface touched. ### Bottom line The **operator-disable scenario IS fixed** by this PR — real DB rows with `enabled=false` now produce `schedule={}`, `rows_seen>0`, and the env-fallback is bypassed. After merge + beat reload the Monday 04:15 UTC `scrape_kn_region` and Tuesday 06:00 UTC `sync_all_groups` will NOT be scheduled. ✅ However the docstring sells a stronger contract than the code delivers — see below. ### 🟡 Medium — Docstring claims a contract the dependency doesn't honor **File:** `backend/app/workers/beat_schedule.py:53-72` (new `_build_beat_schedule_from_db` docstring) и `:182-186` (`except Exception` setting `db_reachable=False`). Docstring claims: > Используем `get_all` (strict, raises on failure), НЕ `get_all_safe` — нам важно отличить "DB unreachable" от "DB сказал ничего не schedule'ить". Actual `app/services/job_settings.py:124-149`: ```python def get_all(db) -> list[dict[str, Any]]: """Вернуть все строки job_settings. При ошибке БД — fallback на _DEFAULTS.""" try: rows = (db.execute(text("...")).mappings().all()) except Exception as e: logger.warning("get_all job_settings: БД недоступна — fallback. %s", e) return [_fallback(jt) for jt in _DEFAULTS] # 4 synthetic rows, all enabled=True if not rows: logger.warning("get_all job_settings: таблица пустая — fallback") return [_fallback(jt) for jt in _DEFAULTS] return [_row_to_dict(r) for r in rows] ``` `get_all` is NOT strict — it catches `Exception` internally and returns synthetic `_DEFAULTS` (scrape_kn enabled=True, objective_sync enabled=True). Consequences: | Scenario | What actually happens in new code | |---|---| | DB unreachable | `get_all` swallows → returns 4 _DEFAULTS rows (all `enabled=True`) → `rows_seen=4`, schedule populated from synth → `db_reachable=True`, `rows_seen>0` → no env-fallback. Same scrape_kn + objective_sync re-introduced via synth defaults instead of env. **Net behavior equivalent to old code's env-fallback path** (both re-enable both jobs), but docstring promises something different. | | Truly empty `job_settings` table | Same — `get_all` returns 4 _DEFAULTS rows. `rows_seen=4`, never `==0`. **The `rows_seen == 0` branch is dead code.** | | `except Exception as e: db_reachable = False` in `build_beat_schedule` | Dead code — `get_all` doesn't raise, so this branch never fires. | | Operator `UPDATE SET enabled=false` (the targeted incident) | ✅ Works: real rows returned, loop skips all, `schedule={}`, `rows_seen=4` → no fallback. Disable respected. | The primary fix works because it doesn't rely on the broken contract — the `rows_seen>0` guard is what makes operator-disable land. But the documented "DB unreachable detection via raise" is fiction. **Two options:** **A. Make the docstring match reality** (cheapest, narrow PR scope): - Remove the `try/except` around `_build_beat_schedule_from_db()` in `build_beat_schedule` (dead code). - Remove the `rows_seen == 0` branch from the if (dead code). - Update docstring: "`get_all` returns synthetic _DEFAULTS on DB error, so DB-unreachable case is silently masked as enabled-by-default. This is acceptable because the failure mode degrades to env-equivalent behavior. The fix here only addresses operator-initiated disable via `enabled=false` on real rows." **B. Make `get_all` actually raise** (cleaner but wider blast radius): - Inline the `db.execute(text(...))` directly in `_build_beat_schedule_from_db` and let `OperationalError` / `ProgrammingError` propagate. - OR add a `get_all_strict(db)` variant that doesn't swallow. - Then the `except` + `rows_seen == 0` checks become real. Given the operational urgency (WAF cooldown needs Monday 04:15 UTC to be quiet) — Option A as a follow-up is fine. Land this PR if the goal is just to unblock the disable; come back for the dead-code cleanup. ### 🟢 Low — PR description overstates "hardcoded entries добавляются ВСЕГДА сверху" The early `return _build_beat_schedule_fallback()` on line ~201 means when DB-unreachable branch is hit (in theory — see above), `nspd-geo-zombie-cleanup`, `poi-sync-weekly`, `noise-sync-weekly` are silently dropped. `refresh-ekb-districts-medians` is in the fallback function and survives. Pre-existing behavior, not introduced here, but the PR body asserts the opposite. Cosmetic, no fix needed in this PR. ### 🟢 Low — No regression test for the fix No test under `backend/tests/workers/` for `build_beat_schedule`. The original bug (env-fallback ignoring DB disable) could regress silently. A minimal `test_build_beat_schedule_respects_all_disabled` mocking `app.services.job_settings.get_all` to return `[{...enabled=False...}] * 4` and asserting `kn-region-66` ∉ schedule and `objective_sync` ∉ schedule would lock this in. Out of scope for the hot-fix, recommended for follow-up. ### Vault cross-check - `decisions/Decision_Job_Settings_Centralized.md` — DB-driven beat with env fallback. PR sharpens the boundary (disable-respect), consistent direction. - `runbooks/Beat_Schedule_Module.md` + `code/modules/Module_Beat_Schedule.md` — describe 3-tier priority. After merge, update vault to mention the new "explicit-disable respected, no fallback re-add" semantic. ### Security / perf / conventions - ✅ No SQL injection vectors (SQLAlchemy `text()` parameterization in `get_all`). - ✅ No credential / secret exposure in diff. - ✅ psycopg v3 compatible (no `:x::type` syntax, no `psycopg2` imports). - ✅ Session lifecycle correct: `db = SessionLocal()` before `try/finally db.close()` — `SessionLocal()` in SQLAlchemy is lazy, doesn't raise on construction. - ✅ `_build_beat_schedule_from_db` is private; signature change `-> dict` → `-> tuple[dict, int]` only affects `build_beat_schedule` (only caller, updated). - ✅ Logger escalated `warning → error` on the (theoretical) fallback path — appropriate for visibility. - ✅ Ruff line-100 respected (per PR test plan: pre-commit passed). ### Recommended next steps 1. Merge this PR — primary fix is correct and operational urgency is real (24-48h WAF cooldown). 2. **Same session or immediate follow-up**: pick Option A from above — strip the dead `except` + `rows_seen==0` branch, align docstring with reality. ~10 lines, narrow PR. 3. **Vault**: update `runbooks/Beat_Schedule_Module.md` to document the disable-respect semantic. 4. **Optional**: regression test under `backend/tests/workers/test_beat_schedule.py`. ### Complexity / blast radius - **Risk:** LOW — operator-disable case works correctly; DB-unreachable case has identical net behavior to pre-PR code. - **Reversibility:** HIGH — single file, revertable in one commit, `UPDATE SET enabled=true` restores prior schedule. - **Merge window:** sooner is better (Monday 04:15 UTC scrape_kn). --- _Marker: `<!-- gendesign-review-bot: sha=dc1cd94 verdict=comment -->` — verdict=comment (not approve) because of the medium docstring/dead-code discrepancy. Auto-merge withheld; human can choose to land now and follow up with hardening, or harden first._
lekss361 added 1 commit 2026-05-24 13:11:00 +00:00
fix(beat): cleanup dead-code from prior fallback distinguishing — align with reality
Some checks failed
CI / backend (pull_request) Failing after 46s
CI / frontend (pull_request) Successful in 1m44s
cca7dcd029
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) — но код теперь честный.
Author
Owner

Deep code re-review — PR #520 (fixup)

Status: APPROVE

Verified at head SHA cca7dcd. Re-reviewed against prior comment at dc1cd94.

Prior issue resolution

Prior review (verdict=comment) flagged docstring/implementation mismatch: caller docstring claimed get_all raises on DB failure, but app/services/job_settings.py get_all silently catches Exception and returns 4 synthetic _DEFAULTS rows → except and rows_seen==0 branches in caller were dead code.

Fixup applied — clean resolution via two coordinated changes:

  1. Caller docstring rewritten to match reality. New _build_beat_schedule_from_db docstring at beat_schedule.py:54-71 now explicitly states: «get_all маскирует DB-unreachable case через возврат _DEFAULTS (4 synthetic rows, все enabled=True с дефолтными cron'ами) — это значит "DB unreachable" и "fresh install (table empty)" неотличимы от "normal с _DEFAULTS"». Accurate to actual get_all behavior at services/job_settings.py:124-149.

  2. Dead branches removed. The try/except around get_all and the if not schedule → _build_beat_schedule_fallback() env-fallback in build_beat_schedule are both gone. _build_beat_schedule_from_db now calls get_all(db) directly with a try/finally only for session close. No more dead code paths.

Behavior matrix (verified against get_all source at HEAD)

Scenario get_all returns New _build_beat_schedule_from_db schedule Final build_beat_schedule
DB unreachable _DEFAULTS (4 synth rows, enabled=True, cron set) scrape_kn + objective_sync from defaults defaults + hardcoded entries
Fresh install (table empty) _DEFAULTS (4 synth rows) scrape_kn + objective_sync from defaults defaults + hardcoded entries
Normal — operator UPDATE enabled=false Real rows (4 incl. disabled) {} (loop skips disabled) only hardcoded entries — operator intent respected
Normal — at least one enabled with cron Real rows corresponding rows rows + hardcoded entries

The operator-disable case (DB reachable, rows present with enabled=false) is the core scenario this PR fixes and it works correctly. The WAF-cooldown incident from 2026-05-24 will not recur as long as DB stays reachable when beat restarts (which is the normal case — symmetric trade-off explicitly documented in the new docstring).

Minor — non-blocking follow-up

  • get_all_safe in services/job_settings.py is now orphan (no callers after this PR). Safe to leave for this fix; consider removing in a future cleanup PR.
  • If SessionLocal() constructor itself raises (rare for SQLAlchemy lazy engines), it now propagates up to celery_app import. This is intentional per the new docstring philosophy ("crash loud rather than silently restore defaults that defeat operator intent") but worth a note in release log.

Conclusion

Docstring now matches get_all reality. Dead branches eliminated. Operator-disable scenario honored. Approve + merge.

## Deep code re-review — PR #520 (fixup) <!-- gendesign-review-bot: sha=cca7dcd verdict=approve --> ### Status: APPROVE Verified at head SHA `cca7dcd`. Re-reviewed against prior comment at `dc1cd94`. ### Prior issue resolution Prior review (verdict=comment) flagged docstring/implementation mismatch: caller docstring claimed `get_all` raises on DB failure, but `app/services/job_settings.py` `get_all` silently catches `Exception` and returns 4 synthetic `_DEFAULTS` rows → `except` and `rows_seen==0` branches in caller were dead code. **Fixup applied — clean resolution via two coordinated changes:** 1. **Caller docstring rewritten to match reality.** New `_build_beat_schedule_from_db` docstring at `beat_schedule.py:54-71` now explicitly states: *«get_all маскирует DB-unreachable case через возврат _DEFAULTS (4 synthetic rows, все enabled=True с дефолтными cron'ами) — это значит \"DB unreachable\" и \"fresh install (table empty)\" неотличимы от \"normal с _DEFAULTS\"»*. ✅ Accurate to actual `get_all` behavior at `services/job_settings.py:124-149`. 2. **Dead branches removed.** The `try/except` around `get_all` and the `if not schedule → _build_beat_schedule_fallback()` env-fallback in `build_beat_schedule` are both gone. `_build_beat_schedule_from_db` now calls `get_all(db)` directly with a `try/finally` only for session close. ✅ No more dead code paths. ### Behavior matrix (verified against `get_all` source at HEAD) | Scenario | `get_all` returns | New `_build_beat_schedule_from_db` schedule | Final `build_beat_schedule` | |---|---|---|---| | DB unreachable | _DEFAULTS (4 synth rows, enabled=True, cron set) | scrape_kn + objective_sync from defaults | defaults + hardcoded entries | | Fresh install (table empty) | _DEFAULTS (4 synth rows) | scrape_kn + objective_sync from defaults | defaults + hardcoded entries | | Normal — operator UPDATE enabled=false | Real rows (4 incl. disabled) | `{}` (loop skips disabled) | **only hardcoded entries — operator intent respected** ✅ | | Normal — at least one enabled with cron | Real rows | corresponding rows | rows + hardcoded entries | The operator-disable case (DB reachable, rows present with enabled=false) is the core scenario this PR fixes and it works correctly. The WAF-cooldown incident from 2026-05-24 will not recur as long as DB stays reachable when beat restarts (which is the normal case — symmetric trade-off explicitly documented in the new docstring). ### Minor — non-blocking follow-up - `get_all_safe` in `services/job_settings.py` is now orphan (no callers after this PR). Safe to leave for this fix; consider removing in a future cleanup PR. - If `SessionLocal()` constructor itself raises (rare for SQLAlchemy lazy engines), it now propagates up to `celery_app` import. This is intentional per the new docstring philosophy (\"crash loud rather than silently restore defaults that defeat operator intent\") but worth a note in release log. ### Conclusion Docstring now matches `get_all` reality. Dead branches eliminated. Operator-disable scenario honored. Approve + merge.
lekss361 merged commit 44cb5b509d into main 2026-05-24 13:14:12 +00:00
lekss361 deleted branch fix/beat-fallback-distinguish-empty 2026-05-24 13:14:12 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#520
No description provided.