import_rosreestr_dkp: курсор last_id живёт только в памяти — рестарт начинает импорт с нуля #3168

Closed
opened 2026-08-27 20:19:59 +00:00 by bot-backend · 3 comments
Collaborator

Выделено из #3074 (27.08.2026): чекпоинты свипов сделаны и задеплоены, курсорные backfill-циклы — другой паттерн, поэтому отдельной задачей.

Проблема

Пять циклов идут курсором WHERE id > last_id ORDER BY id, и last_id живёт только в памяти процесса (backend/app/services/scheduler.py:244 — инициализация, :433 — присвоение после батча). Heartbeat обновляется каждый батч, но позицию курсора не сохраняет (:198 — комментарий прямо называет heartbeat чекпоинтом, хотя позиции в нём нет).

Обрыв — деплой, OOM, рестарт хоста — и следующий запуск начинает с id = 0.

Цена (14 суток, heartbeat_at - started_at)

цикл max, мин прогонов с чекпоинтом
avito_detail_backfill 83 55 0
house_imv_backfill 82 5 0
cian_history_backfill 79 14 0
yandex_detail_backfill 60 13 0
geocode_missing_listings 53 15 0

house_imv_backfill уже терялся: прогон 26.08 отменён.

Что делать

Писать last_id в тот же scrape_runs.counters, которым пользуются свипы, — мержем через runs.update_heartbeat (orchestration/runs.py:686), чтобы не добавлять новых записей в БД. На старте — тот же явный ладдер решения, что и у свипов (orchestration/scheduler.py:607,672).

Ключевое отличие от свипов: у курсора нет проблемы протухания в том же виде — id > last_id остаётся корректным, даже если данные доехали. Но нужен потолок возраста, иначе подхват недельной давности пропустит всё, что появилось до last_id за это время.

Приёмка

  • Обрыв avito_detail_backfill на середине → следующий запуск продолжает с last_id, а не с нуля
  • Решение о подхвате видно в scrape_runs и в логе
  • Дополнительных записей в БД на батч не прибавилось (мерж в существующий heartbeat)
  • Есть потолок возраста подхватываемого курсора

Refs #3074, #2989

Выделено из #3074 (27.08.2026): чекпоинты свипов сделаны и задеплоены, курсорные backfill-циклы — другой паттерн, поэтому отдельной задачей. ## Проблема Пять циклов идут курсором `WHERE id > last_id ORDER BY id`, и `last_id` живёт **только в памяти процесса** (`backend/app/services/scheduler.py:244` — инициализация, `:433` — присвоение после батча). Heartbeat обновляется каждый батч, но позицию курсора не сохраняет (`:198` — комментарий прямо называет heartbeat чекпоинтом, хотя позиции в нём нет). Обрыв — деплой, OOM, рестарт хоста — и следующий запуск начинает с `id = 0`. ## Цена (14 суток, `heartbeat_at - started_at`) | цикл | max, мин | прогонов | с чекпоинтом | |---|---|---|---| | `avito_detail_backfill` | 83 | 55 | 0 | | `house_imv_backfill` | 82 | 5 | 0 | | `cian_history_backfill` | 79 | 14 | 0 | | `yandex_detail_backfill` | 60 | 13 | 0 | | `geocode_missing_listings` | 53 | 15 | 0 | `house_imv_backfill` уже терялся: прогон 26.08 отменён. ## Что делать Писать `last_id` в тот же `scrape_runs.counters`, которым пользуются свипы, — мержем через `runs.update_heartbeat` (`orchestration/runs.py:686`), чтобы не добавлять новых записей в БД. На старте — тот же явный ладдер решения, что и у свипов (`orchestration/scheduler.py:607,672`). Ключевое отличие от свипов: у курсора нет проблемы протухания в том же виде — `id > last_id` остаётся корректным, даже если данные доехали. Но нужен потолок возраста, иначе подхват недельной давности пропустит всё, что появилось до `last_id` за это время. ## Приёмка - [ ] Обрыв `avito_detail_backfill` на середине → следующий запуск продолжает с `last_id`, а не с нуля - [ ] Решение о подхвате видно в `scrape_runs` и в логе - [ ] Дополнительных записей в БД на батч не прибавилось (мерж в существующий heartbeat) - [ ] Есть потолок возраста подхватываемого курсора Refs #3074, #2989
bot-backend added the
enhancement
priority/p1
scope/backend
scrapers
tradein
labels 2026-08-27 20:20:11 +00:00
Author
Collaborator

Уточнение по приоритету (27.08). Пересчёт длительностей по heartbeat_at - started_at (а не по finished_at, который у прерванных прогонов ставит финализатор часами позже) показал, что эта задача весит больше, чем #3074-остаток:

цикл прогонов за 30 сут max, мин avg, мин
avito_detail_backfill 71 150 13
house_imv_backfill 13 82 26
cian_history_backfill 18 79 45
yandex_detail_backfill 29 60 40
geocode_missing_listings 30 53 14

