gendesign/backend/app/workers/tasks/nspd_geo.py
Light1YT e0fff9cfb0
Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m45s
CI / openapi-codegen-check (pull_request) Successful in 1m43s
CI / backend-tests (push) Failing after 8m36s
CI / backend-tests (pull_request) Failing after 8m36s
fix(week-review): backend-аудит v2 — 82 issue (#1560-1656)
Имплементация фиксов 2-го аудита backend/app/** (после merge #1543).
Воркер на файл, точечные правки. Верификация: py_compile 58/58 .py.

Полностью исправлено (82).
Оставлены открытыми (13): partial/needs-cross-file/needs-leha — #1569, #1590, #1593, #1606, #1609, #1617, #1633, #1635, #1637, #1638, #1640, #1642, #1650.

Closes #1560
Closes #1561
Closes #1562
Closes #1563
Closes #1564
Closes #1565
Closes #1566
Closes #1567
Closes #1570
Closes #1571
Closes #1572
Closes #1573
Closes #1574
Closes #1576
Closes #1577
Closes #1578
Closes #1579
Closes #1580
Closes #1581
Closes #1582
Closes #1583
Closes #1584
Closes #1585
Closes #1586
Closes #1587
Closes #1588
Closes #1589
Closes #1591
Closes #1592
Closes #1594
Closes #1595
Closes #1596
Closes #1597
Closes #1598
Closes #1599
Closes #1600
Closes #1601
Closes #1602
Closes #1603
Closes #1604
Closes #1605
Closes #1607
Closes #1608
Closes #1610
Closes #1611
Closes #1612
Closes #1613
Closes #1614
Closes #1615
Closes #1616
Closes #1618
Closes #1619
Closes #1620
Closes #1621
Closes #1622
Closes #1623
Closes #1624
Closes #1625
Closes #1626
Closes #1627
Closes #1628
Closes #1629
Closes #1630
Closes #1631
Closes #1632
Closes #1634
Closes #1636
Closes #1639
Closes #1641
Closes #1643
Closes #1644
Closes #1645
Closes #1646
Closes #1647
Closes #1648
Closes #1649
Closes #1651
Closes #1652
Closes #1653
Closes #1654
Closes #1655
Closes #1656
2026-06-17 01:30:52 +05:00

741 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Bulk NSPD geo backfill — resume-friendly Celery task.
Архитектура (см. data/sql/77_nspd_geo_jobs.sql):
nspd_geo_jobs — журнал заданий (один job = одна bulk-операция)
nspd_geo_targets — список cad-номеров со status (pending/done/failed)
Resume:
1. При worker_ready signal: ищем jobs со status='running' и stale heartbeat
(>10 мин), переключаем их статус на 'resume_needed' + re-enqueue task.
2. process_nspd_geo_job(job_id) сразу читает SELECT WHERE status='pending'
— идемпотентно продолжает с того места, где остановился (UPSERT в
cad_quarters_geom / cad_buildings гарантирует no dup'ов).
Запуск (3 пути):
1. UI: POST /api/v1/admin/scrape/geo (создаёт job, наполняет targets, enqueue task)
2. CLI: python -m app.workers.tasks.nspd_geo --enqueue-region 66
3. Beat: можно добавить cron-расписание в celery_app.py
"""
from __future__ import annotations
import json
import logging
from typing import Any
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.cadastre.bulk_harvest import _derive_quarter_cad, _safe_int, _safe_numeric
from app.services.scrapers.nspd_lite import (
NspdLiteError,
NspdLiteWafError,
fetch_via_rosreestr2coord,
)
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Heartbeat каждые N items (компромисс: чаще = выше DB write, реже = stale-window больше)
HEARTBEAT_EVERY = 5
# WAF backoff (seconds) после первого 403/429
WAF_BACKOFF_BASE_S = 30
# Zombie-thresholds для cleanup_zombies (issue #1215).
# WAF-ветка процесса спит до WAF_BACKOFF_BASE_S * 8 = 240s (4 мин) внутри одной итерации —
# heartbeat за это время НЕ обновляется. Старый порог 2 мин ре-enqueue'ил живые WAF-jobs →
# два worker'а параллельно долбили забаненный NSPD-сервис, углубляя бан + затирали счётчики.
# Поднимаем running-порог выше max WAF-backoff (6 мин > 4 мин). Для 'paused' (consecutive_waf>=8)
# нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу.
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
_ZOMBIE_PAUSED_THRESHOLD = "30 minutes"
# Issue #1655: jobs, застрявшие в 'queued' после ре-enqueue (worker_ready /
# cleanup_zombies ставят 'queued' + apply_async), но чьё broker-сообщение
# потерялось (flush брокера, crash до pickup, purge очереди) — раньше никто
# не ре-reap'ил. cleanup_zombies матчил только 'running'/'paused'.
# Матчим только 'queued' c НЕ-NULL stale heartbeat_at: свежесозданный job
# (enqueue_geo_job) имеет heartbeat_at IS NULL (нет дефолта в схеме 77_) — его
# initial apply_async ещё в полёте, ре-reap'ить рано. Порог = running-порог
# (6 мин): сообщение либо взято воркером (→ status станет 'running' через claim),
# либо потеряно — 6 мин достаточно, чтобы отличить потерю от задержки очереди.
_ZOMBIE_QUEUED_THRESHOLD = "6 minutes"
# ── Helpers для job/target lifecycle ────────────────────────────────────────
def _log(
db: Session, job_id: int, level: str, stage: str, message: str, cad_num: str | None = None
) -> None:
"""Запись в nspd_geo_log (виден через v_scrape_log_unified в admin UI)."""
try:
db.execute(
text(
"""
INSERT INTO nspd_geo_log (job_id, level, stage, cad_num, message)
VALUES (:jid, :lvl, :stg, :cad, :msg)
"""
),
{"jid": job_id, "lvl": level, "stg": stage, "cad": cad_num, "msg": message[:1000]},
)
db.commit()
except Exception as e:
logger.warning("nspd_geo_log write failed for job=%s: %s", job_id, e)
try:
db.rollback()
except Exception:
pass
def _start_job(db: Session, job_id: int) -> bool:
"""Атомарный claim job'а: переводит в 'running' ТОЛЬКО если он ещё не 'running'.
Issue #1621: дублирующие task-сообщения на один job_id (worker_ready resume +
cleanup_zombies beat одновременно, либо overlap контейнеров при rolling-redeploy,
либо два prefork-child при --concurrency>1) раньше оба безусловно делали
UPDATE→'running' и оба входили в while-loop → двойные WAF-хиты + затёртые счётчики.
Теперь claim атомарен: `WHERE job_id=:id AND status <> 'running'` + RETURNING.
Если строка не вернулась — кто-то уже держит claim, второй worker должен выйти.
Returns True если claim получен, False если job уже 'running' (или не найден).
"""
claimed = db.execute(
text(
"""
UPDATE nspd_geo_jobs
SET status = 'running',
started_at = COALESCE(started_at, NOW()),
heartbeat_at = NOW(),
updated_at = NOW()
WHERE job_id = :id
AND status <> 'running'
RETURNING job_id
"""
),
{"id": job_id},
).scalar_one_or_none()
db.commit()
if claimed is None:
return False
_log(db, job_id, "info", "start", "job started/resumed")
return True
def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
sets = ["heartbeat_at = NOW()", "updated_at = NOW()"]
params: dict[str, Any] = {"id": job_id}
for k, v in counts.items():
sets.append(f"{k} = :{k}")
params[k] = v
try:
db.execute(
text(f"UPDATE nspd_geo_jobs SET {', '.join(sets)} WHERE job_id = :id"),
params,
)
db.commit()
except Exception as e:
logger.warning("heartbeat failed for job=%s: %s", job_id, e)
try:
db.rollback()
except Exception:
pass
def _finish_job(db: Session, job_id: int, status: str, error: str | None = None) -> None:
db.execute(
text(
"""
UPDATE nspd_geo_jobs
SET status = :st, finished_at = NOW(), error = :err, updated_at = NOW()
WHERE job_id = :id
"""
),
{"id": job_id, "st": status, "err": error},
)
db.commit()
# ── Persistence для разных видов targets ────────────────────────────────────
def _save_quarter(db: Session, payload: dict, cad_num: str) -> int:
"""Парсит NSPD-response для квартала и UPSERT'ит в cad_quarters_geom."""
feats = (payload.get("data") or {}).get("features") or []
if not feats:
return 0
f = feats[0]
geom_geojson = f.get("geometry")
if not geom_geojson:
return 0
db.execute(
text(
"""
INSERT INTO cad_quarters_geom (cad_number, geom, raw_props, fetched_at)
VALUES (:cad,
-- cad_quarters_geom.geom = geometry(MultiPolygon,4326) NOT NULL (migration 58).
-- NSPD может вернуть одноконтурный Polygon → ST_Multi coerce'ит в MultiPolygon,
-- иначе "Geometry type (Polygon) does not match column type (MultiPolygon)".
-- БЕЗ ST_Transform: rosreestr2coord отдаёт GeoJSON уже в WGS84
-- (EPSG:4326, degrees) для всех area_type — только ST_SetSRID.
-- ST_Transform из 3857 трактовал бы градусы как метры Mercator →
-- коллапс к Null Island (issue #1336).
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)),
CAST(:props AS jsonb), NOW())
ON CONFLICT (cad_number) DO UPDATE
SET geom = EXCLUDED.geom,
raw_props = EXCLUDED.raw_props,
fetched_at = NOW()
"""
),
{
"cad": cad_num,
"g": json.dumps(geom_geojson, ensure_ascii=False),
"props": json.dumps(f.get("properties") or {}, ensure_ascii=False),
},
)
return 1
def _save_building(db: Session, payload: dict, cad_num: str) -> int:
"""Парсит NSPD-response для здания и UPSERT'ит в cad_buildings (wide schema, migration 92).
cad_buildings (migration 92) — широкая схема:
- quarter_cad_number (НЕ quarter_cad_num),
- floors INT (NSPD может отдать "5-7" → _safe_int → NULL, иначе InvalidTextRepresentation),
- source TEXT NOT NULL (нет дефолта) → обязателен,
- geom GEOMETRY(Geometry, 4326) → полиморфный, ST_Multi НЕ нужен (mirror upsert_building).
"""
feats = (payload.get("data") or {}).get("features") or []
if not feats:
return 0
n = 0
for f in feats:
geom_geojson = f.get("geometry")
opts = (f.get("properties") or {}).get("options") or {}
if not geom_geojson:
continue
# Только этого здания (NSPD может вернуть здания всего квартала)
if opts.get("cad_num") != cad_num:
continue
quarter_cad = opts.get("quarter_cad_number") or _derive_quarter_cad(cad_num)
db.execute(
text(
"""
INSERT INTO cad_buildings (
cad_num, quarter_cad_number, geom, purpose, building_name,
readable_address, area, floors, raw_props, source
) VALUES (
:cad, :qcad,
-- БЕЗ ST_Transform: rosreestr2coord отдаёт GeoJSON уже в WGS84
-- (EPSG:4326, degrees) для всех area_type — только ST_SetSRID.
-- ST_Transform из 3857 трактовал бы градусы как метры Mercator →
-- коллапс к Null Island (issue #1336).
ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326),
:purpose, :name, :addr, :area, :floors, CAST(:props AS jsonb), :source
)
ON CONFLICT (cad_num) DO UPDATE
SET quarter_cad_number = EXCLUDED.quarter_cad_number,
geom = EXCLUDED.geom,
purpose = EXCLUDED.purpose,
building_name = EXCLUDED.building_name,
readable_address = EXCLUDED.readable_address,
area = EXCLUDED.area,
floors = EXCLUDED.floors,
raw_props = EXCLUDED.raw_props,
source = EXCLUDED.source
"""
),
{
"cad": cad_num,
"qcad": quarter_cad,
"g": json.dumps(geom_geojson, ensure_ascii=False),
"purpose": opts.get("purpose"),
"name": opts.get("building_name") or opts.get("name"),
"addr": opts.get("readable_address"),
"area": _safe_numeric(opts.get("area")),
"floors": _safe_int(opts.get("floors")),
"props": json.dumps(opts, ensure_ascii=False),
"source": "nspd",
},
)
n += 1
return n
def _save_parcel(db: Session, payload: dict, cad_num: str) -> int:
"""Парсит NSPD-response для земельного участка и UPSERT'ит в cad_parcels_geom.
NB: rosreestr2coord для area_type=1 (parcel) отдаёт GeoJSON уже в WGS84
(EPSG:4326, degrees). Просто ST_SetSRID — без ST_Transform, иначе геометрия
сжимается до точки в районе (0,0) на острове Null. Quarters/buildings идут
через тот же rosreestr2coord path и тоже WGS84 — им ST_Transform тоже НЕ нужен
(issue #1336: раньше ошибочно трансформировались из 3857 → Null Island).
"""
feats = (payload.get("data") or {}).get("features") or []
if not feats:
return 0
f = feats[0]
geom_geojson = f.get("geometry")
if not geom_geojson:
return 0
db.execute(
text(
"""
INSERT INTO cad_parcels_geom (cad_num, geom, raw_props, fetched_at)
VALUES (:cad,
-- cad_parcels.geom = geometry(MultiPolygon,4326) (migration 93;
-- cad_parcels_geom теперь back-compat VIEW над cad_parcels).
-- NSPD/rosreestr2coord отдаёт одноконтурный Polygon → ST_Multi coerce'ит
-- в MultiPolygon, иначе "Geometry type (Polygon) does not match column
-- type (MultiPolygon)". БЕЗ ST_Transform: parcel GeoJSON уже WGS84
-- (см. docstring) — только ST_SetSRID.
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)),
CAST(:props AS jsonb), NOW())
ON CONFLICT (cad_num) DO UPDATE
SET geom = EXCLUDED.geom,
raw_props = EXCLUDED.raw_props,
fetched_at = NOW()
"""
),
{
"cad": cad_num,
"g": json.dumps(geom_geojson, ensure_ascii=False),
"props": json.dumps(f.get("properties") or {}, ensure_ascii=False),
},
)
return 1
def _persist_target(db: Session, thematic_id: int, payload: dict, cad_num: str) -> tuple[int, str]:
"""Returns (n_features_saved, table_name). Полиморфно по thematic_id."""
if thematic_id == 1:
return _save_parcel(db, payload, cad_num), "cad_parcels_geom"
if thematic_id == 2:
return _save_quarter(db, payload, cad_num), "cad_quarters_geom"
if thematic_id == 5:
return _save_building(db, payload, cad_num), "cad_buildings"
return 0, ""
# ── Helper для создания job + targets ──────────────────────────────────────
def enqueue_geo_job(
*,
name: str | None,
job_kind: str,
source_kind: str,
source_params: dict[str, Any],
cad_nums_with_thematic: list[tuple[str, int]],
rate_ms: int = 600,
triggered_by: str = "manual",
) -> int:
"""Создаёт job + targets и возвращает job_id. Не enqueue'ит task сам.
Caller должен сделать `process_nspd_geo_job.apply_async(args=[job_id])`.
"""
import json as _json
db = SessionLocal()
try:
job_id = db.execute(
text(
"""
INSERT INTO nspd_geo_jobs (
name, job_kind, source_kind, source_params,
rate_ms, triggered_by, status, targets_total
) VALUES (:n, :k, :sk, CAST(:sp AS jsonb), :r, :tb, 'queued', :tt)
RETURNING job_id
"""
),
{
"n": name,
"k": job_kind,
"sk": source_kind,
"sp": _json.dumps(source_params, ensure_ascii=False),
"r": rate_ms,
"tb": triggered_by,
"tt": len(cad_nums_with_thematic),
},
).scalar_one()
# Bulk INSERT targets — parametrized to prevent SQL injection
if cad_nums_with_thematic:
db.execute(
text(
"INSERT INTO nspd_geo_targets (job_id, cad_num, thematic_id, status) "
"VALUES (:job_id, :cad_num, :thematic_id, 'pending') "
"ON CONFLICT (job_id, cad_num, thematic_id) DO NOTHING"
),
[
{"job_id": job_id, "cad_num": c, "thematic_id": t}
for c, t in cad_nums_with_thematic
],
)
db.commit()
return int(job_id)
finally:
db.close()
# ── Main task ───────────────────────────────────────────────────────────────
@celery_app.task(
bind=True,
name="tasks.nspd_geo.process_job",
max_retries=0, # resume через worker_ready, не через retry
soft_time_limit=3600 * 6, # 6 часов max — крупные jobs
)
def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
"""Идёт по nspd_geo_targets WHERE job_id=:id AND status='pending'.
Heartbeat каждые HEARTBEAT_EVERY items, persistent counters в job-row.
Idempotent: уже-сохранённые UPSERT'ятся, status target → 'done'.
"""
import time
db = SessionLocal()
try:
# Загружаем параметры job'а
job = (
db.execute(
text("SELECT * FROM nspd_geo_jobs WHERE job_id = :id"),
{"id": job_id},
)
.mappings()
.first()
)
if not job:
return {"error": "job_not_found", "job_id": job_id}
# Атомарный claim (issue #1621): если job уже 'running', значит другое
# task-сообщение на тот же job_id уже в работе — выходим, не дублируя
# WAF-хиты и не затирая счётчики параллельным циклом.
if not _start_job(db, job_id):
logger.info(
"process_nspd_geo_job: job=%s already claimed (running) — skipping duplicate",
job_id,
)
return {"job_id": job_id, "skipped": True, "reason": "already_running"}
# Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings.
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
if job["rate_ms"]:
rate_ms = int(job["rate_ms"])
else:
from app.services.job_settings import get_setting_value
rate_ms = int(get_setting_value("nspd_geo", "rate_ms", 600, db=db))
# Текущие счётчики (для resume — продолжаем с тех значений)
done = int(job["targets_done"] or 0)
failed = int(job["targets_failed"] or 0)
skipped = int(job["targets_skipped"] or 0)
n_requests = int(job["requests_count"] or 0)
n_waf = int(job["waf_blocked_count"] or 0)
consecutive_waf = 0
logger.info("process_nspd_geo_job start: job=%s rate=%dms", job_id, rate_ms)
while True:
target = (
db.execute(
text(
"""
SELECT target_id, cad_num, thematic_id, attempts
FROM nspd_geo_targets
WHERE job_id = :id AND status = 'pending'
ORDER BY target_id
LIMIT 1
"""
),
{"id": job_id},
)
.mappings()
.first()
)
if not target:
break
tid = target["thematic_id"]
cad = target["cad_num"]
try:
# NSPD geo fetch теперь только через rosreestr2coord lib (v5+).
# Старая ветка с urllib-fetch (fetch_geoportal) удалена — community
# пакет нам надёжнее, авторы обновляют под WAF.
payload = {
"data": {
"features": [fetch_via_rosreestr2coord(cad, area_type=tid, timeout=15)]
}
}
payload["data"]["features"] = [f for f in payload["data"]["features"] if f]
# rosreestr2coord v5 убрал internal delay-параметр → rate limit
# делаем здесь, чтобы не словить WAF за высокую частоту.
if rate_ms > 0:
time.sleep(rate_ms / 1000.0)
n_requests += 1
consecutive_waf = 0
n_feats, saved_to = _persist_target(db, tid, payload, cad)
db.execute(
text(
"""
UPDATE nspd_geo_targets
SET status = 'done', result_features = :f,
saved_to_table = :s, fetched_at = NOW(),
attempts = attempts + 1
WHERE target_id = :tid
"""
),
{"f": n_feats, "s": saved_to or None, "tid": target["target_id"]},
)
db.commit()
done += 1
except NspdLiteWafError as e:
consecutive_waf += 1
n_waf += 1
wait = WAF_BACKOFF_BASE_S * min(consecutive_waf, 8)
logger.warning(
"WAF on %s (consecutive=%d), backoff %ds: %s",
cad,
consecutive_waf,
wait,
e,
)
# НЕ помечаем failed — остаётся pending для следующей итерации
_heartbeat(db, job_id, requests_count=n_requests, waf_blocked_count=n_waf)
_log(
db,
job_id,
"warn",
"waf",
f"WAF/429 (consecutive={consecutive_waf}), backoff {wait}s",
cad_num=cad,
)
time.sleep(wait)
if consecutive_waf >= 8:
# 8 подряд WAF — пауза job'а до следующего worker_ready
_finish_job(
db,
job_id,
"paused",
error=f"WAF blocked {consecutive_waf} consecutive",
)
_log(
db,
job_id,
"error",
"paused",
f"job paused after {consecutive_waf} consecutive WAF blocks",
)
return {"job_id": job_id, "paused": True, "reason": "waf"}
continue
except SoftTimeLimitExceeded:
# Celery soft_time_limit=6h истёк — graceful shutdown ДО generic except.
# Issue #1235: `except (NspdLiteError, Exception)` ниже проглатывал бы это
# исключение (SoftTimeLimitExceeded унаследован от Exception) → таска
# продолжала бы цикл неограниченно дольше 6ч, занимая слот geo-очереди.
# Контракт: коммитим финальный heartbeat + статусы targets, помечаем job
# 'paused' (auto-resume через cleanup_zombies/worker_ready), re-raise чтобы
# Celery освободил воркер и зарегистрировал лимит.
logger.warning(
"process_nspd_geo_job soft time limit exceeded: job=%s done=%d failed=%d",
job_id,
done,
failed,
)
try:
_heartbeat(
db,
job_id,
targets_done=done,
targets_failed=failed,
targets_skipped=skipped,
requests_count=n_requests,
waf_blocked_count=n_waf,
)
_finish_job(
db,
job_id,
"paused",
error=f"soft_time_limit exceeded after {done + failed} targets",
)
_log(
db,
job_id,
"warn",
"paused",
f"job paused: Celery soft_time_limit (6h) reached "
f"after {done} done / {failed} failed",
)
except Exception as flush_err:
# Финальный flush не должен скрыть SoftTimeLimitExceeded — лог + re-raise.
logger.exception(
"process_nspd_geo_job: failed to flush state on soft-limit: %s",
flush_err,
)
raise
except (NspdLiteError, Exception) as e:
logger.warning("fetch failed for %s: %s", cad, e)
_log(db, job_id, "warn", "fetch_fail", str(e)[:300], cad_num=cad)
db.execute(
text(
"""
UPDATE nspd_geo_targets
SET status = CASE WHEN attempts >= 2 THEN 'failed' ELSE 'pending' END,
attempts = attempts + 1,
error_msg = :em
WHERE target_id = :tid
"""
),
{"em": str(e)[:500], "tid": target["target_id"]},
)
db.commit()
if int(target["attempts"]) >= 2:
failed += 1
if (done + failed + skipped) % HEARTBEAT_EVERY == 0:
_heartbeat(
db,
job_id,
targets_done=done,
targets_failed=failed,
targets_skipped=skipped,
requests_count=n_requests,
waf_blocked_count=n_waf,
)
_heartbeat(
db,
job_id,
targets_done=done,
targets_failed=failed,
targets_skipped=skipped,
requests_count=n_requests,
waf_blocked_count=n_waf,
)
# done даже при failed targets — UI покажет failed_count отдельно
_finish_job(db, job_id, "done")
_log(
db,
job_id,
"info",
"done",
f"done: {done} ok / {failed} failed / {n_requests} req / {n_waf} WAF blocks",
)
logger.info(
"process_nspd_geo_job done: job=%s done=%d failed=%d req=%d waf=%d",
job_id,
done,
failed,
n_requests,
n_waf,
)
return {
"job_id": job_id,
"done": done,
"failed": failed,
"skipped": skipped,
"requests": n_requests,
"waf_blocked": n_waf,
}
except SoftTimeLimitExceeded:
# Inner handler уже коммитнул heartbeat + статус 'paused' + лог.
# Не перетираем 'paused' на 'failed' через outer fallback — просто пробрасываем,
# чтобы Celery освободил worker и зарегистрировал срабатывание soft_time_limit.
raise
except Exception as e:
logger.exception("process_nspd_geo_job crashed: job=%s: %s", job_id, e)
try:
_finish_job(db, job_id, "failed", error=f"{type(e).__name__}: {e}")
except Exception:
pass
raise
finally:
db.close()
@celery_app.task(name="tasks.nspd_geo.cleanup_zombies")
def cleanup_zombies() -> dict[str, Any]:
"""Periodic zombie cleanup — runs every minute via beat schedule.
Re-enqueues stale nspd_geo_jobs split by status (issue #1215):
- status='running' с heartbeat старше _ZOMBIE_RUNNING_THRESHOLD (6 мин) →
выше max WAF-backoff (240s), не ре-enqueue'ит живые WAF-паузы внутри цикла.
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин) →
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
сразу аннулировал бы защиту и worker снова долбил бы забаненный сервис.
- status='queued' c НЕ-NULL heartbeat старше _ZOMBIE_QUEUED_THRESHOLD (6 мин)
ре-enqueue'нутый job, чьё broker-сообщение потерялось (issue #1655).
heartbeat_at IS NOT NULL отсекает свежесозданные jobs (initial apply_async
ещё в полёте) — их heartbeat ставится только при первом _start_job.
Idempotent: если зомби нет, ничего не делает. Активный job с свежим heartbeat
не матчит WHERE-clause.
"""
db = SessionLocal()
resumed: list[int] = []
try:
rows = (
db.execute(
text(
"""
UPDATE nspd_geo_jobs
SET status = 'queued',
error = COALESCE(error, 'auto-resume by beat cleanup')
WHERE (
status = 'running'
AND heartbeat_at
< NOW() - CAST(:running_threshold AS interval)
)
OR (
status = 'paused'
AND heartbeat_at
< NOW() - CAST(:paused_threshold AS interval)
)
OR (
-- issue #1655: ре-enqueue'нутый job застрял в 'queued'
-- (потерянное broker-сообщение). heartbeat_at IS NOT NULL
-- отсекает свежесозданные jobs с ещё-в-полёте apply_async.
status = 'queued'
AND heartbeat_at IS NOT NULL
AND heartbeat_at
< NOW() - CAST(:queued_threshold AS interval)
)
RETURNING job_id
"""
),
{
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
"queued_threshold": _ZOMBIE_QUEUED_THRESHOLD,
},
)
.mappings()
.all()
)
db.commit()
resumed = [int(r["job_id"]) for r in rows]
for jid in resumed:
try:
process_nspd_geo_job.apply_async(args=[jid], queue="geo")
logger.info("cleanup_zombies: re-enqueued job=%s", jid)
except Exception as e:
logger.warning("cleanup_zombies: enqueue job=%s failed: %s", jid, e)
if resumed:
logger.info("cleanup_zombies: resumed %d zombie job(s): %s", len(resumed), resumed)
except Exception as e:
logger.exception("cleanup_zombies failed: %s", e)
try:
db.rollback()
except Exception:
pass
finally:
db.close()
return {"resumed_count": len(resumed), "resumed_job_ids": resumed}