fix(streaming): isolate in-memory replay payloads (#7644)

* fix(streaming): isolate in-memory replay payloads

* docs(streaming): explain sentinel identity during replay

* docs(streaming): document replay payload ownership

---------

Co-authored-by: yinx65588-boop <yinx65588-boop@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
yinx65588-boop 2026-10-31 08:15:24 +08:00 committed by GitHub
parent ac21b69cbf
commit cdc3e92d6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 119 additions and 2 deletions

View File

@ -722,6 +722,10 @@ DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser
> normal `end` signal. A new reader whose initial attachment loses the race with
> cleanup receives a gap so it can recover the saved result.
>
> In-memory stream events retain the payload as it was published. Reusing a
> producer's data object or editing a received event does not change another
> subscriber's output or reconnect replay.
>
> During SSE replay-gap recovery, the Web UI preserves a reconnect pointer claimed by another run while durable state is loading. An older recovery cannot overwrite that pointer when it rejoins its own stream. Even a stale foreign pointer is preserved, so the next reload may first check that run before active-run recovery rejoins, adding a reconnect cycle.
>
> Store-only SSE and `/wait` consumers can finish after a heartbeat when run-manager

View File

@ -143,7 +143,7 @@ sequenceDiagram
关键组件:
- `runtime/runs/worker.py::run_agent` — 在 `asyncio.Task` 里跑 `agent.astream()`,把每个 chunk 通过 `serialize(chunk, mode=mode)` 转成 JSON,再 `bridge.publish()`。
- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。`stream_bridge.heartbeat_interval_seconds`(默认 15 秒)是 bridge 实例的默认心跳周期,统一作用于 Gateway SSE、`/wait` 和内部 channel watcher;显式传给 `subscribe()` 的值仍可覆盖单次订阅。Memory 和 Redis 都只保留 `queue_maxsize` 条数据事件;游标早于保留水位线时返回 `StreamGap`,不会从当前最早事件静默部分重放。Redis backend 会在写入数据或首个 END 时刷新 retained stream key TTL;启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run,再处理恢复 task;尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error;周期恢复不做非原子的 thread 投影。普通 durable terminal status 本身仍不足以证明尾帧已发布;Redis producer 在 durable admission 前取得短租约,经 request task、worker、finalizer 转移并保留到 END。只有显式 `orphan_recovered` 或 durable terminal 加已失效的 tracked producer lease 才能在 heartbeat 上触发 END fallback;lease loss 会阻止后续 stream 写入,active 或 untracked producer 继续等待,避免截断尾帧。Redis END 通过同一个 Lua 原子完成 lease 校验、既有 END 检查和必要写入,因此重复调用、owner/recovery 竞争和 ACK 丢失重试至多追加一个 END;当 END 已位于 stream 尾部时,后续事件写入会被拒绝;旧 stream 中 END 后已有迟到数据时,仍允许继续写入数据。cleanup 后无 owner 的迟到 END 重试是成功 no-op,不会复活已删除的 stream。普通事件只做 O(1) 尾部 END 检查。`stream_bridge.legacy_end_scan_enabled` 默认 `true`:尾部不是 END 时扫描整个保留窗口,包括正常 run 的首次 END;原子 Lua 的耗时随保留条目数量和 payload 总字节数增长,期间会阻塞其他 Redis 客户端。只有全部旧 writer 停止且旧 replay buffer 已过期或清除后,才可设为 `false` 并重启所有 Gateway,使 END 检查也只看尾部。TTL 为 0 时必须显式清除旧 buffer。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制。
- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。`stream_bridge.heartbeat_interval_seconds`(默认 15 秒)是 bridge 实例的默认心跳周期,统一作用于 Gateway SSE、`/wait` 和内部 channel watcher;显式传给 `subscribe()` 的值仍可覆盖单次订阅。Memory 和 Redis 都只保留 `queue_maxsize` 条数据事件。`MemoryStreamBridge` 在分配事件 ID 前深拷贝发布的 payload,并在 stream Condition 外为每个订阅者返回独立深拷贝,因此生产者复用 payload 或订阅者修改数据都不会影响保留帧及重连重放;心跳与 END sentinel 保持对象身份。游标早于保留水位线时返回 `StreamGap`,不会从当前最早事件静默部分重放。Redis backend 会在写入数据或首个 END 时刷新 retained stream key TTL;启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run,再处理恢复 task;尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error;周期恢复不做非原子的 thread 投影。普通 durable terminal status 本身仍不足以证明尾帧已发布;Redis producer 在 durable admission 前取得短租约,经 request task、worker、finalizer 转移并保留到 END。只有显式 `orphan_recovered` 或 durable terminal 加已失效的 tracked producer lease 才能在 heartbeat 上触发 END fallback;lease loss 会阻止后续 stream 写入,active 或 untracked producer 继续等待,避免截断尾帧。Redis END 通过同一个 Lua 原子完成 lease 校验、既有 END 检查和必要写入,因此重复调用、owner/recovery 竞争和 ACK 丢失重试至多追加一个 END;当 END 已位于 stream 尾部时,后续事件写入会被拒绝;旧 stream 中 END 后已有迟到数据时,仍允许继续写入数据。cleanup 后无 owner 的迟到 END 重试是成功 no-op,不会复活已删除的 stream。普通事件只做 O(1) 尾部 END 检查。`stream_bridge.legacy_end_scan_enabled` 默认 `true`:尾部不是 END 时扫描整个保留窗口,包括正常 run 的首次 END;原子 Lua 的耗时随保留条目数量和 payload 总字节数增长,期间会阻塞其他 Redis 客户端。只有全部旧 writer 停止且旧 replay buffer 已过期或清除后,才可设为 `false` 并重启所有 Gateway,使 END 检查也只看尾部。TTL 为 0 时必须显式清除旧 buffer。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制。
- `stream_bridge.producer_lease_recovery_enabled` 是上述 producer-lease fallback 的 restart-required activate gate。升级时先保持 `false` 完成所有 writer 的滚动发布并排空旧 run,再在所有 Gateway 上同时设为 `true` 并重启;mixed-version 阶段不得启用。
- `app/gateway/services.py::sse_consumer` — 从 bridge 订阅,格式化为 SSE wire 帧。
- `runtime/serialization.py::serialize` — mode-aware 序列化;`messages` mode 下 `serialize_messages_tuple` 把 `(chunk, metadata)` 转成 `[chunk.model_dump(), metadata]`。

View File

@ -54,6 +54,12 @@ successful no-op; a live owner's write retains its existing missing-buffer
### Memory Stream Replay
Memory publication snapshots the payload before allocating an event ID. Each
subscriber receives its own deep copy outside the stream Condition, so producer
reuse and reader edits cannot rewrite retained frames. Heartbeat/END sentinels
keep identity. `tests/test_memory_stream_payload_isolation.py` covers nested
payloads and Worker-to-Gateway SSE/reconnect metadata, including subgraphs.
Memory keeps the exact last evicted event ID as bounded per-run metadata. A
reconnect from that known predecessor replays all retained successors and END,
matching a live subscriber at the same position. Older or unverifiable evicted

View File

@ -7,6 +7,7 @@ import logging
import re
import time
from collections.abc import AsyncIterator
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
@ -116,6 +117,7 @@ class MemoryStreamBridge(StreamBridge):
# -- StreamBridge API ------------------------------------------------------
async def publish(self, run_id: str, event: str, data: Any) -> None:
data = deepcopy(data)
stream = self._get_or_create_stream(run_id)
entry = StreamEvent(id=self._next_id(run_id), event=event, data=data)
async with stream.condition:
@ -191,7 +193,8 @@ class MemoryStreamBridge(StreamBridge):
if entry is END_SENTINEL:
yield END_SENTINEL
return
yield entry
# Consumers identify sentinels with `is`; never deep-copy them.
yield deepcopy(entry) if isinstance(entry, StreamEvent) and entry is not HEARTBEAT_SENTINEL else entry
if should_stop:
return

View File

@ -0,0 +1,104 @@
from copy import deepcopy
import httpx
import pytest
from fastapi import FastAPI, Request
from langgraph_sdk.sse import SSEDecoder, aiter_lines_raw
from starlette.responses import StreamingResponse
from deerflow.runtime.stream_bridge import END_SENTINEL, MemoryStreamBridge
@pytest.mark.asyncio
async def test_publish_captures_each_nested_payload_before_producer_reuses_it():
bridge = MemoryStreamBridge()
payload = {"messages": [{"content": [{"text": "first"}]}]}
first = deepcopy(payload)
try:
await bridge.publish("run", "values", payload)
payload["messages"][0]["content"][0]["text"] = "second"
second = deepcopy(payload)
await bridge.publish("run", "values", payload)
payload["messages"].clear()
await bridge.publish_end("run")
events = [entry async for entry in bridge.subscribe("run")]
assert [entry.data for entry in events[:-1]] == [first, second]
assert events[-1] is END_SENTINEL
finally:
await bridge.close()
@pytest.mark.asyncio
async def test_subscriber_mutation_does_not_reach_another_reader_or_replay():
bridge = MemoryStreamBridge()
expected = {"tool_calls": [{"args": {"paths": ["original.txt"]}}]}
try:
await bridge.publish("run", "metadata", {"run_id": "run"})
await bridge.publish("run", "updates", deepcopy(expected))
await bridge.publish_end("run")
initial = [entry async for entry in bridge.subscribe("run")]
initial[1].data["tool_calls"][0]["args"]["paths"].append("reader-edit.txt")
other = [entry async for entry in bridge.subscribe("run")]
assert other[1].data == expected
replay = [entry async for entry in bridge.subscribe("run", last_event_id=initial[0].id)]
assert replay[0].id == initial[1].id
assert replay[0].event == "updates"
assert replay[0].data == expected
assert other[-1] is replay[-1] is END_SENTINEL
assert replay[0].data is not other[1].data
finally:
await bridge.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("namespace", [(), ("child:task",)], ids=["root", "subgraph"])
async def test_worker_metadata_stays_at_publication_time_in_gateway_sse(namespace):
from langchain_core.messages import AIMessageChunk
from app.gateway.services import sse_consumer
from deerflow.runtime import RunManager, RunStatus
from deerflow.runtime.runs.worker import _publish_stream_item
from deerflow.runtime.serialization import serialize
bridge = MemoryStreamBridge()
manager = RunManager()
record = await manager.create("snapshot-sse")
app = FastAPI()
@app.get("/stream")
async def stream(request: Request):
return StreamingResponse(sse_consumer(bridge, record, request, manager, apply_on_disconnect=False), media_type="text/event-stream")
try:
expected = []
metadata = {"langgraph_node": "model", "tags": [], "langgraph_step": 1}
for step in (1, 2):
metadata["langgraph_step"] = step
metadata["tags"].append(f"step-{step}")
chunk = (AIMessageChunk(content=f"part-{step}", id="answer"), metadata)
expected.append(deepcopy(serialize(chunk, mode="messages")))
await _publish_stream_item(bridge=bridge, run_id=record.run_id, mode="messages", chunk=chunk, namespace=namespace, file_tool_chunk_batcher=None, subagent_events=None)
metadata["tags"].clear()
await manager.set_status(record.run_id, RunStatus.success)
await bridge.publish_end(record.run_id)
async def decode(response):
assert response.status_code == 200
decoder = SSEDecoder()
frames = []
async for line in aiter_lines_raw(response):
if (frame := decoder.decode(line)) is not None:
frames.append(frame)
return frames
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
frames = await decode(await client.get("/stream"))
event_name = "messages" + ("|" + "|".join(namespace) if namespace else "")
assert [(frame.event, frame.data) for frame in frames] == [(event_name, data) for data in expected] + [("end", None)]
replay = await decode(await client.get("/stream", headers={"Last-Event-ID": frames[0].id}))
assert [(frame.id, frame.event, frame.data) for frame in replay] == [(frame.id, frame.event, frame.data) for frame in frames[1:]]
finally:
await manager.shutdown()
await bridge.close()