Root: city-sweep counters (yandex/avito/cian/domclick/newbuilding) wrote lots_fetched/lots_inserted only into scrape_runs.counters jsonb; the dedicated columns total_seen/new_count (schema 015) were never SET, so admin/observability reported total_seen=0 despite real rows saved (audit #1871/#1926). Fix (Option A): single-place extraction in scrape_runs.py via _column_counts() maps counters lots_fetched->total_seen and lots_inserted->new_count (with explicit total_seen/new_count keys taking priority). update_heartbeat, mark_done, mark_failed and mark_banned now SET both columns with COALESCE(CAST(:x AS int), col) so a missing key preserves the existing value. Covers all sweep sources at once since every pipeline routes counters through these finalizers.
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]
|