zai / app /utils /guest_session_pool.py
bg5's picture
Upload 85 files
e0b68d2 verified
Raw History Blame Contribute Delete
28.3 kB
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""匿名访客会话池。"""
import asyncio
import time
from dataclasses import dataclass, field
from threading import Lock
from typing import Dict, Any, List, Optional, Set
import httpx
import jwt
from app.core.config import settings
from app.core.http_client import SharedHttpClients
from app.core.headers import build_dynamic_headers as _build_dynamic_headers
from app.core.httpx_errors import normalize_httpx_exception, normalize_httpx_response
from app.core.resin_compat import apply_resin_account_header
from app.core.upstream_urls import build_upstream_url
from app.utils.fe_version import get_latest_fe_version
from app.utils.logger import logger, log_exception
from app.utils.utlis import mask_token
AUTH_URL = "/api/v1/auths/"
CHATS_URL = "/api/v1/chats/"
def _decode_token_payload(token: str) -> Dict[str, Any]:
"""无需校验签名,仅解密 JWT Payload 以获取元数据。"""
try:
return jwt.decode(token, options={"verify_signature": False})
except Exception:
return {}
@dataclass
class GuestSession:
"""单个匿名访客会话。"""
token: str
user_id: str
username: str
created_at: float = field(default_factory=time.time)
active_requests: int = 0
valid: bool = True
failure_count: int = 0
last_failure_time: float = 0.0
@property
def age(self) -> float:
"""会话存活时间。"""
return time.time() - self.created_at
def snapshot(self) -> Dict[str, str]:
"""获取当前会话快照。"""
return {
"token": self.token,
"user_id": self.user_id,
"username": self.username,
}
class GuestSessionPool:
"""匿名访客会话池,支持最小负载获取与失败替换。"""
def __init__(
self,
pool_size: int = 3,
session_max_age: int = 480,
maintenance_interval: int = 30,
max_failures: int = 10,
):
self.pool_size = max(1, pool_size)
self.session_max_age = max(60, session_max_age)
self.maintenance_interval = max(10, maintenance_interval)
self.max_failures = max(1, max_failures)
self._lock = Lock()
self._sessions: Dict[str, GuestSession] = {}
self._maintenance_task: Optional[asyncio.Task] = None
self._capacity_lock = asyncio.Lock()
self._create_rate_lock = asyncio.Lock()
self._next_create_allowed_at = 0.0
self._create_min_interval = 1.0
self._http_clients = SharedHttpClients(follow_redirects=True)
self._background_tasks: Set[asyncio.Task] = set()
self._cleanup_parallelism = max(1, settings.GUEST_CLEANUP_PARALLELISM)
@staticmethod
def _is_retryable_create_error(exc: Exception) -> bool:
"""匿名会话创建阶段,哪些异常值得重试。"""
return isinstance(exc, httpx.TransportError)
def _track_background_task(self, coro) -> asyncio.Task:
"""跟踪后台任务,避免清理阻塞前台重试路径。"""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
def _on_done(done_task: asyncio.Task):
self._background_tasks.discard(done_task)
try:
done_task.result()
except asyncio.CancelledError:
pass
except Exception as exc:
logger.warning(
"⚠️ 匿名会话后台任务异常",
exc_info=settings.DEBUG_LOGGING,
)
task.add_done_callback(_on_done)
return task
async def _wait_background_tasks(self, cancel: bool = False):
"""等待当前已注册的后台任务结束。"""
pending = list(self._background_tasks)
if pending:
if cancel:
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
async def _delete_sessions_concurrently(self, sessions: List[GuestSession]):
"""并发清理多枚匿名会话,加快池维护速度。"""
if not sessions:
return
semaphore = asyncio.Semaphore(self._cleanup_parallelism)
async def _cleanup(session: GuestSession):
async with semaphore:
await self._delete_all_chats(session)
await asyncio.gather(*(_cleanup(session) for session in sessions))
async def _wait_create_slot(self, reason: str) -> None:
async with self._create_rate_lock:
now = time.time()
wait_seconds = max(0.0, self._next_create_allowed_at - now)
if wait_seconds > 0:
logger.debug(
"[guest_session.create] throttled reason=%s wait_seconds=%.2f",
reason,
wait_seconds,
)
await asyncio.sleep(wait_seconds)
self._next_create_allowed_at = time.time() + self._create_min_interval
async def _create_session(self, reason: str = "unspecified") -> GuestSession:
"""创建一个新的匿名访客会话。
从上游获取真实 token,并解密其内部值作为 session 元数据。
"""
max_retries = 3
for attempt in range(1, max_retries + 1):
try:
await self._wait_create_slot(reason)
fe_version = await get_latest_fe_version()
headers = _build_dynamic_headers(fe_version)
client = self._http_clients.get_client()
response = await client.get(build_upstream_url(AUTH_URL), headers=headers)
if response.status_code != 200:
raise RuntimeError(
f"匿名会话创建失败: HTTP {response.status_code} {response.text[:200]}"
)
data = response.json()
token = str(data.get("token") or "").strip()
if not token:
raise RuntimeError(f"匿名会话创建失败: 未返回 token {data}")
logger.debug(
"[guest_session.create] reason=%s attempt=%s status=%s bytes=%s token=%s",
reason,
attempt,
response.status_code,
response.headers.get("content-length") or len(response.content or b""),
mask_token(token),
)
# 优化:通过解密 token 内部值获取准确的 user_id 和 email
# Payload 结构通常为: {"id": "...", "email": "Guest-...@guest.com"}
payload = _decode_token_payload(token)
user_id = str(
payload.get("id")
or data.get("id")
or data.get("user_id")
or f"guest-{token[:12]}"
).strip()
# 从 email 中提取用户名,例如 Guest-1773376198189
raw_email = (
payload.get("email") or data.get("name") or data.get("email") or ""
)
username = str(raw_email).split("@")[0] or f"Guest-{user_id[:8]}"
logger.debug(
"获取匿名会话成功: user_id=%s, username=%s, reason=%s", user_id, username, reason
)
return GuestSession(
token=token,
user_id=user_id,
username=username,
)
except Exception as exc:
if self._is_retryable_create_error(exc):
logger.warning(
"⚠️ %s",
normalize_httpx_exception(
exc,
method="GET",
url=build_upstream_url(AUTH_URL),
context="guest_session.create",
attempt=attempt,
),
)
if attempt < max_retries:
await asyncio.sleep(min(2 ** (attempt - 1), 4))
continue
else:
log_exception(logger, "❌ 匿名会话创建异常")
raise
async def _delete_all_chats(self, session: GuestSession) -> bool:
"""删除匿名会话的全部对话,尽量释放并发占用。"""
fe_version = await get_latest_fe_version()
headers = _build_dynamic_headers(fe_version)
headers.update(
{
"Authorization": f"Bearer {session.token}",
"Accept": "application/json",
"Content-Type": "application/json",
}
)
apply_resin_account_header(headers, session.token)
try:
client = self._http_clients.get_client()
chats_url = build_upstream_url(CHATS_URL)
response = await client.delete(chats_url, headers=headers)
if response.status_code == 200:
logger.debug("已清理匿名会话聊天记录: %s", session.user_id)
return True
logger.warning(
"⚠️ 清理匿名会话聊天记录失败: %s",
normalize_httpx_response(
response.status_code,
response.text,
content_type=response.headers.get("content-type"),
method="DELETE",
url=chats_url,
context=f"guest_session.cleanup:{session.user_id}",
),
)
except Exception as exc:
logger.warning(
"⚠️ 清理匿名会话聊天记录异常: %s",
normalize_httpx_exception(
exc,
method="DELETE",
url=build_upstream_url(CHATS_URL),
context=f"guest_session.cleanup:{session.user_id}",
),
exc_info=settings.DEBUG_LOGGING,
)
return False
def _list_valid_sessions(
self,
exclude_user_ids: Optional[Set[str]] = None,
) -> List[GuestSession]:
"""获取有效匿名会话列表。"""
excluded = exclude_user_ids or set()
with self._lock:
return [
session
for session in self._sessions.values()
if session.valid
and session.age < self.session_max_age
and session.user_id not in excluded
]
async def _ensure_capacity(self) -> bool:
"""补齐匿名会话池容量。返回 True 表示补齐成功或无需补齐,False 表示补齐失败(未建出任何会话)。"""
async with self._capacity_lock:
while True:
valid_sessions = self._list_valid_sessions()
need = self.pool_size - len(valid_sessions)
if need <= 0:
return True
logger.debug(
"[guest_session.ensure_capacity] valid_sessions=%s pool_size=%s need=%s",
len(valid_sessions),
self.pool_size,
need,
)
results = await asyncio.gather(
*[self._create_session(reason="capacity_refill") for _ in range(need)],
return_exceptions=True,
)
created = 0
duplicate_user_ids: set[str] = set()
errors = set()
with self._lock:
for result in results:
if isinstance(result, GuestSession):
if result.user_id in self._sessions:
duplicate_user_ids.add(result.user_id)
continue
self._sessions[result.user_id] = result
created += 1
elif isinstance(result, Exception):
errors.add(
normalize_httpx_exception(
result,
method="GET",
url=build_upstream_url(AUTH_URL),
context="guest_session.create",
)
)
if errors:
logger.warning(
"⚠️ 匿名会话池补齐失败 (成功 %s/%s): %s",
created,
need,
", ".join(sorted(errors)),
)
if duplicate_user_ids:
self._next_create_allowed_at = max(
self._next_create_allowed_at,
time.time() + self.maintenance_interval,
)
logger.warning(
"⚠️ 匿名会话池补齐遇到重复 guest user_id,停止继续补池并冷却: duplicates=%s current_sessions=%s cooldown_seconds=%s",
", ".join(sorted(duplicate_user_ids)),
len(self._sessions),
self.maintenance_interval,
)
logger.debug(
"[guest_session.ensure_capacity] refill_result created=%s requested=%s duplicates=%s errors=%s total_sessions=%s",
created,
need,
len(duplicate_user_ids),
len(errors),
len(self._sessions),
)
if created == 0 or duplicate_user_ids:
return False
async def _maintenance_loop(self):
"""后台维护:回收过期/失效会话,并补齐池容量。"""
consecutive_failures = 0
recovery_wait_seconds = 300
force_sleep_once = 0
while True:
try:
if force_sleep_once > 0:
sleep_time = force_sleep_once
force_sleep_once = 0
elif consecutive_failures > 0:
sleep_time = self.maintenance_interval * (2 ** (consecutive_failures - 1))
else:
sleep_time = self.maintenance_interval
await asyncio.sleep(sleep_time)
stale_sessions: List[GuestSession] = []
forced_removed_logs: List[str] = []
with self._lock:
for user_id, session in list(self._sessions.items()):
force_remove = session.age > (2 * self.session_max_age)
should_remove = (
force_remove
or (
(not session.valid or session.age > self.session_max_age)
and session.active_requests == 0
)
)
if should_remove:
removed = self._sessions.pop(user_id)
stale_sessions.append(removed)
if force_remove:
forced_removed_logs.append(
f"{removed.user_id}(age={removed.age:.1f}s, "
f"active_requests={removed.active_requests}, valid={removed.valid})"
)
if forced_removed_logs:
logger.warning(
"⚠️ 匿名会话池强制回收超龄会话: " + ", ".join(forced_removed_logs)
)
await self._delete_sessions_concurrently(stale_sessions)
success = await self._ensure_capacity()
if success is False:
consecutive_failures += 1
logger.debug(
"[guest_session.maintenance] ensure_capacity_failed consecutive_failures=%s",
consecutive_failures,
)
if consecutive_failures >= self.max_failures:
logger.warning(
f"⚠️ 匿名会话池补齐连续失败 {consecutive_failures} 次达到限值,"
f"进入恢复等待 {recovery_wait_seconds} 秒后继续"
)
consecutive_failures = 0
force_sleep_once = recovery_wait_seconds
else:
next_sleep = self.maintenance_interval * (2 ** (consecutive_failures - 1))
logger.warning(f"⚠️ 匿名会话池补齐连续失败 {consecutive_failures} 次, 下次重试将等待 {next_sleep} 秒")
else:
if consecutive_failures > 0:
logger.debug("[pool] guest session pool maintenance success")
consecutive_failures = 0
except asyncio.CancelledError:
return
except Exception as exc:
logger.warning(
"⚠️ 匿名会话池后台维护异常",
exc_info=settings.DEBUG_LOGGING,
)
consecutive_failures += 1
if consecutive_failures >= self.max_failures:
logger.warning(
f"⚠️ 匿名会话池后台维护异常连续 {consecutive_failures} 次达到限值,"
f"进入恢复等待 {recovery_wait_seconds} 秒后继续"
)
consecutive_failures = 0
force_sleep_once = recovery_wait_seconds
async def initialize(self):
"""初始化匿名会话池。"""
if self._maintenance_task:
return
logger.debug(
"[guest_session.initialize] start pool_size=%s maintenance_interval=%s session_max_age=%s",
self.pool_size,
self.maintenance_interval,
self.session_max_age,
)
results = await asyncio.gather(
*[self._create_session(reason="startup_initialize") for _ in range(self.pool_size)],
return_exceptions=True,
)
created = 0
errors = set()
with self._lock:
for result in results:
if isinstance(result, GuestSession):
self._sessions[result.user_id] = result
created += 1
elif isinstance(result, Exception):
errors.add(
normalize_httpx_exception(
result,
method="GET",
url=build_upstream_url(AUTH_URL),
context="guest_session.create",
)
)
if errors:
logger.warning(
"⚠️ 匿名会话池初始化失败 (成功 %s/%s): %s",
created,
self.pool_size,
", ".join(sorted(errors)),
)
if created == 0:
try:
fallback = await self._create_session(reason="startup_fallback")
with self._lock:
self._sessions[fallback.user_id] = fallback
created = 1
logger.debug("[pool] guest session pool success initialize")
except Exception as e:
log_exception(
logger,
"[pool] guest session pool initialize failed,retrying in background"
)
else:
logger.debug("[pool] guest session pool initialize success, current capacity: %s", created)
self._maintenance_task = asyncio.create_task(self._maintenance_loop())
async def close(self):
"""关闭匿名会话池。"""
if self._maintenance_task:
self._maintenance_task.cancel()
try:
await self._maintenance_task
except asyncio.CancelledError:
pass
self._maintenance_task = None
with self._lock:
sessions = list(self._sessions.values())
self._sessions.clear()
await self._wait_background_tasks(cancel=True)
idle_sessions = [session for session in sessions if session.active_requests == 0]
try:
await asyncio.wait_for(self._delete_sessions_concurrently(idle_sessions), timeout=3.0)
except asyncio.TimeoutError:
logger.warning("⚠️ 清理匿名会话记录超时,强制关闭")
except Exception as e:
logger.warning(
"⚠️ 清理匿名会话记录异常",
exc_info=settings.DEBUG_LOGGING,
)
finally:
await self._http_clients.close()
async def acquire(
self,
exclude_user_ids: Optional[Set[str]] = None,
) -> GuestSession:
"""按最小忙碌度获取一个可用匿名会话。"""
excluded = exclude_user_ids or set()
max_sessions = self.pool_size * 3
while True:
candidates = self._list_valid_sessions(exclude_user_ids=excluded)
if candidates:
session = min(
candidates,
key=lambda item: (item.active_requests, item.created_at),
)
with self._lock:
current = self._sessions.get(session.user_id)
if current and current.valid and current.user_id not in excluded:
current.active_requests += 1
logger.debug(
"[guest_session.acquire] reuse user_id=%s active_requests=%s valid_sessions=%s",
current.user_id,
current.active_requests,
len(candidates),
)
return current
with self._lock:
current_size = len(self._sessions)
if current_size >= max_sessions:
raise RuntimeError(
f"匿名会话池容量超限: current={current_size}, max={max_sessions}"
)
logger.debug(
"[guest_session.acquire] no reusable session, creating fallback current_size=%s excluded=%s",
current_size,
len(excluded),
)
new_session = await self._create_session(reason="acquire_fallback")
if new_session.user_id in excluded:
await self._delete_all_chats(new_session)
continue
with self._lock:
current_size = len(self._sessions)
if current_size >= max_sessions:
overflow_message = (
f"匿名会话池容量超限: current={current_size}, max={max_sessions}"
)
else:
new_session.active_requests = 1
self._sessions[new_session.user_id] = new_session
return new_session
await self._delete_all_chats(new_session)
logger.warning(f"⚠️ {overflow_message}, 已放弃新建会话: {new_session.user_id}")
raise RuntimeError(overflow_message)
def release(self, user_id: str):
"""释放一个匿名会话占用。"""
with self._lock:
session = self._sessions.get(user_id)
if session:
session.active_requests = max(0, session.active_requests - 1)
logger.debug(
"[guest_session.release] user_id=%s active_requests=%s",
user_id,
session.active_requests,
)
async def report_failure(self, user_id: Optional[str] = None):
"""标记匿名会话失效,并尝试补一个新会话。"""
session: Optional[GuestSession] = None
if user_id:
with self._lock:
session = self._sessions.pop(user_id, None)
if session:
session.valid = False
session.failure_count += 1
session.last_failure_time = time.time()
session.active_requests = 0
if session:
self._track_background_task(self._delete_all_chats(session))
logger.debug("已淘汰匿名会话: %s", session.user_id)
logger.debug(
"[guest_session.report_failure] user_id=%s triggering_capacity_refill=%s",
user_id,
session is not None,
)
await self._ensure_capacity()
async def refresh_auth(self, failed_user_id: Optional[str] = None):
"""兼容 glm-demo 命名:刷新匿名会话。"""
await self.report_failure(failed_user_id)
async def cleanup_idle_chats(self):
"""清理当前空闲匿名会话的聊天记录。"""
with self._lock:
idle_sessions = [
session
for session in self._sessions.values()
if session.valid and session.active_requests == 0
]
await self._delete_sessions_concurrently(idle_sessions)
def get_pool_status(self) -> Dict[str, int]:
"""获取匿名会话池状态。"""
with self._lock:
sessions = list(self._sessions.values())
valid_sessions = [
session for session in sessions if session.valid and session.age < self.session_max_age
]
busy_sessions = [session for session in valid_sessions if session.active_requests > 0]
return {
"total_sessions": len(sessions),
"valid_sessions": len(valid_sessions),
"available_sessions": len(valid_sessions),
"busy_sessions": len(busy_sessions),
"expired_sessions": len(
[session for session in sessions if session.age >= self.session_max_age]
),
}
_guest_session_pool: Optional[GuestSessionPool] = None
_guest_pool_lock = Lock()
def get_guest_session_pool() -> Optional[GuestSessionPool]:
"""获取全局匿名会话池。"""
return _guest_session_pool
async def initialize_guest_session_pool(
pool_size: int = 3,
session_max_age: int = 480,
maintenance_interval: int = 30,
max_failures: int = 10,
) -> GuestSessionPool:
"""初始化全局匿名会话池。"""
global _guest_session_pool
if not settings.ANONYMOUS_MODE:
raise RuntimeError("ANONYMOUS_MODE is disabled")
with _guest_pool_lock:
if _guest_session_pool is None:
_guest_session_pool = GuestSessionPool(
pool_size=pool_size,
session_max_age=session_max_age,
maintenance_interval=maintenance_interval,
max_failures=max_failures,
)
pool = _guest_session_pool
await pool.initialize()
return pool
async def close_guest_session_pool():
"""关闭全局匿名会话池。"""
global _guest_session_pool
with _guest_pool_lock:
pool = _guest_session_pool
_guest_session_pool = None
if pool:
await pool.close()