mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""Retiring a memory stream must release its buffer and wake established readers."""
|
|
|
|
import asyncio
|
|
import json
|
|
import weakref
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from deerflow.runtime import MemoryStreamBridge, RunManager, RunStatus, StreamGap
|
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
|
from deerflow.runtime.stream_bridge.base import END_SENTINEL, HEARTBEAT_SENTINEL
|
|
|
|
|
|
async def _finish(bridge, streams, pending=()):
|
|
for task in pending:
|
|
task.cancel()
|
|
await asyncio.gather(*pending, return_exceptions=True)
|
|
for stream in streams:
|
|
await stream.aclose()
|
|
await bridge.close()
|
|
|
|
|
|
async def _attach_behind_retirement(bridge, run_id, stream, operation, monkeypatch):
|
|
"""Queue retirement before attachment on the real Condition's fair lock."""
|
|
condition = bridge._streams[run_id].condition
|
|
real_acquire = condition.acquire
|
|
retirement_queued = asyncio.Event()
|
|
attachment_queued = asyncio.Event()
|
|
tasks = []
|
|
|
|
async def observe_acquire():
|
|
if asyncio.current_task() is tasks[0]:
|
|
retirement_queued.set()
|
|
else:
|
|
attachment_queued.set()
|
|
return await real_acquire()
|
|
|
|
try:
|
|
async with condition:
|
|
monkeypatch.setattr(condition, "acquire", observe_acquire)
|
|
retirement = bridge.cleanup(run_id) if operation == "cleanup" else bridge.close()
|
|
tasks.append(asyncio.create_task(retirement))
|
|
await asyncio.wait_for(retirement_queued.wait(), 1)
|
|
tasks.append(asyncio.create_task(anext(stream)))
|
|
await asyncio.wait_for(attachment_queued.wait(), 1)
|
|
await asyncio.wait_for(asyncio.shield(tasks[0]), 1)
|
|
return await asyncio.wait_for(asyncio.shield(tasks[1]), 1)
|
|
finally:
|
|
for task in tasks:
|
|
task.cancel()
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["cleanup", "close"])
|
|
@pytest.mark.parametrize("ended", [False, True])
|
|
@pytest.mark.parametrize("with_cursor", [False, True])
|
|
@pytest.mark.parametrize("capacity", [1, 256])
|
|
async def test_attachment_queued_behind_retirement_reports_gap(monkeypatch, operation, ended, with_cursor, capacity):
|
|
bridge = MemoryStreamBridge(queue_maxsize=capacity)
|
|
await bridge.publish("attach-window", "messages", "earlier")
|
|
cursor = bridge._streams["attach-window"].events[0].id if with_cursor else None
|
|
# With capacity one, this makes the cursor the exact last evicted event.
|
|
await bridge.publish("attach-window", "messages", "unread")
|
|
if ended:
|
|
await bridge.publish_end("attach-window")
|
|
stream = bridge.subscribe("attach-window", last_event_id=cursor)
|
|
try:
|
|
assert await _attach_behind_retirement(bridge, "attach-window", stream, operation, monkeypatch) == StreamGap(cursor, None, None)
|
|
with pytest.raises(StopAsyncIteration):
|
|
await anext(stream)
|
|
finally:
|
|
await _finish(bridge, [stream])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("observer", [False, True])
|
|
@pytest.mark.parametrize("emit_gap_on_missing_stream", [False, True])
|
|
async def test_gateway_late_attachment_reloads_instead_of_reporting_an_empty_run(monkeypatch, observer, emit_gap_on_missing_stream):
|
|
from app.gateway.services import sse_consumer
|
|
|
|
manager = RunManager()
|
|
bridge = MemoryStreamBridge()
|
|
record = await manager.create("retirement-attach")
|
|
await manager.set_status(record.run_id, RunStatus.success)
|
|
monkeypatch.setattr(manager, "cancel", AsyncMock())
|
|
request = SimpleNamespace(headers={}, is_disconnected=AsyncMock(return_value=False))
|
|
await bridge.publish(record.run_id, "messages", "saved answer")
|
|
await bridge.publish_end(record.run_id)
|
|
stream = sse_consumer(bridge, record, request, manager, apply_on_disconnect=not observer, emit_gap_on_missing_stream=emit_gap_on_missing_stream)
|
|
try:
|
|
frame = await _attach_behind_retirement(bridge, record.run_id, stream, "cleanup", monkeypatch)
|
|
assert "event: gap" in frame and "event: end" not in frame
|
|
payload = json.loads(next(line[6:] for line in frame.splitlines() if line.startswith("data: ")))
|
|
assert payload["recovery"] == "reload_durable_state"
|
|
assert payload["requested_event_id"] is None
|
|
assert payload["earliest_available_event_id"] is None
|
|
assert payload["latest_available_event_id"] is None
|
|
with pytest.raises(StopAsyncIteration):
|
|
await anext(stream)
|
|
manager.cancel.assert_not_awaited()
|
|
finally:
|
|
await _finish(bridge, [stream])
|
|
await manager.shutdown()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["cleanup", "close"])
|
|
@pytest.mark.parametrize("published", [False, True])
|
|
async def test_retirement_wakes_a_waiting_subscriber(monkeypatch, operation, published):
|
|
bridge = MemoryStreamBridge(heartbeat_interval=30)
|
|
run = "retire-waiting"
|
|
stream = bridge.subscribe(run)
|
|
cursor = None
|
|
if published:
|
|
await bridge.publish(run, "message", {"text": "partial"})
|
|
cursor = (await anext(stream)).id
|
|
else:
|
|
# Establish an empty subscription, then wait on its real Condition.
|
|
initial = bridge.subscribe(run, heartbeat_interval=0.001)
|
|
try:
|
|
assert await anext(initial) is HEARTBEAT_SENTINEL
|
|
finally:
|
|
await initial.aclose()
|
|
condition = bridge._streams[run].condition
|
|
entered = asyncio.Event()
|
|
real_wait = condition.wait
|
|
|
|
async def observe_wait():
|
|
entered.set()
|
|
return await real_wait()
|
|
|
|
monkeypatch.setattr(condition, "wait", observe_wait)
|
|
pending = asyncio.create_task(anext(stream))
|
|
try:
|
|
await asyncio.wait_for(entered.wait(), 1)
|
|
if operation == "cleanup":
|
|
await bridge.cleanup(run)
|
|
else:
|
|
await bridge.close()
|
|
assert await asyncio.wait_for(asyncio.shield(pending), 0.5) == StreamGap(cursor, None, None)
|
|
with pytest.raises(StopAsyncIteration):
|
|
await anext(stream)
|
|
assert not await bridge.stream_exists(run)
|
|
finally:
|
|
await _finish(bridge, [stream], [pending])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["cleanup", "close"])
|
|
@pytest.mark.parametrize("ended", [False, True])
|
|
async def test_retired_buffer_is_released_and_does_not_replay_a_stale_tail(operation, ended):
|
|
class Payload:
|
|
pass
|
|
|
|
bridge = MemoryStreamBridge()
|
|
await bridge.publish("buffer", "metadata", {})
|
|
payload = Payload()
|
|
reference = weakref.ref(payload)
|
|
await bridge.publish("buffer", "messages", payload)
|
|
del payload
|
|
if ended:
|
|
await bridge.publish_end("buffer")
|
|
stream = bridge.subscribe("buffer")
|
|
first = await anext(stream)
|
|
try:
|
|
if operation == "cleanup":
|
|
await bridge.cleanup("buffer")
|
|
else:
|
|
await bridge.close()
|
|
assert reference() is None, "the established generator must not retain the retired payload"
|
|
assert await anext(stream) == StreamGap(first.id, None, None)
|
|
with pytest.raises(StopAsyncIteration):
|
|
await anext(stream)
|
|
finally:
|
|
await _finish(bridge, [stream])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_is_scoped_and_old_readers_cannot_consume_a_recreated_stream():
|
|
bridge = MemoryStreamBridge()
|
|
for run in ("retired", "untouched"):
|
|
await bridge.publish(run, "messages", {"generation": "old", "run": run})
|
|
old = bridge.subscribe("retired")
|
|
cursor = (await anext(old)).id
|
|
try:
|
|
await bridge.cleanup("retired")
|
|
await bridge.cleanup("missing")
|
|
await bridge.cleanup("retired")
|
|
await bridge.publish("retired", "messages", {"generation": "new"})
|
|
await bridge.publish_end("retired")
|
|
await bridge.publish_end("untouched")
|
|
assert await asyncio.wait_for(anext(old), 0.5) == StreamGap(cursor, None, None)
|
|
replacement = [entry async for entry in bridge.subscribe("retired")]
|
|
unaffected = [entry async for entry in bridge.subscribe("untouched")]
|
|
assert replacement[0].data == {"generation": "new"}
|
|
assert replacement[-1] is END_SENTINEL
|
|
assert unaffected[0].data["run"] == "untouched"
|
|
assert unaffected[-1] is END_SENTINEL
|
|
finally:
|
|
await _finish(bridge, [old])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_retires_all_established_readers_and_is_idempotent():
|
|
bridge = MemoryStreamBridge()
|
|
streams = []
|
|
cursors = []
|
|
try:
|
|
for index in range(3):
|
|
await bridge.publish(str(index), "messages", index)
|
|
stream = bridge.subscribe(str(index))
|
|
cursors.append((await anext(stream)).id)
|
|
streams.append(stream)
|
|
await bridge.close()
|
|
await bridge.close()
|
|
for stream, cursor in zip(streams, cursors, strict=True):
|
|
assert await asyncio.wait_for(anext(stream), 0.5) == StreamGap(cursor, None, None)
|
|
assert not bridge._streams and not bridge._counters
|
|
finally:
|
|
await _finish(bridge, streams)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delayed_cleanup_preserves_replay_until_its_grace_expires():
|
|
bridge = MemoryStreamBridge()
|
|
await bridge.publish("grace", "messages", "first")
|
|
stream = bridge.subscribe("grace")
|
|
cleanup = asyncio.create_task(bridge.cleanup("grace", delay=0.05))
|
|
try:
|
|
first = await anext(stream)
|
|
assert first.data == "first"
|
|
assert await bridge.stream_exists("grace")
|
|
await cleanup
|
|
assert await asyncio.wait_for(anext(stream), 0.5) == StreamGap(first.id, None, None)
|
|
finally:
|
|
await _finish(bridge, [stream], [cleanup])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["cleanup", "close"])
|
|
@pytest.mark.parametrize("count", [0, 1, 5])
|
|
async def test_fully_consumed_terminal_generation_keeps_end_even_after_recreation(operation, count):
|
|
bridge = MemoryStreamBridge(queue_maxsize=2)
|
|
for index in range(count):
|
|
await bridge.publish("complete", "messages", index)
|
|
stream = bridge.subscribe("complete", heartbeat_interval=0.001)
|
|
try:
|
|
if count:
|
|
for _ in range(min(count, 2)):
|
|
await anext(stream)
|
|
else:
|
|
assert await anext(stream) is HEARTBEAT_SENTINEL
|
|
await bridge.publish_end("complete")
|
|
if operation == "cleanup":
|
|
await bridge.cleanup("complete")
|
|
else:
|
|
await bridge.close()
|
|
await bridge.publish("complete", "messages", "replacement")
|
|
assert await asyncio.wait_for(anext(stream), 0.5) is END_SENTINEL
|
|
with pytest.raises(StopAsyncIteration):
|
|
await anext(stream)
|
|
finally:
|
|
await _finish(bridge, [stream])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unretired_empty_stream_heartbeats_then_delivers_normally():
|
|
bridge = MemoryStreamBridge(heartbeat_interval=0.001)
|
|
stream = bridge.subscribe("live")
|
|
try:
|
|
assert await anext(stream) is HEARTBEAT_SENTINEL
|
|
await bridge.publish("live", "messages", "answer")
|
|
await bridge.publish_end("live")
|
|
entries = [entry async for entry in stream]
|
|
assert entries[0].data == "answer"
|
|
assert entries[-1] is END_SENTINEL
|
|
finally:
|
|
await _finish(bridge, [stream])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("observer", [False, True])
|
|
@pytest.mark.parametrize("terminal", [False, True])
|
|
async def test_gateway_reports_recovery_gap_without_cancelling_a_running_run(monkeypatch, observer, terminal):
|
|
from app.gateway.services import sse_consumer
|
|
|
|
manager = RunManager()
|
|
bridge = MemoryStreamBridge()
|
|
record = await manager.create("retirement-sse")
|
|
await manager.set_status(record.run_id, RunStatus.running)
|
|
monkeypatch.setattr(manager, "cancel", AsyncMock())
|
|
request = SimpleNamespace(headers={}, is_disconnected=AsyncMock(return_value=False))
|
|
await bridge.publish(record.run_id, "messages", {"text": "partial"})
|
|
stream = sse_consumer(bridge, record, request, manager, apply_on_disconnect=not observer)
|
|
try:
|
|
assert "event: messages" in await anext(stream)
|
|
if terminal:
|
|
await bridge.publish_end(record.run_id)
|
|
await manager.set_status(record.run_id, RunStatus.success)
|
|
await bridge.cleanup(record.run_id)
|
|
frame = await asyncio.wait_for(anext(stream), 0.5)
|
|
if terminal:
|
|
assert "event: end" in frame and "event: gap" not in frame
|
|
else:
|
|
assert "event: gap" in frame and "event: end" not in frame
|
|
payload = json.loads(next(line[6:] for line in frame.splitlines() if line.startswith("data: ")))
|
|
assert payload["recovery"] == "reload_durable_state"
|
|
assert payload["earliest_available_event_id"] is None
|
|
assert payload["latest_available_event_id"] is None
|
|
with pytest.raises(StopAsyncIteration):
|
|
await anext(stream)
|
|
manager.cancel.assert_not_awaited()
|
|
finally:
|
|
await _finish(bridge, [stream])
|
|
await manager.shutdown()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("store_only", [False, True])
|
|
async def test_gateway_wait_finishes_from_terminal_state_after_retirement(store_only):
|
|
from app.gateway.services import wait_for_run_completion
|
|
|
|
store = MemoryRunStore() if store_only else None
|
|
manager = RunManager(store=store)
|
|
bridge = MemoryStreamBridge(heartbeat_interval=30)
|
|
if store_only:
|
|
await store.put("retirement-wait", thread_id="retirement", status="running")
|
|
record = await manager.get("retirement-wait")
|
|
else:
|
|
record = await manager.create("retirement")
|
|
await manager.set_status(record.run_id, RunStatus.running)
|
|
|
|
async def retire_after_first_delivery():
|
|
if store_only:
|
|
await store.update_status(record.run_id, "success")
|
|
else:
|
|
await manager.set_status(record.run_id, RunStatus.success)
|
|
await bridge.cleanup(record.run_id)
|
|
return False
|
|
|
|
request = SimpleNamespace(headers={}, is_disconnected=retire_after_first_delivery)
|
|
await bridge.publish(record.run_id, "messages", "last update")
|
|
try:
|
|
assert await asyncio.wait_for(wait_for_run_completion(bridge, record, request, manager), 0.5)
|
|
finally:
|
|
await bridge.close()
|
|
await manager.shutdown()
|