fix(ops): сторож не снимает живой прогон на долгом COPY
Молчащий heartbeat сторож трактовал как смерть прогона и снимал его по порогу 10 минут. Но запись большой пачки отпечатков в базу на HDD идёт COPY'ем 6-15 минут без единого тика heartbeat — и сторож убивал вполне живой прогон, тут же запуская дубль по тем же статьям. Корпус часами топтался на месте: из лога видно десятки перезапусков подряд с нулевым приростом. Теперь перед снятием сторож спрашивает воркеров queue.index через inspect().active(), выполняется ли ещё таск прогона. Снимаются только настоящие зомби — те, чьего celery_task_id нет ни на одном воркере. Если хоть один воркер не ответил, живость не проверить и не снимается ничего (fail-safe). Проверено на живом прогоне: его таск попал в набор активных, прогон помечен защищённым. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,36 @@
|
|||||||
Останавливается сам, когда источник исчерпан: парсер перестаёт отдавать статьи,
|
Останавливается сам, когда источник исчерпан: парсер перестаёт отдавать статьи,
|
||||||
прогон закрывается с нулём добавленных, и скрипт больше его не трогает
|
прогон закрывается с нулём добавленных, и скрипт больше его не трогает
|
||||||
(--stop-when-empty, по умолчанию включено).
|
(--stop-when-empty, по умолчанию включено).
|
||||||
|
|
||||||
|
Перед подсчётом «занято ли» снимает зависшие прогоны: если worker-indexer упал
|
||||||
|
или перезапустился, прогон навсегда остаётся в статусе running с замолчавшим
|
||||||
|
heartbeat — без этой чистки сторож видит «занято» бесконечно и не запускает
|
||||||
|
вообще ничего, для всех источников сразу (порог тот же, что и в панели
|
||||||
|
отладки — services/api/app/api/admin.py, stale_cutoff).
|
||||||
|
|
||||||
|
Молчащий heartbeat сам по себе ещё не значит «мёртв»: пачка COPY на большой
|
||||||
|
базе идёт десятки минут без единого тика. Поэтому прогон снимается, только
|
||||||
|
если его таска нет среди выполняющихся на воркерах queue.index; не ответил
|
||||||
|
хоть один такой воркер — не снимается ничего. Иначе сторож снимал живой
|
||||||
|
медленный прогон и тут же запускал его дубль по тем же статьям.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
BULK_TYPES = ("wikipedia_ru", "pmc_bulk")
|
BULK_TYPES = ("wikipedia_ru", "pmc_bulk")
|
||||||
|
STALE_MINUTES = 10
|
||||||
|
|
||||||
|
|
||||||
|
def live_task_ids(celery_app) -> set[str] | None:
|
||||||
|
"""ID тасков, выполняющихся на воркерах queue.index; None — кто-то из них не ответил."""
|
||||||
|
queues = celery_app.control.inspect(timeout=10).active_queues() or {}
|
||||||
|
workers = [w for w, qs in queues.items() if any(q["name"] == "queue.index" for q in qs)]
|
||||||
|
if not workers:
|
||||||
|
return None
|
||||||
|
active = celery_app.control.inspect(destination=workers, timeout=10).active()
|
||||||
|
if not active or set(active) != set(workers):
|
||||||
|
return None
|
||||||
|
return {t["id"] for tasks in active.values() for t in tasks}
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -38,7 +63,44 @@ def main() -> None:
|
|||||||
|
|
||||||
types = tuple(t.strip() for t in args.types.split(",") if t.strip())
|
types = tuple(t.strip() for t in args.types.split(",") if t.strip())
|
||||||
|
|
||||||
|
live = live_task_ids(celery_app)
|
||||||
|
|
||||||
with db_session() as s:
|
with db_session() as s:
|
||||||
|
if live is None:
|
||||||
|
print("воркеры queue.index не ответили — живость прогонов не проверить, не снимаю")
|
||||||
|
reaped = []
|
||||||
|
else:
|
||||||
|
reaped = s.execute(text(f"""
|
||||||
|
UPDATE parse_runs SET status='error', stage='finished',
|
||||||
|
error='зависший прогон снят автоматически: таск не выполняется, heartbeat молчал дольше {STALE_MINUTES} минут',
|
||||||
|
finished_at=now()
|
||||||
|
WHERE status='running'
|
||||||
|
AND (heartbeat_at IS NULL OR heartbeat_at < now() - interval '{STALE_MINUTES} minutes')
|
||||||
|
AND (celery_task_id IS NULL OR NOT (celery_task_id = ANY(CAST(:live AS text[]))))
|
||||||
|
RETURNING id, source_id
|
||||||
|
"""), {"live": sorted(live)}).all()
|
||||||
|
# queued без heartbeat — тот же зомби другого вида: send_task не дошёл
|
||||||
|
# или упал между двумя commit при постановке в очередь (celery_task_id
|
||||||
|
# так и остался пустым), воркер о такой задаче никогда не узнает
|
||||||
|
reaped += s.execute(text(f"""
|
||||||
|
UPDATE parse_runs SET status='error', stage='finished',
|
||||||
|
error='зависший прогон снят автоматически: висел в очереди дольше {STALE_MINUTES} минут',
|
||||||
|
finished_at=now()
|
||||||
|
WHERE status='queued'
|
||||||
|
AND celery_task_id IS NULL
|
||||||
|
AND started_at < now() - interval '{STALE_MINUTES} minutes'
|
||||||
|
RETURNING id, source_id
|
||||||
|
""")).all()
|
||||||
|
if reaped:
|
||||||
|
for source_id in {row.source_id for row in reaped}:
|
||||||
|
s.execute(text("""
|
||||||
|
UPDATE parse_sources SET last_status='error',
|
||||||
|
last_error='зависший прогон снят автоматически'
|
||||||
|
WHERE id=:sid AND last_status='running'
|
||||||
|
"""), {"sid": source_id})
|
||||||
|
s.commit()
|
||||||
|
print(f"снято зависших прогонов: {len(reaped)} ({', '.join(str(r.id) for r in reaped)})")
|
||||||
|
|
||||||
active = s.execute(text(
|
active = s.execute(text(
|
||||||
"SELECT count(*) FROM parse_runs WHERE status IN ('queued','running')"
|
"SELECT count(*) FROM parse_runs WHERE status IN ('queued','running')"
|
||||||
)).scalar_one()
|
)).scalar_one()
|
||||||
|
|||||||
Reference in New Issue
Block a user