fix(scheduler): atomic _claim_run via advisory xact lock (Refs #750) #752

Merged
bot-reviewer merged 1 commit from fix/750-scheduler-atomic-claim into main 2026-05-30 16:04:33 +00:00
Collaborator

Что

Закрывает TOCTOU в scheduler._claim_run: конкурентные тики одного source (redeploy-overlap старого+нового контейнера, или 2 реплики воркера) оба проходили has_running_run pre-check до того как любой из них успевал создать running-run → запускался двойной city-sweep → 2× rate к Avito/Cian → бан скрапера.

Как (Path A — advisory xact lock)

После pre-check берём pg_try_advisory_xact_lock(hashtext(:source)):

  • non-blocking: лок занят конкурентным тиком → return None (тик просто пропускается, следующий возьмёт);
  • под локом — double-checked has_running_run (running мог закоммититься в окне между pre-check и взятием лока);
  • лок transaction-scoped → авто-снимается на commit/rollback, отдельного release не нужно.

Почему не Path B (partial unique index)

Спека рекомендовала CREATE UNIQUE INDEX ... WHERE status='running'. Отклонил: миграция упадёт на deploy, если в таблице уже есть ≥2 running-строк на один source — а это ровно то состояние, которое текущий баг и создаёт. Рискованно прямо перед Praktika-demo (Mon 06-01). Advisory lock не трогает схему и деградирует безопасно.

Тесты (tests/test_scheduler.py, offline, DB-mock)

  • test_claim_run_double_call_creates_one_run — 2 последовательных claim → create_run ровно 1×, второй None.
  • test_claim_run_skips_when_advisory_lock_unavailable — лок занят → None, create_run не вызван.
  • test_claim_run_rechecks_running_under_lock — running появился под локом → None + rollback (release).

pytest -k scheduler → 9 passed. ruff check clean (заодно автофикс 5 pre-existing UP017 timezone.utcUTC в этом же файле).

Refs #750

## Что Закрывает TOCTOU в `scheduler._claim_run`: конкурентные тики одного `source` (redeploy-overlap старого+нового контейнера, или 2 реплики воркера) оба проходили `has_running_run` pre-check **до** того как любой из них успевал создать running-run → запускался **двойной city-sweep** → 2× rate к Avito/Cian → бан скрапера. ## Как (Path A — advisory xact lock) После pre-check берём `pg_try_advisory_xact_lock(hashtext(:source))`: - non-blocking: лок занят конкурентным тиком → `return None` (тик просто пропускается, следующий возьмёт); - под локом — **double-checked** `has_running_run` (running мог закоммититься в окне между pre-check и взятием лока); - лок transaction-scoped → авто-снимается на `commit`/`rollback`, отдельного release не нужно. ## Почему не Path B (partial unique index) Спека рекомендовала `CREATE UNIQUE INDEX ... WHERE status='running'`. Отклонил: миграция **упадёт на deploy**, если в таблице уже есть ≥2 running-строк на один source — а это ровно то состояние, которое текущий баг и создаёт. Рискованно прямо перед Praktika-demo (Mon 06-01). Advisory lock не трогает схему и деградирует безопасно. ## Тесты (`tests/test_scheduler.py`, offline, DB-mock) - `test_claim_run_double_call_creates_one_run` — 2 последовательных claim → `create_run` ровно 1×, второй `None`. - `test_claim_run_skips_when_advisory_lock_unavailable` — лок занят → `None`, `create_run` не вызван. - `test_claim_run_rechecks_running_under_lock` — running появился под локом → `None` + `rollback` (release). `pytest -k scheduler` → 9 passed. `ruff check` clean (заодно автофикс 5 pre-existing UP017 `timezone.utc`→`UTC` в этом же файле). Refs #750
bot-backend added 1 commit 2026-05-30 16:02:05 +00:00
Конкурентные scheduler-тики одного source (redeploy-overlap старого+нового
контейнера / 2 реплики) оба проходили has_running_run pre-check и запускали
ДВОЙНОЙ city-sweep → 2x rate к Avito/Cian → бан скрапера.

Path A: transaction-scoped pg_try_advisory_xact_lock(hashtext(source)) после
pre-check сериализует claim; non-blocking (lock занят → return None). Под локом
double-checked has_running_run (running мог появиться в окне pre-check→lock).
Лок авто-снимается на commit/rollback.

Path B (partial unique index WHERE status='running') отклонён: миграция могла
упасть на deploy если dirty-данные уже содержат >=2 running-строк на source
(ровно то, что баг и создаёт) — рискованно перед demo.

Tests: double-call→один run; lock unavailable→skip; running-under-lock→rollback+skip.
bot-reviewer approved these changes 2026-05-30 16:04:23 +00:00
bot-reviewer left a comment
Collaborator

APPROVE

Atomic _claim_run через advisory xact lock (#750). Issue предлагал ОБА пути как приемлемые (рекомендовал B, разрешил A). Worker выбрал A с точным обоснованием, с которым согласен: Path B CREATE UNIQUE INDEX … WHERE status='running' упадёт на deploy если уже есть ≥2 running-строк на source — ровно то состояние, что создаёт этот баг — сломав деплой накануне Praktika. Path A не трогает схему и деградирует безопасно. Здравое суждение в рамках разрешённых опций.

Correctness (advisory-lock паттерн)

  • pg_try_advisory_xact_lock(hashtext(:source)) — non-blocking, transaction-scoped, single-arg bigint форма валидна; конкурентный тик не берёт → None.
  • Double-check под локом: повторный has_running_run ловит run, закоммиченный между pre-check и взятием лока → rollback (release) + None. Остаточное окно закрыто корректно.
  • Лок авто-снимается на commit (success) / rollback (re-check). Конкурентный B не может быть в середине insert пока A держит лок (try non-blocking) → нет uncommitted-read; B либо вышел по None, либо (после commit A) видит running в double-check. Гонка полностью закрыта.

Tests — 3 новых со stateful fake (double-call→one run, lock-unavailable→None, running-under-lock→None+rollback) + признанный ruff UP017 автофикс (timezone.utcUTC, механический). 9 passed, ruff clean.

Scope/security — scheduler.py + тест; нет миграции, нет secrets, нет self-extending триггера.

Advisoryhashtext int4-коллизия может изредка сериализовать два несвязанных source — безвредно (пропущенный тик, ретрай в след. цикле). Live redeploy-overlap → qa.

<!-- gendesign-review-bot: sha=9f950a5 verdict=approve --> ✅ APPROVE Atomic `_claim_run` через advisory xact lock (#750). Issue предлагал ОБА пути как приемлемые (рекомендовал B, разрешил A). Worker выбрал A с точным обоснованием, с которым согласен: Path B `CREATE UNIQUE INDEX … WHERE status='running'` **упадёт на deploy** если уже есть ≥2 running-строк на source — ровно то состояние, что создаёт этот баг — сломав деплой накануне Praktika. Path A не трогает схему и деградирует безопасно. Здравое суждение в рамках разрешённых опций. **Correctness (advisory-lock паттерн)** - `pg_try_advisory_xact_lock(hashtext(:source))` — non-blocking, transaction-scoped, single-arg bigint форма валидна; конкурентный тик не берёт → `None`. - **Double-check под локом**: повторный `has_running_run` ловит run, закоммиченный между pre-check и взятием лока → `rollback` (release) + `None`. Остаточное окно закрыто корректно. - Лок авто-снимается на commit (success) / rollback (re-check). Конкурентный B не может быть в середине insert пока A держит лок (try non-blocking) → нет uncommitted-read; B либо вышел по None, либо (после commit A) видит running в double-check. Гонка полностью закрыта. **Tests** — 3 новых со stateful fake (double-call→one run, lock-unavailable→None, running-under-lock→None+rollback) + признанный ruff UP017 автофикс (`timezone.utc`→`UTC`, механический). 9 passed, ruff clean. **Scope/security** — scheduler.py + тест; нет миграции, нет secrets, нет self-extending триггера. **Advisory** — `hashtext` int4-коллизия может изредка сериализовать два несвязанных source — безвредно (пропущенный тик, ретрай в след. цикле). Live redeploy-overlap → qa.
bot-reviewer merged commit 2226b16cd3 into main 2026-05-30 16:04:33 +00:00
bot-reviewer deleted branch fix/750-scheduler-atomic-claim 2026-05-30 16:04:34 +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#752
No description provided.