from __future__ import annotations import asyncio import hashlib import os from typing import Any from yuxi.utils.logging_config import logger class PollingLease: def __init__(self, config: dict[str, Any] | None = None): cfg = config or {} self._token_hash = hashlib.sha256(cfg.get("bot_token", "").encode()).hexdigest()[:8] self._instance_id = os.urandom(6).hex() self._lease_ttl = cfg.get("polling_lease_ttl", 30) self._acquired = False self._renew_task: asyncio.Task | None = None @property def is_acquired(self) -> bool: return self._acquired async def acquire(self) -> bool: try: from yuxi.storage.redis import get_redis redis = get_redis() except ImportError: logger.error( "[Telegram] Redis not available - lease disabled. " "Multiple instances may poll simultaneously, causing duplicate message processing." ) self._acquired = True return True lease_key = f"telegram:polling_lease:{self._token_hash}" try: acquired = await redis.set(lease_key, self._instance_id, nx=True, ex=self._lease_ttl) except Exception as e: logger.error( f"[Telegram] Redis lease check failed: {e}. " "Proceeding without lease - multiple instances may poll simultaneously." ) self._acquired = True return True if acquired: self._acquired = True self._renew_task = asyncio.create_task(self._renew_loop(lease_key)) logger.info(f"[Telegram] Polling lease acquired (instance={self._instance_id}, ttl={self._lease_ttl}s)") return True logger.debug("[Telegram] Polling lease held by another instance") return False async def release(self) -> None: self._acquired = False if self._renew_task and not self._renew_task.done(): self._renew_task.cancel() try: await self._renew_task except asyncio.CancelledError: pass self._renew_task = None try: from yuxi.storage.redis import get_redis redis = get_redis() lease_key = f"telegram:polling_lease:{self._token_hash}" await redis.delete(lease_key) except Exception: pass async def _renew_loop(self, key: str) -> None: try: from yuxi.storage.redis import get_redis redis = get_redis() except ImportError: return while self._acquired: await asyncio.sleep(self._lease_ttl * 0.5) try: await redis.expire(key, self._lease_ttl) except asyncio.CancelledError: break except Exception: pass