Janlay 38342b15a3
fix(redis): stream retention recovery (#3933)
* fix(gateway): retain redis streams safely

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-04 16:07:42 +08:00

242 lines
9.0 KiB
Python

"""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