deer-flow/backend/tests/test_channel_inbound_idempotency.py
Nan Gao 085924a3d0
fix(channels): run each IM message once across instances, restarts and late redeliveries (#7104)
* fix(channels): run each IM message once across instances, restarts and late redeliveries

Inbound IM messages were deduplicated only by the 10-minute inbound dedupe
store (#7103). Discord direct messages had no key at all, a Discord thread
reply got an instance-dependent key, a Discord thread was created before
dedupe ran, a redelivery past the window started a second run, and two
instances racing on a conversation's first messages bound it to two
threads.

- Discord messages are keyed (discord, guild_id or "dm", posting channel,
  message_id); the posting channel is the same on every instance.
- MessageBus.claim_inbound lets an adapter record the manager's dedupe key
  before a side effect; Discord claims it on the Gateway loop before
  _create_thread, marks the published message dedupe_claimed, and gives the
  claim back when the message is never committed.
- runs.wait / runs.stream / runs.create send an Idempotency-Key derived from
  the dedupe key, and keyed human message ids are uuid5 of it (a uuid4 per
  attempt made every keyed retry a 409). A reused run's status-only /wait or
  stream gap is resolved to this message's own turn via threads.get_state;
  409s about the message's own keyed run keep the key instead of reading as
  a busy thread; an ended reused GitHub run gets no follow-up watcher.
- A conversation's first thread binding is claimed insert-if-absent
  (claim_thread_id on channel_conversations and channel_thread_bindings);
  the loser adopts the winner and deletes the empty thread it minted.
  /new and /agent use still rebind.

Docs: IM_CHANNEL_CONNECTIONS.md, CONFIGURATION.md, channels AGENTS.md,
CHANGELOG (en/zh).

* fix(channels): give back Discord dedupe claims for messages never processed

Review round 1 on #7104. A Discord claim records the manager's dedupe key
before the message is queued, so every path that drops the message unrun
must release it, or a peer refuses the redelivery for the rest of the TTL:

- The hand-off task releases the claim on cancellation too (stop() drains it
  while it resolves the connection identity), via await_drained so the
  cancellation still propagates.
- ChannelManager._discard_pending_inbound releases the key of a claimed
  message that shutdown drops from the queue, after the grace period and on
  a cancelled shutdown.
- The adapter tracks claims until they are committed. A release refused
  because stop() already closed cross-loop intake, or a hand-off cancelled
  before it ran, leaves the claim tracked, and stop() releases it last.

* fix(gateway): keep IM run idempotency stable when /new rebinds the conversation

Review round 2 on #7104. The Gateway scoped every Idempotency-Key by owner
and thread. /new rebinds an IM conversation to a fresh thread, so a late
redelivery of a message admitted on the old thread was requested on the new
one, got a different scoped key and started a second run.

- Keys from trusted internal callers (the IM channel manager) are scoped by
  owner only (`internal-run:` prefix); browser and API keys keep the
  owner-and-thread scope (`http-run:`).
- A key that resolves to a run of another thread or user raises
  RunIdempotencyKeyMismatch, a RuntimeError subclass so in-process launchers
  see the same type, and start_run answers it with 409 "Idempotency-Key
  already used with a different request" instead of a 500. The channel
  manager already treats that 409 as the message's admitted run.

Docs: API.md, IM_CHANNEL_CONNECTIONS.md, gateway AGENTS.md (reworded to stay
under its budget), CHANGELOG (en/zh).

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-19 22:57:19 +08:00

521 lines
22 KiB
Python

"""IM inbound dedupe and run idempotency across replicas, restarts and late redeliveries.
The inbound dedupe store absorbs a redelivery only inside its 10-minute window
and only while the store remembers the key: an in-process store forgets it on
restart, and the shared Postgres store fails open on a database error. Past
that window the manager used to start a second run for a message the Gateway
had already admitted. Every keyed run request now carries an ``Idempotency-Key``
derived from the inbound dedupe key, with a human message id derived from the
same key so the retry's input is identical: the Gateway reuses the admitted run
and the redelivery attaches to its outcome instead of running the turn twice.
Discord gets a dedupe key for direct messages (no guild) and one that does not
depend on replica-local thread routing, and two replicas that each create a
thread for one new conversation converge on the first one bound.
"""
from __future__ import annotations
import asyncio
import tempfile
import uuid
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from langgraph_sdk.errors import ConflictError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.channels.dedupe_store import MemoryInboundDedupeStore
from app.channels.manager import ALREADY_ADMITTED_MESSAGE, THREAD_BUSY_MESSAGE, ChannelManager
from app.channels.message_bus import InboundMessage, MessageBus, OutboundMessage
from app.channels.store import JsonChannelStore, SqlChannelStore
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME
from deerflow.persistence.base import Base
from deerflow.persistence.channel_thread_bindings import ChannelThreadBindingRow
pytestmark = pytest.mark.asyncio
def _manager(*, dedupe: MemoryInboundDedupeStore | None = None, store=None, get_stream_bridge=None) -> ChannelManager:
return ChannelManager(
bus=MessageBus(),
store=store or JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json"),
inbound_dedupe_store=dedupe or MemoryInboundDedupeStore(),
max_concurrency=1,
shutdown_grace_period_seconds=0,
get_stream_bridge=get_stream_bridge,
)
def _client(thread_id: str = "thread-1") -> MagicMock:
client = MagicMock()
client.threads.create = AsyncMock(return_value={"thread_id": thread_id})
client.threads.update = AsyncMock(return_value={"thread_id": thread_id})
client.threads.get = AsyncMock(return_value={"thread_id": thread_id})
client.threads.delete = AsyncMock(return_value=None)
client.runs.wait = AsyncMock(return_value={"messages": [{"type": "human", "content": "hi"}, {"type": "ai", "content": "fresh answer"}]})
client.runs.create = AsyncMock(return_value={"run_id": "run-1", "status": "pending"})
return client
def _slack(event_id: str = "evt-1", *, team_id: str | None = "T1") -> InboundMessage:
metadata = {"event_id": event_id}
if team_id:
metadata["team_id"] = team_id
return InboundMessage(channel_name="slack", chat_id="C1", user_id="U1", text="hi", metadata=metadata)
def _feishu() -> InboundMessage:
return InboundMessage(channel_name="feishu", chat_id="oc-1", user_id="ou-1", text="hi", metadata={"message_id": "om-1"})
def _github() -> InboundMessage:
return InboundMessage(
channel_name="github",
chat_id="acme/repo",
user_id="alice",
owner_user_id="alice",
text="@bot review",
topic_id="7:reviewer",
workspace_id="acme/repo",
metadata={"message_id": "d1:alice:reviewer", "agent_name": "reviewer"},
)
def _discord(*, chat_id: str, channel_id: str, guild_id: str | None, message_id: str = "1300000000000000001", topic_id: str | None = None) -> InboundMessage:
return InboundMessage(
channel_name="discord",
chat_id=chat_id,
user_id="42",
text="hi",
topic_id=topic_id,
metadata={"guild_id": guild_id, "channel_id": channel_id, "message_id": message_id},
)
def _outbounds(manager: ChannelManager) -> list[OutboundMessage]:
sent: list[OutboundMessage] = []
async def capture(msg: OutboundMessage) -> None:
sent.append(msg)
manager.bus.subscribe_outbound(capture)
return sent
def _stream(*parts):
def factory(*_args, **_kwargs):
async def gen():
for event, data in parts:
yield SimpleNamespace(event=event, data=data)
return gen()
return MagicMock(side_effect=factory)
def _request_headers(call) -> dict:
return dict(call.kwargs.get("headers") or {})
def _human_message(call) -> dict:
[message] = call.kwargs["input"]["messages"]
return message
# -- Discord dedupe key --------------------------------------------------------
async def test_discord_direct_message_gets_a_dedupe_key():
"""A DM has no guild, so it used to get no key at all and every replica ran it."""
dm = _discord(chat_id="1200000000000000009", channel_id="1200000000000000009", guild_id=None)
assert ChannelManager._inbound_dedupe_key(dm) == ("discord", "dm", "1200000000000000009", "1300000000000000001")
async def test_discord_key_does_not_depend_on_replica_local_thread_routing():
"""A replica that tracks thread T routes a reply in T to its parent channel;
one that does not treats T as an orphan and routes it to T itself. Both must
see one message."""
tracked = _discord(chat_id="parent-1", channel_id="thread-T", guild_id="guild-1", topic_id="thread-T")
orphaned = _discord(chat_id="thread-T", channel_id="thread-T", guild_id="guild-1", topic_id="thread-new")
assert ChannelManager._inbound_dedupe_key(tracked) == ChannelManager._inbound_dedupe_key(orphaned)
assert ChannelManager._inbound_dedupe_key(tracked) == ("discord", "guild-1", "thread-T", "1300000000000000001")
async def test_discord_guild_channel_key_keeps_its_shape():
msg = _discord(chat_id="chan-1", channel_id="chan-1", guild_id="guild-1")
assert ChannelManager._inbound_dedupe_key(msg) == ("discord", "guild-1", "chan-1", "1300000000000000001")
async def test_worker_runs_a_message_the_adapter_already_claimed_and_drops_its_redelivery():
"""An adapter-claimed message has its key recorded already; the worker must
not read its own claim as a duplicate, but a second delivery still is one."""
manager = _manager()
manager._client = client = _client()
claimed = _discord(chat_id="chan-1", channel_id="chan-1", guild_id="guild-1")
assert await manager.bus.claim_inbound(claimed) is True
assert claimed.dedupe_claimed is True
redelivery = _discord(chat_id="chan-1", channel_id="chan-1", guild_id="guild-1")
assert await manager.bus.claim_inbound(redelivery) is False
await manager.start()
try:
await manager.bus.publish_inbound(claimed)
await manager.bus.publish_inbound(_discord(chat_id="chan-1", channel_id="chan-1", guild_id="guild-1"))
await asyncio.wait_for(manager.bus.join_inbound(), timeout=5)
finally:
await manager.stop()
assert client.runs.wait.await_count == 1
@pytest.mark.parametrize("cancelled", [False, True], ids=["grace-expired", "shutdown-cancelled"])
async def test_shutdown_gives_back_the_claim_of_a_queued_message_it_discards(cancelled):
"""An adapter-claimed message waiting in the queue owns a dedupe key no
worker recorded. Shutdown discards it unprocessed, so it must release the
key, or a peer drops the redelivery for the rest of the TTL."""
dedupe = MemoryInboundDedupeStore()
manager = _manager(dedupe=dedupe)
manager._client = client = _client()
busy = asyncio.Event()
async def run_forever(*_args, **_kwargs):
busy.set()
await asyncio.Event().wait()
client.runs.wait = AsyncMock(side_effect=run_forever)
await manager.start()
await manager.bus.publish_inbound(_slack("evt-busy"))
await asyncio.wait_for(busy.wait(), timeout=5)
queued = _discord(chat_id="chan-1", channel_id="chan-1", guild_id="guild-1")
assert await manager.bus.claim_inbound(queued) is True
await manager.bus.publish_inbound(queued)
if cancelled:
manager._shutdown_grace_period_seconds = 30
stopping = asyncio.create_task(manager.stop())
await asyncio.sleep(0) # stop() is now waiting out the grace period
stopping.cancel()
with pytest.raises(asyncio.CancelledError):
await stopping
else:
await manager.stop()
assert ChannelManager._inbound_dedupe_key(queued) not in dedupe._store
if cancelled:
await manager.stop()
# -- Idempotency-Key on IM run requests ----------------------------------------
async def test_wait_request_carries_an_idempotency_key_stable_across_redeliveries():
clients = []
for _ in range(2):
# A fresh dedupe store per delivery: the window expired, or the process restarted.
manager = _manager()
manager._client = client = _client()
await manager._handle_message(_slack())
clients.append(client)
first, retry = (client.runs.wait.call_args for client in clients)
key = _request_headers(first)["Idempotency-Key"]
assert key == ChannelManager._run_idempotency_key(_slack())
assert _request_headers(retry)["Idempotency-Key"] == key
# The Gateway reuses a keyed run only for an identical input, so the
# human message id must be the same on the redelivery too.
assert _human_message(first) == _human_message(retry)
uuid.UUID(_human_message(first)["id"])
async def test_distinct_messages_get_distinct_keys_and_message_ids():
manager = _manager()
manager._client = client = _client()
await manager._handle_message(_slack("evt-1"))
await manager._handle_message(_slack("evt-2"))
first, second = client.runs.wait.call_args_list
assert _request_headers(first)["Idempotency-Key"] != _request_headers(second)["Idempotency-Key"]
assert _human_message(first)["id"] != _human_message(second)["id"]
async def test_message_without_a_dedupe_key_sends_no_idempotency_key():
manager = _manager()
manager._client = client = _client()
await manager._handle_message(_slack(team_id=None))
await manager._handle_message(_slack(team_id=None))
first, second = client.runs.wait.call_args_list
assert "Idempotency-Key" not in _request_headers(first)
assert _human_message(first)["id"] != _human_message(second)["id"]
async def test_stream_request_carries_the_idempotency_key():
manager = _manager()
manager._client = client = _client()
client.runs.stream = _stream(("values", {"messages": [{"type": "human", "content": "hi"}, {"type": "ai", "content": "streamed"}]}))
await manager._handle_message(_feishu())
assert _request_headers(client.runs.stream.call_args)["Idempotency-Key"] == ChannelManager._run_idempotency_key(_feishu())
async def test_fire_and_forget_create_carries_the_idempotency_key():
import app.gateway.github.run_policy # noqa: F401 — registers GitHub's fire-and-forget policy
manager = _manager()
manager._client = client = _client()
await manager._handle_message(_github())
assert _request_headers(client.runs.create.call_args)["Idempotency-Key"] == ChannelManager._run_idempotency_key(_github())
async def test_idempotency_key_rides_along_with_the_owner_headers():
import app.gateway.github.run_policy # noqa: F401
msg = _github()
manager = _manager()
manager._client = client = _client()
await manager._handle_message(msg)
headers = _request_headers(client.runs.create.call_args)
assert headers["Idempotency-Key"] == ChannelManager._run_idempotency_key(msg)
assert headers.get(INTERNAL_OWNER_USER_ID_HEADER_NAME) == "alice"
# -- attaching a redelivery to the admitted run --------------------------------
def _state_with_turns(manager_msg: InboundMessage) -> dict:
"""Thread state where this message's turn sits between an older and a later turn."""
ours = str(ChannelManager._human_message_id(manager_msg))
return {
"values": {
"messages": [
{"type": "human", "id": "older", "content": "older question"},
{"type": "ai", "content": "older answer"},
{"type": "human", "id": ours, "content": "hi"},
{"type": "ai", "content": "the admitted run's answer"},
{"type": "human", "id": "later", "content": "a later question"},
{"type": "ai", "content": "a later answer"},
]
}
}
async def test_reused_wait_run_replies_with_this_messages_turn_from_thread_state():
"""``/wait`` on a reused run returns status only (the thread head may be a
later run). The manager reads this message's own turn instead."""
msg = _slack()
manager = _manager()
manager._client = client = _client()
client.runs.wait = AsyncMock(return_value={"status": "success", "error": None})
client.threads.get_state = AsyncMock(return_value=_state_with_turns(msg))
sent = _outbounds(manager)
await manager._handle_message(msg)
assert [out.text for out in sent] == ["the admitted run's answer"]
async def test_reused_wait_run_still_in_flight_says_it_is_already_handled_and_keeps_the_key():
msg = _slack()
dedupe = MemoryInboundDedupeStore()
manager = _manager(dedupe=dedupe)
manager._client = client = _client()
client.runs.wait = AsyncMock(return_value={"status": "running", "error": None})
sent = _outbounds(manager)
assert await manager._is_duplicate_inbound(msg) is False
await manager._handle_message(msg)
assert [out.text for out in sent] == [ALREADY_ADMITTED_MESSAGE]
assert ChannelManager._inbound_dedupe_key(msg) in dedupe._store
async def test_reused_stream_with_a_replay_gap_replies_from_thread_state():
msg = _feishu()
manager = _manager()
manager._client = client = _client()
client.runs.stream = _stream(("gap", {"code": "stream_replay_gap", "recovery": "reload_durable_state"}))
client.threads.get_state = AsyncMock(return_value=_state_with_turns(msg))
sent = _outbounds(manager)
await manager._handle_message(msg)
assert [out.text for out in sent if out.is_final] == ["the admitted run's answer"]
@pytest.mark.parametrize(
"detail",
[
"Idempotency-Key already used with a different request",
"Run 3f2a is not active on this worker and cannot be streamed",
],
ids=["input-mismatch", "foreign-active-run"],
)
async def test_conflict_from_an_admitted_keyed_run_is_not_reported_as_a_busy_thread(detail):
"""A 409 that names the keyed run (re-ingested attachments changed the
input; the run is live on a peer) means the message was already admitted:
keep the key and do not tell the user to retry."""
msg = _slack()
dedupe = MemoryInboundDedupeStore()
manager = _manager(dedupe=dedupe)
manager._client = client = _client()
request = httpx.Request("POST", "http://gateway/api/threads/thread-1/runs/wait")
client.runs.wait = AsyncMock(side_effect=ConflictError(detail, response=httpx.Response(409, request=request), body={"detail": detail}))
sent = _outbounds(manager)
assert await manager._is_duplicate_inbound(msg) is False
await manager._handle_message(msg)
assert [out.text for out in sent] == [ALREADY_ADMITTED_MESSAGE]
assert ChannelManager._inbound_dedupe_key(msg) in dedupe._store
async def test_busy_thread_conflict_keeps_its_reply_and_releases_the_key():
msg = _slack()
dedupe = MemoryInboundDedupeStore()
manager = _manager(dedupe=dedupe)
manager._client = client = _client()
request = httpx.Request("POST", "http://gateway/api/threads/thread-1/runs/wait")
detail = "Thread thread-1 already has an active run"
client.runs.wait = AsyncMock(side_effect=ConflictError(detail, response=httpx.Response(409, request=request), body={"detail": detail}))
sent = _outbounds(manager)
assert await manager._is_duplicate_inbound(msg) is False
await manager._handle_message(msg)
assert [out.text for out in sent] == [THREAD_BUSY_MESSAGE]
assert ChannelManager._inbound_dedupe_key(msg) not in dedupe._store
@pytest.mark.parametrize(("status", "watched"), [("pending", True), ("running", True), ("success", False), ("error", False)])
async def test_followup_watcher_only_follows_a_live_run(status, watched):
"""A redelivered GitHub event gets the admitted run back; once that run has
ended its stream may be gone, and a watcher would wait on it forever."""
import app.gateway.github.run_policy # noqa: F401
manager = _manager(get_stream_bridge=lambda: MagicMock())
manager._client = client = _client()
client.runs.create = AsyncMock(return_value={"run_id": "run-1", "status": status})
spawned: list[str] = []
manager._watch_run_and_drain_followups = AsyncMock(side_effect=lambda thread_id, run_id, msg: spawned.append(run_id))
await manager._handle_message(_github())
await asyncio.sleep(0)
assert spawned == (["run-1"] if watched else [])
async def test_redelivery_after_new_rebinds_the_conversation_meets_the_admitted_run(tmp_path):
"""``/new`` points the conversation at a fresh thread, so a late redelivery
is requested there. It carries the same key, which the Gateway scopes by
owner only for this internal caller, and its 409 must not start a run or
read as a busy thread."""
engine = await _shared_sql_store(tmp_path)
try:
store = SqlChannelStore(async_sessionmaker(engine, expire_on_commit=False))
msg = _slack()
first = _manager(store=store)
first._client = first_client = _client("thread-old")
await first._handle_message(msg)
await first._create_thread(_client("thread-new"), _slack("evt-new-command")) # /new
dedupe = MemoryInboundDedupeStore() # the dedupe window expired
redelivery = _manager(dedupe=dedupe, store=store)
redelivery._client = client = _client("thread-unused")
request = httpx.Request("POST", "http://gateway/api/threads/thread-new/runs/wait")
detail = "Idempotency-Key already used with a different request"
client.runs.wait = AsyncMock(side_effect=ConflictError(detail, response=httpx.Response(409, request=request), body={"detail": detail}))
sent = _outbounds(redelivery)
assert await redelivery._is_duplicate_inbound(msg) is False
await redelivery._handle_message(msg)
assert client.runs.wait.call_args.args[0] == "thread-new"
assert _request_headers(client.runs.wait.call_args)["Idempotency-Key"] == _request_headers(first_client.runs.wait.call_args)["Idempotency-Key"]
assert [out.text for out in sent] == [ALREADY_ADMITTED_MESSAGE]
assert ChannelManager._inbound_dedupe_key(msg) in dedupe._store
finally:
await engine.dispose()
# -- converging on one thread across replicas ----------------------------------
async def _shared_sql_store(tmp_path: Path):
engine = create_async_engine(f"sqlite+aiosqlite:///{(tmp_path / 'bindings.db').as_posix()}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all, tables=[ChannelThreadBindingRow.__table__])
return engine
async def test_replica_that_lost_the_first_binding_adopts_the_winner_and_discards_its_thread(tmp_path):
engine = await _shared_sql_store(tmp_path)
try:
factory = async_sessionmaker(engine, expire_on_commit=False)
manager_a, manager_b = _manager(store=SqlChannelStore(factory)), _manager(store=SqlChannelStore(factory))
client_a, client_b = _client("thread-a"), _client("thread-b")
msg = _slack()
assert await manager_a._get_or_create_thread(client_a, msg) == ("thread-a", True)
# B looked the conversation up before A's binding committed.
manager_b._lookup_thread_id = AsyncMock(return_value=None)
assert await manager_b._get_or_create_thread(client_b, msg) == ("thread-a", False)
client_b.threads.delete.assert_awaited_once()
assert client_b.threads.delete.await_args.args == ("thread-b",)
assert await SqlChannelStore(factory).get_thread_id("slack", "C1") == "thread-a"
finally:
await engine.dispose()
async def test_new_command_still_rebinds_the_conversation(tmp_path):
engine = await _shared_sql_store(tmp_path)
try:
store = SqlChannelStore(async_sessionmaker(engine, expire_on_commit=False))
manager = _manager(store=store)
msg = _slack()
await manager._get_or_create_thread(_client("thread-first"), msg)
assert await manager._create_thread(_client("thread-after-new"), msg) == "thread-after-new"
assert await store.get_thread_id("slack", "C1") == "thread-after-new"
finally:
await engine.dispose()
async def test_connection_bound_conversation_converges_through_the_repository(tmp_path):
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'connections.db'}", sqlite_dir=str(tmp_path))
try:
repo = ChannelConnectionRepository(get_session_factory(), cipher=ChannelCredentialCipher.from_key("k"))
connection = await repo.upsert_connection(owner_user_id="alice", provider="slack", external_account_id="U1", workspace_id="T1")
msg = _slack()
msg.connection_id, msg.owner_user_id = connection["id"], "alice"
manager_a, manager_b = _manager(), _manager()
manager_a._connection_repo = manager_b._connection_repo = repo
client_b = _client("thread-b")
assert await manager_a._get_or_create_thread(_client("thread-a"), msg) == ("thread-a", True)
manager_b._lookup_thread_id = AsyncMock(return_value=None)
assert await manager_b._get_or_create_thread(client_b, msg) == ("thread-a", False)
assert client_b.threads.delete.await_args.args == ("thread-b",)
assert await repo.get_thread_id(connection["id"], "C1") == "thread-a"
finally:
await close_engine()