deer-flow/backend/tests/test_redis_stream_cleanup_gap.py
RongJie G bf2cb0e1df
fix(runtime): recover terminal Redis streams after producer loss (#7093)
Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-19 15:53:54 +08:00

651 lines
22 KiB
Python

"""An established subscriber must not idle forever after retained data is deleted."""
import asyncio
import json
import anyio
import pytest
from test_stream_bridge import REDIS_TEST_URL, _DelayedBlockingReadRedis, _FakeRedis, requires_redis
from deerflow.runtime import (
ORPHAN_RECOVERY_STOP_REASON,
RunManager,
RunStatus,
StreamEvent,
StreamGap,
)
from deerflow.runtime.stream_bridge.base import END_SENTINEL, HEARTBEAT_SENTINEL
from deerflow.runtime.stream_bridge.redis import RedisStreamBridge
class _ConnectedRequest:
headers = {}
async def is_disconnected(self):
return False
class _ProducerLeaseBridge:
"""Retained stream with a controlled cross-process producer state."""
producer_lease_recovery_enabled = True
def __init__(self, state="expired"):
self.state = state
self.expired_producer_cleanup_calls: list[str] = []
async def stream_exists(self, run_id):
return True
async def producer_lease_state(self, run_id):
return self.state
async def cleanup_expired_producer(self, run_id):
self.expired_producer_cleanup_calls.append(run_id)
return self.state == "expired"
async def subscribe(self, run_id, *, last_event_id=None):
yield HEARTBEAT_SENTINEL
yield HEARTBEAT_SENTINEL
await asyncio.Event().wait()
class _TailAfterExpiredHeartbeatBridge(_ProducerLeaseBridge):
def __init__(self):
super().__init__("expired")
self.tail_consumed = asyncio.Event()
async def subscribe(self, run_id, *, last_event_id=None):
yield HEARTBEAT_SENTINEL
yield StreamEvent(id="2-0", event="values", data={"tail": True})
self.tail_consumed.set()
yield END_SENTINEL
class _ReactivatedProducerBridge(_ProducerLeaseBridge):
async def cleanup_expired_producer(self, run_id):
self.expired_producer_cleanup_calls.append(run_id)
self.state = "active"
return False
class _FailingProducerCleanupBridge(_ProducerLeaseBridge):
async def cleanup_expired_producer(self, run_id):
self.expired_producer_cleanup_calls.append(run_id)
raise TimeoutError("producer cleanup unavailable")
class _MissingActiveProducerBridge(_ProducerLeaseBridge):
def __init__(self):
super().__init__("active")
async def stream_exists(self, run_id):
return False
class _MissingThenTailBridge(_TailAfterExpiredHeartbeatBridge):
def __init__(self):
super().__init__()
self.exists_calls = 0
async def stream_exists(self, run_id):
self.exists_calls += 1
return self.exists_calls > 1
async def subscribe(self, run_id, *, last_event_id=None):
yield StreamEvent(id="2-0", event="values", data={"tail": True})
self.tail_consumed.set()
yield END_SENTINEL
@pytest.fixture
async def cleanup_bridge(request):
real = request.param == "redis"
bridge = RedisStreamBridge(
redis_url=REDIS_TEST_URL if real else "redis://fake",
key_prefix="test-cleanup-gap",
heartbeat_interval=0.01,
client=None if real else _FakeRedis(),
)
try:
yield bridge
finally:
await bridge.cleanup("cleanup-gap")
await bridge.close()
BACKENDS = ["fake", pytest.param("redis", marks=[pytest.mark.integration, requires_redis])]
@pytest.mark.anyio
@pytest.mark.parametrize("cleanup_bridge", BACKENDS, indirect=True)
@pytest.mark.parametrize("consumed", [1, 64, 65])
async def test_deleted_observed_stream_reports_gap(cleanup_bridge, consumed):
bridge = cleanup_bridge
for index in range(130):
await bridge.publish("cleanup-gap", "messages", {"index": index})
await bridge.publish_end("cleanup-gap")
stream = bridge.subscribe("cleanup-gap")
try:
cursor = None
for _ in range(consumed):
cursor = await anext(stream)
await bridge.cleanup("cleanup-gap")
# The already-fetched batch may drain, but the absent next batch is a gap.
with anyio.fail_after(2):
while True:
entry = await anext(stream)
assert entry is not HEARTBEAT_SENTINEL
assert entry is not END_SENTINEL
if isinstance(entry, StreamGap):
break
cursor = entry
assert entry == StreamGap(cursor.id, None, None)
with pytest.raises(StopAsyncIteration):
await anext(stream)
finally:
await stream.aclose()
@pytest.mark.anyio
@pytest.mark.parametrize("cleanup_bridge", BACKENDS, indirect=True)
async def test_unpublished_stream_still_waits_for_its_producer(cleanup_bridge):
bridge = cleanup_bridge
stream = bridge.subscribe("cleanup-gap")
try:
with anyio.fail_after(2):
assert await anext(stream) is HEARTBEAT_SENTINEL
await bridge.publish("cleanup-gap", "messages", {"index": 0})
await bridge.publish_end("cleanup-gap")
entries = [entry async for entry in stream]
assert entries[0].data == {"index": 0}
assert entries[-1] is END_SENTINEL
finally:
await stream.aclose()
@pytest.mark.anyio
@pytest.mark.parametrize("cleanup_bridge", BACKENDS, indirect=True)
async def test_deleted_stream_after_initial_wakeup_reports_gap(cleanup_bridge):
bridge = cleanup_bridge
delayed = _DelayedBlockingReadRedis(bridge._redis)
bridge._redis = delayed
stream = bridge.subscribe("cleanup-gap", heartbeat_interval=1)
pending = asyncio.create_task(anext(stream))
try:
with anyio.fail_after(2):
await delayed.blocking_read_started.wait()
await bridge.publish("cleanup-gap", "messages", {"index": 0})
await delayed.wake_response_captured.wait()
await bridge.cleanup("cleanup-gap")
delayed.release_wake_response.set()
assert await pending == StreamGap(None, None, None)
with pytest.raises(StopAsyncIteration):
await anext(stream)
finally:
pending.cancel()
await asyncio.gather(pending, return_exceptions=True)
await stream.aclose()
@pytest.mark.anyio
@pytest.mark.parametrize("cleanup_bridge", BACKENDS, indirect=True)
async def test_gateway_emits_recovery_gap_after_stream_cleanup(cleanup_bridge):
from app.gateway.services import sse_consumer
bridge = cleanup_bridge
manager = RunManager()
record = await manager.create("cleanup-gap-thread")
run_id = record.run_id
try:
await manager.set_status(run_id, RunStatus.success)
for index in range(70):
await bridge.publish(run_id, "messages", {"index": index})
await bridge.publish_end(run_id)
stream = sse_consumer(bridge, record, _ConnectedRequest(), manager, apply_on_disconnect=False)
try:
assert (await anext(stream)).startswith("event: messages\n")
await bridge.cleanup(run_id)
with anyio.fail_after(2):
frames = [frame async for frame in stream]
assert frames[-1].startswith("event: gap\n")
data = json.loads(frames[-1].split("data: ", 1)[1])
assert data["recovery"] == "reload_durable_state"
assert data["latest_available_event_id"] is None
assert not any(frame.startswith("event: end\n") for frame in frames)
assert record.status == RunStatus.success
finally:
await stream.aclose()
finally:
await bridge.cleanup(run_id)
await manager.shutdown()
@pytest.mark.anyio
@pytest.mark.parametrize("store_only", [False, True])
async def test_wait_completes_when_gap_has_no_retained_tail(store_only):
from app.gateway.services import wait_for_run_completion
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore() if store_only else None
manager = RunManager(store=store)
if store_only:
await store.put("cleanup-gap", thread_id="cleanup-thread", status="running")
record = await manager.get("cleanup-gap")
else:
record = await manager.create("cleanup-thread")
await manager.set_status(record.run_id, RunStatus.running)
class DeletedStreamBridge:
async def stream_exists(self, run_id):
return False
async def subscribe(self, run_id, *, last_event_id=None):
await asyncio.sleep(0)
if store_only:
await store.update_status(run_id, "success")
else:
await manager.set_status(run_id, RunStatus.success)
yield StreamGap("123-0", None, None)
# A new subscription must not wait on the already-deleted stream.
await asyncio.Event().wait()
try:
with anyio.fail_after(2):
assert await wait_for_run_completion(DeletedStreamBridge(), record, _ConnectedRequest(), manager)
finally:
await manager.shutdown()
@pytest.mark.anyio
@pytest.mark.parametrize("cleanup_bridge", BACKENDS, indirect=True)
@pytest.mark.parametrize("store_only", [False, True])
async def test_wait_survives_cleanup_with_real_bridge(cleanup_bridge, store_only):
from app.gateway.services import wait_for_run_completion
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore() if store_only else None
manager = RunManager(store=store)
if store_only:
await store.put("cleanup-gap", thread_id="cleanup-thread", status="running")
record = await manager.get("cleanup-gap")
else:
record = await manager.create("cleanup-thread")
await manager.set_status(record.run_id, RunStatus.running)
bridge = cleanup_bridge
run_id = record.run_id
class CleanupAfterFirstMessage(_ConnectedRequest):
async def is_disconnected(self):
if store_only:
await store.update_status(run_id, "success")
else:
await manager.set_status(run_id, RunStatus.success)
await bridge.cleanup(run_id)
return False
try:
for index in range(70):
await bridge.publish(run_id, "messages", {"index": index})
await bridge.publish_end(run_id)
with anyio.fail_after(2):
assert await wait_for_run_completion(bridge, record, CleanupAfterFirstMessage(), manager)
finally:
await bridge.cleanup(run_id)
await manager.shutdown()
@pytest.mark.anyio
async def test_sse_terminalizes_retained_stream_after_producer_lease_expires():
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put("expired-producer", thread_id="cleanup-thread", status="success")
manager = RunManager(store=store)
record = await manager.get("expired-producer")
bridge = _ProducerLeaseBridge()
stream = sse_consumer(
bridge,
record,
_ConnectedRequest(),
manager,
apply_on_disconnect=False,
)
try:
with anyio.fail_after(0.5):
frames = [frame async for frame in stream]
assert frames[0] == ": heartbeat\n\n"
assert frames[1].startswith("event: end\n")
assert bridge.expired_producer_cleanup_calls == ["expired-producer"]
finally:
await stream.aclose()
await manager.shutdown()
@pytest.mark.anyio
async def test_wait_terminalizes_retained_stream_after_producer_lease_expires():
from app.gateway.services import wait_for_run_completion
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put("expired-producer", thread_id="cleanup-thread", status="success")
manager = RunManager(store=store)
record = await manager.get("expired-producer")
bridge = _ProducerLeaseBridge()
try:
with anyio.fail_after(0.5):
assert await wait_for_run_completion(
bridge,
record,
_ConnectedRequest(),
manager,
)
assert bridge.expired_producer_cleanup_calls == ["expired-producer"]
finally:
await manager.shutdown()
@pytest.mark.anyio
async def test_sse_does_not_terminalize_when_producer_reactivates_before_cleanup():
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put("reactivated-producer", thread_id="cleanup-thread", status="success")
manager = RunManager(store=store)
record = await manager.get("reactivated-producer")
bridge = _ReactivatedProducerBridge()
stream = sse_consumer(
bridge,
record,
_ConnectedRequest(),
manager,
apply_on_disconnect=False,
)
try:
with anyio.fail_after(0.5):
assert await anext(stream) == ": heartbeat\n\n"
assert await anext(stream) == ": heartbeat\n\n"
assert bridge.expired_producer_cleanup_calls == ["reactivated-producer"]
finally:
await stream.aclose()
await manager.shutdown()
@pytest.mark.anyio
async def test_sse_does_not_terminalize_when_producer_cleanup_fails():
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put("cleanup-failed", thread_id="cleanup-thread", status="success")
manager = RunManager(store=store)
record = await manager.get("cleanup-failed")
bridge = _FailingProducerCleanupBridge()
stream = sse_consumer(
bridge,
record,
_ConnectedRequest(),
manager,
apply_on_disconnect=False,
)
try:
with anyio.fail_after(0.5):
assert await anext(stream) == ": heartbeat\n\n"
assert await anext(stream) == ": heartbeat\n\n"
assert bridge.expired_producer_cleanup_calls == ["cleanup-failed"]
finally:
await stream.aclose()
await manager.shutdown()
@pytest.mark.anyio
async def test_sse_orphan_recovery_waits_for_active_stream_producer():
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put(
"orphan-active-producer",
thread_id="cleanup-thread",
status="error",
stop_reason=ORPHAN_RECOVERY_STOP_REASON,
)
manager = RunManager(store=store)
record = await manager.get("orphan-active-producer")
bridge = _ProducerLeaseBridge("active")
stream = sse_consumer(
bridge,
record,
_ConnectedRequest(),
manager,
apply_on_disconnect=False,
)
try:
with anyio.fail_after(0.5):
assert await anext(stream) == ": heartbeat\n\n"
finally:
await stream.aclose()
await manager.shutdown()
@pytest.mark.anyio
async def test_sse_drains_tail_published_after_expired_heartbeat():
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put("expired-tail", thread_id="cleanup-thread", status="success")
manager = RunManager(store=store)
record = await manager.get("expired-tail")
bridge = _TailAfterExpiredHeartbeatBridge()
stream = sse_consumer(
bridge,
record,
_ConnectedRequest(),
manager,
apply_on_disconnect=False,
)
try:
with anyio.fail_after(0.5):
frames = [frame async for frame in stream]
assert frames[0] == ": heartbeat\n\n"
assert frames[1].startswith("event: values\n")
assert frames[2].startswith("event: end\n")
assert bridge.tail_consumed.is_set()
finally:
await stream.aclose()
await manager.shutdown()
@pytest.mark.anyio
async def test_wait_drains_tail_published_after_expired_heartbeat():
from app.gateway.services import wait_for_run_completion
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put("expired-tail", thread_id="cleanup-thread", status="success")
manager = RunManager(store=store)
record = await manager.get("expired-tail")
bridge = _TailAfterExpiredHeartbeatBridge()
try:
with anyio.fail_after(0.5):
assert await wait_for_run_completion(
bridge,
record,
_ConnectedRequest(),
manager,
)
assert bridge.tail_consumed.is_set()
finally:
await manager.shutdown()
@pytest.mark.anyio
async def test_missing_stream_does_not_terminalize_an_active_producer():
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put("active-missing", thread_id="cleanup-thread", status="success")
manager = RunManager(store=store)
record = await manager.get("active-missing")
stream = sse_consumer(
_MissingActiveProducerBridge(),
record,
_ConnectedRequest(),
manager,
apply_on_disconnect=False,
)
try:
with anyio.fail_after(0.5):
assert await anext(stream) == ": heartbeat\n\n"
finally:
await stream.aclose()
await manager.shutdown()
@pytest.mark.anyio
async def test_missing_stream_rechecks_after_expiry_and_drains_new_tail():
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put("missing-then-tail", thread_id="cleanup-thread", status="success")
manager = RunManager(store=store)
record = await manager.get("missing-then-tail")
bridge = _MissingThenTailBridge()
stream = sse_consumer(
bridge,
record,
_ConnectedRequest(),
manager,
apply_on_disconnect=False,
)
try:
with anyio.fail_after(0.5):
frames = [frame async for frame in stream]
assert bridge.exists_calls == 2
assert frames[0].startswith("event: values\n")
assert frames[1].startswith("event: end\n")
assert bridge.tail_consumed.is_set()
finally:
await stream.aclose()
await manager.shutdown()
@pytest.mark.anyio
async def test_wait_rechecks_missing_stream_after_expiry_and_drains_new_tail():
from app.gateway.services import wait_for_run_completion
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put("missing-then-tail", thread_id="cleanup-thread", status="success")
manager = RunManager(store=store)
record = await manager.get("missing-then-tail")
bridge = _MissingThenTailBridge()
try:
with anyio.fail_after(0.5):
assert await wait_for_run_completion(
bridge,
record,
_ConnectedRequest(),
manager,
)
assert bridge.exists_calls == 2
assert bridge.tail_consumed.is_set()
finally:
await manager.shutdown()
@pytest.mark.anyio
@pytest.mark.parametrize("producer_state", ["active", "untracked"])
async def test_sse_keeps_waiting_for_nonexpired_terminal_producer(producer_state):
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
await store.put("live-producer", thread_id="cleanup-thread", status="success")
manager = RunManager(store=store)
record = await manager.get("live-producer")
stream = sse_consumer(
_ProducerLeaseBridge(producer_state),
record,
_ConnectedRequest(),
manager,
apply_on_disconnect=False,
)
try:
with anyio.fail_after(0.5):
assert await anext(stream) == ": heartbeat\n\n"
finally:
await stream.aclose()
await manager.shutdown()
@pytest.mark.anyio
async def test_sse_keeps_waiting_for_uncommitted_local_terminal_status():
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
manager = RunManager(store=store)
record = await manager.create_or_reject("cleanup-thread")
await manager.try_start(record.run_id)
await manager.set_status(record.run_id, RunStatus.success, persist=False)
assert record.terminal_status_persisted is False
stream = sse_consumer(
_ProducerLeaseBridge(),
record,
_ConnectedRequest(),
manager,
apply_on_disconnect=False,
)
try:
with anyio.fail_after(0.5):
assert await anext(stream) == ": heartbeat\n\n"
finally:
await stream.aclose()
await manager.shutdown(timeout=0)
@pytest.mark.anyio
@pytest.mark.parametrize("terminal_with_retained_tail", [False, True])
async def test_empty_gap_does_not_complete_running_or_retained_run(terminal_with_retained_tail):
from app.gateway.services import wait_for_run_completion
manager = RunManager()
record = await manager.create("cleanup-control")
await manager.set_status(record.run_id, RunStatus.success if terminal_with_retained_tail else RunStatus.running)
resumed = asyncio.Event()
allow_end = asyncio.Event()
class GapThenEndBridge:
subscriptions = 0
async def stream_exists(self, run_id):
# Initial probe must not skip the subscription.
return True if self.subscriptions == 0 else terminal_with_retained_tail
async def subscribe(self, run_id, *, last_event_id=None):
self.subscriptions += 1
if self.subscriptions == 1:
yield StreamGap("123-0", None, None)
return
resumed.set()
await allow_end.wait()
yield END_SENTINEL
task = asyncio.create_task(wait_for_run_completion(GapThenEndBridge(), record, _ConnectedRequest(), manager))
try:
with anyio.fail_after(2):
await resumed.wait()
assert not task.done()
allow_end.set()
assert await task
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await manager.shutdown()