mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
651 lines
22 KiB
Python
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()
|