"""Redis Streams-backed stream bridge.""" from __future__ import annotations import asyncio import inspect import json import logging from collections.abc import AsyncIterator, Mapping from typing import Any try: from redis.asyncio import Redis from redis.exceptions import RedisError, ResponseError except ImportError: # pragma: no cover - only hit when the optional extra is missing # ``redis`` is an optional extra (mirrors the ``postgres``/asyncpg path in # persistence/engine.py). This module is imported lazily from # ``make_stream_bridge`` only when ``stream_bridge.type == "redis"``, so the # hint surfaces exactly when a Redis bridge is requested without the package. raise ImportError( "stream_bridge.type is set to 'redis' but the redis package is not installed.\n" "Install it with:\n" " cd backend && uv sync --all-packages --extra redis\n" "On the next `make dev` the redis extra is auto-detected from config.yaml\n" "(stream_bridge.type: redis) and reinstalled, so it will not be wiped again.\n" "Or switch to stream_bridge.type: memory in config.yaml for single-process deployment." ) from None from .base import END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent logger = logging.getLogger(__name__) _KIND_EVENT = "event" _KIND_END = "end" # Batch size for ``XREAD``. Reading more than one entry per round-trip collapses # a large ``Last-Event-ID`` replay into far fewer calls; live tailing still # yields each event as it arrives because the consume loop returns mid-batch on # the end marker. _XREAD_COUNT = 64 # Maximum consecutive transient Redis errors (``ConnectionError``, # ``TimeoutError``, etc.) tolerated during ``subscribe`` before the error # propagates to the caller. Brief blips are retried with exponential backoff # capped at ``heartbeat_interval``. _MAX_SUBSCRIBE_RETRIES = 3 class RedisStreamBridge(StreamBridge): """Per-run stream bridge backed by Redis Streams. Each run is stored in one Redis Stream and subscribers read directly with ``XREAD``. This keeps the SSE bridge usable across multiple gateway worker processes while preserving ``Last-Event-ID`` replay semantics. """ supports_cross_process = True def __init__( self, *, redis_url: str, queue_maxsize: int = 256, key_prefix: str = "deerflow:stream_bridge", max_connections: int | None = None, stream_ttl_seconds: int | None = 86400, client: Redis | None = None, ) -> None: self._redis_url = redis_url self._maxsize = max(1, queue_maxsize) self._key_prefix = key_prefix.rstrip(":") if stream_ttl_seconds is not None and stream_ttl_seconds > 0: self._stream_ttl_seconds = stream_ttl_seconds else: self._stream_ttl_seconds = None # Each live SSE subscriber holds one pooled connection blocked in # ``XREAD ... BLOCK`` for up to ``heartbeat_interval``. ``max_connections`` # caps that pool; ``None`` keeps redis-py's effectively-unbounded default. self._redis = client if client is not None else Redis.from_url(redis_url, decode_responses=True, max_connections=max_connections) self._owns_client = client is None def _stream_key(self, run_id: str) -> str: return f"{self._key_prefix}:{run_id}" async def _xadd_retained(self, key: str, fields: dict[str, str], *, maxlen: int) -> None: if self._stream_ttl_seconds is None: await self._redis.xadd( key, fields, maxlen=maxlen, approximate=False, ) return async with self._redis.pipeline(transaction=True) as pipe: pipe.xadd( key, fields, maxlen=maxlen, approximate=False, ) pipe.expire(key, self._stream_ttl_seconds) await pipe.execute() @staticmethod def _decode(value: Any) -> str: if isinstance(value, bytes): return value.decode("utf-8") return str(value) @classmethod def _normalise_fields(cls, fields: Mapping[Any, Any]) -> dict[str, str]: return {cls._decode(key): cls._decode(value) for key, value in fields.items()} @staticmethod def _encode_data(data: Any) -> str: return json.dumps(data, default=str, ensure_ascii=False, separators=(",", ":")) @staticmethod def _decode_data(raw: str | None) -> Any: if raw is None: return None try: return json.loads(raw) except json.JSONDecodeError: logger.warning("Redis stream bridge received non-JSON event data") return raw def _entry_from_redis(self, event_id: str, fields: Mapping[Any, Any]) -> StreamEvent: payload = self._normalise_fields(fields) kind = payload.get("kind", _KIND_EVENT) if kind == _KIND_END: return END_SENTINEL return StreamEvent( id=event_id, event=payload.get("event", "message"), data=self._decode_data(payload.get("data")), ) async def publish(self, run_id: str, event: str, data: Any) -> None: key = self._stream_key(run_id) await self._xadd_retained( key, { "kind": _KIND_EVENT, "event": event, "data": self._encode_data(data), }, maxlen=self._maxsize, ) async def publish_end(self, run_id: str) -> None: # Keep the configured number of data events plus the internal end marker. key = self._stream_key(run_id) await self._xadd_retained( key, {"kind": _KIND_END}, maxlen=self._maxsize + 1, ) async def stream_exists(self, run_id: str) -> bool: """Return whether Redis still has retained stream data for *run_id*.""" return bool(await self._redis.exists(self._stream_key(run_id))) async def subscribe( self, run_id: str, *, last_event_id: str | None = None, heartbeat_interval: float = 15.0, ) -> AsyncIterator[StreamEvent]: key = self._stream_key(run_id) stream_id = last_event_id or "0-0" block_ms = max(1, int(heartbeat_interval * 1000)) if heartbeat_interval > 0 else 1 consecutive_errors = 0 while True: try: response = await self._redis.xread({key: stream_id}, count=_XREAD_COUNT, block=block_ms) except ResponseError: # The only client-controllable stream ID is the Last-Event-ID # header, so a rejected ID means a malformed reconnect token: # fall back to replaying from the earliest retained event. We key # off the control flow rather than the error wording, which is the # server's text (Redis/Valkey/Dragonfly) and not a stable API. If # the reset read from "0-0" also fails, the stream/connection is # genuinely broken, so re-raise. if stream_id == "0-0": raise logger.warning( "Redis rejected Last-Event-ID %r for stream bridge; replaying from earliest retained event", stream_id, exc_info=True, ) stream_id = "0-0" continue except RedisError: consecutive_errors += 1 if consecutive_errors > _MAX_SUBSCRIBE_RETRIES: raise delay = min(2**consecutive_errors, heartbeat_interval) logger.warning( "Transient Redis error in stream bridge subscriber (retry %d/%d); backing off %.1fs", consecutive_errors, _MAX_SUBSCRIBE_RETRIES, delay, exc_info=True, ) await asyncio.sleep(delay) continue else: consecutive_errors = 0 if not response: yield HEARTBEAT_SENTINEL continue for _stream_name, entries in response: for event_id, fields in entries: event_id = self._decode(event_id) stream_id = event_id entry = self._entry_from_redis(event_id, fields) if entry is END_SENTINEL: yield END_SENTINEL return yield entry async def cleanup(self, run_id: str, *, delay: float = 0) -> None: if delay > 0: await asyncio.sleep(delay) await self._redis.delete(self._stream_key(run_id)) async def close(self) -> None: if not self._owns_client: return close = getattr(self._redis, "aclose", None) or getattr(self._redis, "close", None) if close is None: return result = close() if inspect.isawaitable(result): await result