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
383 lines
15 KiB
Python
383 lines
15 KiB
Python
"""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]
|