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
Имплементация фиксов 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
172 lines
6.3 KiB
Python
172 lines
6.3 KiB
Python
"""LEGACY/BOOTSTRAP Celery task: ETL Антоновского SQLite → наша PG.
|
||
|
||
⚠️ В обычной работе НЕ нужен — наш `tasks.scrape_objective.sync_all_groups`
|
||
тянет данные с api.objctv.ru напрямую в PostgreSQL (см. celery_app.py beat).
|
||
|
||
Этот ETL остаётся как ОДНОРАЗОВЫЙ инструмент для:
|
||
- bootstrap PG из Антоновского SQLite (например при первом запуске,
|
||
до того как наш beat отработал)
|
||
- аварийного восстановления если наш sync долго не работал, а Антоновский
|
||
/sf/ продолжал тянуть свою SQLite
|
||
|
||
Триггерится только вручную через POST /api/v1/admin/scrape/objective (UI).
|
||
Beat-расписания НЕТ.
|
||
|
||
Файл-источник: settings.objective_anton_sqlite_path
|
||
(на проде смонтирован bind-mount-ом docker-compose из /opt/gendesign/site-finder).
|
||
|
||
Логирует прогресс в objective_scrape_runs с group_name='anton-etl'.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.core.db import SessionLocal
|
||
from app.services.objective_etl import get_sqlite_info, run_etl
|
||
from app.workers.celery_app import celery_app
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _start_run(db: Session, triggered_by: str) -> int:
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO objective_scrape_runs (group_name, triggered_by, status)
|
||
VALUES (:gn, :tb, 'running')
|
||
RETURNING run_id
|
||
"""
|
||
),
|
||
{"gn": "anton-etl", "tb": triggered_by},
|
||
).scalar_one()
|
||
db.commit()
|
||
return int(row)
|
||
|
||
|
||
def _heartbeat(db: Session, run_id: int, **counts: int) -> None:
|
||
sets = ["heartbeat_at = NOW()"]
|
||
params: dict[str, Any] = {"rid": run_id}
|
||
for k, v in counts.items():
|
||
sets.append(f"{k} = :{k}")
|
||
params[k] = v
|
||
try:
|
||
db.execute(
|
||
text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
|
||
params,
|
||
)
|
||
db.commit()
|
||
except Exception as e:
|
||
logger.warning("heartbeat failed for run=%s: %s", run_id, e)
|
||
try:
|
||
db.rollback()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _finish_run(
|
||
db: Session, run_id: int, *, status: str, error: str | None = None, **counts: int
|
||
) -> None:
|
||
sets = ["finished_at = NOW()", "status = :status", "error = :error"]
|
||
params: dict[str, Any] = {"rid": run_id, "status": status, "error": error}
|
||
for k, v in counts.items():
|
||
sets.append(f"{k} = :{k}")
|
||
params[k] = v
|
||
db.execute(
|
||
text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
|
||
params,
|
||
)
|
||
db.commit()
|
||
|
||
|
||
@celery_app.task(
|
||
bind=True,
|
||
name="tasks.objective_etl.import_anton_objective",
|
||
max_retries=0, # ETL идемпотентный, но при сбое лучше человек посмотрит
|
||
)
|
||
def import_anton_objective(
|
||
self: Any,
|
||
triggered_by: str = "manual",
|
||
sqlite_path: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Полный ETL из Антоновского SQLite в нашу PG.
|
||
|
||
Args:
|
||
triggered_by: 'manual' | 'beat' | 'api' — для журнала
|
||
sqlite_path: переопределить settings.objective_anton_sqlite_path
|
||
|
||
Возвращает counts по таблицам + run_id.
|
||
"""
|
||
sqlite_path_eff = sqlite_path or settings.objective_anton_sqlite_path
|
||
db = SessionLocal()
|
||
run_id: int | None = None
|
||
try:
|
||
run_id = _start_run(db, triggered_by)
|
||
logger.info("import_anton_objective started, run_id=%s, sqlite=%s", run_id, sqlite_path_eff)
|
||
|
||
# Преобразуем sqlalchemy-style db_url в обычный postgresql://
|
||
# (psycopg.connect принимает postgresql://, не postgresql+psycopg://).
|
||
db_url = settings.database_url.replace("postgresql+psycopg://", "postgresql://")
|
||
|
||
# Прогресс-callback пишет в БД счётчики + heartbeat
|
||
# Stages: mapping_start/done, crm_start/progress/done, lots_start/progress/done
|
||
def _cb(stage: str, n: int) -> None:
|
||
kw: dict[str, int] = {}
|
||
if stage.startswith("mapping"):
|
||
kw["rows_history"] = n # переиспользуем поле; mapping count
|
||
elif stage.startswith("crm"):
|
||
kw["rows_corpus_room"] = n
|
||
elif stage.startswith("lots"):
|
||
kw["rows_lots"] = n
|
||
_heartbeat(db, run_id, **kw)
|
||
|
||
result = run_etl(
|
||
sqlite_path_eff,
|
||
db_url,
|
||
batch_size=2000,
|
||
progress_cb=_cb,
|
||
)
|
||
|
||
_finish_run(
|
||
db,
|
||
run_id,
|
||
status="done",
|
||
error=None,
|
||
rows_lots=result["lots"],
|
||
rows_corpus_room=result["corp_room_month"],
|
||
rows_history=result["mappings"], # переиспользуем поле под mappings
|
||
reports_ok=3,
|
||
reports_failed=0,
|
||
)
|
||
logger.info("import_anton_objective done, run_id=%s, result=%s", run_id, result)
|
||
return {"run_id": run_id, "triggered_by": triggered_by, **result}
|
||
|
||
except FileNotFoundError as e:
|
||
logger.error("SQLite not found: %s", e)
|
||
if run_id:
|
||
try:
|
||
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
|
||
except Exception:
|
||
pass
|
||
# Логируем полезную диагностику, но НЕ возвращаем dict —
|
||
# re-raise, чтобы Celery-таск ушёл в FAILURE (не SUCCESS) и не
|
||
# рассинхронился с objective_scrape_runs.status='failed' (#1623).
|
||
info = get_sqlite_info(sqlite_path_eff)
|
||
logger.error("sqlite_not_found diagnostics for run=%s: %s", run_id, info)
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.exception("import_anton_objective failed: %s", e)
|
||
if run_id:
|
||
try:
|
||
_finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}")
|
||
except Exception:
|
||
pass
|
||
raise
|
||
finally:
|
||
db.close()
|