gendesign/backend/app/workers/tasks/nspd_geo.py
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

693 lines
28 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"
# ── 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) -> None:
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
"""
),
{"id": job_id},
)
db.commit()
_log(db, job_id, "info", "start", "job started/resumed")
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}
_start_job(db, job_id)
# Если в 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 снова долбил бы забаненный сервис.
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)
)
RETURNING job_id
"""
),
{
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
"paused_threshold": _ZOMBIE_PAUSED_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}