fix(freshness): output-floor on cycle-SUM over fresh_days window (#1945, #1947)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m45s
CI / backend-tests (pull_request) Successful in 11m57s

Adversarial review found the single-newest last_success_work basis fragile:
a load cycle = MULTIPLE done-runs (objective per group_name, kn per region +
resume), so the newest run alone misrepresents the cycle. Replace with
SUM(work_col) FILTER(done) over the source's own fresh_days window.

- recent_output = SUM(COALESCE(work_col,0)) FILTER done in fresh_days window
  (make_interval secs => fresh_days*86400; no :: cast; NULL-counter -> 0)
- downgrade when status==ok and recent_output < min_output_rows (strict <)
- registry floors kept: kn_flats=50000 (healthy ~376k, broken sum <=3670),
  objective=1000 (SUM 7d ~946k); comments rewritten to cycle-sum basis +
  kn_flats zombie-resume KNOWN LIMITATION
- window=fresh_days keeps the #1947 aging-FP fix (7-8d run stays in window)
- tests: low-output-cycle->failed, above-floor->ok, boundary==floor->ok,
  weekly-aging->ok, age-stale precedence, registry floors

Verified on prod: domrf_kn_flats latest snapshot 2026-06-22=9 flats vs healthy
2026-05-17=376604 (broken 5+ weeks); objective SUM(rows_lots,7d)=946264.
This commit is contained in:
Light1YT 2026-06-27 12:36:50 +05:00
parent 9c9b50ceca
commit c646a71001
2 changed files with 273 additions and 74 deletions

View file

@ -1280,12 +1280,21 @@ class FreshnessSource(BaseModel):
stale_days: float = 30.0 # < stale_days → stale, иначе critical stale_days: float = 30.0 # < stale_days → stale, иначе critical
critical: bool = False # критичный источник (для Sentry-алёртов в beat-таске) critical: bool = False # критичный источник (для Sentry-алёртов в beat-таске)
# True → источник, который прогнался успешно И недавно (был бы fresh по возрасту), # True → источник, который прогнался успешно И недавно (был бы fresh по возрасту),
# НО произвёл 0 work-строк в окне (SUM(work_col) FILTER(status='done') == 0), # НО SUM(work_col) по done-прогонам цикла (окно fresh_days) < min_output_rows,
# ДАУНГРЕЙДИТСЯ до "failed". Ловит "лоадер-прогнался-но-ничего-не-извлёк" # ДАУНГРЕЙДИТСЯ до "failed". Ловит "лоадер-прогнался-но-почти-ничего-не-извлёк"
# (напр. WAF-break / тихий пустой extract: status=done, но flats_count=0). По # (напр. WAF-break / тихий частичный extract: status=done, но flats_count суммарно
# умолчанию OFF — включаем только там, где 0 строк = поломка (не legitimate-0, # 9 при здоровых ~376k). По умолчанию OFF — включаем только там, где малый выход =
# как nspd/nspd_geo/cadastre). Применимо только к run-ledger (не data-table) режиму. # поломка (не legitimate-low, как nspd/nspd_geo/cadastre). Применимо только к
# run-ledger (не data-table) режиму.
alert_on_zero_output: bool = False alert_on_zero_output: bool = False
# Пол (floor) для recent_output = SUM(work_col) по done-прогонам ЦИКЛА в окне
# fresh_days (НЕ один прогон — цикл объединяет несколько прогонов: objective по
# group_name, kn по region_codes+resume). Проверяется ТОЛЬКО при
# alert_on_zero_output=True: recent_output < min_output_rows → даунгрейд (строгое <).
# Окно = fresh_days, поэтому здоровый недельный прогон возрастом 78d остаётся
# внутри окна и не ложно-срабатывает (#1947 fix). Default 1 → флагует только если
# суммарный выход цикла = 0 (безопасный минимальный catch).
min_output_rows: int = 1
# Реестр источников. Run-ledger таблицы (kn/objective/nspd_geo/cadastre) проверены на # Реестр источников. Run-ledger таблицы (kn/objective/nspd_geo/cadastre) проверены на
@ -1295,9 +1304,12 @@ class FreshnessSource(BaseModel):
# забаненных manual-прогона 2026-04-30, никогда не шедулился → ложный "no successful runs"). # забаненных manual-прогона 2026-04-30, никогда не шедулился → ложный "no successful runs").
# Пороги: # Пороги:
# kn — beat ~еженедельно (scrape_kn cron, Mon) → fresh<8d, stale<14d # kn — beat ~еженедельно (scrape_kn cron, Mon) → fresh<8d, stale<14d
# kn_flats — та же run-таблица, work_col=flats_count + alert_on_zero_output (issue # kn_flats — та же run-таблица, work_col=flats_count + alert_on_zero_output +
# #1945: kn-run status=done но flats_count=0 ~5 недель — монитор молчал, # min_output_rows=50000 (issue #1945: kn-run status=done но flats_count
# т.к. kn смотрит objects_count ~1548, не flats_count) → fresh<8d, stale<14d # мал/нестабилен — монитор смотрел objects_count ~1548, не flats_count;
# #1947 fix: floor сравнивается с SUM(flats_count) по done-прогонам цикла
# в окне fresh_days=8d — ловит low-output 9/2296/3670 и не ложит на 78d
# aging) → fresh<8d, stale<14d
# objective — beat ~еженедельно (objective_sync) + критичный → fresh<7d, stale<14d # objective — beat ~еженедельно (objective_sync) + критичный → fresh<7d, stale<14d
# nspd — data-table: harvest_stale_quarters (пн) + lazy-refresh → fresh<14d, stale<30d # nspd — data-table: harvest_stale_quarters (пн) + lazy-refresh → fresh<14d, stale<30d
# nspd_geo — ручной bulk geo-job → fresh<7d, stale<30d # nspd_geo — ручной bulk geo-job → fresh<7d, stale<30d
@ -1318,11 +1330,23 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
), ),
FreshnessSource( FreshnessSource(
# kn-run пишет ДВА счётчика: objects_count (ЖК, ~1548 — выше) и flats_count # kn-run пишет ДВА счётчика: objects_count (ЖК, ~1548 — выше) и flats_count
# (квартиры). Источник истины для "лоадер прогнался, но ничего не извлёк" # (квартиры). Источник истины для "лоадер прогнался, но почти ничего не извлёк"
# (issue #1945: domrf_kn FLATS-лоадер status=done, но flats_count=0 ~5 недель, # (issue #1945: domrf_kn FLATS-лоадер status=done, но flats_count мал/нестабилен,
# монитор молчал — objects_count оставался здоровым). Та же run-таблица и пороги, # монитор молчал — objects_count оставался здоровым). Та же run-таблица и пороги,
# что у kn, но work_col=flats_count + alert_on_zero_output → 0 квартир за неделю # что у kn, но work_col=flats_count + alert_on_zero_output + min_output_rows.
# done-прогонов даунгрейдит otherwise-fresh источник до failed. #
# Floor сравнивается с recent_output = SUM(flats_count) по ВСЕМ done-прогонам в
# окне fresh_days=8d (kn пишет строку на region_codes + resume — это НЕ один
# прогон). Магнитуды (прод gendesign-postgres-1, #1947): healthy цикл ~376k
# квартир (snapshot 05-17=376604); сломанное состояние — суммарно ≤3670, сейчас
# SUM(flats_count,8d)=9 (latest done = resume, считает только хвост; ground truth
# snapshot 2026-06-22=9, сломан 5+ недель). Порог 50000 чисто разделяет:
# healthy ≫ 50k ≫ broken ≤3670.
#
# KNOWN LIMITATION: zombie-resume, загрузивший лишь малый хвост БЕЗ полного
# прогона в окне, может транзиентно недосчитать → максимум одно-цикловый ложный
# алёрт, самоисцеляется следующим полным прогоном. Приемлемо (а в текущем реальном
# resume-кейсе snapshot реально сломан=9 → флаг корректен).
source="kn_flats", source="kn_flats",
label="DOM.РФ kn-API (квартиры)", label="DOM.РФ kn-API (квартиры)",
table="kn_scrape_runs", table="kn_scrape_runs",
@ -1331,6 +1355,7 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
stale_days=14.0, stale_days=14.0,
critical=True, critical=True,
alert_on_zero_output=True, alert_on_zero_output=True,
min_output_rows=50000,
), ),
FreshnessSource( FreshnessSource(
source="objective", source="objective",
@ -1340,9 +1365,16 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
fresh_days=7.0, fresh_days=7.0,
stale_days=14.0, stale_days=14.0,
critical=True, critical=True,
# Объектив-sync, прогнавшийся с 0 лотов за неделю — тоже поломка (тихий # Объектив-sync, прогнавшийся с почти нулём лотов — тоже поломка (тихий
# пустой extract), а не легитимный 0. Даунгрейд otherwise-fresh до failed. # частичный extract), а не легитимный low. objective пишет строку на group_name
# (4 группы: Свердловская/Тюмень/Пермь/Челябинск), поэтому floor сравнивается с
# recent_output = SUM(rows_lots) по группам в окне fresh_days=7d (прод: SUM 7d=
# 946264; даже наименьшая отдельная группа Челябинск=68387). Floor 1000 ловит
# вырожденный цикл с огромным запасом, не задевая нормальный разброс — и
# робастен к тому, КАКАЯ группа дописалась последней (single-newest был бы
# хрупок: 324570 или 68387 в зависимости от порядка финиша).
alert_on_zero_output=True, alert_on_zero_output=True,
min_output_rows=1000,
), ),
FreshnessSource( FreshnessSource(
source="nspd", source="nspd",
@ -1403,15 +1435,21 @@ def compute_freshness(db: Session) -> dict[str, Any]:
- last_attempt_at max(started_at) (или created_at fallback) по всем прогонам - last_attempt_at max(started_at) (или created_at fallback) по всем прогонам
- last_status status последнего прогона - last_status status последнего прогона
- objects_updated_24h / _7d сумма run-счётчика по done-прогонам в окне - objects_updated_24h / _7d сумма run-счётчика по done-прогонам в окне
- recent_output SUM(work_col) по done-прогонам цикла в окне fresh_days (NULL
если успехов в окне нет)
- age_days возраст last_success в днях (NULL если успехов не было) - age_days возраст last_success в днях (NULL если успехов не было)
- freshness fresh / stale / critical / unknown (по порогам источника) - freshness fresh / stale / critical / unknown (по порогам источника)
- status ok / stale / failed (для UI-светофора) - status ok / stale / failed (для UI-светофора)
- reason пояснение к zero-output-даунгрейду (иначе None) - reason пояснение к output-floor-даунгрейду (иначе None)
Zero-output (issue #1945): для run-ledger источников с alert_on_zero_output=True Output-floor (issue #1945, redesigned #1947): для run-ledger источников с
источник, который иначе был бы freshok (недавний успешный прогон), но произвёл alert_on_zero_output=True источник, который иначе был бы freshok (недавний
0 work-строк за 7d окно (objects_updated_7d == 0), ДАУНГРЕЙДИТСЯ до failed ловит успешный прогон), но SUM(work_col) по ВСЕМ done-прогонам цикла в окне fresh_days
"лоадер-прогнался-но-ничего-не-извлёк" (kn flats_count=0 ~5 недель при status=done). (recent_output) < min_output_rows, ДАУНГРЕЙДИТСЯ до failed ловит
"лоадер-прогнался-но-почти-ничего-не-извлёк" (kn flats_count суммарно 9 при
здоровых ~376k). Cycle-SUM (не один прогон) робастен к multi-run циклам: objective
пишет строку на group_name, kn на region_codes + resume. Окно fresh_days (не
хардкод 7d) здоровый недельный прогон 78d остаётся в окне (нет aging-FP).
Только когда возраст НЕ покрывает поломку (status=='ok'), чтобы не двойнить. Только когда возраст НЕ покрывает поломку (status=='ok'), чтобы не двойнить.
Для data-table источников (src.timestamp_col задан, напр. nspd nspd_quarter_dumps) Для data-table источников (src.timestamp_col задан, напр. nspd nspd_quarter_dumps)
@ -1496,7 +1534,25 @@ def compute_freshness(db: Session) -> dict[str, Any]:
SELECT status FROM {src.table} SELECT status FROM {src.table}
ORDER BY {order_col} DESC NULLS LAST ORDER BY {order_col} DESC NULLS LAST
LIMIT 1 LIMIT 1
) AS last_status ) AS last_status,
-- Cycle-sum: SUM(work_col) по ВСЕМ done-прогонам в окне
-- fresh_days. Логический цикл загрузки = НЕСКОЛЬКО прогонов
-- (objective по строке на group_name, kn по region_codes
-- + resume), поэтому суммируем, а не берём один прогон.
-- Окно = fresh_days (per-source), а НЕ хардкод 7d: здоровый
-- недельный прогон возрастом 78d остаётся ВНУТРИ окна
-- нет aging-FP (#1947). Inner COALESCE(work_col,0) — NULL-
-- счётчик на done-прогоне вносит 0, не NULL. fresh_days
-- float из доверенного реестра f-string безопасен. Окно
-- через make_interval(secs => fresh_days*86400): secs
-- double precision, поэтому дробный fresh_days валиден (у
-- days int-only 7.5 упал бы). NB без двоеточийного cast.
SUM(COALESCE({src.work_col}, 0)) FILTER (
WHERE status = 'done'
AND COALESCE(finished_at, {started})
> now()
- make_interval(secs => {src.fresh_days} * 86400)
) AS recent_output
FROM {src.table} FROM {src.table}
""" """
), ),
@ -1509,6 +1565,13 @@ def compute_freshness(db: Session) -> dict[str, Any]:
last_success = row["last_success_at"] if row else None last_success = row["last_success_at"] if row else None
last_attempt = row["last_attempt_at"] if row else None last_attempt = row["last_attempt_at"] if row else None
last_status = row["last_status"] if row else None last_status = row["last_status"] if row else None
# SUM(work_col) по done-прогонам в окне fresh_days (run-ledger режим = весь
# цикл загрузки). В data-table режиме колонки нет → None; .get() терпит
# отсутствие ключа в mapping-строке. None также когда done-прогонов в окне нет.
recent_output_raw = row.get("recent_output") if row else None
recent_output: int | None = (
int(recent_output_raw) if recent_output_raw is not None else None
)
age_days: float | None = None age_days: float | None = None
if last_success is not None: if last_success is not None:
@ -1530,25 +1593,32 @@ def compute_freshness(db: Session) -> dict[str, Any]:
else: # critical else: # critical
status = "failed" status = "failed"
# Zero-output downgrade (issue #1945): источник прогнался успешно И недавно # Output-floor downgrade (issue #1945, redesigned #1947): источник прогнался
# (был бы fresh→ok по возрасту), НО за окно done-прогонов произвёл 0 work-строк # успешно И недавно (был бы fresh→ok по возрасту), НО SUM(work_col) по всем
# (objects_updated_7d == SUM(work_col) FILTER(status='done') в 7d == 0) → # done-прогонам ЦИКЛА (окно fresh_days) ниже порога min_output_rows → ДАУНГРЕЙД
# ДАУНГРЕЙД до "failed", чтобы beat-таска (алёртит на stale/failed) выстрелила. # до "failed", чтобы beat-таска (алёртит на stale/failed) выстрелила.
# Только для run-ledger (timestamp_col is None) с alert_on_zero_output=True и # - cycle-SUM (а не один прогон): цикл = несколько done-прогонов (objective —
# ТОЛЬКО когда источник иначе был бы fresh/ok — по возрасту stale/failed уже # по group_name, kn — по region_codes + resume), сумма даёт магнитуду цикла
# покрыт, не двойним. last_success is None уже отсеян (status=='ok' гарантирует # (objective ~946k, kn_flats ~376k healthy) → робастно к multi-run.
# недавний успех), поэтому это ДОПОЛНИТЕЛЬНЫЙ способ завалить otherwise-fresh. # - окно fresh_days (а не хардкод 7d): здоровый недельный прогон 78d остаётся
# внутри окна → нет aging-FP первой версии #1947.
# Только run-ledger (timestamp_col is None) c alert_on_zero_output=True и ТОЛЬКО
# когда источник иначе был бы fresh/ok (по возрасту stale/failed уже покрыт — не
# двойним). status=='ok' гарантирует недавний успех (last_success is not None).
# Строгое '<': SUM ровно на пороге → ok (порог = минимально-приемлемый объём).
reason: str | None = None reason: str | None = None
if ( if (
src.alert_on_zero_output src.alert_on_zero_output
and src.timestamp_col is None and src.timestamp_col is None
and status == "ok" and status == "ok"
and upd_7d == 0 and recent_output is not None
and recent_output < src.min_output_rows
): ):
status = "failed" status = "failed"
reason = ( reason = (
f"zero-output: прогон успешен (age={age_days}d), но {src.work_col}=0 " f"low-output: done-прогоны за {src.fresh_days:g}d дали "
f"за 7d done-прогонов — лоадер прогнался, но ничего не извлёк (#1945)" f"SUM({src.work_col})={recent_output} < порога {src.min_output_rows} "
f"(#1945, #1947)"
) )
if src.critical and severity[status] > severity[worst_critical]: if src.critical and severity[status] > severity[worst_critical]:
@ -1564,11 +1634,15 @@ def compute_freshness(db: Session) -> dict[str, Any]:
"last_status": last_status, "last_status": last_status,
"objects_updated_24h": upd_24h, "objects_updated_24h": upd_24h,
"objects_updated_7d": upd_7d, "objects_updated_7d": upd_7d,
# SUM(work_col) по done-прогонам цикла в окне fresh_days (None для
# data-table режима / без done-прогонов в окне). Additive — база для
# output-floor-проверки, полезно и для UI-диагностики.
"recent_output": recent_output,
"age_days": age_days, "age_days": age_days,
"thresholds": {"fresh_days": src.fresh_days, "stale_days": src.stale_days}, "thresholds": {"fresh_days": src.fresh_days, "stale_days": src.stale_days},
"freshness": freshness, "freshness": freshness,
"status": status, "status": status,
# Доп. контекст при zero-output-даунгрейде (None в обычном случае). # Доп. контекст при output-floor-даунгрейде (None в обычном случае).
# Additive — не ломает существующий UI/JSON-контракт. # Additive — не ломает существующий UI/JSON-контракт.
"reason": reason, "reason": reason,
} }

