mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(channels): share chat-to-thread bindings through the database ChannelManager kept its IM chat-to-thread bindings (channel_name:chat_id[:topic_id] -> thread_id) in a per-process JSON file, channels/store.json, loaded once and rewritten whole on every change. With several Gateway processes a binding created on one replica was invisible to the others (the next message for the same chat opened a second thread) and concurrent writers clobbered each other's file. - New channel_thread_bindings table (migration 0036_channel_thread_bindings) and harness package deerflow.persistence.channel_thread_bindings: key PK (the legacy composite string), channel_name, chat_id, nullable topic_id, thread_id, user_id, epoch created_at/updated_at; index (channel_name, chat_id). Dialect upsert keeps created_at; prefix remove and batched ON CONFLICT DO NOTHING import. - app.channels.store.ChannelStore is an async protocol: SqlChannelStore for sqlite/postgres, JsonChannelStore (file I/O via asyncio.to_thread) for database.backend: memory. resolve_channel_store picks at ChannelService construction; ChannelService.start() imports an existing store.json once into an empty table and renames it store.json.migrated (a concurrently importing peer neither duplicates nor clobbers; a populated table leaves the file alone). - Callers updated: manager, connection_identity.lookup_thread_id, Feishu (send() awaits the mapping writer; the synchronous lark callback bridges its two store calls to the Gateway loop with a bounded wait), Discord (channels_dir). - Head pins in the 0027_0028/0029/0032/0034/0035 migration tests point at the new head; docs (channels and migrations AGENTS.md, CONFIGURATION.md, README) and CHANGELOG updated. * docs(changelog): link the channel thread bindings entry to #6558 * fix(channels): reject only the Gateway loop in the Feishu store bridge lark-oapi 1.5.5 invokes the message callback synchronously from its async frame handler, on the private event loop that FeishuChannel._run_ws starts for the SDK thread. The bridge's guard treated any running loop as unsafe, so every real inbound message skipped the stored-topic lookup and the pending-mapping restore: a reply whose parent card was mapped but whose root was not fell back to the unknown root, and could consume an unrelated pending clarification. Only a wait issued from the Gateway loop itself (the loop that executes the submitted coroutine) is refused now; the SDK's own loop may block for the bounded wait, as that thread blocked on the JSON rewrite before. Tests drive _on_message from a worker thread running its own loop, the production shape, for both the lookup and the write path, and pin the Gateway-loop refusal. * fix(channels): defer unresolved Feishu lookups and harden the store.json import Review follow-ups on the chat-to-thread binding store (#6558): - Import: a legacy entry whose key, channel_name, chat_id, topic_id, thread_id or user_id exceeds its column width is skipped and counted with the malformed ones instead of failing the whole batch on PostgreSQL (SQLite silently accepted the same row, so the backends diverged on one file). The corrupt-file warning now says what each caller does: the JSON store starts fresh, the import is skipped and the file kept. - Feishu bridge: store calls run on the Gateway loop under a 3s asyncio.wait_for (the SDK loop also services ws keepalives; the old 10s bound could stall it per message), and the lark-thread wait is that budget plus 1s. A deadline or store error is StoreLookupUnresolved, never a miss: _on_message no longer routes such a reply onto its unknown root (which created a second thread and later overwrote the parent alias). The message joins a per-chat FIFO drained on the Gateway loop with backoff for up to 30s, then is dropped with a warning. _on_message is split into parse / resolve / dispatch so the deferred path dispatches the same way; later messages for the chat queue behind the deferred one. * fix(tests): expect follow_redirects in the Jina client kwargs after #5808 #5808 made JinaClient pass follow_redirects=True to httpx.AsyncClient and updated test_jina_client.py, but test_jina_response_limit.py and test_jina_retry_after.py still pin the exact client kwargs to proxy and trust_env, so every backend-unit-tests shard that lands on them fails on main (the backend unit-test workflow only runs on pull requests, which is why the push to main did not surface it). Update the two expectations to include the new keyword. (cherry picked from commit d5aa23156ce19ce36d95df03953442f83ae6d050) * Revert "fix(tests): expect follow_redirects in the Jina client kwargs after #5808" This reverts commit 5bee00bfb09c65a30f77fb4df47a1f82c47ecef7. * fix(channels): bound deferred Feishu routing by the inbound queue capacity Review follow-up on #6558: a store outage let every subsequent message for a chat join the deferred queue with no capacity check (the bus reservation was only taken after routing succeeded), and each queued message started a fresh 30s retry budget when it reached the head, so a burst could be retained for N x 30s and grow without bound. - Intake is reserved BEFORE routing for every store-backed message (bus.reserve_inbound is thread-safe and already used from SDK threads), so deferred and live messages share the one configured bound, channels.inbound_queue_maxsize; a message that cannot be admitted is dropped at once with a warning, never queued or routed as a miss. - The reservation travels with the message into dispatch (committed by _prepare_inbound), is released on the batch path (the flush reserves at publish time), on drop after the budget, on a failed deferral, on dispatch failure and for every leftover when the drain task is cancelled by stop(). - The retry budget runs from the message's arrival: a queued message whose budget is spent when it reaches the head is dropped without a lookup. - The bounded resolver returns the unresolved outcome instead of raising it through the tracked task, which logged an ERROR for an expected, handled store timeout. - Upstream's new blocking-io test constructs the store by its old class name; it now uses JsonChannelStore. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
358 lines
12 KiB
Python
358 lines
12 KiB
Python
"""Exercise the adapter against real loopback WebSocket connections."""
|
|
|
|
import asyncio
|
|
import json
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from websockets.asyncio.server import serve
|
|
|
|
from app.channels import qq
|
|
from app.channels.message_bus import MessageBus
|
|
|
|
|
|
def ready(sequence=1):
|
|
return {
|
|
"op": 0,
|
|
"s": sequence,
|
|
"t": "READY",
|
|
"d": {"session_id": "fixture-session", "user": {"id": "99"}},
|
|
}
|
|
|
|
|
|
def message():
|
|
return {
|
|
"op": 0,
|
|
"s": 2,
|
|
"t": "C2C_MESSAGE_CREATE",
|
|
"d": {
|
|
"id": "fixture-message",
|
|
"content": "hello",
|
|
"author": {"user_openid": "alice"},
|
|
},
|
|
}
|
|
|
|
|
|
async def acknowledge(socket):
|
|
async for raw in socket:
|
|
if json.loads(raw).get("op") == 1:
|
|
await socket.send(json.dumps({"op": 11}))
|
|
|
|
|
|
def adapter(monkeypatch, port):
|
|
channel = qq.QQChannel(MessageBus(), {"app_id": "fixture-app", "client_secret": "fixture-secret"})
|
|
channel._api_request = AsyncMock(return_value={"url": f"ws://127.0.0.1:{port}/"})
|
|
channel._get_access_token = AsyncMock(return_value="fixture-token")
|
|
# Only test-local discovery may use plaintext loopback.
|
|
monkeypatch.setattr(qq, "validate_gateway_url", lambda url: url)
|
|
monkeypatch.setattr(qq, "RECONNECT_DELAY", 0.001)
|
|
return channel
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_resume_invalid_session_reidentify_and_deduplicate(monkeypatch):
|
|
authentications = []
|
|
recovered = asyncio.Event()
|
|
|
|
async def gateway(socket):
|
|
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 100}}))
|
|
authentications.append(json.loads(await socket.recv()))
|
|
attempt = len(authentications)
|
|
if attempt == 1:
|
|
await socket.send(json.dumps(ready()))
|
|
await socket.send(json.dumps(message()))
|
|
await socket.send(json.dumps({"op": 7}))
|
|
elif attempt == 2:
|
|
await socket.send(json.dumps({"op": 9, "d": False}))
|
|
else:
|
|
await socket.send(json.dumps(ready()))
|
|
await socket.send(json.dumps(message()))
|
|
recovered.set()
|
|
await acknowledge(socket)
|
|
|
|
async with serve(gateway, "127.0.0.1", 0) as server:
|
|
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
|
try:
|
|
await channel.start()
|
|
await asyncio.wait_for(recovered.wait(), 3)
|
|
await asyncio.wait_for(channel._events.join(), 3)
|
|
assert channel.is_running
|
|
assert [auth["op"] for auth in authentications] == [2, 6, 2]
|
|
assert authentications[0]["d"]["intents"] == 1 << 25
|
|
assert authentications[1]["d"]["session_id"] == "fixture-session"
|
|
assert authentications[1]["d"]["seq"] == 2
|
|
# Duplicate delivery is handled by ChannelManager's failure-aware
|
|
# dedupe in production; this lifecycle fixture only checks that a
|
|
# recovered event reaches the bus.
|
|
assert channel.bus.inbound_queue.qsize() == 1
|
|
assert channel.bus.get_inbound_nowait().thread_ts == "fixture-message"
|
|
finally:
|
|
await channel.stop()
|
|
assert not channel.is_running
|
|
assert channel._listener is channel._worker is channel._http is None
|
|
assert not [task for task in asyncio.all_tasks() if task.get_name().startswith("qq-")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_heartbeat_ack_reconnects(monkeypatch):
|
|
authentications = []
|
|
reconnected = asyncio.Event()
|
|
|
|
async def gateway(socket):
|
|
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 30}}))
|
|
authentications.append(json.loads(await socket.recv()))
|
|
await socket.send(json.dumps(ready()))
|
|
if len(authentications) == 1:
|
|
await socket.wait_closed()
|
|
else:
|
|
reconnected.set()
|
|
await acknowledge(socket)
|
|
|
|
async with serve(gateway, "127.0.0.1", 0) as server:
|
|
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
|
try:
|
|
await channel.start()
|
|
await asyncio.wait_for(reconnected.wait(), 3)
|
|
assert authentications[1]["op"] == 6
|
|
finally:
|
|
await channel.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_timeout_closes_socket_and_owned_tasks(monkeypatch):
|
|
closed = asyncio.Event()
|
|
|
|
async def gateway(socket):
|
|
try:
|
|
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 100}}))
|
|
await acknowledge(socket)
|
|
finally:
|
|
closed.set()
|
|
|
|
async with serve(gateway, "127.0.0.1", 0) as server:
|
|
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
|
monkeypatch.setattr(qq, "START_TIMEOUT", 0.1)
|
|
with pytest.raises(TimeoutError):
|
|
await channel.start()
|
|
await asyncio.wait_for(closed.wait(), 3)
|
|
assert channel._listener is channel._worker is channel._http is None
|
|
assert not channel.is_running
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_cancellation_drains_transport_setup(monkeypatch):
|
|
channel = qq.QQChannel(MessageBus(), {"app_id": "fixture-app", "client_secret": "fixture-secret"})
|
|
started, release = asyncio.Event(), asyncio.Event()
|
|
client = AsyncMock()
|
|
|
|
async def setup():
|
|
started.set()
|
|
await release.wait()
|
|
channel._http = client
|
|
|
|
monkeypatch.setattr(channel, "_setup_transport", setup)
|
|
task = asyncio.create_task(channel.start())
|
|
await started.wait()
|
|
task.cancel()
|
|
release.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
client.aclose.assert_awaited_once()
|
|
assert channel._http is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(("close_code", "expected_op"), [(4004, 2), (4006, 2), (4007, 2), (4009, 6)])
|
|
async def test_provider_close_codes_choose_identify_or_resume(monkeypatch, close_code, expected_op):
|
|
authentications = []
|
|
recovered = asyncio.Event()
|
|
|
|
async def gateway(socket):
|
|
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 100}}))
|
|
authentications.append(json.loads(await socket.recv()))
|
|
await socket.send(json.dumps(ready()))
|
|
if len(authentications) == 1:
|
|
await socket.close(code=close_code)
|
|
else:
|
|
recovered.set()
|
|
await acknowledge(socket)
|
|
|
|
async with serve(gateway, "127.0.0.1", 0) as server:
|
|
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
|
try:
|
|
await channel.start()
|
|
await asyncio.wait_for(recovered.wait(), 3)
|
|
assert authentications[1]["op"] == expected_op
|
|
finally:
|
|
await channel.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_heartbeat_uses_ready_sequence(monkeypatch):
|
|
heartbeats = []
|
|
observed = asyncio.Event()
|
|
|
|
async def gateway(socket):
|
|
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 100}}))
|
|
assert json.loads(await socket.recv())["op"] == 2
|
|
await socket.send(json.dumps(ready()))
|
|
async for raw in socket:
|
|
frame = json.loads(raw)
|
|
if frame.get("op") == 1:
|
|
heartbeats.append(frame["d"])
|
|
await socket.send(json.dumps({"op": 11}))
|
|
observed.set()
|
|
|
|
async with serve(gateway, "127.0.0.1", 0) as server:
|
|
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
|
try:
|
|
await channel.start()
|
|
await asyncio.wait_for(observed.wait(), 3)
|
|
assert heartbeats[0] == 1
|
|
finally:
|
|
await channel.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_successful_resume_restarts_heartbeats(monkeypatch):
|
|
connections = 0
|
|
resumed_heartbeat = asyncio.Event()
|
|
|
|
async def gateway(socket):
|
|
nonlocal connections
|
|
connections += 1
|
|
attempt = connections
|
|
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 100}}))
|
|
authentication = json.loads(await socket.recv())
|
|
if attempt == 1:
|
|
assert authentication["op"] == 2
|
|
await socket.send(json.dumps(ready()))
|
|
await socket.send(json.dumps({"op": 7}))
|
|
else:
|
|
assert authentication["op"] == 6
|
|
await socket.send(json.dumps({"op": 0, "s": 3, "t": "RESUMED", "d": {}}))
|
|
async for raw in socket:
|
|
frame = json.loads(raw)
|
|
if frame.get("op") == 1:
|
|
await socket.send(json.dumps({"op": 11}))
|
|
if attempt > 1:
|
|
assert frame["d"] == 3
|
|
resumed_heartbeat.set()
|
|
|
|
async with serve(gateway, "127.0.0.1", 0) as server:
|
|
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
|
try:
|
|
await channel.start()
|
|
await asyncio.wait_for(resumed_heartbeat.wait(), 3)
|
|
assert channel.is_running
|
|
finally:
|
|
await channel.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slow_inbound_work_does_not_block_heartbeats(monkeypatch):
|
|
entered, release, heartbeats_received = (
|
|
asyncio.Event(),
|
|
asyncio.Event(),
|
|
asyncio.Event(),
|
|
)
|
|
count = 0
|
|
|
|
async def gateway(socket):
|
|
nonlocal count
|
|
await socket.send(json.dumps({"op": 10, "d": {"heartbeat_interval": 30}}))
|
|
await socket.recv()
|
|
await socket.send(json.dumps(ready()))
|
|
await socket.send(json.dumps(message()))
|
|
async for raw in socket:
|
|
if json.loads(raw).get("op") == 1:
|
|
await socket.send(json.dumps({"op": 11}))
|
|
count += 1
|
|
if count >= 3:
|
|
heartbeats_received.set()
|
|
|
|
async def slow_inbound(frame):
|
|
entered.set()
|
|
await release.wait()
|
|
|
|
async with serve(gateway, "127.0.0.1", 0) as server:
|
|
channel = adapter(monkeypatch, server.sockets[0].getsockname()[1])
|
|
monkeypatch.setattr(channel, "_handle_inbound", slow_inbound)
|
|
try:
|
|
await channel.start()
|
|
await asyncio.wait_for(entered.wait(), 3)
|
|
await asyncio.wait_for(heartbeats_received.wait(), 3)
|
|
assert not release.is_set()
|
|
assert channel.is_running
|
|
finally:
|
|
release.set()
|
|
await channel.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_stop_drains_http_client_cleanup():
|
|
channel = qq.QQChannel(MessageBus(), {"app_id": "fixture-app", "client_secret": "fixture-secret"})
|
|
entered, release = asyncio.Event(), asyncio.Event()
|
|
|
|
async def close():
|
|
entered.set()
|
|
await release.wait()
|
|
|
|
client = AsyncMock()
|
|
client.aclose.side_effect = close
|
|
channel._http = client
|
|
task = asyncio.create_task(channel.stop())
|
|
await entered.wait()
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
assert not task.done()
|
|
task.cancel()
|
|
release.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
client.aclose.assert_awaited_once()
|
|
assert channel._http is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_service_registers_starts_and_disposes_qq(tmp_path, monkeypatch):
|
|
from app.channels import service as service_module
|
|
from app.channels.store import JsonChannelStore
|
|
from deerflow.config.app_config import AppConfig
|
|
|
|
store = JsonChannelStore(tmp_path / "channels.json")
|
|
config = {
|
|
"app_id": "fixture-app",
|
|
"client_secret": "fixture-secret",
|
|
"enabled": True,
|
|
}
|
|
app_config = AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}})
|
|
repository = object()
|
|
service = service_module.ChannelService({"qq": config}, connection_repo=repository, app_config=app_config, store=store)
|
|
|
|
async def connected(self, url):
|
|
self._ready.set()
|
|
await asyncio.Event().wait()
|
|
|
|
monkeypatch.setattr(
|
|
qq.QQChannel,
|
|
"_api_request",
|
|
AsyncMock(return_value={"url": "wss://api.sgroup.qq.com/websocket/"}),
|
|
)
|
|
monkeypatch.setattr(qq.QQChannel, "_run_connection", connected)
|
|
service._running = True # Direct-start fixture models an active service.
|
|
try:
|
|
assert await service._start_channel("qq", config)
|
|
channel = service.get_channel("qq")
|
|
assert isinstance(channel, qq.QQChannel)
|
|
assert channel.is_running
|
|
assert channel.bus is service.bus
|
|
assert channel._connection_repo is repository
|
|
assert not channel.supports_streaming
|
|
finally:
|
|
channel = service.get_channel("qq")
|
|
if channel is not None:
|
|
await service._stop_and_discard_channel("qq", channel)
|
|
assert service.get_channel("qq") is None
|
|
assert channel._listener is channel._worker is channel._http is None
|