Source code for litestar_queues.events.streaming

"""Plugin-owned WebSocket and SSE streaming endpoints for queue events.

Imported only when a stream config is present and enabled, so base package
imports stay free of routing and Channels-driver imports.

The router and both relays are private: ``QueuePlugin`` is their only caller,
and applications configure streaming through
:class:`~litestar_queues.events.stream_config.EventStreamConfig` instead.
``StreamMetrics`` stays public because it describes the observability surface
an application can record against.
"""

import asyncio
import contextlib
import inspect
import time
from collections import OrderedDict
from collections.abc import Container, Sequence
from typing import TYPE_CHECKING, Any, Protocol, cast

from litestar import Request, Router, WebSocket, get, websocket
from litestar.exceptions import PermissionDeniedException, WebSocketException
from litestar.params import FromPath
from litestar.response import ServerSentEvent

from litestar_queues.events.channels import QueueChannels
from litestar_queues.events.models import QueueEvent, QueueEventScope

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from litestar.types import SSEData

    from litestar_queues.config import QueueConfig
    from litestar_queues.events.stream_config import EventStreamConfig
    from litestar_queues.events.typing import ChannelsLike, ChannelsStreamBackend, ChannelsSubscriptionBackend
    from litestar_queues.namespace import QueueNamespace
    from litestar_queues.observability import QueueObservabilityRuntimeProtocol

__all__ = ("StreamMetrics",)

_STREAM_DEDUP_MAX_KEYS = 1024


def _resolve_channels_backend(socket: "Any") -> "ChannelsLike | None":
    if hasattr(socket, "channels_plugin"):
        return cast("ChannelsLike", socket.channels_plugin)
    scope = getattr(socket, "scope", None)
    if isinstance(scope, dict):
        scoped = scope.get("channels") or scope.get("queue_event_channels")
        if scoped is not None:
            return cast("ChannelsLike", scoped)
    app = getattr(socket, "app", None)
    state = getattr(app, "state", None)
    if state is not None:
        for key in ("queue_event_channels_backend", "channels", "queue_event_channels"):
            with contextlib.suppress(KeyError, TypeError):
                value = state[key]
                if value is not None:
                    return cast("ChannelsLike", value)
            value = getattr(state, key, None)
            if value is not None:
                return cast("ChannelsLike", value)
    return None


@contextlib.asynccontextmanager
async def _event_stream(
    backend: "ChannelsLike", channels: "Sequence[str]", *, history: "int"
) -> "AsyncIterator[AsyncIterator[bytes]]":
    if hasattr(backend, "start_subscription"):
        subscription_backend = cast("ChannelsSubscriptionBackend", backend)
        async with subscription_backend.start_subscription(list(channels), history=history) as subscriber:
            yield subscriber.iter_events()
        return

    if not hasattr(backend, "subscribe") or not hasattr(backend, "stream_events"):
        msg = "Queue event streaming requires a ChannelsPlugin or ChannelsBackend-like object."
        raise RuntimeError(msg)

    stream_backend = cast("ChannelsStreamBackend", backend)
    await stream_backend.subscribe(list(channels))
    try:
        yield _backend_events(stream_backend.stream_events(), set(channels))
    finally:
        await stream_backend.unsubscribe(list(channels))


async def _backend_events(events: "AsyncIterator[tuple[str, bytes]]", channels: "set[str]") -> "AsyncIterator[bytes]":
    async for channel, payload in events:
        if channel in channels:
            yield payload


def _decode_event(raw_event: "bytes | str") -> "QueueEvent | None":
    try:
        return QueueEvent.from_json(raw_event)
    except (KeyError, TypeError, ValueError):
        return None


