Files
DocsGPT/docsgpt/streaming/sse_leases.py
T

131 lines
4.9 KiB
Python

"""Per-connection leases behind the per-user SSE connection cap.
Each open stream holds a member in the sorted set ``user:{id}:sse_leases``,
scored by when it last refreshed. Dropping stale members and counting the rest
enforces ``SSE_MAX_CONCURRENT_PER_USER``. A stream refreshes its lease while it
runs and removes it when it ends; a lease whose stream died without cleanup
(SIGKILL, OOM) ages out after the lease TTL. Unlike a shared counter with a
TTL, an expiring lease can't take another stream's slot with it or drive the
count negative.
"""
from __future__ import annotations
import logging
import time
import uuid
from contextlib import aclosing
from dataclasses import dataclass
from typing import Any, AsyncIterator, Optional, TypeVar
import anyio
from docsgpt.core.settings import settings
from docsgpt.events.keys import connection_leases_key
from docsgpt.streaming.async_redis import ASYNC_REDIS_OP_TIMEOUT_SECONDS
logger = logging.getLogger(__name__)
T = TypeVar("T")
class StreamCapExceeded(Exception):
"""Raised when the user already holds ``SSE_MAX_CONCURRENT_PER_USER`` streams."""
def lease_ttl_seconds() -> float:
"""Seconds a lease survives without a refresh.
A stream yields at least once per keepalive interval and refreshes on the
first yield after a third of the TTL, so four keepalives of headroom keep a
live stream's lease from expiring.
"""
return max(60.0, 4.0 * float(settings.SSE_KEEPALIVE_SECONDS))
@dataclass
class StreamLease:
"""One stream's claim on a slot in its user's connection cap."""
redis: Any
key: str
lease_id: str
ttl: float
refreshed_at: float
async def refresh_if_due(self) -> None:
"""Re-stamp the lease once a third of its TTL has passed; failures are logged, not raised."""
now = time.monotonic()
if now - self.refreshed_at < self.ttl / 3:
return
self.refreshed_at = now
try:
with anyio.fail_after(ASYNC_REDIS_OP_TIMEOUT_SECONDS):
async with self.redis.pipeline(transaction=True) as pipe:
# XX: a lease pruned or cleared while refreshes were failing
# stays gone instead of retaking a slot past the cap.
pipe.zadd(self.key, {self.lease_id: time.time()}, xx=True)
pipe.expire(self.key, int(self.ttl))
await pipe.execute()
except Exception:
logger.debug("SSE lease refresh failed for key=%s", self.key, exc_info=True)
async def release(self) -> None:
"""Remove this lease; shielded so it completes during cancellation, and bounded."""
with anyio.move_on_after(ASYNC_REDIS_OP_TIMEOUT_SECONDS, shield=True):
try:
await self.redis.zrem(self.key, self.lease_id)
except Exception:
logger.debug("SSE lease release failed for key=%s", self.key, exc_info=True)
async def acquire_stream_lease(redis: Any, user_id: str) -> Optional[StreamLease]:
"""Claim a connection slot for ``user_id``.
Args:
redis: The async Redis client, or ``None`` when unavailable.
user_id: The user opening the stream.
Returns:
The lease to refresh and release, or ``None`` when the cap is disabled
or Redis is unavailable or failing (fail-open).
Raises:
StreamCapExceeded: The user already holds the maximum number of streams.
"""
cap = int(settings.SSE_MAX_CONCURRENT_PER_USER)
if cap <= 0 or redis is None:
return None
key = connection_leases_key(user_id)
ttl = lease_ttl_seconds()
lease = StreamLease(redis=redis, key=key, lease_id=uuid.uuid4().hex, ttl=ttl, refreshed_at=time.monotonic())
now = time.time()
try:
with anyio.fail_after(ASYNC_REDIS_OP_TIMEOUT_SECONDS):
# One transaction, so concurrent connects are counted one at a time.
async with redis.pipeline(transaction=True) as pipe:
pipe.zremrangebyscore(key, "-inf", now - ttl)
pipe.zadd(key, {lease.lease_id: now})
pipe.zcard(key)
pipe.expire(key, int(ttl))
results = await pipe.execute()
except Exception:
logger.debug("SSE lease acquire failed for user=%s; failing open", user_id, exc_info=True)
# EXEC may have run before its reply was lost; the stream opens without
# a lease, so its member must not keep counting against the cap.
await lease.release()
return None
if int(results[2]) > cap:
await lease.release()
raise StreamCapExceeded()
return lease
async def hold_lease(stream: AsyncIterator[T], lease: Optional[StreamLease]) -> AsyncIterator[T]:
"""Yield from ``stream``, refreshing ``lease`` between frames; closing this closes ``stream``."""
async with aclosing(stream):
async for item in stream:
yield item
if lease is not None:
await lease.refresh_if_due()