devs_key = ",".join(developers) — список джойнился как пришёл, а приходит он прямо
из тела запроса (`developers` в admin_scrape, порядок произвольный). Один и тот же
набор в другом порядке давал ДРУГОЙ ключ: синглтон молча переставал быть
синглтоном, два свипа шли параллельно по одним и тем же разработчикам.
Второе следствие того же корня: force_release_lock строит ключ этой же функцией.
Оператор, снимающий залипший лок и перечисливший разработчиков в ином порядке,
молча не снимал ничего.
sorted(set(...)) закрывает оба случая: порядок и повторы ('A','A' ≡ 'A').
Про переход: формат ключа меняется, поэтому лок, удерживаемый ПРЯМО СЕЙЧАС под
старым ключом, осиротеет и истечёт по TTL. Практического риска нет — kn-свип
не отрабатывает с 28.06 (DOM.РФ отдаёт страницу блокировки, см. #2443).
Тесты сравнивают ЗНАЧЕНИЯ ключей, поэтому красное на origin/main — про разошедшиеся
ключи, а не про отсутствующую функцию:
один набор в двух порядках → два разных ключа → падает
повтор в списке меняет ключ → падает
разные наборы остаются разными — контроль, зелёный с обеих сторон
регион остаётся в ключе — контроль, зелёный с обеих сторон
None/[] по-прежнему дают '*' — контроль, зелёный с обеих сторон
Первый контроль не для симметрии: ловит «починку» через огрубление ключа
(отбросить developers вовсе) — тогда свипы по разным разработчикам блокировали бы
друг друга.
Прогоны: -k "scrape_kn or lock" — 114 passed rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Два concurrency-бага в scrape_kn:
1. Lock value="1" без owner-токена + TTL=30мин = заявленной длительности
sweep'а (нулевой запас). Sweep > TTL → lock истекает, второй sweep
стартует, первый при завершении безусловным r.delete сносит ЧУЖОЙ
lock → возможен третий параллельный.
2. SIGKILL/redeploy mid-sweep: finally не выполняется, lock живёт до
30 мин. worker_ready метит run 'zombie' и enqueue'ит resume_kn_run
БЕЗ countdown, который ловит lock_held и возвращает skipped без
retry → run теряется до недельного beat.
Patch:
- _region_lock: value = uuid4().hex; release через Lua check-and-delete
(`if GET == ARGV[1] then DEL else 0 end`) — не сносим чужой lock.
- _LOCK_TTL_SECONDS 30 → 45 мин (полтора max sweep duration, запас).
- resume_kn_run: при lock_held → raise self.retry(countdown=300,
max_retries=12) → ~час окно для подхвата вместо silent skip.
- scrape_kn_region (scheduled) намеренно остаётся skipped — beat
поднимет в следующий weekly tick (другая семантика).
11 новых юнит-тестов (token uniqueness, Lua guard, retry-not-skip,
existing behaviors). 16/16 scrape_kn тестов зелёные. ruff clean.
Closes#1216
Симптом: POST /admin/scrape/kn возвращает task_id, но run_id не появляется
в kn_scrape_runs. Причина: зомби-worker оставил Redis-lock с TTL=2h, новые
задачи скипаются с reason='lock_held' молча.
Backend:
- _LOCK_TTL_SECONDS 7200 → 1800 (full sweep ~25-30 мин — потолок).
- force_release_lock() helper + новый POST /api/v1/admin/scrape/release-lock.
- TriggerKnRequest +force: bool — при true перед apply_async удалить lock.
- _log_task_received() / _log_task_skipped() — kn_scrape_log пишет «task
подхвачен/skip» с run_id=NULL до основного flow. Теперь UI показывает
whether worker реально получил задачу.
Frontend:
- Чекбокс ⚡ Force в форме запуска.
- Кнопка 🔓 Снять Redis-lock (отдельный endpoint).
- Уведомление о результате release.