"""Distributed lease for Synology Chat polling coordination. When multiple instances are deployed, prevents duplicate polling operations by acquiring a distributed lock before starting the polling loop. Supports both in-memory (single-instance) and Redis-based (multi-instance) modes. """ from __future__ import annotations import asyncio import time from typing import Any from yuxi.utils.logging_config import logger _LEASE_TTL_SECONDS = 30 _LEASE_RENEW_INTERVAL = 10 class PollingLease: """In-memory polling lease for single-instance deployment.""" def __init__(self, ttl_seconds: int = _LEASE_TTL_SECONDS): self._ttl = ttl_seconds self._acquired: bool = False self._expires_at: float = 0.0 async def acquire(self) -> bool: if self._acquired and time.monotonic() < self._expires_at: return False self._acquired = True self._expires_at = time.monotonic() + self._ttl return True async def release(self) -> None: self._acquired = False async def renew(self) -> bool: if not self._acquired: return False self._expires_at = time.monotonic() + self._ttl return True @property def is_held(self) -> bool: return self._acquired and time.monotonic() < self._expires_at class RedisPollingLease: """Redis-based distributed polling lease for multi-instance deployment.""" def __init__( self, redis_client: Any, lease_key: str = "synologychat:polling:lease", ttl_seconds: int = _LEASE_TTL_SECONDS ): self._redis = redis_client self._lease_key = lease_key self._ttl = ttl_seconds self._instance_id = f"{id(self)}:{time.time()}" async def acquire(self) -> bool: try: result = await self._redis.set(self._lease_key, self._instance_id, nx=True, ex=self._ttl) return bool(result) except Exception as e: logger.warning(f"[SynologyChat] Redis lease acquire failed: {e}") return False async def release(self) -> None: try: script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ await self._redis.eval(script, 1, self._lease_key, self._instance_id) except Exception as e: logger.warning(f"[SynologyChat] Redis lease release failed: {e}") async def renew(self) -> bool: try: script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end """ result = await self._redis.eval(script, 1, self._lease_key, self._instance_id, self._ttl) return bool(result) except Exception as e: logger.warning(f"[SynologyChat] Redis lease renew failed: {e}") return False @property def is_held(self) -> bool: return True async def check_held(self) -> bool: try: current = await self._redis.get(self._lease_key) return current is not None and current.decode() == self._instance_id except Exception: return False async def with_polling_lease( lease: PollingLease | RedisPollingLease, poll_fn, renew_interval: int = _LEASE_RENEW_INTERVAL, ) -> None: if not await lease.acquire(): logger.info("[SynologyChat] Polling lease not acquired, another instance may be polling") return logger.info("[SynologyChat] Polling lease acquired") renew_task: asyncio.Task | None = None try: async def _renew_loop(): while True: await asyncio.sleep(renew_interval) if not await lease.renew(): logger.warning("[SynologyChat] Failed to renew polling lease") renew_task = asyncio.create_task(_renew_loop()) await poll_fn() finally: if renew_task: renew_task.cancel() await lease.release() logger.info("[SynologyChat] Polling lease released")