gendesign/tradein-mvp/backend/app/services/scrape_runs.py
bot-backend 89d05bbfec
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / test (push) Successful in 2m24s
Deploy Trade-In / build-backend (push) Successful in 54s
Deploy Trade-In / deploy (push) Successful in 49s
fix(tradein): persist scrape_runs.total_seen/new_count from sweep counters (#1926) (#1928)
2026-06-26 18:09:17 +00:00

383 lines
15 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 _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
Все sweep-counters (YandexCitySweepCounters / CitySweepCounters / …) пишут число
объявлений в выдаче как ``lots_fetched`` и число впервые вставленных как
``lots_inserted``. Раньше эти значения попадали ТОЛЬКО в counters jsonb, а
выделенные колонки total_seen/new_count оставались 0 → admin/observability
показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926).
Приоритет ключей:
- total_seen ← 'total_seen' (если уже есть в counters) иначе 'lots_fetched'
- new_count ← 'new_count' (если уже есть) иначе 'lots_inserted'
Возвращает (total_seen, new_count); None для ключа, которого нет в counters —
тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).
"""
def _pick(*keys: str) -> int | None:
for key in keys:
val = counters.get(key)
if val is not None:
try:
return int(val)
except (TypeError, ValueError):
return None
return None
return _pick("total_seen", "lots_fetched"), _pick("new_count", "lots_inserted")
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 + total_seen/new_count колонки.
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и
пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926).
COALESCE: если ключа нет в counters — старое значение колонки сохраняется.
"""
total_seen, new_count = _column_counts(counters)
db.execute(
text(
"""
UPDATE scrape_runs
SET heartbeat_at = NOW(),
counters = CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id
"""
),
{
"run_id": run_id,
"counters": json.dumps(counters),
"total_seen": total_seen,
"new_count": new_count,
},
)
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 + total_seen/new_count.
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
в выделенные колонки — иначе admin/observability показывает 0 (audit #1926).
"""
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(
"""
UPDATE scrape_runs
SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(),
counters = CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id AND status = 'running'
RETURNING id
"""
),
{
"run_id": run_id,
"counters": json.dumps(counters),
"total_seen": total_seen,
"new_count": new_count,
},
).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
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(
"""
UPDATE scrape_runs
SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(),
error = :error, counters = CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id AND status = 'running'
RETURNING id
"""
),
{
"run_id": run_id,
"error": error[:1000],
"counters": json.dumps(counters),
"total_seen": total_seen,
"new_count": new_count,
},
).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
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(
"""
UPDATE scrape_runs
SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
error = :error, counters = CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id AND status = 'running'
RETURNING id
"""
),
{
"run_id": run_id,
"error": error[:1000],
"counters": json.dumps(counters),
"total_seen": total_seen,
"new_count": new_count,
},
).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]
def list_all(
db: Session,
*,
source: str | None = None,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[int, list[dict[str, Any]]]:
"""Unified-список runs по всем source'ам с опц. фильтрами source/status.
Возвращает (total, rows): total — кол-во строк под фильтром (без limit/offset),
rows — текущая страница (ORDER BY started_at DESC, LIMIT/OFFSET).
Используется единой scrapers-страницей вместо 3 per-source /runs.
"""
where = ["1=1"]
params: dict[str, Any] = {"limit": limit, "offset": offset}
if source is not None:
where.append("source = :source")
params["source"] = source
if status is not None:
where.append("status = :status")
params["status"] = status
where_sql = " AND ".join(where)
total_row = db.execute(
text(f"SELECT COUNT(*) AS n FROM scrape_runs WHERE {where_sql}"),
params,
).fetchone()
total = int(total_row.n) if total_row is not None else 0
rows = (
db.execute(
text(
f"""
SELECT id AS run_id, source, run_type, status, params, counters,
total_seen, new_count, started_at, finished_at,
heartbeat_at, error AS error_text
FROM scrape_runs
WHERE {where_sql}
ORDER BY started_at DESC NULLS LAST
LIMIT CAST(:limit AS int) OFFSET CAST(:offset AS int)
"""
),
params,
)
.mappings()
.all()
)
return total, [dict(r) for r in rows]