Ни один из пяти чекпоинта не пишет. Для сравнения — все свипы, у которых чекпоинты уже есть, укладываются в 68 минут; из циклов без чекпоинтов два превышают этот потолок вдвое.

**Уточнение по приоритету (27.08).** Пересчёт длительностей по `heartbeat_at - started_at` (а не по `finished_at`, который у прерванных прогонов ставит финализатор часами позже) показал, что эта задача весит больше, чем #3074-остаток: | цикл | прогонов за 30 сут | max, мин | avg, мин | |---|---|---|---| | `avito_detail_backfill` | 71 | **150** | 13 | | `house_imv_backfill` | 13 | 82 | 26 | | `cian_history_backfill` | 18 | 79 | 45 | | `yandex_detail_backfill` | 29 | 60 | 40 | | `geocode_missing_listings` | 30 | 53 | 14 | Ни один из пяти чекпоинта не пишет. Для сравнения — все свипы, у которых чекпоинты уже есть, укладываются в 68 минут; из циклов без чекпоинтов два превышают этот потолок вдвое.
Author
Collaborator

Поправка: тикет заведён мной с неверной посылкой. Дефект существует, но не там, где я написал.

Я привязал курсор WHERE id > last_id к пяти циклам — avito_detail_backfill, house_imv_backfill, cian_history_backfill, yandex_detail_backfill, geocode_missing_listings. Это ошибка атрибуции: строки scheduler.py:244,433 лежат внутри функции import_rosreestr_dkp, которая начинается на строке 162. Я взял номера строк, не проверив, чья это функция.

Пять названных циклов дефекта не имеют. Они отбирают работу предикатом состояния, а не позицией: avito_detail_backfillWHERE detail_enriched_at IS NULL ... ORDER BY ... LIMIT (app/tasks/avito_detail_backfill.py:342-349), остальные устроены так же (NOT EXISTS / IS NULL + LIMIT). Такой отбор резюмируется сам: обработанные строки перестают попадать в выборку. Докстринг backfill_cian_history называет это прямо — «idempotent by design».

Дефект реален у шестого цикла, в таблицу тикета не попавшего: import_rosreestr_dkp (source = rosreestr_dkp_import). Там курсор действительно числовой и живёт только в памяти, а комментарий на :198 называет heartbeat чекпоинтом, хотя позиции в нём нет.

Замер потерь, который я приводил (150 минут у avito_detail_backfill и далее) относится к циклам без дефекта и к делу не относится — снимаю его.

Переименовываю тикет под фактический дефект. Правка — в PR ниже.

**Поправка: тикет заведён мной с неверной посылкой. Дефект существует, но не там, где я написал.** Я привязал курсор `WHERE id > last_id` к пяти циклам — `avito_detail_backfill`, `house_imv_backfill`, `cian_history_backfill`, `yandex_detail_backfill`, `geocode_missing_listings`. Это ошибка атрибуции: строки `scheduler.py:244,433` лежат внутри функции `import_rosreestr_dkp`, которая начинается на **строке 162**. Я взял номера строк, не проверив, чья это функция. **Пять названных циклов дефекта не имеют.** Они отбирают работу предикатом состояния, а не позицией: `avito_detail_backfill` — `WHERE detail_enriched_at IS NULL ... ORDER BY ... LIMIT` (`app/tasks/avito_detail_backfill.py:342-349`), остальные устроены так же (`NOT EXISTS` / `IS NULL` + `LIMIT`). Такой отбор резюмируется сам: обработанные строки перестают попадать в выборку. Докстринг `backfill_cian_history` называет это прямо — «idempotent by design». **Дефект реален у шестого цикла**, в таблицу тикета не попавшего: `import_rosreestr_dkp` (`source = rosreestr_dkp_import`). Там курсор действительно числовой и живёт только в памяти, а комментарий на `:198` называет heartbeat чекпоинтом, хотя позиции в нём нет. Замер потерь, который я приводил (150 минут у `avito_detail_backfill` и далее) относится к циклам без дефекта и к делу не относится — снимаю его. Переименовываю тикет под фактический дефект. Правка — в PR ниже.
bot-backend changed title from Скрапперы: курсорные backfill-циклы не переживают рестарт — last_id живёт только в памяти to import_rosreestr_dkp: курсор last_id живёт только в памяти — рестарт начинает импорт с нуля 2026-08-27 22:09:38 +00:00
Owner

Проверено в коде на forgejo/main — сделано, закрываю.

PR #3176 (merged 2026-08-28) — курсор переживает рестарт через _resume_dkp_cursor (services/scheduler.py:348).

Проверено в коде на forgejo/main — сделано, закрываю. PR #3176 (merged 2026-08-28) — курсор переживает рестарт через `_resume_dkp_cursor` (services/scheduler.py:348).
Sign in to join this conversation.
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#3168
No description provided.