View file

@ -4,8 +4,9 @@
nspd / nspd_geo / cadastre kn и kn_flats делят kn_scrape_runs, разные work_col) nspd / nspd_geo / cadastre kn и kn_flats делят kn_scrape_runs, разные work_col)
и для каждого считает свежесть. Здесь DB замокан: на каждый per-source SELECT и для каждого считает свежесть. Здесь DB замокан: на каждый per-source SELECT
возвращаем заранее заданную строку, чтобы проверить классификацию возвращаем заранее заданную строку, чтобы проверить классификацию
fresh/stale/critical, маппинг status (ok/stale/failed), zero-output downgrade fresh/stale/critical, маппинг status (ok/stale/failed), output-floor downgrade
(#1945) и агрегат overall_status по критичным источникам. (#1945 / redesigned #1947 — recent_output = SUM(work_col) по done-прогонам цикла в
окне fresh_days < min_output_rows) и агрегат overall_status по критичным источникам.
""" """
from __future__ import annotations from __future__ import annotations
@ -68,6 +69,7 @@ def test_freshness_mixed_statuses() -> None:
"upd_24h": 1500, "upd_24h": 1500,
"upd_7d": 1500, "upd_7d": 1500,
"last_status": "done", "last_status": "done",
"recent_output": 376000, # здоровый цикл flats_count → kn_flats ok
}, },
"objective_scrape_runs": { "objective_scrape_runs": {
"last_success_at": _ago(10.0), # fresh<7, stale<14 → stale "last_success_at": _ago(10.0), # fresh<7, stale<14 → stale
@ -75,6 +77,7 @@ def test_freshness_mixed_statuses() -> None:
"upd_24h": 0, "upd_24h": 0,
"upd_7d": 325000, "upd_7d": 325000,
"last_status": "done", "last_status": "done",
"recent_output": 325000,
}, },
# nspd теперь data-table режим (nspd_quarter_dumps.fetched_at_utc): # nspd теперь data-table режим (nspd_quarter_dumps.fetched_at_utc):
# last_success_at = MAX(timestamp_col), last_status = NULL (нет run-ledger). # last_success_at = MAX(timestamp_col), last_status = NULL (нет run-ledger).
@ -150,6 +153,7 @@ def _healthy_run_rows_except_nspd(nspd_row: dict[str, Any]) -> dict[str, dict[st
"upd_24h": 10, "upd_24h": 10,
"upd_7d": 100, "upd_7d": 100,
"last_status": "done", "last_status": "done",
"recent_output": 100000, # выше любого floor → flagged-источники ok
} }
rows: dict[str, dict[str, Any]] = {} rows: dict[str, dict[str, Any]] = {}
for s in admin_scrape._FRESHNESS_SOURCES: for s in admin_scrape._FRESHNESS_SOURCES:
@ -224,6 +228,7 @@ def test_freshness_all_healthy() -> None:
"upd_24h": 10, "upd_24h": 10,
"upd_7d": 100, "upd_7d": 100,
"last_status": "done", "last_status": "done",
"recent_output": 100000, # выше любого floor → flagged-источники ok
} }
rows = { rows = {
s.table: dict(fresh_row) for s in admin_scrape._FRESHNESS_SOURCES s.table: dict(fresh_row) for s in admin_scrape._FRESHNESS_SOURCES
@ -249,15 +254,18 @@ def test_freshness_critical_source_failed_drives_overall() -> None:
"upd_24h": 1, "upd_24h": 1,
"upd_7d": 1, "upd_7d": 1,
"last_status": "done", "last_status": "done",
"recent_output": 100000,
} }
rows = {s.table: dict(base) for s in admin_scrape._FRESHNESS_SOURCES} rows = {s.table: dict(base) for s in admin_scrape._FRESHNESS_SOURCES}
# objective критичный — обнулим успех. # objective критичный — обнулим успех (last_success=None → нет done-прогона,
# recent_output=None → floor не применяется, status=failed по отсутствию успеха).
rows["objective_scrape_runs"] = { rows["objective_scrape_runs"] = {
"last_success_at": None, "last_success_at": None,
"last_attempt_at": _ago(5.0), "last_attempt_at": _ago(5.0),
"upd_24h": 0, "upd_24h": 0,
"upd_7d": 0, "upd_7d": 0,
"last_status": "running", "last_status": "running",
"recent_output": None,
} }
_override_db(_make_db(rows)) _override_db(_make_db(rows))
try: try:
@ -281,37 +289,48 @@ def test_classify_freshness_boundaries() -> None:
assert f(100.0, 2.0, 5.0) == "critical" assert f(100.0, 2.0, 5.0) == "critical"
def test_zero_output_downgrades_otherwise_fresh_source() -> None: # NB по мокам output-floor: _make_db ключуется по ИМЕНИ таблицы → kn и kn_flats
"""issue #1945: kn_flats прогнался успешно И недавно (был бы fresh→ok по возрасту), # делят одну kn_scrape_runs-строку, поэтому оба видят ОДИН recent_output (per-work_col
но flats_count=0 за 7d done-прогонов DOWNGRADE до failed (alertable). # дивергенция objects_count vs flats_count есть только в живом SQL, не в моке). Здесь
# мок проверяет Python-сравнение (recent_output < min_output_rows + все guard'ы);
# семантику оконной SQL-SUM подтвердили на проде (objective SUM(rows_lots,7d)=946264,
# kn_flats SUM(flats_count,8d)=9 — gendesign-postgres-1, #1947).
Та же run-таблица kn_scrape_runs у kn (alert_on_zero_output=False) и kn_flats
(True). Возвращаем строку с upd_7d=0 kn остаётся ok (нет флага = нет ложного def test_output_floor_downgrades_low_output_cycle() -> None:
срабатывания), kn_flats падает в failed (zero-output) с пояснением в reason. """issue #1945 / #1947: kn_flats прогнался успешно И недавно (был бы fresh→ok), но
recent_output=SUM(flats_count) по циклу=9 < min_output_rows=50000 failed (alertable).
Прод-сценарий: цикл дал суммарно flats_count=9 (latest done = resume, считает хвост)
при здоровых ~376k. kn БЕЗ флага (alert_on_zero_output=False) branch не входит,
остаётся ok даже читая ту же низкую recent_output (no false positive).
""" """
fresh_zero = { # Сломанный цикл: свежий (1d), но recent_output (SUM flats_count за 8d)=9.
broken_kn = {
"last_success_at": _ago(1.0), # < fresh_days=8 → был бы fresh "last_success_at": _ago(1.0), # < fresh_days=8 → был бы fresh
"last_attempt_at": _ago(1.0), "last_attempt_at": _ago(1.0),
"upd_24h": 0, "upd_24h": 9,
"upd_7d": 0, # 0 work-строк за окно done-прогонов → zero-output "upd_7d": 9,
"last_status": "done", "last_status": "done",
"recent_output": 9, # SUM(flats_count) по done-прогонам цикла ≪ 50000
} }
# Остальные источники свежие+ненулевые, чтобы изолировать поведение kn/kn_flats. # Остальные источники свежие+здоровые, чтобы изолировать поведение kn/kn_flats.
nonzero_fresh = { healthy = {
"last_success_at": _ago(0.5), "last_success_at": _ago(0.5),
"last_attempt_at": _ago(0.5), "last_attempt_at": _ago(0.5),
"upd_24h": 10, "upd_24h": 100,
"upd_7d": 100, "upd_7d": 1000,
"last_status": "done", "last_status": "done",
"recent_output": 100000, # выше любого floor
} }
rows: dict[str, dict[str, Any]] = {} rows: dict[str, dict[str, Any]] = {}
for s in admin_scrape._FRESHNESS_SOURCES: for s in admin_scrape._FRESHNESS_SOURCES:
if s.timestamp_col is not None: if s.timestamp_col is not None:
rows[s.table] = {**nonzero_fresh, "last_status": None} rows[s.table] = {**healthy, "last_status": None, "recent_output": None}
elif s.table == "kn_scrape_runs": elif s.table == "kn_scrape_runs":
rows[s.table] = dict(fresh_zero) rows[s.table] = dict(broken_kn)
else: else:
rows[s.table] = dict(nonzero_fresh) rows[s.table] = dict(healthy)
_override_db(_make_db(rows)) _override_db(_make_db(rows))
try: try:
resp = TestClient(app).get(ENDPOINT) resp = TestClient(app).get(ENDPOINT)
@ -322,14 +341,16 @@ def test_zero_output_downgrades_otherwise_fresh_source() -> None:
body = resp.json() body = resp.json()
by_src = {s["source"]: s for s in body["sources"]} by_src = {s["source"]: s for s in body["sources"]}
# kn_flats: otherwise-fresh, но flats_count=0 → DOWNGRADE до failed (alertable). # kn_flats: otherwise-fresh, но recent_output=9 < 50000 → DOWNGRADE до failed.
assert by_src["kn_flats"]["freshness"] == "fresh" # по возрасту был бы fresh assert by_src["kn_flats"]["freshness"] == "fresh" # по возрасту был бы fresh
assert by_src["kn_flats"]["status"] == "failed" # zero-output downgrade assert by_src["kn_flats"]["status"] == "failed" # output-floor downgrade
assert by_src["kn_flats"]["objects_updated_7d"] == 0 assert by_src["kn_flats"]["recent_output"] == 9
assert by_src["kn_flats"]["reason"] is not None assert by_src["kn_flats"]["reason"] is not None
assert "flats_count" in by_src["kn_flats"]["reason"] assert "flats_count" in by_src["kn_flats"]["reason"]
assert "9" in by_src["kn_flats"]["reason"]
# kn — та же таблица, upd_7d=0, но БЕЗ флага → остаётся ok (no false positive). # kn — alert_on_zero_output=False гейтит ВЕСЬ branch → ok, даже читая recent_output=9
# из той же mocked-строки (per-work_col дивергенция есть только в живом SQL).
assert by_src["kn"]["status"] == "ok" assert by_src["kn"]["status"] == "ok"
assert by_src["kn"]["reason"] is None assert by_src["kn"]["reason"] is None
@ -338,25 +359,26 @@ def test_zero_output_downgrades_otherwise_fresh_source() -> None:
assert body["overall_status"] == "failed" assert body["overall_status"] == "failed"
def test_zero_output_no_false_positive_when_nonzero() -> None: def test_output_floor_no_false_positive_when_above_floor() -> None:
"""alert_on_zero_output=True источник с НЕнулевым выходом → остаётся ok (нет даунгрейда). """alert_on_zero_output источник с recent_output ВЫШЕ floor → остаётся ok.
Регрессия: zero-output даунгрейд НЕ должен бить по здоровому источнику, который Регрессия: output-floor НЕ должен бить по здоровому источнику, чей цикл извлёк
прогнался и реально извлёк строки. нормальный объём (kn_flats ~376k > 50000; objective ~946k > 1000).
""" """
nonzero_fresh = { healthy = {
"last_success_at": _ago(1.0), "last_success_at": _ago(1.0),
"last_attempt_at": _ago(1.0), "last_attempt_at": _ago(1.0),
"upd_24h": 12, "upd_24h": 12,
"upd_7d": 1234, # ненулевой выход "upd_7d": 1234,
"last_status": "done", "last_status": "done",
"recent_output": 376000, # здоровый цикл ≫ всех floor'ов
} }
rows: dict[str, dict[str, Any]] = {} rows: dict[str, dict[str, Any]] = {}
for s in admin_scrape._FRESHNESS_SOURCES: for s in admin_scrape._FRESHNESS_SOURCES:
rows[s.table] = ( rows[s.table] = (
{**nonzero_fresh, "last_status": None} {**healthy, "last_status": None, "recent_output": None}
if s.timestamp_col is not None if s.timestamp_col is not None
else dict(nonzero_fresh) else dict(healthy)
) )
_override_db(_make_db(rows)) _override_db(_make_db(rows))
try: try:
@ -366,38 +388,134 @@ def test_zero_output_no_false_positive_when_nonzero() -> None:
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
by_src = {s["source"]: s for s in resp.json()["sources"]} by_src = {s["source"]: s for s in resp.json()["sources"]}
# Все alert_on_zero_output-источники с ненулевым выходом → ok, reason=None. # Все alert_on_zero_output-источники с выходом выше floor → ok, reason=None.
for s in admin_scrape._FRESHNESS_SOURCES: for s in admin_scrape._FRESHNESS_SOURCES:
if s.alert_on_zero_output: if s.alert_on_zero_output:
assert by_src[s.source]["status"] == "ok", s.source assert by_src[s.source]["status"] == "ok", s.source
assert by_src[s.source]["reason"] is None, s.source assert by_src[s.source]["reason"] is None, s.source
def test_zero_output_not_applied_when_already_stale() -> None: def test_output_floor_boundary_equal_floor_stays_ok() -> None:
"""Если источник уже stale/failed по возрасту — zero-output НЕ передоунгрейдит """Граница: recent_output == min_output_rows → остаётся ok (проверка строгое '<').
(age уже покрывает), и не двойнит. objective: старый успех (10d stale) + upd_7d=0
остаётся stale (НЕ перепрыгивает в failed через zero-output).""" kn_flats recent_output ровно 50000 (= floor) НЕ даунгрейдится (порог = минимально-
приемлемый объём, на нём ещё ok). Регрессия на off-by-one в сравнении.
"""
kn_flats = next(s for s in admin_scrape._FRESHNESS_SOURCES if s.source == "kn_flats")
at_floor = {
"last_success_at": _ago(1.0),
"last_attempt_at": _ago(1.0),
"upd_24h": 0,
"upd_7d": kn_flats.min_output_rows,
"last_status": "done",
"recent_output": kn_flats.min_output_rows, # ровно на пороге → ok
}
healthy = {
"last_success_at": _ago(0.5),
"last_attempt_at": _ago(0.5),
"upd_24h": 100,
"upd_7d": 1000,
"last_status": "done",
"recent_output": 1000000,
}
rows: dict[str, dict[str, Any]] = {}
for s in admin_scrape._FRESHNESS_SOURCES:
if s.timestamp_col is not None:
rows[s.table] = {**healthy, "last_status": None, "recent_output": None}
elif s.table == "kn_scrape_runs":
rows[s.table] = dict(at_floor)
else:
rows[s.table] = dict(healthy)
_override_db(_make_db(rows))
try:
resp = TestClient(app).get(ENDPOINT)
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
by_src = {s["source"]: s for s in resp.json()["sources"]}
assert by_src["kn_flats"]["recent_output"] == kn_flats.min_output_rows
assert by_src["kn_flats"]["status"] == "ok" # ровно на пороге → НЕ failed
assert by_src["kn_flats"]["reason"] is None
def test_output_floor_no_false_positive_on_weekly_window_aging() -> None:
"""#1947 fix: здоровый недельный прогон возрастом 78d остаётся в окне fresh_days=8d
recent_output здоров (376k) НЕ ложно-флагуется.
Регрессия на самый первый #1947-баг: оконная SUM была хардкод 7d, поэтому прогон
78d (>7d назад) давал SUM=0 при status=ok ложный downgrade ~раз/неделю. Теперь
окно = fresh_days (8d) прогон 7.5d ВНУТРИ окна, recent_output=376k > floor ok.
upd_7d=0 (мок старого 7d-счётчика) НЕ влияет floor смотрит recent_output.
"""
weekly_aged = {
"last_success_at": _ago(7.5), # < fresh_days=8 → всё ещё fresh, и внутри окна
"last_attempt_at": _ago(7.5),
"upd_24h": 0,
"upd_7d": 0, # старый 7d-счётчик: прогон >7d назад → 0 (раньше ложно флагал)
"last_status": "done",
"recent_output": 376000, # окно fresh_days=8d ловит прогон 7.5d → цикл здоров
}
healthy = {
"last_success_at": _ago(0.5),
"last_attempt_at": _ago(0.5),
"upd_24h": 50,
"upd_7d": 500,
"last_status": "done",
"recent_output": 100000,
}
rows: dict[str, dict[str, Any]] = {}
for s in admin_scrape._FRESHNESS_SOURCES:
if s.timestamp_col is not None:
rows[s.table] = {**healthy, "last_status": None, "recent_output": None}
elif s.table == "kn_scrape_runs":
rows[s.table] = dict(weekly_aged)
else:
rows[s.table] = dict(healthy)
_override_db(_make_db(rows))
try:
resp = TestClient(app).get(ENDPOINT)
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
by_src = {s["source"]: s for s in resp.json()["sources"]}
# kn_flats: fresh по возрасту (7.5<8), upd_7d=0 НЕ важен — floor смотрит
# recent_output (376k > 50000, окно fresh_days покрыло прогон) → ok, без downgrade.
assert by_src["kn_flats"]["freshness"] == "fresh"
assert by_src["kn_flats"]["status"] == "ok"
assert by_src["kn_flats"]["objects_updated_7d"] == 0
assert by_src["kn_flats"]["recent_output"] == 376000
assert by_src["kn_flats"]["reason"] is None
def test_output_floor_not_applied_when_already_stale() -> None:
"""Если источник уже stale/failed по возрасту — output-floor НЕ передоунгрейдит
(age уже покрывает), и не двойнит. objective: старый успех (10d stale) +
recent_output=0 остаётся stale (НЕ перепрыгивает в failed через floor)."""
base_fresh = { base_fresh = {
"last_success_at": _ago(0.5), "last_success_at": _ago(0.5),
"last_attempt_at": _ago(0.5), "last_attempt_at": _ago(0.5),
"upd_24h": 5, "upd_24h": 5,
"upd_7d": 50, "upd_7d": 50,
"last_status": "done", "last_status": "done",
"recent_output": 500000,
} }
rows: dict[str, dict[str, Any]] = {} rows: dict[str, dict[str, Any]] = {}
for s in admin_scrape._FRESHNESS_SOURCES: for s in admin_scrape._FRESHNESS_SOURCES:
rows[s.table] = ( rows[s.table] = (
{**base_fresh, "last_status": None} {**base_fresh, "last_status": None, "recent_output": None}
if s.timestamp_col is not None if s.timestamp_col is not None
else dict(base_fresh) else dict(base_fresh)
) )
# objective: stale по возрасту (10d: fresh<7, stale<14) + 0 выход. # objective: stale по возрасту (10d: fresh<7, stale<14) + нулевой выход.
rows["objective_scrape_runs"] = { rows["objective_scrape_runs"] = {
"last_success_at": _ago(10.0), "last_success_at": _ago(10.0),
"last_attempt_at": _ago(10.0), "last_attempt_at": _ago(10.0),
"upd_24h": 0, "upd_24h": 0,
"upd_7d": 0, "upd_7d": 0,
"last_status": "done", "last_status": "done",
"recent_output": 0,
} }
_override_db(_make_db(rows)) _override_db(_make_db(rows))
try: try:
@ -407,19 +525,21 @@ def test_zero_output_not_applied_when_already_stale() -> None:
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
by_src = {s["source"]: s for s in resp.json()["sources"]} by_src = {s["source"]: s for s in resp.json()["sources"]}
# zero-output применяется ТОЛЬКО к otherwise-ok; stale остаётся stale, reason=None. # output-floor применяется ТОЛЬКО к otherwise-ok; stale остаётся stale, reason=None.
assert by_src["objective"]["status"] == "stale" assert by_src["objective"]["status"] == "stale"
assert by_src["objective"]["reason"] is None assert by_src["objective"]["reason"] is None
def test_kn_flats_registry_config() -> None: def test_kn_flats_registry_config() -> None:
"""kn_flats: та же run-таблица что у kn, но work_col=flats_count + alert_on_zero_output, """kn_flats: та же run-таблица что у kn, но work_col=flats_count + alert_on_zero_output
критичный, недельные пороги. kn (objects_count) остаётся без флага (unchanged).""" + min_output_rows=50000, критичный, недельные пороги. kn (objects_count) без флага."""
kn_flats = next(s for s in admin_scrape._FRESHNESS_SOURCES if s.source == "kn_flats") kn_flats = next(s for s in admin_scrape._FRESHNESS_SOURCES if s.source == "kn_flats")
assert kn_flats.table == "kn_scrape_runs" assert kn_flats.table == "kn_scrape_runs"
assert kn_flats.work_col == "flats_count" assert kn_flats.work_col == "flats_count"
assert kn_flats.timestamp_col is None # run-ledger режим assert kn_flats.timestamp_col is None # run-ledger режим
assert kn_flats.alert_on_zero_output is True assert kn_flats.alert_on_zero_output is True
# healthy цикл ~376k ≫ floor ≫ broken-sum ≤3670 (прод #1947).
assert kn_flats.min_output_rows == 50000
assert kn_flats.critical is True assert kn_flats.critical is True
assert kn_flats.fresh_days == 8.0 assert kn_flats.fresh_days == 8.0
assert kn_flats.stale_days == 14.0 assert kn_flats.stale_days == 14.0
@ -427,14 +547,19 @@ def test_kn_flats_registry_config() -> None:
kn = next(s for s in admin_scrape._FRESHNESS_SOURCES if s.source == "kn") kn = next(s for s in admin_scrape._FRESHNESS_SOURCES if s.source == "kn")
assert kn.work_col == "objects_count" assert kn.work_col == "objects_count"
assert kn.alert_on_zero_output is False # существующий источник unchanged assert kn.alert_on_zero_output is False # существующий источник unchanged
assert kn.min_output_rows == 1 # default — не проверяется без флага
objective = next(s for s in admin_scrape._FRESHNESS_SOURCES if s.source == "objective") objective = next(s for s in admin_scrape._FRESHNESS_SOURCES if s.source == "objective")
assert objective.alert_on_zero_output is True assert objective.alert_on_zero_output is True
# healthy SUM(rows_lots,7d)~946k по 4 группам; floor 1000 с огромным запасом.
assert objective.min_output_rows == 1000
# Legitimate-0 источники НЕ должны иметь флаг (избегаем ложных срабатываний). # Legitimate-low источники НЕ должны иметь флаг (избегаем ложных срабатываний);
# min_output_rows остаётся default=1 и не проверяется.
for name in ("nspd", "nspd_geo", "cadastre"): for name in ("nspd", "nspd_geo", "cadastre"):
src = next(s for s in admin_scrape._FRESHNESS_SOURCES if s.source == name) src = next(s for s in admin_scrape._FRESHNESS_SOURCES if s.source == name)
assert src.alert_on_zero_output is False, name assert src.alert_on_zero_output is False, name
assert src.min_output_rows == 1, name
def test_kn_thresholds_match_weekly_cadence() -> None: def test_kn_thresholds_match_weekly_cadence() -> None: