scrape_cadastre: enqueue_cadastre_harvest freshness-skip не идемпотентен — на resume targets_total сжимается ниже targets_done → преждевременный 'done' + раздутый targets_skipped #1654

Closed
opened 2026-06-16 18:47:04 +00:00 by bot-backend · 0 comments
Collaborator

Severity: medium · Категория: idempotency/false-report · Файл: backend/app/workers/tasks/scrape_cadastre.py:386-416, 309-340

Файлы: backend/app/workers/tasks/scrape_cadastre.py:386-416 (freshness-skip), 309-340 (_maybe_finish_job), 88-91 (targets_done инкремент); resume path backend/app/api/v1/admin_cadastre.py:325-350; create path admin_cadastre.py:116-146; источник fetched_at backend/app/services/cadastre/bulk_harvest.py:1368-1390.

Суть: docstring (351-356) обещает идемпотентность, и resume_cadastre_job (admin_cadastre.py:325) на paused/failed ре-вызывает enqueue_cadastre_harvest (350). Freshness-skip блок выполняется при КАЖДОМ вызове:

  • targets_total SET = len(quarters) - len(skipped_fresh) (412) — перетирается, не CAS;
  • targets_skipped INCREMENT = COALESCE(targets_skipped,0) + len(skipped_fresh) (408) — раздувается каждый resume.

На первом запуске _create_job ставит targets_total=len(quarters) (admin_cadastre.py:145), skip_fresh_hours по дефолту = 1 (scrape_cadastre.py:386), но ещё ничего не fresh → skipped_fresh пуст. После частичного прогона обойдённые quarters получают cad_quarter_stats.fetched_at=NOW() (upsert_quarter_stats, bulk_harvest.py:1390). targets_done только инкрементится (scrape_cadastre.py:90) и НИКОГДА не сбрасывается.

На resume (<1ч): skipped_fresh теперь = все обойдённые в run1 quarters → targets_total RESET вниз (напр. 100→40), а targets_done сохранил накопленное (напр. 60). _maybe_finish_job (309-340): total>0 AND (done+failed) >= total → 60>=40 → status='done' при первом же завершении ре-enqueue'нутого quarter, пока остальные 40 ещё в полёте; targets_done продолжает расти выше targets_total; progress_pct >100%.

Импакт: job помечается завершённым раньше времени, прогресс/skipped-метрики некорректны, оставшиеся quarters дообрабатываются вне учёта статуса. Отличается от #1236 (там job вечно 'running').

Фикс: вычислять targets_total как len(quarters) - len(skipped_fresh) ТОЛЬКО при создании job (или хранить immutable original_total и считать прогресс от него); targets_skipped — SET, не INCREMENT, либо помечать quarters skipped через persistent состояние (per-quarter статус), а не пересчитывать на каждый вызов. Альтернатива: на resume пропускать freshness-skip целиком (resume = продолжение, не свежий scope).

Почему баг: targets_skipped increment and targets_total reset are not guarded against repeat calls. The freshness adjustment assumes a single invocation but the resume path invokes it again with a different (larger) skipped_fresh set, breaking the done/total invariant that _maybe_finish_job relies on and inflating the skipped metric.

Как чинить: Make the skip adjustment idempotent: compute targets_total = (count of quarters NOT in skipped_fresh) and SET targets_skipped = len(skipped_fresh) (assignment, not increment); or gate the whole skip block behind a phase_state flag so it only runs on the first enqueue. Also reset/recompute targets_done relative to the new scope on resume, or base completion on remaining pending targets rather than absolute counters.


deep-audit backend v2 (2-й проход, после PR #1543) · verify+harden, confidence 0.82 · unit B43

**Severity:** medium · **Категория:** idempotency/false-report · **Файл:** `backend/app/workers/tasks/scrape_cadastre.py:386-416, 309-340` Файлы: backend/app/workers/tasks/scrape_cadastre.py:386-416 (freshness-skip), 309-340 (_maybe_finish_job), 88-91 (targets_done инкремент); resume path backend/app/api/v1/admin_cadastre.py:325-350; create path admin_cadastre.py:116-146; источник fetched_at backend/app/services/cadastre/bulk_harvest.py:1368-1390. Суть: docstring (351-356) обещает идемпотентность, и resume_cadastre_job (admin_cadastre.py:325) на paused/failed ре-вызывает enqueue_cadastre_harvest (350). Freshness-skip блок выполняется при КАЖДОМ вызове: - targets_total SET = `len(quarters) - len(skipped_fresh)` (412) — перетирается, не CAS; - targets_skipped INCREMENT = `COALESCE(targets_skipped,0) + len(skipped_fresh)` (408) — раздувается каждый resume. На первом запуске _create_job ставит targets_total=len(quarters) (admin_cadastre.py:145), skip_fresh_hours по дефолту = 1 (scrape_cadastre.py:386), но ещё ничего не fresh → skipped_fresh пуст. После частичного прогона обойдённые quarters получают cad_quarter_stats.fetched_at=NOW() (upsert_quarter_stats, bulk_harvest.py:1390). targets_done только инкрементится (scrape_cadastre.py:90) и НИКОГДА не сбрасывается. На resume (<1ч): skipped_fresh теперь = все обойдённые в run1 quarters → targets_total RESET вниз (напр. 100→40), а targets_done сохранил накопленное (напр. 60). _maybe_finish_job (309-340): `total>0 AND (done+failed) >= total` → 60>=40 → status='done' при первом же завершении ре-enqueue'нутого quarter, пока остальные 40 ещё в полёте; targets_done продолжает расти выше targets_total; progress_pct >100%. Импакт: job помечается завершённым раньше времени, прогресс/skipped-метрики некорректны, оставшиеся quarters дообрабатываются вне учёта статуса. Отличается от #1236 (там job вечно 'running'). Фикс: вычислять targets_total как `len(quarters) - len(skipped_fresh)` ТОЛЬКО при создании job (или хранить immutable original_total и считать прогресс от него); targets_skipped — SET, не INCREMENT, либо помечать quarters skipped через persistent состояние (per-quarter статус), а не пересчитывать на каждый вызов. Альтернатива: на resume пропускать freshness-skip целиком (resume = продолжение, не свежий scope). **Почему баг:** targets_skipped increment and targets_total reset are not guarded against repeat calls. The freshness adjustment assumes a single invocation but the resume path invokes it again with a different (larger) skipped_fresh set, breaking the done/total invariant that _maybe_finish_job relies on and inflating the skipped metric. **Как чинить:** Make the skip adjustment idempotent: compute targets_total = (count of quarters NOT in skipped_fresh) and SET targets_skipped = len(skipped_fresh) (assignment, not increment); or gate the whole skip block behind a phase_state flag so it only runs on the first enqueue. Also reset/recompute targets_done relative to the new scope on resume, or base completion on remaining pending targets rather than absolute counters. --- <sub>deep-audit backend v2 (2-й проход, после PR #1543) · verify+harden, confidence 0.82 · unit B43</sub>
bot-backend added the
week ревью 1
label 2026-06-16 18:47:04 +00:00
Sign in to join this conversation.
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#1654
No description provided.