mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 16:16:48 +00:00
fix(api): address review on the event-loop streams
- Per-user SSE cap uses per-connection leases in a sorted set instead of a shared INCR/DECR counter with a TTL. A stream that outlived the TTL could let the counter expire, then decrement another stream's slot or drive the count negative. Leases refresh while a stream sends frames and age out when a stream dies without cleanup. - Shielded cleanup is bounded per step: stream close and on_close in ClosingStreamingResponse, unsubscribe and close in AsyncTopic, lease release, and the replay-budget check, so a dead Redis connection can't hold a request or a graceful shutdown. - Artifact downloads send an ASCII filename plus an RFC 5987 filename* for non-ASCII names, build headers before opening the file, and close disk handles off the event loop. - Type hints and docstrings on the new helpers.
This commit is contained in:
1 parent
54b540ea42
commit
e889392eba
17 files changed
+911
-255
No files matched your search
@@ -31,6 +31,11 @@ OnSubscribe = Callable[[], Union[None, Awaitable[None]]]
|
||||
# payload, whose PONG the client swallows before ``get_message`` returns.
|
||||
_LIVENESS_PING = "docsgpt-liveness"
|
||||
|
||||
# Upper bound on each teardown call (unsubscribe, close). Teardown is shielded
|
||||
# from the cancellation that ended the stream, so without a bound a dead
|
||||
# connection could hold the stream's task and a graceful shutdown.
|
||||
_TEARDOWN_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
class AsyncTopic:
|
||||
"""An async pub/sub channel identified by a string name."""
|
||||
@@ -145,12 +150,11 @@ class AsyncTopic:
|
||||
yield data if isinstance(data, bytes) else str(data).encode("utf-8")
|
||||
finally:
|
||||
# Client disconnect cancels this generator at the ``await
|
||||
# get_message`` above; without shielding, the cancellation could
|
||||
# re-fire mid-teardown and skip ``aclose()``, leaking the pooled
|
||||
# connection back to nothing. Shield so unsubscribe + aclose
|
||||
# always complete and the connection returns to the pool.
|
||||
with anyio.CancelScope(shield=True):
|
||||
if on_subscribe_fired:
|
||||
# get_message`` above. Each teardown call is shielded so the
|
||||
# cancellation can't skip it and strand the pooled connection, and
|
||||
# bounded separately so a stuck unsubscribe still lets the close run.
|
||||
if on_subscribe_fired:
|
||||
with anyio.move_on_after(_TEARDOWN_TIMEOUT_SECONDS, shield=True):
|
||||
try:
|
||||
await pubsub.unsubscribe(self.name)
|
||||
except Exception:
|
||||
@@ -159,6 +163,7 @@ class AsyncTopic:
|
||||
self.name,
|
||||
exc_info=True,
|
||||
)
|
||||
with anyio.move_on_after(_TEARDOWN_TIMEOUT_SECONDS, shield=True):
|
||||
try:
|
||||
await pubsub.aclose()
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Upper bound on any one lease command, so a stalled Redis connection can't
|
||||
# hold a request, a stream, or its cleanup.
|
||||
_REDIS_OP_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
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(_REDIS_OP_TIMEOUT_SECONDS):
|
||||
async with self.redis.pipeline(transaction=True) as pipe:
|
||||
pipe.zadd(self.key, {self.lease_id: time.time()})
|
||||
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(_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_id = uuid.uuid4().hex
|
||||
now = time.time()
|
||||
try:
|
||||
with anyio.fail_after(_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_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)
|
||||
return None
|
||||
lease = StreamLease(redis=redis, key=key, lease_id=lease_id, ttl=ttl, refreshed_at=time.monotonic())
|
||||
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()
|
||||
Reference in new issue
Block a user