[docs] class StreamMetrics(Protocol): """Optional metric callbacks used by the WebSocket and SSE stream relays."""
[docs] def on_connect(self, scope: "QueueEventScope") -> None: """Record a stream connection."""
[docs] def on_event(self, scope: "QueueEventScope") -> None: """Record an event sent to a stream client."""
[docs] def on_heartbeat(self, scope: "QueueEventScope") -> None: """Record a heartbeat sent to a stream client."""
[docs] def on_dedup_drop(self, scope: "QueueEventScope") -> None: """Record a deduplicated event dropped by the stream relay."""
[docs] def on_denial(self, scope: "QueueEventScope", reason: str) -> None: """Record an authorization denial."""
[docs] def on_disconnect(self, scope: "QueueEventScope", duration_seconds: float) -> None: """Record stream connection lifetime."""
class _RuntimeStreamMetrics: """Record stream metrics through the queue observability runtime.""" __slots__ = ("_runtime",) def __init__(self, runtime: "QueueObservabilityRuntimeProtocol") -> "None": self._runtime = runtime def on_connect(self, scope: "QueueEventScope") -> None: attributes = _scope_attributes(scope) self._runtime.record_counter("litestar_queues.stream.connections", attributes=attributes) self._runtime.record_gauge_delta("litestar_queues.stream.active", 1, attributes=attributes) def on_event(self, scope: "QueueEventScope") -> None: self._runtime.record_counter("litestar_queues.stream.events_sent", attributes=_scope_attributes(scope)) def on_heartbeat(self, scope: "QueueEventScope") -> None: self._runtime.record_counter("litestar_queues.stream.heartbeats", attributes=_scope_attributes(scope)) def on_dedup_drop(self, scope: "QueueEventScope") -> None: self._runtime.record_counter("litestar_queues.stream.dedup_drops", attributes=_scope_attributes(scope)) def on_denial(self, scope: "QueueEventScope", reason: str) -> None: attributes = _scope_attributes(scope) attributes["reason"] = reason self._runtime.record_counter("litestar_queues.stream.auth_denials", attributes=attributes) def on_disconnect(self, scope: "QueueEventScope", duration_seconds: float) -> None: attributes = _scope_attributes(scope) self._runtime.record_gauge_delta("litestar_queues.stream.active", -1, attributes=attributes) self._runtime.record_duration( "litestar_queues.stream.connection.duration", duration_seconds, attributes=attributes ) async def _stream_queue_events_ws( socket: Any, channels: Sequence[str], *, history: int = 0, channels_backend: "ChannelsLike | None" = None, heartbeat_interval: float = 25.0, stream_metrics: StreamMetrics | None = None, scope: QueueEventScope = "task", ) -> None: """Stream queue events to a WebSocket with a heartbeat and serialized sends. The caller owns route paths, guards, tenant filtering, and authorization. Subscriber backpressure is intentionally left to the configured Channels backend. """ backend = channels_backend or _resolve_channels_backend(socket) if backend is None: msg = "A Litestar Channels backend or plugin is required to stream queue events." raise RuntimeError(msg) await socket.accept() _record_metric(stream_metrics, "on_connect", scope) started_at = time.perf_counter() send_lock = asyncio.Lock() stop = asyncio.Event() try: async with _event_stream(backend, channels, history=history) as events: event_task = asyncio.create_task(_pump_events(socket, events, send_lock, stream_metrics, scope)) heartbeat_task = asyncio.create_task( _pump_heartbeat(socket, send_lock, heartbeat_interval, stop, stream_metrics=stream_metrics, scope=scope) ) await _wait_for_stream_tasks(event_task, heartbeat_task, stop) finally: _record_metric(stream_metrics, "on_disconnect", scope, time.perf_counter() - started_at) async def _wait_for_stream_tasks( event_task: "asyncio.Task[None]", heartbeat_task: "asyncio.Task[None]", stop: "asyncio.Event" ) -> None: tasks = {event_task, heartbeat_task} try: done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) for task in done: task.result() for task in pending: task.cancel() with contextlib.suppress(asyncio.CancelledError): await task finally: stop.set() for task in tasks: if not task.done(): task.cancel() with contextlib.suppress(asyncio.CancelledError): await task async def _pump_events( socket: Any, events: "Any", send_lock: "asyncio.Lock", stream_metrics: StreamMetrics | None, scope: QueueEventScope ) -> None: seen_dedup_keys: "OrderedDict[str, None]" = OrderedDict() async for raw_event in events: event = _decode_event(raw_event) if event is None: continue dedup_key = event.event_key if event.event_key is not None else event.id if dedup_key in seen_dedup_keys: seen_dedup_keys.move_to_end(dedup_key) _record_metric(stream_metrics, "on_dedup_drop", scope) continue seen_dedup_keys[dedup_key] = None if len(seen_dedup_keys) > _STREAM_DEDUP_MAX_KEYS: seen_dedup_keys.popitem(last=False) if not await _send_json(socket, send_lock, event.to_dict()): return _record_metric(stream_metrics, "on_event", scope) async def _pump_heartbeat( socket: Any, send_lock: "asyncio.Lock", interval: float, stop: "asyncio.Event", *, stream_metrics: StreamMetrics | None, scope: QueueEventScope, ) -> None: if interval <= 0: await stop.wait() return while not stop.is_set(): try: await asyncio.wait_for(stop.wait(), timeout=interval) except asyncio.TimeoutError: pass else: return if not await _send_json(socket, send_lock, {"type": "ping"}): return _record_metric(stream_metrics, "on_heartbeat", scope) async def _send_json(socket: Any, send_lock: "asyncio.Lock", payload: dict[str, object]) -> bool: async with send_lock: try: await socket.send_json(payload) except (OSError, RuntimeError): return False except Exception as exc: if exc.__class__.__name__ == "WebSocketDisconnect": return False raise return True def _record_metric(stream_metrics: StreamMetrics | None, method_name: str, *args: object) -> None: if stream_metrics is not None: getattr(stream_metrics, method_name)(*args) def _scope_attributes(scope: "QueueEventScope") -> "dict[str, str]": return {"scope": scope} def _resolve_observability_runtime( connection: Any, config: "QueueConfig" ) -> "QueueObservabilityRuntimeProtocol | None": if config.observability is None: return None app = getattr(connection, "app", None) state = getattr(app, "state", None) if state is None: return None key = config.observability_runtime_state_key with contextlib.suppress(KeyError, TypeError): runtime = state[key] if runtime is not None: return cast("QueueObservabilityRuntimeProtocol", runtime) runtime = getattr(state, key, None) if runtime is not None: return cast("QueueObservabilityRuntimeProtocol", runtime) return None def _resolve_stream_metrics(connection: Any, config: "QueueConfig") -> "StreamMetrics | None": runtime = _resolve_observability_runtime(connection, config) if runtime is not None and runtime.enabled: return _RuntimeStreamMetrics(runtime) return None def _configured_stream_channels_backend( config: "QueueConfig", channels_backend: "ChannelsLike | None" ) -> "ChannelsLike | None": if channels_backend is not None: return channels_backend if config.events is not None: return config.events.channels return None def _build_stream_router( config: "QueueConfig", stream_config: "EventStreamConfig", *, channels_backend: "ChannelsLike | None" = None ) -> "Router": """Build plugin-owned queue-event stream handlers for configured scopes. Returns: A router containing one handler per recognized configured scope for each enabled transport (WebSocket and/or SSE). """ stream_config = stream_config.resolve(config.names) authorizer = stream_config.channel_authorizer history = stream_config.replay_limit configured_channels_backend = _configured_stream_channels_backend(config, channels_backend) async def _authorize( connection: Any, scope: "QueueEventScope", key: str | None, *, websocket: bool, stream_metrics: StreamMetrics | None, ) -> None: if authorizer is None: return result = authorizer(connection, scope, key) if inspect.isawaitable(result): result = await result if not result: _record_metric(stream_metrics, "on_denial", scope, "authz") msg = "Channel authorization denied" if websocket: raise WebSocketException(detail=msg, code=4003) raise PermissionDeniedException(detail=msg) async def _relay(socket: "WebSocket", scope: "QueueEventScope", key: str | None, channel: str) -> None: stream_metrics = _resolve_stream_metrics(socket, config) await _authorize(socket, scope, key, websocket=True, stream_metrics=stream_metrics) backend = configured_channels_backend if backend is None: backend = _resolve_channels_backend(socket) await _stream_queue_events_ws( socket, [channel], history=history, channels_backend=backend, heartbeat_interval=stream_config.heartbeat_interval, stream_metrics=stream_metrics, scope=scope, ) async def _sse(connection: Any, scope: "QueueEventScope", key: str | None, channel: str) -> Any: stream_metrics = _resolve_stream_metrics(connection, config) await _authorize(connection, scope, key, websocket=False, stream_metrics=stream_metrics) backend = configured_channels_backend if backend is None: backend = _resolve_channels_backend(connection) return _stream_queue_events_sse( connection, [channel], history=history, channels_backend=backend, heartbeat_interval=stream_config.heartbeat_interval, stream_metrics=stream_metrics, scope=scope, ) handlers: list[Any] = [] if "websocket" in stream_config.transports: _append_task_handler(handlers, stream_config.scopes, _relay, config.names) _append_queue_handler(handlers, stream_config.scopes, _relay, config.names) _append_worker_handler(handlers, stream_config.scopes, _relay, config.names) _append_global_handler(handlers, stream_config.scopes, _relay, config.names) _append_custom_handler(handlers, stream_config.scopes, _relay, config.names) if "sse" in stream_config.transports: _append_sse_task_handler(handlers, stream_config.scopes, _sse, config.names) _append_sse_queue_handler(handlers, stream_config.scopes, _sse, config.names) _append_sse_worker_handler(handlers, stream_config.scopes, _sse, config.names) _append_sse_global_handler(handlers, stream_config.scopes, _sse, config.names) _append_sse_custom_handler(handlers, stream_config.scopes, _sse, config.names) return Router( path=stream_config.path, route_handlers=handlers, guards=list(stream_config.guards) if stream_config.guards else None, opt=dict(stream_config.opt) if stream_config.opt else None, include_in_schema=stream_config.include_in_schema, ) def _stream_queue_events_sse( connection: Any, channels: Sequence[str], *, history: int = 0, channels_backend: "ChannelsLike | None" = None, heartbeat_interval: float = 25.0, stream_metrics: StreamMetrics | None = None, scope: QueueEventScope = "task", ) -> "ServerSentEvent": """Return a server-sent event stream for queue events.""" return ServerSentEvent( _sse_events( connection, channels, history=history, channels_backend=channels_backend, heartbeat_interval=heartbeat_interval, stream_metrics=stream_metrics, scope=scope, ) ) async def _sse_events( connection: Any, channels: Sequence[str], *, history: int, channels_backend: "ChannelsLike | None", heartbeat_interval: float, stream_metrics: StreamMetrics | None, scope: QueueEventScope, ) -> "AsyncIterator[SSEData]": backend = channels_backend or _resolve_channels_backend(connection) if backend is None: msg = "A Litestar Channels backend or plugin is required to stream queue events." raise RuntimeError(msg) _record_metric(stream_metrics, "on_connect", scope) started_at = time.perf_counter() stream_context = _event_stream(backend, channels, history=history) events = await stream_context.__aenter__() try: async for frame in _sse_event_frames( events, heartbeat_interval=heartbeat_interval, stream_metrics=stream_metrics, scope=scope ): yield frame finally: await stream_context.__aexit__(None, None, None) _record_metric(stream_metrics, "on_disconnect", scope, time.perf_counter() - started_at) async def _sse_event_frames( events: "Any", *, heartbeat_interval: float, stream_metrics: StreamMetrics | None, scope: QueueEventScope ) -> "AsyncIterator[SSEData]": seen_dedup_keys: "OrderedDict[str, None]" = OrderedDict() iterator = events.__aiter__() next_event = asyncio.create_task(anext(iterator)) try: while True: if heartbeat_interval <= 0: try: raw_event = await next_event except StopAsyncIteration: return else: done, _pending = await asyncio.wait({next_event}, timeout=heartbeat_interval) if not done: _record_metric(stream_metrics, "on_heartbeat", scope) yield {"comment": "ping", "data": None} continue try: raw_event = next_event.result() except StopAsyncIteration: return next_event = asyncio.create_task(anext(iterator)) frame = _sse_frame(raw_event, seen_dedup_keys, stream_metrics, scope) if frame is not None: yield frame finally: if not next_event.done(): next_event.cancel() with contextlib.suppress(asyncio.CancelledError): await next_event def _sse_frame( raw_event: "bytes | str", seen_dedup_keys: "OrderedDict[str, None]", stream_metrics: StreamMetrics | None, scope: QueueEventScope, ) -> "dict[str, str] | None": event = _decode_event(raw_event) if event is None: return None dedup_key = event.event_key if event.event_key is not None else event.id if dedup_key in seen_dedup_keys: seen_dedup_keys.move_to_end(dedup_key) _record_metric(stream_metrics, "on_dedup_drop", scope) return None seen_dedup_keys[dedup_key] = None if len(seen_dedup_keys) > _STREAM_DEDUP_MAX_KEYS: seen_dedup_keys.popitem(last=False) _record_metric(stream_metrics, "on_event", scope) return {"event": event.type, "data": event.to_json().decode("utf-8")} def _append_task_handler(handlers: list[Any], scopes: Container[str], relay: Any, namespace: "QueueNamespace") -> None: if "task" not in scopes: return @websocket("/tasks/{task_id:str}", name=namespace.registration("event", "stream", "task")) async def task_stream(socket: "WebSocket", task_id: FromPath[str]) -> None: await relay(socket, "task", task_id, QueueChannels.task(task_id, namespace=namespace)) handlers.append(task_stream) def _append_sse_task_handler( handlers: list[Any], scopes: Container[str], relay: Any, namespace: "QueueNamespace" ) -> None: if "task" not in scopes: return @get( "/sse/tasks/{task_id:str}", name=namespace.registration("event", "sse", "task"), media_type="text/event-stream" ) async def task_sse(request: Request, task_id: FromPath[str]) -> Any: return await relay(request, "task", task_id, QueueChannels.task(task_id, namespace=namespace)) handlers.append(task_sse) def _append_queue_handler(handlers: list[Any], scopes: Container[str], relay: Any, namespace: "QueueNamespace") -> None: if "queue" not in scopes: return @websocket("/queues/{queue:str}", name=namespace.registration("event", "stream", "queue")) async def queue_stream(socket: "WebSocket", queue: FromPath[str]) -> None: await relay(socket, "queue", queue, QueueChannels.queue(queue, namespace=namespace)) handlers.append(queue_stream) def _append_sse_queue_handler( handlers: list[Any], scopes: Container[str], relay: Any, namespace: "QueueNamespace" ) -> None: if "queue" not in scopes: return @get( "/sse/queues/{queue:str}", name=namespace.registration("event", "sse", "queue"), media_type="text/event-stream" ) async def queue_sse(request: Request, queue: FromPath[str]) -> Any: return await relay(request, "queue", queue, QueueChannels.queue(queue, namespace=namespace)) handlers.append(queue_sse) def _append_worker_handler( handlers: list[Any], scopes: Container[str], relay: Any, namespace: "QueueNamespace" ) -> None: if "worker" not in scopes: return @websocket("/workers/{worker_id:str}", name=namespace.registration("event", "stream", "worker")) async def worker_stream(socket: "WebSocket", worker_id: FromPath[str]) -> None: await relay(socket, "worker", worker_id, QueueChannels.worker(worker_id, namespace=namespace)) handlers.append(worker_stream) def _append_sse_worker_handler( handlers: list[Any], scopes: Container[str], relay: Any, namespace: "QueueNamespace" ) -> None: if "worker" not in scopes: return @get( "/sse/workers/{worker_id:str}", name=namespace.registration("event", "sse", "worker"), media_type="text/event-stream", ) async def worker_sse(request: Request, worker_id: FromPath[str]) -> Any: return await relay(request, "worker", worker_id, QueueChannels.worker(worker_id, namespace=namespace)) handlers.append(worker_sse) def _append_global_handler( handlers: list[Any], scopes: Container[str], relay: Any, namespace: "QueueNamespace" ) -> None: if "global" not in scopes: return @websocket("/global", name=namespace.registration("event", "stream", "global")) async def global_stream(socket: "WebSocket") -> None: await relay(socket, "global", None, QueueChannels.global_channel(namespace=namespace)) handlers.append(global_stream) def _append_sse_global_handler( handlers: list[Any], scopes: Container[str], relay: Any, namespace: "QueueNamespace" ) -> None: if "global" not in scopes: return @get("/sse/global", name=namespace.registration("event", "sse", "global"), media_type="text/event-stream") async def global_sse(request: Request) -> Any: return await relay(request, "global", None, QueueChannels.global_channel(namespace=namespace)) handlers.append(global_sse) def _append_custom_handler( handlers: list[Any], scopes: Container[str], relay: Any, namespace: "QueueNamespace" ) -> None: if "custom" not in scopes: return @websocket("/custom/{scope_key:str}", name=namespace.registration("event", "stream", "custom")) async def custom_stream(socket: "WebSocket", scope_key: FromPath[str]) -> None: await relay(socket, "custom", scope_key, QueueChannels.custom(scope_key, namespace=namespace)) handlers.append(custom_stream) def _append_sse_custom_handler( handlers: list[Any], scopes: Container[str], relay: Any, namespace: "QueueNamespace" ) -> None: if "custom" not in scopes: return @get( "/sse/custom/{scope_key:str}", name=namespace.registration("event", "sse", "custom"), media_type="text/event-stream", ) async def custom_sse(request: Request, scope_key: FromPath[str]) -> Any: return await relay(request, "custom", scope_key, QueueChannels.custom(scope_key, namespace=namespace)) handlers.append(custom_sse)