206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
# Celery-задачи для синхронизации автомобилей и листинга IAAI.
|
||
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
import json
|
||
import logging
|
||
|
||
from celery import shared_task
|
||
from redis import Redis
|
||
|
||
from ..core.config import Settings
|
||
from ..scraper import IAAIScraper
|
||
from ..storage.db import PersistenceService
|
||
|
||
logger = logging.getLogger("iaai_scraper.worker.tasks")
|
||
|
||
SYNC_LISTING_LOCK_KEY = "iaai:locks:sync_listing"
|
||
|
||
|
||
def _run_browser_job(func, *args, **kwargs):
|
||
# Playwright Sync API нельзя запускать в потоке с активным asyncio loop.
|
||
# Celery/зависимости могут поднимать loop в worker-процессе, поэтому
|
||
# браузерный код выполняем в отдельном thread без loop.
|
||
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="iaai-browser") as executor:
|
||
future = executor.submit(func, *args, **kwargs)
|
||
return future.result()
|
||
|
||
|
||
def _sync_listing_lock_ttl_seconds() -> int:
|
||
settings = Settings()
|
||
# Небольшой запас к hard time limit задачи, чтобы lock самоснимался после сбоев.
|
||
return max(settings.celery.task_time_limit + 120, 300)
|
||
|
||
|
||
def _get_persistence() -> PersistenceService:
|
||
return PersistenceService(Settings())
|
||
|
||
|
||
def _get_redis() -> Redis:
|
||
settings = Settings()
|
||
return Redis.from_url(settings.redis.url, decode_responses=True)
|
||
|
||
|
||
def _release_lock_if_owner(redis_client: Redis, key: str, owner: str) -> None:
|
||
try:
|
||
current_owner = redis_client.get(key)
|
||
if current_owner == owner:
|
||
redis_client.delete(key)
|
||
except Exception as exc:
|
||
logger.warning("Failed to release lock %s: %s", key, exc)
|
||
|
||
|
||
def _has_other_active_sync_listing_task(task) -> bool:
|
||
try:
|
||
inspector = task.app.control.inspect(timeout=1.0)
|
||
active_map = inspector.active() or {}
|
||
except Exception as exc:
|
||
logger.warning("Failed to inspect active tasks: %s", exc)
|
||
return False
|
||
|
||
current_task_id = task.request.id
|
||
for worker_tasks in active_map.values():
|
||
for item in worker_tasks or []:
|
||
name = str(item.get("name") or "")
|
||
task_id = str(item.get("id") or "")
|
||
if (
|
||
name == "iaai_scraper.worker.tasks.sync_listing_task"
|
||
and task_id
|
||
and task_id != current_task_id
|
||
):
|
||
return True
|
||
return False
|
||
|
||
|
||
@shared_task(
|
||
name="iaai_scraper.worker.tasks.sync_vehicle_task",
|
||
bind=True,
|
||
max_retries=2,
|
||
default_retry_delay=30,
|
||
acks_late=True,
|
||
)
|
||
def sync_vehicle_task(self, vehicle_url: str, lane: str = "iaai"):
|
||
# Скрапинг и upsert одного автомобиля.
|
||
persistence = _get_persistence()
|
||
persistence.create_tables()
|
||
|
||
try:
|
||
def _job():
|
||
with IAAIScraper() as scraper:
|
||
return scraper.sync_vehicle(vehicle_url, lane=lane)
|
||
|
||
result = _run_browser_job(_job)
|
||
logger.info("sync_vehicle_task completed: %s", vehicle_url)
|
||
return {
|
||
"status": "success",
|
||
"vehicle_url": vehicle_url,
|
||
"db_action": result.get("db_action"),
|
||
"images_upserted": result.get("images_upserted", 0),
|
||
}
|
||
|
||
except Exception as exc:
|
||
logger.error("sync_vehicle_task failed: %s — %s", vehicle_url, exc)
|
||
raise self.retry(exc=exc)
|
||
|
||
|
||
@shared_task(
|
||
name="iaai_scraper.worker.tasks.sync_listing_task",
|
||
bind=True,
|
||
max_retries=1,
|
||
default_retry_delay=120,
|
||
acks_late=True,
|
||
)
|
||
def sync_listing_task(
|
||
self,
|
||
make: str | None = None,
|
||
model: str | None = None,
|
||
lane: str = "iaai_cars",
|
||
limit: int | None = None,
|
||
only_new: bool | None = None,
|
||
):
|
||
# Полный цикл: листинг + sync всех найденных машин.
|
||
persistence = _get_persistence()
|
||
persistence.create_tables()
|
||
task_id = self.request.id or "unknown"
|
||
redis_client = _get_redis()
|
||
|
||
lock_acquired = False
|
||
lock_ttl = _sync_listing_lock_ttl_seconds()
|
||
try:
|
||
lock_acquired = bool(
|
||
redis_client.set(
|
||
SYNC_LISTING_LOCK_KEY,
|
||
task_id,
|
||
nx=True,
|
||
ex=lock_ttl,
|
||
)
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("Failed to acquire sync lock in Redis: %s", exc)
|
||
|
||
if not lock_acquired:
|
||
# Возможен stale lock после рестарта worker. Если активного sync_listing нет —
|
||
# снимаем lock и пытаемся взять его заново.
|
||
if not _has_other_active_sync_listing_task(self):
|
||
try:
|
||
stale_owner = redis_client.get(SYNC_LISTING_LOCK_KEY)
|
||
if stale_owner:
|
||
logger.warning(
|
||
"Removing stale sync lock held by task %s",
|
||
stale_owner,
|
||
)
|
||
redis_client.delete(SYNC_LISTING_LOCK_KEY)
|
||
lock_acquired = bool(
|
||
redis_client.set(
|
||
SYNC_LISTING_LOCK_KEY,
|
||
task_id,
|
||
nx=True,
|
||
ex=lock_ttl,
|
||
)
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("Failed to recover stale sync lock: %s", exc)
|
||
|
||
if not lock_acquired:
|
||
logger.info("sync_listing_task skipped: another sync is already running")
|
||
return {
|
||
"status": "skipped",
|
||
"reason": "sync_already_running",
|
||
"task_id": task_id,
|
||
}
|
||
|
||
try:
|
||
self.update_state(state="STARTED", meta={"stage": "sync_listing_started", "task_id": task_id})
|
||
def _job():
|
||
with IAAIScraper() as scraper:
|
||
return scraper.sync_listing(
|
||
make=make,
|
||
model=model,
|
||
lane=lane,
|
||
limit=limit,
|
||
only_new=only_new,
|
||
)
|
||
|
||
result = _run_browser_job(_job)
|
||
|
||
summary = {
|
||
"task_id": task_id,
|
||
"run_id": result.get("run_id"),
|
||
"cars_upserted": result.get("cars_upserted", 0),
|
||
"cars_failed": result.get("cars_failed", 0),
|
||
"images_upserted": result.get("images_upserted", 0),
|
||
"skipped_existing": result.get("skipped_existing", 0),
|
||
"elapsed_seconds": result.get("elapsed_seconds"),
|
||
}
|
||
logger.info(
|
||
"sync_listing_task completed: %d upserted, %d failed",
|
||
summary["cars_upserted"], summary["cars_failed"],
|
||
)
|
||
return {"status": "success", **summary}
|
||
|
||
except Exception as exc:
|
||
logger.error("sync_listing_task failed: %s", exc)
|
||
raise self.retry(exc=exc)
|
||
finally:
|
||
if lock_acquired:
|
||
_release_lock_if_owner(redis_client, SYNC_LISTING_LOCK_KEY, task_id)
|