mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
* 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>
242 lines
9.0 KiB
Python
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
|