fix(tradein/scraper): пропуск расписания пишет строку прогона со статусом skipped (#2658) #2662

Merged
bot-backend merged 2 commits from fix/2658-loud-skip-status into main 2026-08-05 18:24:05 +00:00
Collaborator

Что было

Пропуск наступившего окна был немым: logger + сдвиг next_run_at, ни строки в scrape_runs, ни изменения last_run_at. cian_history_backfill так простоял 37 дней на протухших куках Циана (истекли 30.06 08:21 UTC, last_run_at = 29.06) и снаружи выглядел работающим — расписание исправно «переезжало» вперёд, а docker-логи с warning терялись на каждом редеплое.

Статус 'skipped' заведён ещё миграцией 015 и локализован во фронте как «пропущено» — в проде у него было 0 строк (проверено: done|2926 banned|130 zombie|85 cancelled|50 failed|41, skipped отсутствует). Полностью построенный и ни разу не использованный механизм.

Плюс алерт про куки стоял в недостижимой ветке — той, где verify_session вернул None. На протухших куках до неё не доходит никогда: load_session сам фильтрует expires_at_estimate > NOW() и отдаёт None ещё в первой, немой ветке.

Что стало

Пять мест, где расписание пропускалось без следа, теперь пишут строку scrape_runs(status='skipped') с машиночитаемой причиной в error и человеческим пояснением в counters.detail:

# Место Слаг причины
1 kit _claim_run — уже есть running-прогон already_running
2 kit _claim_run — advisory-lock занят конкурентным тиком concurrent_claim
3 kit _claim_run — running появился под локом (double-check) running_appeared_under_lock
4 kit scheduler_loop — enabled-расписание без handler'а unknown_source
5 продуктовый cian pre_claim (единственный в кодовой базе) cian_cookies_missing / cian_cookies_expired / cian_cookies_invalid

Число совпало с заявленным в issue: 1 pre_claim + 4 в планировщике kit. В пятом месте — две «немых» точки возврата (нет валидных кук / Циан не принимает куки), обе теперь пишут строку; слагов там три, чтобы «кук нет вовсе» отличалось от «протухли» и от «помечены невалидными».

Схлопывание. Подряд идущие одинаковые пропуски одного source не плодят строк: обновляется последняя skipped-строка (finished_at + счётчик counters.skips). Без этого already_running и unknown_source писали бы строку каждый тик (60 с) — они не двигают next_run_at, и расписание переотбирается get_due_schedules до устранения причины. Побочный эффект приятный: у cian_history_backfill вместо 37 одинаковых строк была бы одна с skips=37, started_at 30.06 и свежим finished_at — «пропуски идут с такого-то, столько-то раз».

Алерт. Обе ветки cookie-гейта теперь зовут logger.error, а не sentry_sdk.capture_message(level="warning"):

  • в scraper-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR) (scheduler_main.py:59) — ERROR-запись сама становится событием, а warning-уровень до него не дотягивает;
  • заодно исчезает try/except: pass вокруг sentry-вызова и лишний импорт;
  • причина остаётся в трёх местах сразу: docker-лог, событие GlitchTip и строка scrape_runs, которая переживает редеплой.

Предупреждаем заранее, а не по факту (COOKIE_EXPIRY_WARN_DAYS = 5). Обновление кук — ручная операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания приходит, когда сбор уже встал. Новый планировщик для этого не нужен — проверка встроена в тот же _cian_pre_claim, который и так исполняется раз в сутки в окне расписания; save_session ставит TTL 30 дней, так что окно предупреждения широкое. Алерт по факту протухания при этом остался — заранее ≠ вместо.

