"""Redis-based distributed locking for task coordination.""" import logging import os import uuid from contextlib import contextmanager from typing import Generator import redis logger = logging.getLogger("uvicorn.error") # Lua compare-and-delete script: only DEL the key if its current value # matches our owner token. This prevents a process that lost the lock # (e.g. via TTL expiry) from accidentally releasing a different acquirer's # lock. _RELEASE_SCRIPT = ( "if redis.call('GET', KEYS[1]) == ARGV[1] then " "return redis.call('DEL', KEYS[1]) " "else return 0 end" ) def get_redis_client() -> redis.Redis: """Get Redis client from Celery broker URL.""" broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") # socket_keepalive + health_check_interval keep the connection alive # across the Redis HAProxy 30s idle timeout (see celery_app.py). return redis.from_url( broker_url, decode_responses=True, socket_keepalive=True, health_check_interval=25, ) @contextmanager def redis_lock( lock_name: str, timeout: int = 3600 * 4 ) -> Generator[bool, None, None]: """Distributed lock using Redis with an owner-fencing token. Args: lock_name: Unique name for the lock timeout: Lock expiration time in seconds (default: 4 hours) Yields: bool: True if lock was acquired, False otherwise Example: with redis_lock("scrape_listings") as acquired: if not acquired: logger.warning("Another scrape is already running") return # ... do work ... """ client = get_redis_client() lock_key = f"lock:{lock_name}" # Per-acquirer fencing token: only the holder can release the lock. owner_token = uuid.uuid4().hex # Try to acquire the lock; store the owner token as the value. acquired = client.set(lock_key, owner_token, nx=True, ex=timeout) try: yield bool(acquired) finally: # Release the lock only if we acquired it AND we still own it. # The Lua compare-and-delete guards against the case where our TTL # expired and a different process picked up the lock; we won't # accidentally delete their key. if acquired: try: release = client.register_script(_RELEASE_SCRIPT) release(keys=[lock_key], args=[owner_token]) logger.info(f"Released lock: {lock_name}") except redis.RedisError as e: logger.warning(f"Failed to release lock {lock_name}: {e}")