gendesign/tradein-mvp/backend/app/services/scrape_runs.py
bot-backend 80a063b482
Some checks failed
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Failing after 24s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
feat(observability): sentry alert on N consecutive scraper failures (Refs #827) (#833)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-30 20:10:29 +00:00

257 lines
10 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.

"""Utility functions для scrape_runs table — tracking long-running pipeline runs.
Таблица scrape_runs создана в 015_scrape_runs.sql.
Расширена в 051_scrape_runs_extend.sql: params/counters/error/finished_at/cancelled.
"""
from __future__ import annotations
import json
import logging
from typing import Any
import sentry_sdk
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Количество последовательных неудачных запусков (failed/banned), при достижении
# которого отправляется Sentry alert. Алерт срабатывает ровно при N-й подряд ошибке
# (anti-spam: не на каждой последующей).
CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3
def _alert_if_consecutive_failures(db: Session, source: str) -> None:
"""Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD
завершённых запусков для данного source имеют статус 'failed' или 'banned'.
Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу — т.е. запрос
возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является
failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога.
Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный
Sentry НЕ должен нарушать вызывающий mark_* путь.
"""
n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD
try:
# Берём последние N+1 завершённых (non-running) запусков по source.
# Сортируем по finished_at DESC чтобы самые свежие шли первыми.
rows = db.execute(
text(
"""
SELECT status FROM scrape_runs
WHERE source = :source
AND status IN ('failed', 'banned', 'done', 'cancelled')
ORDER BY finished_at DESC NULLS LAST
LIMIT :limit
"""
),
{"source": source, "limit": n + 1},
).fetchall()
if len(rows) < n:
# Ещё не набралось N завершённых запусков вообще — алерт не нужен.
return
# Первые N должны быть все failed/banned.
first_n = rows[:n]
if not all(r.status in ("failed", "banned") for r in first_n):
return
# (N+1)-й запуск, если есть, тоже должен НЕ быть failed/banned — иначе мы уже
# должны были отправить алерт раньше и не стоит дублировать.
if len(rows) > n and rows[n].status in ("failed", "banned"):
return
# Стрик ровно достиг порога — отправляем алерт.
sentry_sdk.capture_message(
f"Scraper source '{source}' has {n} consecutive failed/banned runs — "
"manual intervention may be required (expired cookies / ban / broken parser).",
level="error",
)
logger.error(
"sentry alert sent: source=%s has %d consecutive failed/banned runs", source, n
)
except Exception:
pass # sentry_sdk not initialised in dev, or query failed — best-effort only
def _alert_on_run_id(db: Session, run_id: int) -> None:
"""Вспомогательная обёртка: извлекает source по run_id и вызывает
_alert_if_consecutive_failures. Best-effort — не бросает исключений.
"""
try:
row = db.execute(
text("SELECT source FROM scrape_runs WHERE id = :run_id"),
{"run_id": run_id},
).fetchone()
if row is None:
return
_alert_if_consecutive_failures(db, str(row.source))
except Exception:
pass
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
"""INSERT scrape_runs(source, status='running', params, started_at=NOW()).
run_type DEFAULT 'city_sweep' (из 051 миграции).
Returns run_id (bigint).
"""
row = db.execute(
text(
"""
INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at)
VALUES (:source, 'running', CAST(:params AS jsonb), NOW(), NOW())
RETURNING id
"""
),
{"source": source, "params": json.dumps(params, ensure_ascii=False)},
).fetchone()
db.commit()
assert row is not None, "scrape_runs INSERT returned no id"
return int(row.id)
def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None:
"""UPDATE heartbeat_at=NOW(), counters=:counters."""
db.execute(
text(
"""
UPDATE scrape_runs
SET heartbeat_at = NOW(), counters = CAST(:counters AS jsonb)
WHERE id = :run_id
"""
),
{"run_id": run_id, "counters": json.dumps(counters)},
)
db.commit()
def is_cancelled(db: Session, run_id: int) -> bool:
"""Проверить status='cancelled' (cooperative cancel в long-running pipeline)."""
row = db.execute(
text("SELECT status FROM scrape_runs WHERE id = :id"),
{"id": run_id},
).fetchone()
return row is not None and row.status == "cancelled"
def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
"""Финализация run: status='done', finished_at=NOW(), counters."""
row = db.execute(
text(
"""
UPDATE scrape_runs
SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(),
counters = CAST(:counters AS jsonb)
WHERE id = :run_id AND status = 'running'
RETURNING id
"""
),
{"run_id": run_id, "counters": json.dumps(counters)},
).first()
if row is None:
logger.warning("mark_done no-op: run_id=%d not in 'running' state", run_id)
db.commit()
def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
"""Финализация run: status='failed', error (первые 1000 символов).
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
он мог оставить сессию в error state — rollback сбрасывает состояние.
"""
try:
db.rollback() # defensive: cascade-safe if prior UPDATE left txn in error state
except Exception:
pass
row = db.execute(
text(
"""
UPDATE scrape_runs
SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(),
error = :error, counters = CAST(:counters AS jsonb)
WHERE id = :run_id AND status = 'running'
RETURNING id
"""
),
{"run_id": run_id, "error": error[:1000], "counters": json.dumps(counters)},
).first()
if row is None:
logger.warning("mark_failed no-op: run_id=%d not in 'running' state", run_id)
db.commit()
# Алерт после коммита — статус уже персистирован в БД; best-effort (не бросает).
_alert_on_run_id(db, run_id)
def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
"""Финализация run: status='banned' (IP заблокирован Avito — 403/captcha).
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
Отличается от 'failed': это external constraint, не наш bug. Cooldown 2-4 часа.
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
он мог оставить сессию в error state — rollback сбрасывает состояние.
"""
try:
db.rollback() # defensive: cascade-safe if prior UPDATE left txn in error state
except Exception:
pass
row = db.execute(
text(
"""
UPDATE scrape_runs
SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
error = :error, counters = CAST(:counters AS jsonb)
WHERE id = :run_id AND status = 'running'
RETURNING id
"""
),
{"run_id": run_id, "error": error[:1000], "counters": json.dumps(counters)},
).first()
if row is None:
logger.warning("mark_banned no-op: run_id=%d not in 'running' state", run_id)
db.commit()
# Алерт после коммита — статус уже персистирован в БД; best-effort (не бросает).
_alert_on_run_id(db, run_id)
def mark_cancelled(db: Session, run_id: int) -> bool:
"""Set status='cancelled' если currently 'running'. Returns True если cancelled."""
result = db.execute(
text(
"""
UPDATE scrape_runs
SET status = 'cancelled', finished_at = NOW()
WHERE id = :run_id AND status = 'running'
RETURNING id
"""
),
{"run_id": run_id},
).fetchone()
db.commit()
return result is not None
def list_recent(db: Session, *, source: str, limit: int = 10) -> list[dict[str, Any]]:
"""Список последних N runs по source, в порядке убывания started_at."""
rows = (
db.execute(
text(
"""
SELECT id AS run_id, source, status, params, counters, error,
started_at, finished_at, heartbeat_at
FROM scrape_runs
WHERE source = :source
ORDER BY started_at DESC NULLS LAST
LIMIT :limit
"""
),
{"source": source, "limit": limit},
)
.mappings()
.all()
)
return [dict(r) for r in rows]