deer-flow/backend/tests/blocking_io/test_buzz_channel_seen_events.py
早上肚子疼 3b601922ff
fix(buzz): move seen-event persistence off event loop (#5103)
* fix(buzz): move seen-event persistence off event loop

* fix(buzz): address seen-event persistence review

* fix(buzz): replace stale scheduled flush tasks

* fix(buzz): harden final seen-event flush

* fix(buzz): make seen-event shutdown retryable

* fix(buzz): quiesce persistence after channel stop

* fix(buzz): drain late events on repeated stop

---------

Co-authored-by: zaoshangduziteng <309590849+zaoshangduziteng@users.noreply.github.com>
2026-08-31 23:18:34 +08:00

175 lines
6.3 KiB
Python

"""Regression coverage for Buzz replay persistence at the channel boundary."""
from __future__ import annotations
import asyncio
import json
import threading
from pathlib import Path
import pytest
from app.channels import buzz_nostr, buzz_seen_events
from app.channels.buzz import BuzzChannel
from app.channels.buzz_seen_events import BuzzSeenEventStore
from app.channels.message_bus import MessageBus
pytestmark = pytest.mark.asyncio
_BOT_PUBLIC = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9"
_OWNER_PUBLIC = "11" * 32
_CHANNEL_ID = "136852ee-63e1-49c2-8927-413b5ee8e5f7"
def _event() -> dict:
tags = [["h", _CHANNEL_ID], ["p", _BOT_PUBLIC]]
created_at = 1_700_000_100
content = "@DeerFlow hello"
return {
"id": buzz_nostr.event_id(_OWNER_PUBLIC, created_at, 9, tags, content),
"pubkey": _OWNER_PUBLIC,
"created_at": created_at,
"kind": 9,
"tags": tags,
"content": content,
# Signature verification is patched below. Keeping a correctly shaped
# event makes this test independent of the optional ``buzz`` extra, so
# the default blocking-I/O CI job cannot silently skip the regression.
"sig": "00" * 64,
}
def _channel(path: Path, *, seen_events: BuzzSeenEventStore | None = None) -> tuple[BuzzChannel, list]:
channel = BuzzChannel(
bus=MessageBus(),
config={
"relay_url": "wss://buzz.example.com",
"private_key": "unused-by-this-test",
"allowed_users": [_OWNER_PUBLIC],
"seen_event_store": seen_events or BuzzSeenEventStore(path),
},
)
channel._keys = buzz_nostr.NostrKeys(secret=b"", pubkey_hex=_BOT_PUBLIC)
published = []
async def publish(message) -> None:
published.append(message)
channel._publish = publish
return channel, published
async def test_channel_stop_persists_replay_guard_without_blocking(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A clean stop suppresses the same relay event after channel restart."""
monkeypatch.setattr(buzz_nostr, "verify_event", lambda _event: True)
path = tmp_path / "buzz-seen-events.json"
event_frame = json.dumps(["EVENT", "buzz-chat", _event()])
first, first_messages = _channel(path)
await first.handle_relay_frame(event_frame)
assert len(first_messages) == 1
first._running = True
first.bus.subscribe_outbound(first._on_outbound)
await first.stop()
restarted, restarted_messages = _channel(path)
await restarted.handle_relay_frame(event_frame)
assert restarted_messages == []
async def test_channel_stop_retries_seen_event_flush_after_cancellation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A Gateway timeout must leave seen-event cleanup retryable."""
path = tmp_path / "buzz-seen-events.json"
seen_events = BuzzSeenEventStore(path)
channel, _ = _channel(path, seen_events=seen_events)
write_started = threading.Event()
release_write = threading.Event()
retry_write_started = threading.Event()
write_snapshot = seen_events._write_snapshot
write_count = 0
def fail_cancelled_write(payload: dict[str, list[str]]) -> bool:
nonlocal write_count
write_count += 1
if write_count == 1:
write_started.set()
assert release_write.wait(timeout=2)
return False
retry_write_started.set()
return write_snapshot(payload)
seen_events._write_snapshot = fail_cancelled_write
monkeypatch.setattr(buzz_seen_events, "FLUSH_DELAY_SECONDS", 60)
await seen_events.arecord(_CHANNEL_ID, "event-1")
channel._running = True
channel.bus.subscribe_outbound(channel._on_outbound)
first_stop = asyncio.create_task(channel.stop())
assert await asyncio.to_thread(write_started.wait, 2)
first_stop.cancel()
with pytest.raises(asyncio.CancelledError):
await first_stop
retry_stop = asyncio.create_task(channel.stop())
assert not await asyncio.to_thread(retry_write_started.wait, 0.05)
release_write.set()
await retry_stop
restarted = BuzzSeenEventStore(path)
assert await restarted.aseen(_CHANNEL_ID, "event-1")
async def test_abandoned_relay_records_are_drained_by_retried_stop_or_restart(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Late relay records stay unscheduled but remain reachable to cleanup."""
path = tmp_path / "buzz-seen-events.json"
seen_events = BuzzSeenEventStore(path)
channel, _ = _channel(path, seen_events=seen_events)
relay_started = asyncio.Event()
release_abandoned_relay = asyncio.Event()
async def cancellation_resistant_relay() -> None:
relay_started.set()
try:
await asyncio.Future()
except asyncio.CancelledError:
await release_abandoned_relay.wait()
await seen_events.arecord(_CHANNEL_ID, "late-event")
monkeypatch.setattr("app.channels.buzz.STOP_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(buzz_seen_events, "FLUSH_DELAY_SECONDS", 0.01)
channel._task = asyncio.create_task(cancellation_resistant_relay())
channel._running = True
channel.bus.subscribe_outbound(channel._on_outbound)
await relay_started.wait()
abandoned_relay = channel._task
assert abandoned_relay is not None
await channel.stop()
release_abandoned_relay.set()
await abandoned_relay
assert await seen_events.aseen(_CHANNEL_ID, "late-event")
await asyncio.sleep(0.05)
stopped_view = BuzzSeenEventStore(path)
assert not await stopped_view.aseen(_CHANNEL_ID, "late-event")
await channel.stop()
retried_stop_view = BuzzSeenEventStore(path)
assert await retried_stop_view.aseen(_CHANNEL_ID, "late-event")
await seen_events.arecord(_CHANNEL_ID, "restart-event")
await asyncio.sleep(0.05)
still_stopped_view = BuzzSeenEventStore(path)
assert not await still_stopped_view.aseen(_CHANNEL_ID, "restart-event")
monkeypatch.setattr(buzz_nostr, "parse_private_key", lambda _value: buzz_nostr.NostrKeys(secret=b"", pubkey_hex=_BOT_PUBLIC))
channel._spawn_connection = lambda: None
await channel.start()
await asyncio.sleep(0.05)
restarted_view = BuzzSeenEventStore(path)
assert await restarted_view.aseen(_CHANNEL_ID, "restart-event")
await channel.stop()