"""client_agent 空闲会话自动归档后台任务。""" from __future__ import annotations import asyncio import logging import time from .archive_lock import SessionArchiveLock from .archive_schedule import SessionArchiveSchedule logger = logging.getLogger("client_agent.idle_archive_worker") class IdleArchiveWorker: """扫描到期会话并调用统一 MemoryService 完成归档。""" RETRY_BASE_DELAY = 30 RETRY_MAX_DELAY = 15 * 60 def __init__( self, *, redis, memory_service, interval: int = 10, batch_size: int = 100, lock_ttl: int = 120, clock=time.time, ): self.redis = redis self.memory_service = memory_service self.interval = interval self.batch_size = batch_size self.clock = clock self.schedule = SessionArchiveSchedule(redis) self.lock = SessionArchiveLock(redis, default_ttl=lock_ttl) self._stop_event = asyncio.Event() async def archive_due_sessions(self) -> int: """处理一批到期会话,返回成功归档数量。""" session_ids = await self.schedule.list_due( now=self.clock(), limit=self.batch_size ) archived_count = 0 for session_id in session_ids: token = await self.lock.acquire(session_id) if token is None: continue try: if await self._archive_one(session_id): archived_count += 1 finally: await self.lock.release(session_id, token) return archived_count async def recover_due_index(self) -> int: """应用启动时从会话元数据恢复归档调度索引。""" try: return await self.schedule.rebuild_due_index() except Exception: logger.exception("failed to rebuild idle archive due index") return 0 async def _archive_one(self, session_id: str) -> bool: """归档单个会话;失败时保留消息并推迟下一次扫描。""" owner = await self.redis.get(f"session:{session_id}") if owner is None: await self.schedule.remove(session_id) return False owner = owner.decode() if isinstance(owner, bytes) else owner meta = await self.schedule.get_meta(session_id) retry_count = int(meta.get("archive_retry_count", 0) or 0) await self.schedule.mark_processing(session_id) try: warnings = await self.memory_service.close_session( customer_id=int(owner), session_id=session_id, ) if warnings: await self._schedule_retry( session_id, retry_count=retry_count + 1, error=";".join(warnings), ) return False await self.redis.delete(f"session:{session_id}") await self.schedule.remove(session_id) return True except Exception as exc: logger.exception("idle session archive failed: session_id=%s", session_id) await self._schedule_retry( session_id, retry_count=retry_count + 1, error=f"{type(exc).__name__}: {exc}", ) return False async def _schedule_retry( self, session_id: str, *, retry_count: int, error: str ) -> None: """按指数退避登记下一次归档尝试。""" delay = min( self.RETRY_BASE_DELAY * (2 ** max(0, retry_count - 1)), self.RETRY_MAX_DELAY, ) next_retry_at = self.clock() + delay await self.schedule.mark_retry( session_id, retry_count=retry_count, error=error, next_retry_at=next_retry_at, ) await self.schedule.reschedule(session_id, due_at=next_retry_at) async def retry_now(self, session_id: str) -> None: """提供手工补偿入口,立即把指定会话放回扫描队列。""" await self.redis.hset( self.schedule.meta_key(session_id), mapping={"archive_status": "pending"}, ) await self.schedule.reschedule(session_id, due_at=self.clock()) async def run(self) -> None: """启动后台循环,异常不会击穿主应用。""" while not self._stop_event.is_set(): try: await self.archive_due_sessions() except asyncio.CancelledError: raise except Exception: logger.exception("idle archive scan failed") try: await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval) except asyncio.TimeoutError: continue async def stop(self) -> None: """请求后台循环停止。""" self._stop_event.set() __all__ = ["IdleArchiveWorker"]