ГЛАВНЫЙ unblock. Эмпирически (prod worker):
• flats endpoint /portal-kn/api/sales/portal/table @ concurrency=2 — 0 WAF-бан
на 250 объектах / 251 req, 77163 flat-строк (vs текущие 9 во всём sweep);
• extras /сервисы/api/object/{id}/* отдают 403 СРАЗУ (volume-independent, мертвы
с 2026-06-03) И травят cookies сессии → последующие flats на ней тоже 403.
flats_count рухнул 3670→9 именно из-за shared-session отравления.
Fix: flats и extras больше НИКОГДА не делят сессию.
- run_region_sweep(extras_isolated=True, дефолт): warm flats-сессия тянет ТОЛЬКО
flats (extras на ней не вызываются) → flats_count восстанавливается; extras
уходят отдельным best-effort проходом _run_extras_pass с recycle BrowserSession
на каждый WAF-403 (яд не накапливается, flats-сессия уже закрыта).
- _run_extras_pass: ранний обрыв после N подряд полностью-WAF объектов
(_EXTRAS_ABORT_AFTER_CONSEC_WAF=5) — extras мертвы целиком, нет смысла жечь
~1500 релончей браузера. Любой сбой паса → log, run НЕ падает.
- extras_isolated=False → старое combined-поведение (poison-prone).
- config: scrape_kn_extras_isolated=True (ENV SCRAPE_KN_EXTRAS_ISOLATED);
прокинут через оба worker call-site.
- рефактор: _process_flats_result / _process_extras_result / _extras_coros
делят upsert-логику между combined-loop и isolated-pass.
- tests: _extras_coros без flats, _is_waf_poisoned, recycle-на-poison,
early-abort на wholesale-WAF.
Сохраняет throttle + optional-proxy из предыдущего коммита.
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Celery task wrapper for naш.дом.рф kn-API sweeps."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import random
|
||
import time
|
||
import uuid
|
||
from collections.abc import Iterator
|
||
from contextlib import contextmanager
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import redis
|
||
|
||
from app.core.config import settings
|
||
from app.services.scrapers.domrf_kn import run_region_sweep
|
||
from app.workers.celery_app import celery_app
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Lock TTL — full Свердл sweep с extras занимает ~25-30 мин. Держим 45 мин —
|
||
# полтора max sweep duration: запас на хвостовые retry внутри sweep'а и не
|
||
# даём lock'у истечь под живым worker'ом (фикс P2 issue #1216 — раньше TTL
|
||
# равнялся sweep duration → race на границе).
|
||
# Если worker умер с lock'ом — он истечёт за 45 мин, а не за 2 часа (раньше),
|
||
# heartbeat-based autoclean в /queue endpoint всё равно подхватит зомби-runs.
|
||
_LOCK_TTL_SECONDS = 45 * 60
|
||
|
||
# Lua check-and-delete: освобождаем lock ТОЛЬКО если value совпадает с нашим
|
||
# owner-токеном. Защита от P2 race: TTL может истечь под живым sweep'ом, второй
|
||
# sweep встанет под тем же key с НОВЫМ токеном; финализация первого без guard
|
||
# снесла бы чужой lock → возможен третий параллельный sweep (issue #1216).
|
||
_RELEASE_LOCK_LUA = (
|
||
'if redis.call("GET", KEYS[1]) == ARGV[1] '
|
||
'then return redis.call("DEL", KEYS[1]) '
|
||
"else return 0 end"
|
||
)
|
||
|
||
|
||
def _lock_key(region_code: int, developers: list[str] | None) -> str:
|
||
devs_key = ",".join(developers) if developers else "*"
|
||
return f"scrape:kn:lock:{region_code}:{devs_key}"
|
||
|
||
|
||
def _log_task_received(region_code: int, devs: list[str] | None, task_id: str | None) -> None:
|
||
"""Записать в kn_scrape_log что worker получил задачу. Без run_id —
|
||
он появится позже в run_region_sweep. Best-effort: SQL ошибки гасим."""
|
||
from sqlalchemy import text
|
||
|
||
from app.core.db import SessionLocal
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO kn_scrape_log (run_id, level, stage, message)
|
||
VALUES (NULL, 'info', 'task_received', :msg)
|
||
"""
|
||
),
|
||
{
|
||
"msg": (
|
||
f"Worker подхватил task_id={task_id or '?'}, region={region_code},"
|
||
f" devs={devs or '*'}"
|
||
)[:500],
|
||
},
|
||
)
|
||
db.commit()
|
||
except Exception as e:
|
||
logger.warning("log_task_received failed: %s", e)
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def _log_task_skipped(
|
||
region_code: int, devs: list[str] | None, task_id: str | None, reason: str
|
||
) -> None:
|
||
from sqlalchemy import text
|
||
|
||
from app.core.db import SessionLocal
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO kn_scrape_log (run_id, level, stage, message)
|
||
VALUES (NULL, 'warn', 'task_skipped', :msg)
|
||
"""
|
||
),
|
||
{
|
||
"msg": (
|
||
f"task_id={task_id or '?'} skipped: {reason}, region={region_code},"
|
||
f" devs={devs or '*'}"
|
||
)[:500],
|
||
},
|
||
)
|
||
db.commit()
|
||
except Exception as e:
|
||
logger.warning("log_task_skipped failed: %s", e)
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def force_release_lock(region_code: int, developers: list[str] | None) -> bool:
|
||
"""Принудительно удалить Redis-lock (для emergency manual trigger,
|
||
когда зомби-worker оставил key). Возвращает True если ключ был."""
|
||
key = _lock_key(region_code, developers)
|
||
r = redis.Redis.from_url(settings.redis_url)
|
||
try:
|
||
return bool(r.delete(key))
|
||
except Exception as e:
|
||
logger.warning("force_release_lock %s failed: %s", key, e)
|
||
return False
|
||
|
||
|
||
@contextmanager
|
||
def _region_lock(region_code: int, developers: list[str] | None) -> Iterator[bool]:
|
||
"""Redis singleton lock so beat double-fire or two workers can't run the
|
||
same (region, devs) sweep concurrently. SET NX EX is atomic.
|
||
|
||
Owner-token (uuid4 hex) кладём в value и снимаем lock через Lua
|
||
check-and-delete — иначе истекший по TTL lock, перехваченный другим
|
||
sweep'ом, был бы снесён первым worker'ом при finally → возможен третий
|
||
параллельный sweep (P2 race, issue #1216).
|
||
"""
|
||
key = _lock_key(region_code, developers)
|
||
token = uuid.uuid4().hex
|
||
r = redis.Redis.from_url(settings.redis_url)
|
||
acquired = r.set(key, token, nx=True, ex=_LOCK_TTL_SECONDS)
|
||
try:
|
||
yield bool(acquired)
|
||
finally:
|
||
if acquired:
|
||
try:
|
||
# Lua check-and-delete: атомарно, мимо python GIL → нельзя
|
||
# снести чужой lock даже если наш TTL уже истёк.
|
||
r.eval(_RELEASE_LOCK_LUA, 1, key, token)
|
||
except Exception as e:
|
||
# Глотаем намеренно: lock истечёт сам по TTL. Логируем,
|
||
# чтобы не молча терять Redis-фейлы (не bare except).
|
||
logger.warning("release lock %s failed: %s", key, e)
|
||
|
||
|
||
@celery_app.task(bind=True, name="tasks.scrape_kn.scrape_kn_region", max_retries=2)
|
||
def scrape_kn_region(
|
||
self: Any,
|
||
region_code: int,
|
||
developers: list[str] | None = None,
|
||
skip_jitter: bool = False,
|
||
extras: bool = True,
|
||
download_photos: bool = False,
|
||
fetch_flats: bool = True,
|
||
) -> dict[str, Any]:
|
||
"""Run a full kn-API sweep for one region. Returns summary dict.
|
||
|
||
For scheduled runs (skip_jitter=False), sleeps a random 0..SCRAPE_KN_JITTER_SECONDS
|
||
interval before starting so multiple workers / repeated weekly cycles don't hit
|
||
DOM.РФ at exactly the same minute. Manual triggers pass skip_jitter=True for
|
||
immediate execution.
|
||
"""
|
||
# Сразу пишем «task received» в kn_scrape_log БЕЗ run_id (run_id ещё не
|
||
# создан — он появится в run_region_sweep). Это позволяет UI видеть
|
||
# «worker подхватил задачу» даже если потом сработает lock_held или
|
||
# упадём ещё до Phase A.
|
||
_log_task_received(region_code, developers, self.request.id)
|
||
|
||
if not skip_jitter and settings.scrape_kn_jitter_seconds > 0:
|
||
delay = random.uniform(0, settings.scrape_kn_jitter_seconds)
|
||
logger.info("jitter delay %.0fs before scrape", delay)
|
||
time.sleep(delay)
|
||
|
||
state_path: str | None = settings.scrape_kn_state_path or None
|
||
if state_path and not Path(state_path).exists():
|
||
logger.warning("storage_state %s not found — bootstrap will run cold", state_path)
|
||
state_path = None
|
||
|
||
with _region_lock(region_code, developers) as got_lock:
|
||
if not got_lock:
|
||
logger.warning(
|
||
"scrape_kn_region SKIPPED region=%s devs=%s — another sweep is"
|
||
" already running (Redis lock held)",
|
||
region_code,
|
||
developers,
|
||
)
|
||
_log_task_skipped(region_code, developers, self.request.id, "lock_held")
|
||
return {
|
||
"skipped": True,
|
||
"reason": "lock_held",
|
||
"region_code": region_code,
|
||
"developers": developers,
|
||
"lock_key": _lock_key(region_code, developers),
|
||
}
|
||
|
||
logger.info(
|
||
"scrape_kn_region start region=%s devs=%s extras=%s photos=%s state=%s",
|
||
region_code,
|
||
developers,
|
||
extras,
|
||
download_photos,
|
||
state_path,
|
||
)
|
||
return asyncio.run(
|
||
run_region_sweep(
|
||
region_code=region_code,
|
||
developers=developers,
|
||
load_state=state_path,
|
||
fetch_flats=fetch_flats,
|
||
extras=extras,
|
||
download_photos_binary=download_photos,
|
||
# #1945 anti-ban: throttle + optional proxy + flats/extras isolation.
|
||
browser_concurrency=settings.scrape_kn_browser_concurrency,
|
||
request_jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
|
||
request_jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
|
||
proxy_url=settings.scrape_kn_proxy_url,
|
||
extras_isolated=settings.scrape_kn_extras_isolated,
|
||
)
|
||
)
|
||
|
||
|
||
@celery_app.task(bind=True, name="tasks.scrape_kn.resume_kn_run", max_retries=12)
|
||
def resume_kn_run(self: Any, run_id: int) -> dict[str, Any]:
|
||
"""Resume a previously interrupted sweep using objects_snapshot +
|
||
progress_obj_index from kn_scrape_runs. Triggered by worker_ready hook
|
||
when a stale 'running' row is found (heartbeat >5 min).
|
||
|
||
На lock_held делаем self.retry(countdown=300, max_retries=12) — окно
|
||
в ~час суммарно. Без retry resume пропускался до недельного beat
|
||
(P2 issue #1216): worker_ready помечал run 'zombie' и enqueue'ил resume
|
||
без countdown; live sweep держал lock → resume получал skipped и run
|
||
терялся до следующего планового запуска.
|
||
"""
|
||
from sqlalchemy import text
|
||
|
||
from app.core.db import SessionLocal
|
||
|
||
state_path: str | None = settings.scrape_kn_state_path or None
|
||
if state_path and not Path(state_path).exists():
|
||
state_path = None
|
||
|
||
# Reconstruct region/devs from the stale row to scope the lock correctly.
|
||
db = SessionLocal()
|
||
try:
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT region_codes, developer_ids
|
||
FROM kn_scrape_runs
|
||
WHERE run_id = :rid
|
||
"""
|
||
),
|
||
{"rid": run_id},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
finally:
|
||
db.close()
|
||
if not row:
|
||
return {"skipped": True, "reason": "run_not_found", "run_id": run_id}
|
||
|
||
region_codes = list(row["region_codes"] or [])
|
||
developers = list(row["developer_ids"]) if row["developer_ids"] else None
|
||
if not region_codes:
|
||
return {"skipped": True, "reason": "no_region", "run_id": run_id}
|
||
region_code = region_codes[0]
|
||
|
||
_log_task_received(region_code, developers, self.request.id)
|
||
|
||
with _region_lock(region_code, developers) as got_lock:
|
||
if not got_lock:
|
||
logger.warning(
|
||
"resume_kn_run lock_held run=%s region=%s — retry in 5min"
|
||
" (live sweep still holds the lock)",
|
||
run_id,
|
||
region_code,
|
||
)
|
||
_log_task_skipped(region_code, developers, self.request.id, "lock_held_retry")
|
||
# 5 мин × 12 retries = 60 мин окно. Покрывает full sweep
|
||
# duration (~30 мин) + запас. Celery поднимет Retry exception,
|
||
# task поставится обратно в очередь с countdown=300.
|
||
raise self.retry(
|
||
exc=RuntimeError(f"lock_held for run={run_id} region={region_code}"),
|
||
countdown=300,
|
||
max_retries=12,
|
||
)
|
||
|
||
logger.info("resume_kn_run start run=%s region=%s devs=%s", run_id, region_code, developers)
|
||
return asyncio.run(
|
||
run_region_sweep(
|
||
region_code=region_code,
|
||
developers=developers,
|
||
load_state=state_path,
|
||
resume_from_run_id=run_id,
|
||
# #1945 anti-ban: throttle + optional proxy + flats/extras isolation.
|
||
browser_concurrency=settings.scrape_kn_browser_concurrency,
|
||
request_jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
|
||
request_jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
|
||
proxy_url=settings.scrape_kn_proxy_url,
|
||
extras_isolated=settings.scrape_kn_extras_isolated,
|
||
)
|
||
)
|