ForcePilot/backend/package/yuxi/channels/adapters/synologychat/lease.py
Kris 69f4319023 feat(synologychat): 完成Synology Chat适配器的多维度优化
此提交对Synology Chat适配器进行了全面改进:
1. 新增消息去重、分布式轮询租约、bot名称配置等功能
2. 优化URL提取逻辑,自动清理尾部标点符号
3. 重构发送逻辑,提取通用重试工具函数并优化SID缓存
4. 完善文档提示与配置项,新增轮询租约类型支持
5. 修复认证API路径硬编码问题,调整交互组件提示文案
6. 增加Webhook模式下的DSM客户端兜底初始化
7. 优化导入顺序与代码结构,清理冗余空行
2026-05-13 16:15:22 +08:00

141 lines
4.4 KiB
Python

"""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()}"
self._last_known_held: bool = False
async def acquire(self) -> bool:
try:
result = await self._redis.set(self._lease_key, self._instance_id, nx=True, ex=self._ttl)
self._last_known_held = bool(result)
return self._last_known_held
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)
self._last_known_held = False
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)
self._last_known_held = bool(result)
return self._last_known_held
except Exception as e:
logger.warning(f"[SynologyChat] Redis lease renew failed: {e}")
return False
@property
def is_held(self) -> bool:
return self._last_known_held
async def check_held(self) -> bool:
try:
current = await self._redis.get(self._lease_key)
self._last_known_held = current is not None and current.decode() == self._instance_id
return self._last_known_held
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")