Files
DocsGPT/docsgpt/streaming/async_redis.py
T
Alex 54b540ea42 feat(api): serve long-lived streams on the event loop
Move GET /api/events, the remote-device command stream and artifact
downloads from Flask to Starlette routes mounted ahead of the Flask
catch-all. On Flask each held an a2wsgi threadpool slot for as long as
its response stayed open, and because uvicorn drops writes after a
client disconnects, a closed tab never released it.

- asgi_auth: one JWT/OIDC gate for Starlette routes; the chat reconnect
  reader uses it too
- ClosingStreamingResponse closes the body iterator and releases the
  SSE slot or file handle even when the client leaves before the first
  frame
- AsyncTopic liveness probe replaces the sync client's socket_timeout
  guard against half-open pub/sub sockets
- ASYNC_REDIS_MAX_CONNECTIONS sizes the async Redis pool; every open
  stream holds a connection and redis-py defaults to 100
2026-09-14 08:33:46 +01:00

51 lines
1.9 KiB
Python

"""Lazy async Redis client for the native-async (event-loop) routes.
Async twin of :func:`docsgpt.cache.get_redis_instance`. The Starlette-mounted
routes — the chat reconnect reader, ``/api/events`` and the device command
stream — wait on Redis from the event loop, so they need a ``redis.asyncio``
client rather than the sync one used by the producer side. The app runs a
single ASGI worker / event loop, so a module-level singleton is sufficient
and avoids reconnecting per request.
"""
from __future__ import annotations
import logging
from typing import Optional
import redis.asyncio as aioredis
from docsgpt.core.settings import settings
logger = logging.getLogger(__name__)
_async_redis: Optional[aioredis.Redis] = None
_creation_failed = False
async def get_async_redis_instance() -> Optional[aioredis.Redis]:
"""Return a process-wide async Redis client, or ``None`` if unavailable.
``from_url`` builds the client without opening a socket (connection is
lazy), so a transient broker outage surfaces later on the first command
rather than here. Mirrors the sync client's ``socket_connect_timeout``
and ``health_check_interval`` so a half-open TCP can't wedge the tail
loop past its keepalive cadence. Every open stream holds a pooled
connection, so the pool is sized by ``ASYNC_REDIS_MAX_CONNECTIONS``
rather than redis-py's default of 100.
"""
global _async_redis, _creation_failed
if _async_redis is None and not _creation_failed:
try:
_async_redis = aioredis.Redis.from_url(
settings.CACHE_REDIS_URL,
socket_connect_timeout=2,
health_check_interval=10,
max_connections=int(settings.ASYNC_REDIS_MAX_CONNECTIONS),
)
except ValueError as e:
logger.error("Invalid Redis URL for async client: %s", e)
_creation_failed = True
_async_redis = None
return _async_redis