Монитор нулевых прогонов (#2625)

Не сломан и не смешан. Обе alert-выборки (_alert_if_consecutive_failures, _alert_if_consecutive_zero_results) отбирают status IN ('failed','banned','done','cancelled')'skipped' туда не попадает, поэтому пропуск не считается нулевым прогоном и не прерывает стрик реальных нулевых. Разводить было нечего: разделение уже обеспечено SQL-фильтром, тест это фиксирует (test_zero_result_monitor_ignores_skipped_rows).

Честная оговорка: строки-пропуски существующему монитору «видимы» лишь в смысле «теперь их видно в БД и в UI» — в его счётчик они намеренно не входят. Громкость пропуска обеспечивает алерт из пункта выше, а не zero-result-монитор.

Тесты

Новый tests/test_scrape_skip_visibility.py (16 тестов): запись строки в каждом из пяти мест, схлопывание, порядок rollbackmark_skipped (иначе INSERT улетел бы в откат advisory-лока), happy-path без skip-строк, достижимость ERROR-алерта на протухших куках, предупреждение заранее, тишина на свежих куках, неломание zero-result-монитора. Три существующих parity-теста _claim_run дополнены проверкой db.skip_rows == 1.

Фальсификация. С застэшенной реализацией новый файл падает целиком на ImportError (новые символы) — это слабое «красное», поэтому отдельно прогнан пробник тем же сценарием, но без импорта новых имён: на старом коде он падает по ассерту, а в логе видно ровно немую ветку из issue:

AssertionError: нет ERROR-записи о протухших куках
WARNING app.services.product_handlers: scheduler: cian_history_backfill skipped — no valid session cookies in DB

После возврата реализации — зелено. Полный прогон бэкенда: 3016 passed, 1 падение — tests/test_search_api.py::test_search_cache_hit (401 вместо 200), не связано с этим PR: оно падает и файлом в одиночку, а изменённые модули (scheduler/cian_session/product_handlers/kit runs) в search API не участвуют.

Что НЕ входит

  • Не чиним сами куки Циана — это ручная операция и отдельный разговор; здесь только сделали их протухание видимым.
  • Не трогали деактивацию/TTL (#2659), витрины (#2660), прокси-пул.
  • Миграций нет: 'skipped' уже в CHECK-констрейнте (015 + 051), колонки error/counters есть.
  • mark_skipped добавлен только в kit-копию runs (строки-пропуски создаёт исключительно планировщик); расхождение с app-копией отмечено в её докстринге.

Refs #2658

## Что было Пропуск наступившего окна был **немым**: `logger` + сдвиг `next_run_at`, ни строки в `scrape_runs`, ни изменения `last_run_at`. `cian_history_backfill` так простоял 37 дней на протухших куках Циана (истекли 30.06 08:21 UTC, `last_run_at` = 29.06) и снаружи выглядел работающим — расписание исправно «переезжало» вперёд, а docker-логи с `warning` терялись на каждом редеплое. Статус `'skipped'` заведён ещё миграцией 015 и локализован во фронте как «пропущено» — в проде у него было **0 строк** (проверено: `done|2926 banned|130 zombie|85 cancelled|50 failed|41`, `skipped` отсутствует). Полностью построенный и ни разу не использованный механизм. Плюс алерт про куки стоял в **недостижимой** ветке — той, где `verify_session` вернул `None`. На протухших куках до неё не доходит никогда: `load_session` сам фильтрует `expires_at_estimate > NOW()` и отдаёт `None` ещё в первой, немой ветке. ## Что стало **Пять мест**, где расписание пропускалось без следа, теперь пишут строку `scrape_runs(status='skipped')` с машиночитаемой причиной в `error` и человеческим пояснением в `counters.detail`: | # | Место | Слаг причины | |---|---|---| | 1 | kit `_claim_run` — уже есть running-прогон | `already_running` | | 2 | kit `_claim_run` — advisory-lock занят конкурентным тиком | `concurrent_claim` | | 3 | kit `_claim_run` — running появился под локом (double-check) | `running_appeared_under_lock` | | 4 | kit `scheduler_loop` — enabled-расписание без handler'а | `unknown_source` | | 5 | продуктовый cian `pre_claim` (единственный в кодовой базе) | `cian_cookies_missing` / `cian_cookies_expired` / `cian_cookies_invalid` | Число совпало с заявленным в issue: 1 `pre_claim` + 4 в планировщике kit. В пятом месте — две «немых» точки возврата (нет валидных кук / Циан не принимает куки), обе теперь пишут строку; слагов там три, чтобы «кук нет вовсе» отличалось от «протухли» и от «помечены невалидными». **Схлопывание.** Подряд идущие одинаковые пропуски одного source не плодят строк: обновляется последняя `skipped`-строка (`finished_at` + счётчик `counters.skips`). Без этого `already_running` и `unknown_source` писали бы строку **каждый тик (60 с)** — они не двигают `next_run_at`, и расписание переотбирается `get_due_schedules` до устранения причины. Побочный эффект приятный: у `cian_history_backfill` вместо 37 одинаковых строк была бы одна с `skips=37`, `started_at` 30.06 и свежим `finished_at` — «пропуски идут с такого-то, столько-то раз». **Алерт.** Обе ветки cookie-гейта теперь зовут `logger.error`, а не `sentry_sdk.capture_message(level="warning")`: - в scraper-контейнере GlitchTip поднят с `LoggingIntegration(event_level=ERROR)` (`scheduler_main.py:59`) — ERROR-запись сама становится событием, а warning-уровень до него не дотягивает; - заодно исчезает `try/except: pass` вокруг sentry-вызова и лишний импорт; - причина остаётся в трёх местах сразу: docker-лог, событие GlitchTip и строка `scrape_runs`, которая переживает редеплой. **Предупреждаем заранее, а не по факту** (`COOKIE_EXPIRY_WARN_DAYS = 5`). Обновление кук — ручная операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания приходит, когда сбор уже встал. Новый планировщик для этого не нужен — проверка встроена в тот же `_cian_pre_claim`, который и так исполняется раз в сутки в окне расписания; `save_session` ставит TTL 30 дней, так что окно предупреждения широкое. Алерт по факту протухания при этом остался — заранее ≠ вместо. ## Монитор нулевых прогонов (#2625) Не сломан и не смешан. Обе alert-выборки (`_alert_if_consecutive_failures`, `_alert_if_consecutive_zero_results`) отбирают `status IN ('failed','banned','done','cancelled')` — `'skipped'` туда не попадает, поэтому пропуск **не считается** нулевым прогоном и **не прерывает** стрик реальных нулевых. Разводить было нечего: разделение уже обеспечено SQL-фильтром, тест это фиксирует (`test_zero_result_monitor_ignores_skipped_rows`). Честная оговорка: строки-пропуски существующему монитору «видимы» лишь в смысле «теперь их видно в БД и в UI» — в его счётчик они намеренно не входят. Громкость пропуска обеспечивает алерт из пункта выше, а не zero-result-монитор. ## Тесты Новый `tests/test_scrape_skip_visibility.py` (16 тестов): запись строки в каждом из пяти мест, схлопывание, порядок `rollback` → `mark_skipped` (иначе INSERT улетел бы в откат advisory-лока), happy-path без skip-строк, достижимость ERROR-алерта на протухших куках, предупреждение заранее, тишина на свежих куках, неломание zero-result-монитора. Три существующих parity-теста `_claim_run` дополнены проверкой `db.skip_rows == 1`. **Фальсификация.** С застэшенной реализацией новый файл падает целиком на `ImportError` (новые символы) — это слабое «красное», поэтому отдельно прогнан пробник тем же сценарием, но без импорта новых имён: на старом коде он падает **по ассерту**, а в логе видно ровно немую ветку из issue: ``` AssertionError: нет ERROR-записи о протухших куках WARNING app.services.product_handlers: scheduler: cian_history_backfill skipped — no valid session cookies in DB ``` После возврата реализации — зелено. Полный прогон бэкенда: `3016 passed`, 1 падение — `tests/test_search_api.py::test_search_cache_hit` (401 вместо 200), не связано с этим PR: оно падает и файлом в одиночку, а изменённые модули (scheduler/cian_session/product_handlers/kit runs) в search API не участвуют. ## Что НЕ входит - Не чиним сами куки Циана — это ручная операция и отдельный разговор; здесь только сделали их протухание видимым. - Не трогали деактивацию/TTL (#2659), витрины (#2660), прокси-пул. - Миграций нет: `'skipped'` уже в CHECK-констрейнте (015 + 051), колонки `error`/`counters` есть. - `mark_skipped` добавлен только в kit-копию `runs` (строки-пропуски создаёт исключительно планировщик); расхождение с app-копией отмечено в её докстринге. Refs #2658
bot-backend added 1 commit 2026-08-05 17:36:59 +00:00
fix(tradein/scraper): пропуск расписания пишет строку прогона со статусом skipped (#2658)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m41s
0b54b96984
Пропуск наступившего окна был немым: logger + сдвиг next_run_at, ни строки в
scrape_runs, ни изменения last_run_at. cian_history_backfill так простоял 37 дней
на протухших куках Циана и снаружи выглядел работающим — next_run_at исправно
двигался вперёд, а docker-логи с warning'ом терялись на каждом редеплое.

Статус 'skipped' заведён ещё миграцией 015 и локализован во фронте («пропущено»),
но в проде имел 0 строк — механизм построен и ни разу не использован. Задействуем
его во всех пяти местах, где расписание пропускалось без следа: kit `_claim_run`
(already_running / concurrent_claim / running_appeared_under_lock), kit
`scheduler_loop` (unknown_source) и продуктовый cian `pre_claim`. Причина — слаг в
`error`, по нему «нет кук» отличается от «уже бежит» запросом, а не грепом логов.

Подряд идущие одинаковые пропуски схлопываются в одну строку со счётчиком
`counters.skips`: «уже бежит» и «неизвестный source» не двигают next_run_at и
иначе плодили бы строку каждый тик (60 с).

Алерт про куки жил в недостижимой ветке: он стоял там, где verify_session вернул
None, а на протухших куках load_session сам фильтрует expires_at_estimate > NOW()
и отдаёт None ещё в первой, немой ветке. Теперь алерт в обеих ветках и через
logger.error — в scraper-контейнере GlitchTip поднят с LoggingIntegration
(event_level=ERROR), поэтому прежний capture_message(level="warning") событием не
становился. Плюс предупреждение ЗАРАНЕЕ (COOKIE_EXPIRY_WARN_DAYS=5) в том же
pre_claim: обновление кук — ручная операция, алерт по факту протухания приходит,
когда сбор уже встал.

Монитор нулевых прогонов (#2625) не трогаем: обе alert-выборки отбирают
failed/banned/done/cancelled, поэтому 'skipped' в стрик не попадает и его не
прерывает — пропуск не «прогон вернул ноль лотов», смешивать нельзя.
Light1YT added 1 commit 2026-08-05 18:11:25 +00:00
fix(tradein/scraper): фильтр skipped в админке + освежение схлопнутой строки (#2658)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
b800760c24
Правки по ревью PR #2662.

Фильтр статуса. `GET /admin/scrape/runs?status=skipped` отдавал 422 — 'skipped' не
было в Literal, а во фронте не было чипа. Строки рисовались, но задать вопрос
«что сейчас пропускается» на единственной поверхности, построенной ровно для
этого, было нельзя. Добавлено в оба места (translateStatus «пропущено» и
нейтральный бейдж уже умели).

Схлопывание освежает строку. UPDATE двигал только finished_at/heartbeat_at, из-за
чего живой стрик замерзал: списки прогонов сортируют ORDER BY started_at DESC и
берут limit=20, поэтому 37-дневный пропуск утонул бы под свежими прогонами других
источников — след в базе есть, на экране нет. Теперь started_at = NOW(), а начало
стрика переезжает в counters.first_skip_at; сортировку общего списка не трогаем
(она про все источники, чинить надо было одну строку). Там же обновляется
counters.detail — иначе в строке 37 дней висел текст «протухли 1 день назад»,
хотя именно эта цифра и есть предмет issue. jsonb_set заменён на `||` +
jsonb_build_object: три вложенных jsonb_set читать в 3 ночи невозможно, а NULL в
jsonb_set обнуляет весь counters.

Поиск последней строки. `ORDER BY id DESC` не ложится на индекс
(source, started_at DESC) из миграции 015 — для unknown_source (тикает каждые
60 с бессрочно) это отбор всех строк источника с сортировкой раз в минуту.
Теперь ORDER BY started_at DESC, id DESC.

session_expires_at получил valid_only: предупреждение «скоро протухнут» считает
срок ИМЕННО той записи, которую взял load_session — при нескольких аккаунтах
свежайшая-любая может быть чужой протухшей строкой. Диагностика после None
по-прежнему смотрит на свежайшую любую (валидных там нет по определению).

Запись пропуска намеренно НЕ обёрнута в свой try/except: если db.execute падает,
то падает и claim следующего расписания в этом же тике — тик срывается в любом
случае, а глушить исключение здесь значило бы вернуть ровно тот немой пропуск,
ради которого заведён #2658. Самовосстановление через 60 с.
Author
Collaborator

Правки по ревью — коммит b800760c, CI зелёный.

1. Фильтр skipped (главное). Literal в admin.py + RUN_STATUS_ALL в RunsTable.tsx. translateStatus («пропущено») и нейтральный бейдж уже умели, так что чип появился без правок рендера. Тест test_admin_runs_filter_accepts_skipped_status читает аннотацию эндпоинта — падает, если слаг выпадет из Literal.

2. started_at при схлопывании — выбран bump, не смена сортировки. started_at = NOW(), а начало стрика переезжает в counters.first_skip_at (берётся из старого started_at, поэтому точное, а не выводимое из skips × каденс).

Почему не ORDER BY GREATEST(started_at, finished_at): этот ORDER BY — в list_all/list_recent, то есть в списке по ВСЕМ источникам. Он молча переставил бы все строки (долгий прогон, закончившийся позже, прыгал бы выше начатого позднее), а чинить надо было ровно одну строку — schлопнутую. Плюс GREATEST не ложится на (source, started_at DESC), то есть цена платится на каждом открытии таблицы, а не раз в тик. Bump локален, обратим и не меняет смысл «списка по времени запуска».

3. counters.detail освежается. Заодно заменил три вложенных jsonb_set на COALESCE(counters,'{}') || jsonb_build_object(...): короче, а главное — jsonb_set(target, path, NULL) обнуляет ВЕСЬ counters, и с detail=None это была бы мина.

4. Индекс. ORDER BY started_at DESC, id DESC — ложится на scrape_runs_source_started_idx (source, started_at DESC) из миграции 015; id DESC только разводит ties. Для unknown_source (тик каждые 60 с бессрочно) это снимает пересортировку всех строк источника раз в минуту.

5. Свой try/except вокруг записи — сознательно НЕ добавлен. Если db.execute падает, то падает и has_running_run/create_run следующего расписания в этом же тике: тик срывается в любом случае, «раньше не могло уронить» верно только для самой ветки, не для тика. А проглотить исключение здесь — вернуть ровно тот немой пропуск, ради которого заведён #2658 (лог есть, следа нет). Самовосстановление через 60 с, next_run_at не двигался, значит расписание останется due.

6. session_expires_at(valid_only=...) — забрал, правка дешёвая. Предупреждение «скоро протухнут» считает срок ИМЕННО той записи, которую взял load_session (фильтр валидности + last_invalid_at); диагностика после None по-прежнему смотрит на свежайшую любую — валидных там нет по определению. Один запрос, ветвление параметром, без f-string в SQL.

Тесты. 20 в test_scrape_skip_visibility.py (+4 новых), целевые сюиты 85 passed, полный бэкенд зелёный на CI. Фальсификация без git stash (патч-файл → checkout -- → прогон → git apply): на коде первого коммита падают по ассерту ровно 4 новых теста — collapse_refreshes_detail_and_started_at, latest_lookup_uses_indexed_order, warns_before_expiry_not_after (проверка valid_only=True), admin_runs_filter_accepts_skipped_status; после git apply снова зелено.

Правки по ревью — коммит `b800760c`, CI зелёный. **1. Фильтр `skipped` (главное).** `Literal` в `admin.py` + `RUN_STATUS_ALL` в `RunsTable.tsx`. `translateStatus` («пропущено») и нейтральный бейдж уже умели, так что чип появился без правок рендера. Тест `test_admin_runs_filter_accepts_skipped_status` читает аннотацию эндпоинта — падает, если слаг выпадет из `Literal`. **2. `started_at` при схлопывании — выбран bump, не смена сортировки.** `started_at = NOW()`, а начало стрика переезжает в `counters.first_skip_at` (берётся из старого `started_at`, поэтому точное, а не выводимое из `skips` × каденс). Почему не `ORDER BY GREATEST(started_at, finished_at)`: этот `ORDER BY` — в `list_all`/`list_recent`, то есть в списке по ВСЕМ источникам. Он молча переставил бы все строки (долгий прогон, закончившийся позже, прыгал бы выше начатого позднее), а чинить надо было ровно одну строку — schлопнутую. Плюс `GREATEST` не ложится на `(source, started_at DESC)`, то есть цена платится на каждом открытии таблицы, а не раз в тик. Bump локален, обратим и не меняет смысл «списка по времени запуска». **3. `counters.detail` освежается.** Заодно заменил три вложенных `jsonb_set` на `COALESCE(counters,'{}') || jsonb_build_object(...)`: короче, а главное — `jsonb_set(target, path, NULL)` обнуляет ВЕСЬ `counters`, и с `detail=None` это была бы мина. **4. Индекс.** `ORDER BY started_at DESC, id DESC` — ложится на `scrape_runs_source_started_idx (source, started_at DESC)` из миграции 015; `id DESC` только разводит ties. Для `unknown_source` (тик каждые 60 с бессрочно) это снимает пересортировку всех строк источника раз в минуту. **5. Свой `try/except` вокруг записи — сознательно НЕ добавлен.** Если `db.execute` падает, то падает и `has_running_run`/`create_run` следующего расписания в этом же тике: тик срывается в любом случае, «раньше не могло уронить» верно только для самой ветки, не для тика. А проглотить исключение здесь — вернуть ровно тот немой пропуск, ради которого заведён #2658 (лог есть, следа нет). Самовосстановление через 60 с, `next_run_at` не двигался, значит расписание останется due. **6. `session_expires_at(valid_only=...)`** — забрал, правка дешёвая. Предупреждение «скоро протухнут» считает срок ИМЕННО той записи, которую взял `load_session` (фильтр валидности + `last_invalid_at`); диагностика после `None` по-прежнему смотрит на свежайшую любую — валидных там нет по определению. Один запрос, ветвление параметром, без f-string в SQL. **Тесты.** 20 в `test_scrape_skip_visibility.py` (+4 новых), целевые сюиты `85 passed`, полный бэкенд зелёный на CI. Фальсификация без `git stash` (патч-файл → `checkout --` → прогон → `git apply`): на коде первого коммита падают **по ассерту** ровно 4 новых теста — `collapse_refreshes_detail_and_started_at`, `latest_lookup_uses_indexed_order`, `warns_before_expiry_not_after` (проверка `valid_only=True`), `admin_runs_filter_accepts_skipped_status`; после `git apply` снова зелено.
bot-backend merged commit 96d62e418b into main 2026-08-05 18:24:05 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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#2662
No description provided.