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 (#6558)
* 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>
This commit is contained in:
parent
cd15687284
commit
6c4c74a504
24
CHANGELOG.md
24
CHANGELOG.md
@ -833,6 +833,29 @@ This release closes that milestone with **439 merged pull requests**.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **channels:** IM conversations now keep their DeerFlow thread across Gateway
|
||||
replicas. The `ChannelManager` kept its 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, so
|
||||
with several Gateway instances a binding created on one was invisible to the
|
||||
others — the next message for the same chat landing elsewhere opened a second
|
||||
thread — and concurrent writers clobbered each other's file. The bindings now
|
||||
live in the shared `channel_thread_bindings` table (migration
|
||||
`0038_channel_thread_bindings`) whenever `database.backend` is `sqlite` or
|
||||
`postgres`; `memory` keeps the JSON file. On the first start after upgrading,
|
||||
an existing `store.json` is imported once into an empty table (`INSERT … ON
|
||||
CONFLICT DO NOTHING`, so two replicas importing at the same time cannot
|
||||
duplicate a binding) and renamed `store.json.migrated`; a populated table
|
||||
leaves the file untouched; entries whose key components exceed the table's
|
||||
column widths are skipped and counted instead of failing the whole import.
|
||||
The store API is async so the database never blocks the Gateway loop;
|
||||
Feishu's synchronous lark callback bridges its lookups to that loop with a
|
||||
short bounded wait, and a lookup the database does not answer in time is
|
||||
retried on the Gateway loop (up to 30 s, then the message is dropped with a
|
||||
warning) rather than routed as a missing mapping onto a new thread; such
|
||||
deferred messages hold a slot of the bounded inbound queue
|
||||
(`channels.inbound_queue_maxsize`), so a database outage cannot grow the
|
||||
backlog, and one that cannot be admitted is dropped with a warning. ([#6558])
|
||||
- **runtime:** The JSONL event store no longer loses events after a torn final
|
||||
line. A write interrupted mid-record left the file without a trailing newline,
|
||||
so the next append was glued onto the partial record and both became one
|
||||
@ -9426,5 +9449,6 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#6537]: https://github.com/bytedance/deer-flow/pull/6537
|
||||
[#6543]: https://github.com/bytedance/deer-flow/pull/6543
|
||||
[#6556]: https://github.com/bytedance/deer-flow/pull/6556
|
||||
[#6558]: https://github.com/bytedance/deer-flow/pull/6558
|
||||
[#6580]: https://github.com/bytedance/deer-flow/pull/6580
|
||||
[#6582]: https://github.com/bytedance/deer-flow/pull/6582
|
||||
|
||||
@ -527,7 +527,7 @@ Browser login uses `HttpOnly` session cookies. The login page offers a "keep me
|
||||
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. Kubernetes replicas run one worker per Pod, which the worker count cannot see: declare them with `deployment.multi_instance: true` (or `DEER_FLOW_MULTI_INSTANCE=1`, which deploy tooling such as a Helm chart can set from its replica count) so the startup gate enforces the same prerequisites instead of staying inert. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE, `/wait`, and internal stream consumers use `stream_bridge.heartbeat_interval_seconds` (default `15`) for idle liveness checks; changing it requires a Gateway restart. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. Failed-login counters and lockouts for `POST /api/v1/auth/login/local` (`auth.local.max_login_attempts` / `lockout_seconds`) are kept in the shared `login_throttle` table whenever the application database is SQLite or Postgres (`auth.local.throttle_storage: auto`, the default), so every replica enforces one lockout per client IP; `memory` keeps the historical per-process counter, which under N replicas hands an attacker N × `max_login_attempts` guesses and logs a startup warning. IM channel state and other process-local services still need their own multi-worker coordination.
|
||||
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. Kubernetes replicas run one worker per Pod, which the worker count cannot see: declare them with `deployment.multi_instance: true` (or `DEER_FLOW_MULTI_INSTANCE=1`, which deploy tooling such as a Helm chart can set from its replica count) so the startup gate enforces the same prerequisites instead of staying inert. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE, `/wait`, and internal stream consumers use `stream_bridge.heartbeat_interval_seconds` (default `15`) for idle liveness checks; changing it requires a Gateway restart. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. Failed-login counters and lockouts for `POST /api/v1/auth/login/local` (`auth.local.max_login_attempts` / `lockout_seconds`) are kept in the shared `login_throttle` table whenever the application database is SQLite or Postgres (`auth.local.throttle_storage: auto`, the default), so every replica enforces one lockout per client IP; `memory` keeps the historical per-process counter, which under N replicas hands an attacker N × `max_login_attempts` guesses and logs a startup warning. IM chat-to-thread bindings live in the shared `channel_thread_bindings` table whenever the application database is SQLite or Postgres (an existing `channels/store.json` is imported once at startup and renamed `store.json.migrated`); the remaining IM channel state (each instance's platform connections, follow-up buffers) still needs its own multi-worker coordination.
|
||||
>
|
||||
> In single-process JSONL deployments, cancelling an admitted event-store mutation waits for its background file I/O, rollback, and bookkeeping to settle before releasing the thread write lock. This prevents an older cancelled write from recreating deleted records or rolling back a later successful write. Cancellation can therefore wait on slow storage; it does not stop an in-flight filesystem operation. Callers still waiting to acquire the lock can cancel without starting a mutation. A batch spanning multiple threads drains its current thread group before propagating cancellation; subsequent thread groups do not start.
|
||||
>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -49,12 +49,13 @@ async def lookup_thread_id(msg: InboundMessage, *, repo: Any, store: Any) -> str
|
||||
|
||||
A message carrying a resolved connection is mapped in the connection repository
|
||||
and nowhere else (``ChannelManager._store_thread_id`` writes only there); every
|
||||
other message is mapped in the JSON ``ChannelStore``. Adapters that gate on "is
|
||||
this thread already ours?" must use this rather than reading the store directly,
|
||||
or bound users' threads look unengaged.
|
||||
other message is mapped in the global ``ChannelStore`` (the shared
|
||||
``channel_thread_bindings`` table, or the legacy JSON file on a memory database).
|
||||
Adapters that gate on "is this thread already ours?" must use this rather than
|
||||
reading the store directly, or bound users' threads look unengaged.
|
||||
"""
|
||||
if msg.connection_id and repo is not None:
|
||||
return await repo.get_thread_id(msg.connection_id, msg.chat_id, msg.topic_id)
|
||||
if store is None:
|
||||
return None
|
||||
return store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
|
||||
return await store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
|
||||
|
||||
@ -113,8 +113,8 @@ class DiscordChannel(Channel):
|
||||
self._allowed_channels: frozenset[str] = _parse_allowed_channels(config.get("allowed_channels"))
|
||||
|
||||
# Session tracking: channel_id -> Discord thread_id (in-memory, persisted to JSON).
|
||||
# Uses a dedicated JSON file separate from ChannelStore, which maps IM
|
||||
# conversations to DeerFlow thread IDs — a different concern.
|
||||
# Uses a dedicated JSON file next to the channel store's state, separate from
|
||||
# the ChannelStore itself, which maps IM conversations to DeerFlow thread IDs.
|
||||
self._active_threads: dict[str, str] = {}
|
||||
# Reverse-lookup set for O(1) thread ID checks (avoids O(n) scan of _active_threads.values()).
|
||||
self._active_thread_ids: set[str] = set()
|
||||
@ -127,9 +127,9 @@ class DiscordChannel(Channel):
|
||||
# error) — ChannelService then stops the instance, and an ungated flush
|
||||
# would overwrite the persisted mappings with an empty snapshot (#2897).
|
||||
self._thread_store_loaded = False
|
||||
store = config.get("channel_store")
|
||||
if store is not None:
|
||||
self._thread_store_path = store._path.parent / "discord_threads.json"
|
||||
channels_dir = getattr(config.get("channel_store"), "channels_dir", None)
|
||||
if channels_dir is not None:
|
||||
self._thread_store_path = Path(channels_dir) / "discord_threads.json"
|
||||
else:
|
||||
self._thread_store_path = Path.home() / ".deer-flow" / "channels" / "discord_threads.json"
|
||||
|
||||
|
||||
@ -9,7 +9,12 @@ import re
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Coroutine
|
||||
from concurrent.futures import CancelledError as FutureCancelledError
|
||||
from concurrent.futures import Future
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, TypeVar
|
||||
|
||||
from app.channels.base import Channel, ChannelStopTimeout
|
||||
from app.channels.commands import is_known_channel_command, strip_leading_mentions
|
||||
@ -37,6 +42,91 @@ from deerflow.uploads.manager import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
PENDING_CLARIFICATION_TTL_SECONDS = 30 * 60
|
||||
# Channel-store deadlines. ``_on_message`` runs on lark-oapi's private event loop,
|
||||
# which also carries the SDK's websocket reads, pongs and its 120s ping loop:
|
||||
# every second the bridge blocks that loop is a second the connection cannot
|
||||
# service keepalives, so the bound is a few seconds (one indexed SELECT per
|
||||
# candidate), not the database's 30s command timeout. The deadline lives on the
|
||||
# Gateway-loop side (``asyncio.wait_for`` around the store calls, which actually
|
||||
# cancels them); the bridge wait only adds slack for a stalled Gateway loop.
|
||||
FEISHU_STORE_LOOKUP_TIMEOUT_SECONDS = 3.0
|
||||
FEISHU_STORE_BRIDGE_TIMEOUT_SECONDS = FEISHU_STORE_LOOKUP_TIMEOUT_SECONDS + 1.0
|
||||
# A lookup that missed its deadline (or failed) is *unresolved*, never a miss:
|
||||
# the message is deferred to the Gateway loop, which retries with backoff up to
|
||||
# the database's own command budget and then drops it rather than opening a new
|
||||
# conversation for a reply whose mapping the database could still have served.
|
||||
# The budget runs from the message's arrival, not from when it reaches its
|
||||
# chat's queue head, and every deferred message holds a bus intake reservation
|
||||
# taken before routing, so the deferred backlog is bounded by
|
||||
# ``inbound_queue_maxsize`` exactly like live messages (a message that cannot be
|
||||
# admitted is dropped with a warning, never queued).
|
||||
FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS = 30.0
|
||||
FEISHU_STORE_RESOLVE_RETRY_BACKOFF_SECONDS = 1.0
|
||||
FEISHU_STORE_RESOLVE_RETRY_MAX_BACKOFF_SECONDS = 8.0
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class StoreLookupUnresolved(Exception):
|
||||
"""A channel-store call produced no answer: deadline, store error, no Gateway loop, or channel stop.
|
||||
|
||||
Distinct from a *miss* (the store answered "no mapping"): an unresolved lookup
|
||||
must never route a message as if the mapping did not exist.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, *, future: Future[Any] | None = None) -> None:
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
# The still-running bridge future when the lark-thread wait gave up before
|
||||
# the coroutine's own deadline; the deferred path awaits its outcome first.
|
||||
self.future = future
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ParsedMessage:
|
||||
"""The content of one lark message event, independent of how it is routed."""
|
||||
|
||||
chat_id: str
|
||||
msg_id: str
|
||||
sender_id: str
|
||||
root_id: str | None
|
||||
parent_id: str | None
|
||||
feishu_thread_id: str | None
|
||||
chat_type: str | None
|
||||
text: str
|
||||
files: list[dict[str, Any]]
|
||||
msg_type: InboundMessageType = InboundMessageType.CHAT
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Routing:
|
||||
"""Where a parsed message goes: resolved (stored mapping), plain miss, or pending clarification."""
|
||||
|
||||
topic_id: str | None
|
||||
from_stored_mapping: bool
|
||||
from_pending: bool
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RoutingAttempt:
|
||||
"""Retry state for one message: once a pending clarification is consumed the route is decided.
|
||||
|
||||
``reservation`` is the bus intake slot taken before routing; it travels with
|
||||
the message into dispatch (committed there) or is released on every drop.
|
||||
``arrived_at`` anchors the per-message retry budget.
|
||||
"""
|
||||
|
||||
parsed: _ParsedMessage
|
||||
reservation: InboundReservation | None = None
|
||||
arrived_at: float = field(default_factory=time.monotonic)
|
||||
pending: dict[str, Any] | None = None
|
||||
attempted: bool = False
|
||||
|
||||
def release_reservation(self) -> None:
|
||||
if self.reservation is not None:
|
||||
self.reservation.release()
|
||||
self.reservation = None
|
||||
|
||||
|
||||
FEISHU_INBOUND_BATCH_WINDOW_SECONDS = 0.75
|
||||
FEISHU_MAX_INBOUND_FILE_BYTES = 20_000_000
|
||||
SOURCE_PREVIEW_METADATA_KEY = "feishu_source_preview"
|
||||
@ -85,6 +175,10 @@ class FeishuChannel(Channel):
|
||||
self._CreateImageRequestBody = None
|
||||
self._GetMessageResourceRequest = None
|
||||
self._thread_lock = threading.Lock()
|
||||
# Per-chat FIFO of messages whose routing is deferred to the Gateway loop
|
||||
# (unresolved store lookups); guarded by ``_thread_lock``. A chat with a
|
||||
# queue has one drain task owning that deque.
|
||||
self._deferred_routing: dict[str, deque[_RoutingAttempt]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _non_empty_str(value: Any) -> str | None:
|
||||
@ -712,10 +806,10 @@ class FeishuChannel(Channel):
|
||||
running_card_id,
|
||||
)
|
||||
fallback_card_id = await self._reply_card(source_message_id, card_text)
|
||||
self._remember_thread_mapping(msg, source_message_id, fallback_card_id)
|
||||
await self._remember_thread_mapping(msg, source_message_id, fallback_card_id)
|
||||
self._remember_pending_clarification(msg, fallback_card_id)
|
||||
else:
|
||||
self._remember_thread_mapping(msg, source_message_id, running_card_id)
|
||||
await self._remember_thread_mapping(msg, source_message_id, running_card_id)
|
||||
self._remember_pending_clarification(msg, running_card_id)
|
||||
logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id)
|
||||
elif msg.is_final:
|
||||
@ -723,7 +817,7 @@ class FeishuChannel(Channel):
|
||||
source_message_id,
|
||||
self._compose_card_text(msg.text, msg.metadata),
|
||||
)
|
||||
self._remember_thread_mapping(msg, source_message_id, final_card_id)
|
||||
await self._remember_thread_mapping(msg, source_message_id, final_card_id)
|
||||
self._remember_pending_clarification(msg, final_card_id)
|
||||
elif awaited_running_card_task:
|
||||
logger.warning(
|
||||
@ -736,7 +830,7 @@ class FeishuChannel(Channel):
|
||||
msg.text,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
self._remember_thread_mapping(msg, source_message_id, created_card_id)
|
||||
await self._remember_thread_mapping(msg, source_message_id, created_card_id)
|
||||
|
||||
if msg.is_final:
|
||||
self._running_card_ids.pop(source_message_id, None)
|
||||
@ -747,7 +841,7 @@ class FeishuChannel(Channel):
|
||||
|
||||
# -- internal ----------------------------------------------------------
|
||||
|
||||
def _remember_thread_mapping(self, msg: OutboundMessage, *topic_ids: str | None) -> None:
|
||||
async def _remember_thread_mapping(self, msg: OutboundMessage, *topic_ids: str | None) -> None:
|
||||
store = self.config.get("channel_store")
|
||||
if store is None or not msg.thread_id:
|
||||
return
|
||||
@ -771,7 +865,7 @@ class FeishuChannel(Channel):
|
||||
continue
|
||||
seen.add(topic_id)
|
||||
try:
|
||||
store.set_thread_id(
|
||||
await store.set_thread_id(
|
||||
self.name,
|
||||
msg.chat_id,
|
||||
msg.thread_id,
|
||||
@ -781,6 +875,58 @@ class FeishuChannel(Channel):
|
||||
except Exception:
|
||||
logger.exception("[Feishu] failed to remember thread mapping for topic_id=%s", topic_id)
|
||||
|
||||
def _run_store_call(self, make_coroutine: Callable[[], Coroutine[Any, Any, _T]], *, name: str, msg_id: Any) -> _T:
|
||||
"""Run an async ``channel_store`` call from the synchronous lark callback.
|
||||
|
||||
The store is async (it may be the shared database table). ``_on_message``
|
||||
runs on lark-oapi's thread, so the call is submitted to the Gateway loop
|
||||
(``_main_loop``) through the tracked threadsafe-future helper (``stop()``
|
||||
drains or cancels it) and awaited here for at most
|
||||
``FEISHU_STORE_BRIDGE_TIMEOUT_SECONDS``.
|
||||
|
||||
That callback does run inside an event loop: ``_run_ws`` gives the SDK
|
||||
thread its own loop and lark-oapi 1.5.5 calls ``_on_message``
|
||||
synchronously from its async frame handler, so ``get_running_loop()``
|
||||
succeeds here on every real message. Blocking that loop briefly is fine —
|
||||
it is the SDK's private loop in its own thread, the one that used to block
|
||||
on the JSON file rewrite the store did inline — because the coroutine runs
|
||||
on the Gateway loop, not on it. The only wait that must be refused is one
|
||||
issued from the Gateway loop itself (``get_running_loop() is _main_loop``):
|
||||
it would block the loop that has to execute the submitted coroutine.
|
||||
|
||||
Every way of *not* getting an answer — no running Gateway loop, called on
|
||||
the Gateway loop, channel stopping, bridge wait exhausted, cancelled by
|
||||
stop, or the coroutine's own ``StoreLookupUnresolved`` — raises
|
||||
``StoreLookupUnresolved`` so the caller can defer the message; a timed-out
|
||||
wait is never reported as "no mapping". Other exceptions the coroutine
|
||||
raised propagate unchanged.
|
||||
"""
|
||||
loop = self._main_loop
|
||||
if loop is None or not loop.is_running():
|
||||
logger.warning("[Feishu] main loop not running, cannot %s", name)
|
||||
raise StoreLookupUnresolved("main loop not running")
|
||||
try:
|
||||
current = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
current = None
|
||||
if current is loop:
|
||||
logger.warning("[Feishu] %s requested on the Gateway loop itself; a blocking wait here would deadlock it, skipping the channel store call", name)
|
||||
raise StoreLookupUnresolved("called on the Gateway loop")
|
||||
future = self._submit_threadsafe_coroutine_future(make_coroutine(), loop, name=name, msg_id=msg_id)
|
||||
if future is None:
|
||||
logger.info("[Feishu] channel stopping, skipped %s", name)
|
||||
raise StoreLookupUnresolved("channel stopping")
|
||||
try:
|
||||
return future.result(timeout=FEISHU_STORE_BRIDGE_TIMEOUT_SECONDS)
|
||||
except TimeoutError:
|
||||
# Not cancelled: the coroutine carries its own deadline and the
|
||||
# deferred path awaits its outcome before retrying.
|
||||
logger.warning("[Feishu] %s did not answer within %.0fs; deferring message %s", name, FEISHU_STORE_BRIDGE_TIMEOUT_SECONDS, msg_id)
|
||||
raise StoreLookupUnresolved("bridge wait exhausted", future=future) from None
|
||||
except FutureCancelledError:
|
||||
logger.info("[Feishu] %s cancelled by channel stop", name)
|
||||
raise StoreLookupUnresolved("cancelled by channel stop") from None
|
||||
|
||||
def _remember_pending_clarification(self, msg: OutboundMessage, card_message_id: str | None) -> None:
|
||||
if not msg.is_final or msg.metadata.get(PENDING_CLARIFICATION_METADATA_KEY) is not True:
|
||||
return
|
||||
@ -834,41 +980,239 @@ class FeishuChannel(Channel):
|
||||
self._pending_clarifications.pop(key, None)
|
||||
return None
|
||||
|
||||
def _ensure_pending_thread_mapping(self, chat_id: str, user_id: str, pending: dict[str, Any]) -> None:
|
||||
def _route_without_store(self, parsed: _ParsedMessage) -> _Routing:
|
||||
"""Routing for a channel constructed without a ``channel_store`` (no lookups possible)."""
|
||||
topic_id: str | None = parsed.root_id or parsed.msg_id
|
||||
if parsed.chat_type == "p2p":
|
||||
topic_id = None
|
||||
if parsed.msg_type != InboundMessageType.CHAT:
|
||||
return _Routing(topic_id, False, False)
|
||||
pending = self._consume_pending_clarification(parsed.chat_id, parsed.sender_id)
|
||||
pending_topic_id = self._non_empty_str(pending.get("topic_id")) if pending else None
|
||||
if not pending_topic_id:
|
||||
return _Routing(topic_id, False, False)
|
||||
return _Routing(pending_topic_id, False, True)
|
||||
|
||||
async def _resolve_routing(self, attempt: _RoutingAttempt) -> _Routing:
|
||||
"""Decide a message's topic on the Gateway loop, consulting and updating the channel store.
|
||||
|
||||
Stored mappings for the root / parent / thread ids win (``_first_stored_topic``);
|
||||
otherwise the route is the root (or the message itself; ``None`` in P2P chats),
|
||||
unless a pending clarification for the sender names a topic — that topic's
|
||||
mapping is then persisted so the manager finds the clarified thread. The
|
||||
pending clarification is consumed once: a retried attempt keeps the decision
|
||||
and only redoes the write.
|
||||
"""
|
||||
store = self.config.get("channel_store")
|
||||
topic_id = self._non_empty_str(pending.get("topic_id"))
|
||||
thread_id = self._non_empty_str(pending.get("thread_id"))
|
||||
if store is None or not topic_id or not thread_id:
|
||||
return
|
||||
parsed = attempt.parsed
|
||||
if attempt.pending is None:
|
||||
candidates = [candidate for candidate in (self._non_empty_str(parsed.root_id), parsed.parent_id, parsed.feishu_thread_id) if candidate]
|
||||
if candidates:
|
||||
stored = await self._first_stored_topic(store, parsed.chat_id, candidates)
|
||||
if stored:
|
||||
return _Routing(stored, True, False)
|
||||
topic_id: str | None = parsed.root_id or parsed.msg_id
|
||||
if parsed.chat_type == "p2p":
|
||||
topic_id = None
|
||||
if parsed.msg_type != InboundMessageType.CHAT:
|
||||
return _Routing(topic_id, False, False)
|
||||
pending = self._consume_pending_clarification(parsed.chat_id, parsed.sender_id)
|
||||
if not (pending and self._non_empty_str(pending.get("topic_id"))):
|
||||
return _Routing(topic_id, False, False)
|
||||
attempt.pending = pending
|
||||
pending_topic_id = self._non_empty_str(attempt.pending.get("topic_id"))
|
||||
thread_id = self._non_empty_str(attempt.pending.get("thread_id"))
|
||||
if thread_id:
|
||||
await store.set_thread_id(self.name, parsed.chat_id, thread_id, topic_id=pending_topic_id, user_id=parsed.sender_id)
|
||||
return _Routing(pending_topic_id, False, True)
|
||||
|
||||
async def _resolve_routing_bounded(self, attempt: _RoutingAttempt) -> _Routing | StoreLookupUnresolved:
|
||||
"""``_resolve_routing`` under the store deadline; no answer in time (or a store error) is *unresolved*.
|
||||
|
||||
The unresolved outcome is *returned*, not raised: this coroutine runs as a
|
||||
tracked submission whose failure the base class reports at ERROR, and an
|
||||
unanswered lookup during a store outage is an expected, handled outcome.
|
||||
"""
|
||||
attempt.attempted = True
|
||||
try:
|
||||
store.set_thread_id(self.name, chat_id, thread_id, topic_id=topic_id, user_id=user_id)
|
||||
except Exception:
|
||||
logger.exception("[Feishu] failed to restore pending clarification mapping for topic_id=%s", topic_id)
|
||||
return await asyncio.wait_for(self._resolve_routing(attempt), FEISHU_STORE_LOOKUP_TIMEOUT_SECONDS)
|
||||
except TimeoutError:
|
||||
return StoreLookupUnresolved(f"channel store did not answer within {FEISHU_STORE_LOOKUP_TIMEOUT_SECONDS:g}s")
|
||||
except Exception as exc:
|
||||
# A failing store is not "no mapping": routing on it would open a new
|
||||
# conversation for a reply whose thread the database still knows.
|
||||
logger.warning("[Feishu] channel store call failed for message %s (%s); treating the lookup as unresolved", attempt.parsed.msg_id, exc.__class__.__name__, exc_info=True)
|
||||
return StoreLookupUnresolved(f"channel store error: {exc.__class__.__name__}")
|
||||
|
||||
def _resolve_topic_id(
|
||||
self,
|
||||
chat_id: str,
|
||||
msg_id: str,
|
||||
*,
|
||||
root_id: str | None,
|
||||
parent_id: str | None,
|
||||
thread_id: str | None,
|
||||
) -> tuple[str, bool]:
|
||||
store = self.config.get("channel_store")
|
||||
candidates = [root_id, parent_id, thread_id]
|
||||
async def _first_stored_topic(self, store: Any, chat_id: str, candidates: list[str]) -> str | None:
|
||||
"""The first candidate topic id the store already maps to a thread, in priority order."""
|
||||
for candidate in candidates:
|
||||
if await store.get_thread_id(self.name, chat_id, topic_id=candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
if store is not None:
|
||||
for candidate in candidates:
|
||||
candidate = self._non_empty_str(candidate)
|
||||
if not candidate:
|
||||
continue
|
||||
def _defer_routing(self, attempt: _RoutingAttempt, unresolved: StoreLookupUnresolved) -> None:
|
||||
"""Hand an unresolved message to the Gateway loop (lark thread; never blocks).
|
||||
|
||||
The chat gets a FIFO deque and one drain task; later messages for the same
|
||||
chat queue behind it in ``_on_message`` so none overtakes a deferred
|
||||
predecessor onto a different thread. Without a running Gateway loop the
|
||||
message is dropped (logged) — nothing could dispatch it anyway.
|
||||
"""
|
||||
parsed = attempt.parsed
|
||||
loop = self._main_loop
|
||||
if loop is None or not loop.is_running():
|
||||
attempt.release_reservation()
|
||||
logger.warning("[Feishu] dropping message %s for chat %s: %s and no Gateway loop to defer it to", parsed.msg_id, parsed.chat_id, unresolved.reason)
|
||||
return
|
||||
queue: deque[_RoutingAttempt] = deque([attempt])
|
||||
with self._thread_lock:
|
||||
existing = self._deferred_routing.get(parsed.chat_id)
|
||||
if existing is not None:
|
||||
existing.append(attempt)
|
||||
return
|
||||
self._deferred_routing[parsed.chat_id] = queue
|
||||
scheduled = self._submit_threadsafe_coroutine(
|
||||
self._drain_deferred_routing(parsed.chat_id, queue, unresolved.future),
|
||||
loop,
|
||||
name="deferred_routing",
|
||||
msg_id=parsed.msg_id,
|
||||
)
|
||||
if not scheduled:
|
||||
with self._thread_lock:
|
||||
if self._deferred_routing.get(parsed.chat_id) is queue:
|
||||
del self._deferred_routing[parsed.chat_id]
|
||||
attempt.release_reservation()
|
||||
logger.warning("[Feishu] dropping message %s for chat %s: %s and the channel is stopping", parsed.msg_id, parsed.chat_id, unresolved.reason)
|
||||
return
|
||||
logger.info("[Feishu] deferred message %s for chat %s: %s; retrying on the Gateway loop for up to %.0fs", parsed.msg_id, parsed.chat_id, unresolved.reason, FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS)
|
||||
|
||||
async def _drain_deferred_routing(self, chat_id: str, queue: deque[_RoutingAttempt], first_future: Future[Any] | None) -> None:
|
||||
"""Resolve and dispatch a chat's deferred messages in order, then retire the queue."""
|
||||
try:
|
||||
while True:
|
||||
with self._thread_lock:
|
||||
if not queue:
|
||||
if self._deferred_routing.get(chat_id) is queue:
|
||||
del self._deferred_routing[chat_id]
|
||||
return
|
||||
attempt = queue[0]
|
||||
routing: _Routing | None = None
|
||||
if first_future is not None:
|
||||
# The lark thread's own call may still finish (its deadline is
|
||||
# the coroutine's); use that answer before spending retries.
|
||||
pending_first, first_future = first_future, None
|
||||
try:
|
||||
outcome = await asyncio.shield(asyncio.wrap_future(pending_first))
|
||||
except StoreLookupUnresolved:
|
||||
outcome = None
|
||||
routing = outcome if isinstance(outcome, _Routing) else None
|
||||
if routing is None:
|
||||
routing = await self._retry_routing(attempt)
|
||||
try:
|
||||
if store.get_thread_id(self.name, chat_id, topic_id=candidate):
|
||||
return candidate, True
|
||||
except Exception:
|
||||
logger.exception("[Feishu] failed to resolve stored topic mapping for topic_id=%s", candidate)
|
||||
if routing is None:
|
||||
attempt.release_reservation()
|
||||
logger.warning(
|
||||
"[Feishu] dropping message %s for chat %s: the channel store did not answer within %gs of its arrival; not routing it as a new conversation",
|
||||
attempt.parsed.msg_id,
|
||||
chat_id,
|
||||
FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS,
|
||||
)
|
||||
else:
|
||||
self._dispatch_inbound(attempt.parsed, routing, reservation=attempt.reservation)
|
||||
attempt.reservation = None
|
||||
finally:
|
||||
with self._thread_lock:
|
||||
if queue and queue[0] is attempt:
|
||||
queue.popleft()
|
||||
finally:
|
||||
with self._thread_lock:
|
||||
leftovers = list(queue)
|
||||
queue.clear()
|
||||
if self._deferred_routing.get(chat_id) is queue:
|
||||
del self._deferred_routing[chat_id]
|
||||
for leftover in leftovers:
|
||||
leftover.release_reservation()
|
||||
if leftovers:
|
||||
logger.warning("[Feishu] dropping %d deferred messages for chat %s: the channel is stopping", len(leftovers), chat_id)
|
||||
|
||||
return root_id or msg_id, False
|
||||
async def _retry_routing(self, attempt: _RoutingAttempt) -> _Routing | None:
|
||||
"""Retry the bounded resolution with backoff until the message's own budget; ``None`` when it never answered.
|
||||
|
||||
The budget is ``FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS`` from the message's
|
||||
arrival, so a message that waited behind others in its chat's queue does not
|
||||
start a fresh budget at the head: one whose budget is already spent is dropped
|
||||
without another lookup.
|
||||
"""
|
||||
deadline = attempt.arrived_at + FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS
|
||||
backoff = FEISHU_STORE_RESOLVE_RETRY_BACKOFF_SECONDS
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
if attempt.attempted:
|
||||
await asyncio.sleep(min(backoff, remaining))
|
||||
backoff = min(backoff * 2, FEISHU_STORE_RESOLVE_RETRY_MAX_BACKOFF_SECONDS)
|
||||
if time.monotonic() >= deadline:
|
||||
return None
|
||||
outcome = await self._resolve_routing_bounded(attempt)
|
||||
if isinstance(outcome, _Routing):
|
||||
return outcome
|
||||
logger.debug("[Feishu] retrying routing for message %s: %s", attempt.parsed.msg_id, outcome.reason)
|
||||
|
||||
def _dispatch_inbound(self, parsed: _ParsedMessage, routing: _Routing, *, reservation: InboundReservation | None = None) -> None:
|
||||
"""Build the inbound for a routed message and hand it to batching / the Gateway loop.
|
||||
|
||||
``reservation`` is the intake slot taken before routing; it is handed to
|
||||
``_schedule_prepare_inbound`` (committed or released there) or released on the
|
||||
batch path (the batch flush reserves when it publishes) and on failure.
|
||||
"""
|
||||
try:
|
||||
source_preview = None
|
||||
if self._should_include_source_preview(chat_type=parsed.chat_type, root_id=parsed.root_id, parent_id=parsed.parent_id, thread_id=parsed.feishu_thread_id):
|
||||
source_preview = self._compact_source_preview(parsed.text)
|
||||
|
||||
metadata = {
|
||||
"message_id": parsed.msg_id,
|
||||
"root_id": parsed.root_id,
|
||||
"parent_id": parsed.parent_id,
|
||||
"thread_id": parsed.feishu_thread_id,
|
||||
"topic_id": routing.topic_id,
|
||||
"user_id": parsed.sender_id,
|
||||
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY: routing.from_pending,
|
||||
}
|
||||
if source_preview:
|
||||
metadata[SOURCE_PREVIEW_METADATA_KEY] = source_preview
|
||||
|
||||
inbound = self._make_inbound(
|
||||
chat_id=parsed.chat_id,
|
||||
user_id=parsed.sender_id,
|
||||
text=parsed.text,
|
||||
msg_type=parsed.msg_type,
|
||||
thread_ts=parsed.msg_id,
|
||||
files=parsed.files,
|
||||
metadata=metadata,
|
||||
)
|
||||
inbound.topic_id = routing.topic_id
|
||||
|
||||
if self._is_batchable_file_inbound(
|
||||
msg_type=parsed.msg_type,
|
||||
text=parsed.text,
|
||||
files=parsed.files,
|
||||
root_id=parsed.root_id,
|
||||
parent_id=parsed.parent_id,
|
||||
thread_id=parsed.feishu_thread_id,
|
||||
):
|
||||
if reservation is not None:
|
||||
reservation.release()
|
||||
self._queue_file_inbound_batch(parsed.msg_id, inbound)
|
||||
return
|
||||
|
||||
self._schedule_prepare_inbound(parsed.msg_id, inbound, reservation=reservation)
|
||||
except Exception:
|
||||
if reservation is not None:
|
||||
reservation.release()
|
||||
logger.exception("[Feishu] error dispatching message %s", parsed.msg_id)
|
||||
|
||||
@staticmethod
|
||||
def _is_batchable_file_inbound(
|
||||
@ -888,11 +1232,13 @@ class FeishuChannel(Channel):
|
||||
inbound: InboundMessage,
|
||||
*,
|
||||
source_message_ids: list[str] | None = None,
|
||||
reservation: InboundReservation | None = None,
|
||||
) -> None:
|
||||
if self._main_loop and self._main_loop.is_running():
|
||||
reservation = self._reserve_inbound(inbound)
|
||||
if reservation is None:
|
||||
return
|
||||
reservation = self._reserve_inbound(inbound)
|
||||
if reservation is None:
|
||||
return
|
||||
logger.info("[Feishu] publishing inbound message to bus (type=%s, msg_id=%s)", inbound.msg_type.value, msg_id)
|
||||
scheduled = self._submit_threadsafe_coroutine(
|
||||
self._prepare_inbound(
|
||||
@ -909,6 +1255,8 @@ class FeishuChannel(Channel):
|
||||
if not scheduled:
|
||||
logger.info("[Feishu] main loop stopped before reserved inbound could be scheduled")
|
||||
else:
|
||||
if reservation is not None:
|
||||
reservation.release()
|
||||
logger.warning("[Feishu] main loop not running, cannot publish inbound message")
|
||||
|
||||
def _schedule_batch_flush(self, key: tuple[str, str], source_message_id: str) -> None:
|
||||
@ -1074,93 +1422,110 @@ class FeishuChannel(Channel):
|
||||
await self._reply_card(message_id, "Feishu connected to DeerFlow.")
|
||||
return True
|
||||
|
||||
def _on_message(self, event) -> None:
|
||||
"""Called by lark-oapi when a message is received (runs in lark thread)."""
|
||||
try:
|
||||
logger.info("[Feishu] raw event received: type=%s", type(event).__name__)
|
||||
message = event.event.message
|
||||
chat_id = message.chat_id
|
||||
msg_id = message.message_id
|
||||
sender_id = event.event.sender.sender_id.open_id
|
||||
def _parse_message_event(self, event) -> _ParsedMessage | None:
|
||||
"""Extract text, attachments and ids from a lark message event; ``None`` for an empty message."""
|
||||
message = event.event.message
|
||||
chat_id = message.chat_id
|
||||
msg_id = message.message_id
|
||||
sender_id = event.event.sender.sender_id.open_id
|
||||
|
||||
root_id = getattr(message, "root_id", None) or None
|
||||
chat_type = getattr(message, "chat_type", None)
|
||||
parent_id = self._non_empty_str(getattr(message, "parent_id", None))
|
||||
feishu_thread_id = self._non_empty_str(getattr(message, "thread_id", None))
|
||||
root_id = getattr(message, "root_id", None) or None
|
||||
chat_type = getattr(message, "chat_type", None)
|
||||
parent_id = self._non_empty_str(getattr(message, "parent_id", None))
|
||||
feishu_thread_id = self._non_empty_str(getattr(message, "thread_id", None))
|
||||
|
||||
# Parse message content
|
||||
content = json.loads(message.content)
|
||||
# Parse message content
|
||||
content = json.loads(message.content)
|
||||
|
||||
# files_list store the any-file-key in feishu messages, which can be used to download the file content later
|
||||
# In Feishu channel, image_keys are independent of file_keys.
|
||||
# The file_key includes files, videos, and audio, but does not include stickers.
|
||||
files_list = []
|
||||
# files_list store the any-file-key in feishu messages, which can be used to download the file content later
|
||||
# In Feishu channel, image_keys are independent of file_keys.
|
||||
# The file_key includes files, videos, and audio, but does not include stickers.
|
||||
files_list = []
|
||||
|
||||
if "text" in content:
|
||||
# Handle plain text messages
|
||||
text = content["text"]
|
||||
elif "file_key" in content:
|
||||
file_key = content.get("file_key")
|
||||
if isinstance(file_key, str) and file_key:
|
||||
files_list.append({"file_key": file_key})
|
||||
text = "[file]"
|
||||
else:
|
||||
text = ""
|
||||
elif "image_key" in content:
|
||||
image_key = content.get("image_key")
|
||||
if isinstance(image_key, str) and image_key:
|
||||
files_list.append({"image_key": image_key})
|
||||
text = "[image]"
|
||||
else:
|
||||
text = ""
|
||||
elif "content" in content and isinstance(content["content"], list):
|
||||
# Handle rich-text messages with a top-level "content" list (e.g., topic groups/posts)
|
||||
text_paragraphs: list[str] = []
|
||||
for paragraph in content["content"]:
|
||||
if isinstance(paragraph, list):
|
||||
paragraph_text_parts: list[str] = []
|
||||
for element in paragraph:
|
||||
if isinstance(element, dict):
|
||||
# Include both normal text and @ mentions
|
||||
if element.get("tag") in ("text", "at"):
|
||||
text_value = element.get("text", "")
|
||||
if text_value:
|
||||
paragraph_text_parts.append(text_value)
|
||||
elif element.get("tag") == "img":
|
||||
image_key = element.get("image_key")
|
||||
if isinstance(image_key, str) and image_key:
|
||||
files_list.append({"image_key": image_key})
|
||||
paragraph_text_parts.append("[image]")
|
||||
elif element.get("tag") in ("file", "media"):
|
||||
file_key = element.get("file_key")
|
||||
if isinstance(file_key, str) and file_key:
|
||||
files_list.append({"file_key": file_key})
|
||||
paragraph_text_parts.append("[file]")
|
||||
if paragraph_text_parts:
|
||||
# Join text segments within a paragraph with spaces to avoid "helloworld"
|
||||
text_paragraphs.append(" ".join(paragraph_text_parts))
|
||||
|
||||
# Join paragraphs with blank lines to preserve paragraph boundaries
|
||||
text = "\n\n".join(text_paragraphs)
|
||||
if "text" in content:
|
||||
# Handle plain text messages
|
||||
text = content["text"]
|
||||
elif "file_key" in content:
|
||||
file_key = content.get("file_key")
|
||||
if isinstance(file_key, str) and file_key:
|
||||
files_list.append({"file_key": file_key})
|
||||
text = "[file]"
|
||||
else:
|
||||
text = ""
|
||||
text = text.strip()
|
||||
elif "image_key" in content:
|
||||
image_key = content.get("image_key")
|
||||
if isinstance(image_key, str) and image_key:
|
||||
files_list.append({"image_key": image_key})
|
||||
text = "[image]"
|
||||
else:
|
||||
text = ""
|
||||
elif "content" in content and isinstance(content["content"], list):
|
||||
# Handle rich-text messages with a top-level "content" list (e.g., topic groups/posts)
|
||||
text_paragraphs: list[str] = []
|
||||
for paragraph in content["content"]:
|
||||
if isinstance(paragraph, list):
|
||||
paragraph_text_parts: list[str] = []
|
||||
for element in paragraph:
|
||||
if isinstance(element, dict):
|
||||
# Include both normal text and @ mentions
|
||||
if element.get("tag") in ("text", "at"):
|
||||
text_value = element.get("text", "")
|
||||
if text_value:
|
||||
paragraph_text_parts.append(text_value)
|
||||
elif element.get("tag") == "img":
|
||||
image_key = element.get("image_key")
|
||||
if isinstance(image_key, str) and image_key:
|
||||
files_list.append({"image_key": image_key})
|
||||
paragraph_text_parts.append("[image]")
|
||||
elif element.get("tag") in ("file", "media"):
|
||||
file_key = element.get("file_key")
|
||||
if isinstance(file_key, str) and file_key:
|
||||
files_list.append({"file_key": file_key})
|
||||
paragraph_text_parts.append("[file]")
|
||||
if paragraph_text_parts:
|
||||
# Join text segments within a paragraph with spaces to avoid "helloworld"
|
||||
text_paragraphs.append(" ".join(paragraph_text_parts))
|
||||
|
||||
logger.info(
|
||||
"[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, parent_id=%s, thread_id=%s, chat_type=%s, sender=%s, text_len=%d",
|
||||
chat_id,
|
||||
msg_id,
|
||||
root_id,
|
||||
parent_id,
|
||||
feishu_thread_id,
|
||||
chat_type,
|
||||
sender_id,
|
||||
len(text or ""),
|
||||
)
|
||||
# Join paragraphs with blank lines to preserve paragraph boundaries
|
||||
text = "\n\n".join(text_paragraphs)
|
||||
else:
|
||||
text = ""
|
||||
text = text.strip()
|
||||
|
||||
if not (text or files_list):
|
||||
logger.info("[Feishu] empty text, ignoring message")
|
||||
logger.info(
|
||||
"[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, parent_id=%s, thread_id=%s, chat_type=%s, sender=%s, text_len=%d",
|
||||
chat_id,
|
||||
msg_id,
|
||||
root_id,
|
||||
parent_id,
|
||||
feishu_thread_id,
|
||||
chat_type,
|
||||
sender_id,
|
||||
len(text or ""),
|
||||
)
|
||||
|
||||
if not (text or files_list):
|
||||
logger.info("[Feishu] empty text, ignoring message")
|
||||
return None
|
||||
return _ParsedMessage(
|
||||
chat_id=chat_id,
|
||||
msg_id=msg_id,
|
||||
sender_id=sender_id,
|
||||
root_id=root_id,
|
||||
parent_id=parent_id,
|
||||
feishu_thread_id=feishu_thread_id,
|
||||
chat_type=chat_type,
|
||||
text=text,
|
||||
files=files_list,
|
||||
)
|
||||
|
||||
def _on_message(self, event) -> None:
|
||||
"""Called by lark-oapi when a message is received (runs synchronously on the SDK's loop thread)."""
|
||||
try:
|
||||
parsed = self._parse_message_event(event)
|
||||
if parsed is None:
|
||||
return
|
||||
msg_id, chat_id, sender_id, text = parsed.msg_id, parsed.chat_id, parsed.sender_id, parsed.text
|
||||
|
||||
connect_code = self._pending_connect_code(text)
|
||||
if connect_code:
|
||||
@ -1191,75 +1556,40 @@ class FeishuChannel(Channel):
|
||||
# inbound so ChannelManager._handle_command parses the bare command.
|
||||
command_text = strip_leading_mentions(text)
|
||||
if _is_feishu_command(command_text):
|
||||
msg_type = InboundMessageType.COMMAND
|
||||
text = command_text
|
||||
else:
|
||||
msg_type = InboundMessageType.CHAT
|
||||
parsed.msg_type = InboundMessageType.COMMAND
|
||||
parsed.text = command_text
|
||||
|
||||
# topic_id determines which LangGraph thread the message maps to.
|
||||
# P2P chats: topic_id=None so all messages share one thread (like Telegram DMs).
|
||||
# But check stored mappings first for backward compatibility with pre-upgrade P2P threads.
|
||||
topic_id, resolved_from_stored_mapping = self._resolve_topic_id(
|
||||
chat_id,
|
||||
msg_id,
|
||||
root_id=root_id,
|
||||
parent_id=parent_id,
|
||||
thread_id=feishu_thread_id,
|
||||
)
|
||||
if chat_type == "p2p" and not resolved_from_stored_mapping:
|
||||
topic_id = None
|
||||
resolved_from_pending = False
|
||||
if msg_type == InboundMessageType.CHAT and not resolved_from_stored_mapping:
|
||||
pending = self._consume_pending_clarification(chat_id, sender_id)
|
||||
pending_topic_id = self._non_empty_str(pending.get("topic_id")) if pending else None
|
||||
if pending_topic_id:
|
||||
topic_id = pending_topic_id
|
||||
self._ensure_pending_thread_mapping(chat_id, sender_id, pending)
|
||||
resolved_from_pending = True
|
||||
|
||||
source_preview = None
|
||||
if self._should_include_source_preview(
|
||||
chat_type=chat_type,
|
||||
root_id=root_id,
|
||||
parent_id=parent_id,
|
||||
thread_id=feishu_thread_id,
|
||||
):
|
||||
source_preview = self._compact_source_preview(text)
|
||||
|
||||
metadata = {
|
||||
"message_id": msg_id,
|
||||
"root_id": root_id,
|
||||
"parent_id": parent_id,
|
||||
"thread_id": feishu_thread_id,
|
||||
"topic_id": topic_id,
|
||||
"user_id": sender_id,
|
||||
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY: resolved_from_pending,
|
||||
}
|
||||
if source_preview:
|
||||
metadata[SOURCE_PREVIEW_METADATA_KEY] = source_preview
|
||||
|
||||
inbound = self._make_inbound(
|
||||
chat_id=chat_id,
|
||||
user_id=sender_id,
|
||||
text=text,
|
||||
msg_type=msg_type,
|
||||
thread_ts=msg_id,
|
||||
files=files_list,
|
||||
metadata=metadata,
|
||||
)
|
||||
inbound.topic_id = topic_id
|
||||
|
||||
if self._is_batchable_file_inbound(
|
||||
msg_type=msg_type,
|
||||
text=text,
|
||||
files=files_list,
|
||||
root_id=root_id,
|
||||
parent_id=parent_id,
|
||||
thread_id=feishu_thread_id,
|
||||
):
|
||||
self._queue_file_inbound_batch(msg_id, inbound)
|
||||
if self.config.get("channel_store") is None:
|
||||
self._dispatch_inbound(parsed, self._route_without_store(parsed))
|
||||
return
|
||||
|
||||
self._schedule_prepare_inbound(msg_id, inbound)
|
||||
# Reserve bounded intake *before* routing: a message that may be deferred
|
||||
# while the store is slow holds a real slot of the inbound queue, so the
|
||||
# deferred backlog shares ``inbound_queue_maxsize`` with live messages.
|
||||
# The placeholder only feeds the bus's capacity warning.
|
||||
placeholder = self._make_inbound(chat_id=chat_id, user_id=sender_id, text=parsed.text, msg_type=parsed.msg_type, thread_ts=msg_id, files=parsed.files)
|
||||
reservation = self._reserve_inbound(placeholder)
|
||||
if reservation is None:
|
||||
logger.warning("[Feishu] dropping message %s for chat %s: inbound intake capacity exhausted", msg_id, chat_id)
|
||||
return
|
||||
attempt = _RoutingAttempt(parsed, reservation=reservation)
|
||||
with self._thread_lock:
|
||||
queue = self._deferred_routing.get(chat_id)
|
||||
if queue is not None:
|
||||
# Per-chat FIFO: never overtake a deferred predecessor onto another thread.
|
||||
queue.append(attempt)
|
||||
logger.info("[Feishu] queued message %s behind a deferred message for chat %s", msg_id, chat_id)
|
||||
return
|
||||
try:
|
||||
outcome = self._run_store_call(lambda: self._resolve_routing_bounded(attempt), name="resolve_topic_mapping", msg_id=msg_id)
|
||||
except StoreLookupUnresolved as unresolved:
|
||||
outcome = unresolved
|
||||
if isinstance(outcome, StoreLookupUnresolved):
|
||||
self._defer_routing(attempt, outcome)
|
||||
return
|
||||
self._dispatch_inbound(parsed, outcome, reservation=attempt.reservation)
|
||||
except Exception:
|
||||
logger.exception("[Feishu] error processing message")
|
||||
|
||||
@ -147,7 +147,7 @@ BOUND_IDENTITY_UNAVAILABLE_MESSAGE = "Channel connection verification is tempora
|
||||
# GitHub). This is parity with every other IM channel's dedupe (same
|
||||
# mechanism, same TTL), not a channel-specific gap. True idempotency against
|
||||
# a late/manual redelivery would require persisting the dedupe key in
|
||||
# ``ChannelStore`` instead, which is not implemented here.
|
||||
# the database-backed ``ChannelStore`` instead, which is not implemented here.
|
||||
# Follow-up buffering for busy fire_and_forget threads (issue #4121 Slice 2).
|
||||
# A ConflictError on a channel opted into ChannelRunPolicy.buffer_followups_on_busy
|
||||
# buffers the triggering message per-thread instead of only logging it; a
|
||||
@ -2452,7 +2452,7 @@ class ChannelManager:
|
||||
)
|
||||
return
|
||||
|
||||
self.store.set_thread_id(
|
||||
await self.store.set_thread_id(
|
||||
msg.channel_name,
|
||||
msg.chat_id,
|
||||
thread_id,
|
||||
|
||||
@ -17,7 +17,7 @@ RUNTIME_CHANNEL_DISABLED_FLAG = "_runtime_disabled"
|
||||
class ChannelRuntimeConfigStore:
|
||||
"""JSON-backed store for channel credentials entered from the UI.
|
||||
|
||||
This intentionally mirrors ``ChannelStore``: local/private deployments get
|
||||
This intentionally mirrors ``JsonChannelStore``: local/private deployments get
|
||||
durable runtime configuration without needing a public callback URL or a
|
||||
config.yaml edit.
|
||||
"""
|
||||
|
||||
@ -14,7 +14,7 @@ from app.channels.base import Channel, ChannelStopTimeout
|
||||
from app.channels.manager import DEFAULT_CHANNEL_MAX_CONCURRENCY, DEFAULT_CHANNEL_SHUTDOWN_GRACE_PERIOD_SECONDS, DEFAULT_GATEWAY_URL, DEFAULT_LANGGRAPH_URL, ChannelManager
|
||||
from app.channels.message_bus import DEFAULT_INBOUND_QUEUE_MAXSIZE, MessageBus
|
||||
from app.channels.runtime_config_store import merge_runtime_channel_configs
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import ChannelStore, SqlChannelStore, resolve_channel_store
|
||||
from deerflow.utils.file_io import await_drained
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -128,13 +128,18 @@ class ChannelService:
|
||||
require_bound_identity: bool = False,
|
||||
app_config: AppConfig | None = None,
|
||||
get_stream_bridge: Callable[[], StreamBridge | None] | None = None,
|
||||
store: ChannelStore | None = None,
|
||||
) -> None:
|
||||
config = dict(channels_config or {})
|
||||
inbound_queue_maxsize = _resolve_positive_int(config, "inbound_queue_maxsize", DEFAULT_INBOUND_QUEUE_MAXSIZE)
|
||||
max_concurrency = _resolve_positive_int(config, "max_concurrency", DEFAULT_CHANNEL_MAX_CONCURRENCY)
|
||||
shutdown_grace_period_seconds = _resolve_non_negative_float(config, "shutdown_grace_period_seconds", DEFAULT_CHANNEL_SHUTDOWN_GRACE_PERIOD_SECONDS)
|
||||
self.bus = MessageBus(inbound_queue_maxsize=inbound_queue_maxsize)
|
||||
self.store = ChannelStore()
|
||||
# Chat-to-thread bindings: the shared ``channel_thread_bindings`` table
|
||||
# for a sqlite/postgres database, the legacy JSON file otherwise. The
|
||||
# JSON path resolves ``base_dir`` through realpath, so ``from_app_config``
|
||||
# runs this constructor off the loop.
|
||||
self.store: ChannelStore = store if store is not None else resolve_channel_store(app_config)
|
||||
self._connection_repo = connection_repo
|
||||
self._get_stream_bridge = get_stream_bridge
|
||||
langgraph_url = _resolve_service_url(config, "langgraph_url", _CHANNELS_LANGGRAPH_URL_ENV, DEFAULT_LANGGRAPH_URL)
|
||||
@ -212,6 +217,17 @@ class ChannelService:
|
||||
async with self._manager_start_lock:
|
||||
if generation != self._shutdown_generation or self._running:
|
||||
return
|
||||
if isinstance(self.store, SqlChannelStore):
|
||||
# One-time move of a pre-database ``channels/store.json`` into the
|
||||
# shared table (idempotent; a peer replica importing concurrently is
|
||||
# tolerated). It runs under the start lock, after the generation
|
||||
# check and before the manager starts, so the bindings are in the
|
||||
# table before the first message is routed (a known chat would
|
||||
# otherwise get a second thread), and a stop() that lands during
|
||||
# the import bumps the generation and invalidates this start.
|
||||
await self.store.import_legacy_json()
|
||||
if generation != self._shutdown_generation:
|
||||
return
|
||||
await self.manager.start()
|
||||
if generation != self._shutdown_generation:
|
||||
# Keep newer starts waiting until this late start is drained.
|
||||
@ -660,9 +676,9 @@ async def start_channel_service(
|
||||
global _channel_service
|
||||
if _channel_service is not None:
|
||||
return _channel_service
|
||||
# from_app_config reads the JSON channel store and runtime config files;
|
||||
# keep that disk IO off the event loop. asyncio.to_thread forwards both
|
||||
# args and kwargs to the target callable.
|
||||
# from_app_config resolves the channel store path (realpath) and reads the
|
||||
# runtime config files; keep that disk IO off the event loop.
|
||||
# asyncio.to_thread forwards both args and kwargs to the target callable.
|
||||
service = await asyncio.to_thread(ChannelService.from_app_config, app_config, get_stream_bridge=get_stream_bridge)
|
||||
_channel_service = service
|
||||
|
||||
|
||||
@ -1,25 +1,95 @@
|
||||
"""ChannelStore — persists IM chat-to-DeerFlow thread mappings."""
|
||||
"""ChannelStore — persists IM chat-to-DeerFlow thread bindings.
|
||||
|
||||
``ChannelManager`` maps every unbound IM conversation
|
||||
(``channel_name:chat_id[:topic_id]``) to the DeerFlow thread it runs on. Two
|
||||
implementations share the :class:`ChannelStore` protocol:
|
||||
|
||||
- :class:`SqlChannelStore` keeps the bindings in the ``channel_thread_bindings``
|
||||
table of the application database (SQLite or PostgreSQL). Every Gateway
|
||||
replica sharing that database sees the same bindings, so a conversation
|
||||
created on one replica continues on the same thread when the next message
|
||||
lands on another. This is the store for every non-``memory`` database.
|
||||
- :class:`JsonChannelStore` is the historical single file at
|
||||
``{base_dir}/channels/store.json``, loaded once per process and rewritten
|
||||
atomically on every change. It stays for ``database.backend: memory`` (no
|
||||
database to share) and as the source of the one-time import below. With
|
||||
several Gateway processes each loads its own copy: a binding created on one
|
||||
is invisible to the others and concurrent writers clobber each other's file.
|
||||
|
||||
The protocol is async because the SQL store runs on the async engine and must
|
||||
never block the Gateway event loop; the JSON store offloads its file I/O through
|
||||
``asyncio.to_thread`` for the same reason (anchored by
|
||||
``tests/blocking_io/test_channel_thread_binding_store.py``). Callers in a
|
||||
synchronous SDK callback (Feishu's lark thread) bridge to the loop through
|
||||
``Channel._submit_threadsafe_coroutine_future``.
|
||||
|
||||
Selection (:func:`resolve_channel_store`) follows ``database.backend``:
|
||||
``memory`` -> JSON; ``sqlite`` / ``postgres`` -> SQL whenever the persistence
|
||||
engine is initialised, else JSON with a warning (a bare ``ChannelService``
|
||||
without the Gateway lifespan). ``ChannelService.start()`` then runs
|
||||
:meth:`SqlChannelStore.import_legacy_json` once: if ``store.json`` exists and the
|
||||
table is empty every entry is inserted with ``ON CONFLICT DO NOTHING`` (two
|
||||
replicas importing concurrently cannot duplicate or clobber a binding) and the
|
||||
file is renamed to ``store.json.migrated``; a populated table leaves the file
|
||||
alone, and a missing file is a no-op.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.channel_thread_bindings import ChannelThreadBinding, SqlChannelThreadBindingRepository, binding_key, split_binding_key
|
||||
from deerflow.persistence.channel_thread_bindings.model import BINDING_KEY_LENGTH, CHANNEL_NAME_LENGTH, CHAT_ID_LENGTH, THREAD_ID_LENGTH, TOPIC_ID_LENGTH, USER_ID_LENGTH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Suffix appended to ``store.json`` once its entries live in the database.
|
||||
MIGRATED_SUFFIX = ".migrated"
|
||||
|
||||
class ChannelStore:
|
||||
|
||||
class ChannelStore(Protocol):
|
||||
"""Async mapping from an IM conversation/topic to its DeerFlow ``thread_id``."""
|
||||
|
||||
async def get_thread_id(self, channel_name: str, chat_id: str, topic_id: str | None = None) -> str | None:
|
||||
"""Look up the DeerFlow thread_id for a given IM conversation/topic."""
|
||||
...
|
||||
|
||||
async def set_thread_id(self, channel_name: str, chat_id: str, thread_id: str, *, topic_id: str | None = None, user_id: str = "") -> None:
|
||||
"""Create or update the mapping for an IM conversation/topic (an update keeps ``created_at``)."""
|
||||
...
|
||||
|
||||
async def remove(self, channel_name: str, chat_id: str, topic_id: str | None = None) -> bool:
|
||||
"""Remove one topic mapping, or — without ``topic_id`` — the base mapping and every topic of that chat."""
|
||||
...
|
||||
|
||||
async def list_entries(self, channel_name: str | None = None) -> list[dict[str, Any]]:
|
||||
"""List all stored mappings, optionally filtered by channel."""
|
||||
...
|
||||
|
||||
|
||||
def default_store_path() -> Path:
|
||||
"""``{base_dir}/channels/store.json`` (resolves ``base_dir`` through realpath: call off the loop)."""
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
return Path(get_paths().base_dir) / "channels" / "store.json"
|
||||
|
||||
|
||||
class JsonChannelStore:
|
||||
"""JSON-file-backed store that maps IM conversations to DeerFlow threads.
|
||||
|
||||
Data layout (on disk)::
|
||||
|
||||
{
|
||||
"<channel_name>:<chat_id>": {
|
||||
"<channel_name>:<chat_id>[:<topic_id>]": {
|
||||
"thread_id": "<uuid>",
|
||||
"user_id": "<platform_user>",
|
||||
"created_at": 1700000000.0,
|
||||
@ -28,38 +98,39 @@ class ChannelStore:
|
||||
...
|
||||
}
|
||||
|
||||
The store is intentionally simple — a single JSON file that is atomically
|
||||
rewritten on every mutation. For production workloads with high concurrency,
|
||||
this can be swapped for a proper database backend.
|
||||
The file is read once (lazily, on the first access) and atomically
|
||||
rewritten on every mutation, with the lock held across the mutation and the
|
||||
write so concurrent writers in this process serialize. Nothing re-reads the
|
||||
file afterwards, so processes sharing one file do not see each other's
|
||||
bindings — use :class:`SqlChannelStore` whenever an application database
|
||||
exists. Construction is I/O-free; every operation runs its file work in a
|
||||
worker thread.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path | None = None) -> None:
|
||||
if path is None:
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
path = Path(get_paths().base_dir) / "channels" / "store.json"
|
||||
self._path = Path(path)
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._data: dict[str, dict[str, Any]] = self._load()
|
||||
self._path = Path(path) if path is not None else default_store_path()
|
||||
self._data: dict[str, dict[str, Any]] | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# -- persistence -------------------------------------------------------
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self._path
|
||||
|
||||
def _load(self) -> dict[str, dict[str, Any]]:
|
||||
if self._path.exists():
|
||||
try:
|
||||
return json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
logger.warning("Corrupt channel store at %s, starting fresh", self._path)
|
||||
return {}
|
||||
@property
|
||||
def channels_dir(self) -> Path:
|
||||
"""The directory sibling channel state files (Discord thread map, Buzz seen events) live in."""
|
||||
return self._path.parent
|
||||
|
||||
# -- persistence (worker thread, lock held by the caller) ---------------
|
||||
|
||||
def _ensure_loaded(self) -> dict[str, dict[str, Any]]:
|
||||
if self._data is None:
|
||||
self._data = _read_legacy_file(self._path, log_missing=False, on_corrupt="Corrupt channel store at %s, starting fresh") or {}
|
||||
return self._data
|
||||
|
||||
def _save(self) -> None:
|
||||
fd = tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
dir=self._path.parent,
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
)
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = tempfile.NamedTemporaryFile(mode="w", dir=self._path.parent, suffix=".tmp", delete=False)
|
||||
try:
|
||||
json.dump(self._data, fd, indent=2)
|
||||
fd.close()
|
||||
@ -69,37 +140,19 @@ class ChannelStore:
|
||||
Path(fd.name).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
# -- key helpers -------------------------------------------------------
|
||||
# -- sync bodies -------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _key(channel_name: str, chat_id: str, topic_id: str | None = None) -> str:
|
||||
if topic_id:
|
||||
return f"{channel_name}:{chat_id}:{topic_id}"
|
||||
return f"{channel_name}:{chat_id}"
|
||||
|
||||
# -- public API --------------------------------------------------------
|
||||
|
||||
def get_thread_id(self, channel_name: str, chat_id: str, topic_id: str | None = None) -> str | None:
|
||||
"""Look up the DeerFlow thread_id for a given IM conversation/topic."""
|
||||
def _get_sync(self, key: str) -> str | None:
|
||||
with self._lock:
|
||||
entry = self._data.get(self._key(channel_name, chat_id, topic_id))
|
||||
entry = self._ensure_loaded().get(key)
|
||||
return entry["thread_id"] if entry else None
|
||||
|
||||
def set_thread_id(
|
||||
self,
|
||||
channel_name: str,
|
||||
chat_id: str,
|
||||
thread_id: str,
|
||||
*,
|
||||
topic_id: str | None = None,
|
||||
user_id: str = "",
|
||||
) -> None:
|
||||
"""Create or update the mapping for an IM conversation/topic."""
|
||||
def _set_sync(self, key: str, thread_id: str, user_id: str) -> None:
|
||||
with self._lock:
|
||||
key = self._key(channel_name, chat_id, topic_id)
|
||||
data = self._ensure_loaded()
|
||||
now = time.time()
|
||||
existing = self._data.get(key)
|
||||
self._data[key] = {
|
||||
existing = data.get(key)
|
||||
data[key] = {
|
||||
"thread_id": thread_id,
|
||||
"user_id": user_id,
|
||||
"created_at": existing["created_at"] if existing else now,
|
||||
@ -107,47 +160,33 @@ class ChannelStore:
|
||||
}
|
||||
self._save()
|
||||
|
||||
def remove(self, channel_name: str, chat_id: str, topic_id: str | None = None) -> bool:
|
||||
"""Remove a mapping.
|
||||
|
||||
If ``topic_id`` is provided, only that specific conversation/topic mapping is removed.
|
||||
If ``topic_id`` is omitted, all mappings whose key starts with
|
||||
``"<channel_name>:<chat_id>"`` (including topic-specific ones) are removed.
|
||||
|
||||
Returns True if at least one mapping was removed.
|
||||
"""
|
||||
def _remove_sync(self, channel_name: str, chat_id: str, topic_id: str | None) -> bool:
|
||||
with self._lock:
|
||||
# Remove a specific conversation/topic mapping.
|
||||
data = self._ensure_loaded()
|
||||
if topic_id is not None:
|
||||
key = self._key(channel_name, chat_id, topic_id)
|
||||
if key in self._data:
|
||||
del self._data[key]
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
# Remove all mappings for this channel/chat_id (base and any topic-specific keys).
|
||||
prefix = self._key(channel_name, chat_id)
|
||||
keys_to_delete = [k for k in self._data if k == prefix or k.startswith(prefix + ":")]
|
||||
key = binding_key(channel_name, chat_id, topic_id)
|
||||
if key not in data:
|
||||
return False
|
||||
del data[key]
|
||||
self._save()
|
||||
return True
|
||||
prefix = binding_key(channel_name, chat_id)
|
||||
keys_to_delete = [k for k in data if k == prefix or k.startswith(prefix + ":")]
|
||||
if not keys_to_delete:
|
||||
return False
|
||||
|
||||
for k in keys_to_delete:
|
||||
del self._data[k]
|
||||
del data[k]
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def list_entries(self, channel_name: str | None = None) -> list[dict[str, Any]]:
|
||||
"""List all stored mappings, optionally filtered by channel."""
|
||||
def _list_sync(self, channel_name: str | None) -> list[dict[str, Any]]:
|
||||
# Snapshot under the lock, format after release: a concurrent writer
|
||||
# cannot resize the dict while this iterates.
|
||||
with self._lock:
|
||||
entries = [(key, entry.copy()) for key, entry in self._data.items()]
|
||||
|
||||
entries = [(key, entry.copy()) for key, entry in self._ensure_loaded().items()]
|
||||
results = []
|
||||
for key, entry in entries:
|
||||
parts = key.split(":", 2)
|
||||
ch = parts[0]
|
||||
chat = parts[1] if len(parts) > 1 else ""
|
||||
topic = parts[2] if len(parts) > 2 else None
|
||||
ch, chat, topic = split_binding_key(key)
|
||||
if channel_name and ch != channel_name:
|
||||
continue
|
||||
item: dict[str, Any] = {"channel_name": ch, "chat_id": chat, **entry}
|
||||
@ -155,3 +194,207 @@ class ChannelStore:
|
||||
item["topic_id"] = topic
|
||||
results.append(item)
|
||||
return results
|
||||
|
||||
# -- public API --------------------------------------------------------
|
||||
|
||||
async def get_thread_id(self, channel_name: str, chat_id: str, topic_id: str | None = None) -> str | None:
|
||||
return await asyncio.to_thread(self._get_sync, binding_key(channel_name, chat_id, topic_id))
|
||||
|
||||
async def set_thread_id(self, channel_name: str, chat_id: str, thread_id: str, *, topic_id: str | None = None, user_id: str = "") -> None:
|
||||
await asyncio.to_thread(self._set_sync, binding_key(channel_name, chat_id, topic_id), thread_id, user_id)
|
||||
|
||||
async def remove(self, channel_name: str, chat_id: str, topic_id: str | None = None) -> bool:
|
||||
return await asyncio.to_thread(self._remove_sync, channel_name, chat_id, topic_id)
|
||||
|
||||
async def list_entries(self, channel_name: str | None = None) -> list[dict[str, Any]]:
|
||||
return await asyncio.to_thread(self._list_sync, channel_name)
|
||||
|
||||
|
||||
class SqlChannelStore:
|
||||
"""Database-backed store over ``channel_thread_bindings``, shared by every replica using the database."""
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession], *, legacy_path: str | Path | None = None) -> None:
|
||||
self._repo = SqlChannelThreadBindingRepository(session_factory)
|
||||
self._legacy_path = Path(legacy_path) if legacy_path is not None else None
|
||||
|
||||
@property
|
||||
def legacy_path(self) -> Path | None:
|
||||
"""The ``store.json`` this store imports from (and whose directory sibling state files use)."""
|
||||
return self._legacy_path
|
||||
|
||||
@property
|
||||
def channels_dir(self) -> Path | None:
|
||||
return self._legacy_path.parent if self._legacy_path is not None else None
|
||||
|
||||
async def get_thread_id(self, channel_name: str, chat_id: str, topic_id: str | None = None) -> str | None:
|
||||
return await self._repo.get_thread_id(binding_key(channel_name, chat_id, topic_id))
|
||||
|
||||
async def set_thread_id(self, channel_name: str, chat_id: str, thread_id: str, *, topic_id: str | None = None, user_id: str = "") -> None:
|
||||
await self._repo.upsert(ChannelThreadBinding.build(channel_name, chat_id, thread_id, topic_id=topic_id, user_id=user_id, now=time.time()))
|
||||
|
||||
async def remove(self, channel_name: str, chat_id: str, topic_id: str | None = None) -> bool:
|
||||
if topic_id is not None:
|
||||
return await self._repo.delete(binding_key(channel_name, chat_id, topic_id))
|
||||
return await self._repo.delete_prefix(binding_key(channel_name, chat_id)) > 0
|
||||
|
||||
async def list_entries(self, channel_name: str | None = None) -> list[dict[str, Any]]:
|
||||
return [binding.to_entry() for binding in await self._repo.list(channel_name)]
|
||||
|
||||
async def import_legacy_json(self) -> int:
|
||||
"""Move the entries of ``legacy_path`` into the table once; returns how many rows this call inserted.
|
||||
|
||||
No file, no legacy path or a populated table is a no-op (the file is
|
||||
left where it is in the last case: a populated table is the source of
|
||||
truth and the operator may still want the JSON). A corrupt file is
|
||||
kept and reported. Otherwise every well-formed entry is inserted with
|
||||
``ON CONFLICT DO NOTHING`` — so two replicas starting together both
|
||||
import without duplicating or clobbering anything — and the file is
|
||||
renamed to ``store.json.migrated``; a rename that fails because a peer
|
||||
already renamed it is tolerated.
|
||||
"""
|
||||
path = self._legacy_path
|
||||
if path is None:
|
||||
return 0
|
||||
entries = await asyncio.to_thread(lambda: _read_legacy_file(path, log_missing=True, on_corrupt="Corrupt legacy channel store at %s; skipping the import (file kept)"))
|
||||
if entries is None:
|
||||
return 0
|
||||
if await self._repo.count() > 0:
|
||||
logger.info("Channel thread bindings are already in the database; leaving legacy %s in place without importing it", path)
|
||||
return 0
|
||||
now = time.time()
|
||||
bindings: list[ChannelThreadBinding] = []
|
||||
skipped = 0
|
||||
for key, entry in entries.items():
|
||||
binding = _legacy_binding(key, entry, now)
|
||||
if binding is None:
|
||||
skipped += 1
|
||||
continue
|
||||
bindings.append(binding)
|
||||
if skipped:
|
||||
logger.warning(
|
||||
"Skipping %d malformed or oversized entries in legacy channel store %s (not an object, no thread_id, empty channel/chat, or a component longer than its column)",
|
||||
skipped,
|
||||
path,
|
||||
)
|
||||
inserted = await self._repo.insert_missing(bindings)
|
||||
renamed = await asyncio.to_thread(_rename_migrated, path)
|
||||
logger.info(
|
||||
"Imported %d of %d chat-to-thread bindings from %s into channel_thread_bindings%s",
|
||||
inserted,
|
||||
len(bindings),
|
||||
path,
|
||||
f"; renamed to {path.name}{MIGRATED_SUFFIX}" if renamed else " (file already moved by a peer)",
|
||||
)
|
||||
return inserted
|
||||
|
||||
|
||||
def _read_legacy_file(path: Path, *, log_missing: bool, on_corrupt: str) -> dict[str, Any] | None:
|
||||
"""Parse ``store.json``; ``None`` when missing or corrupt.
|
||||
|
||||
``on_corrupt`` is the caller's warning (``%s`` = path): the JSON store starts
|
||||
fresh, the import skips and keeps the file — the two outcomes differ, so the
|
||||
wording is the caller's.
|
||||
"""
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
if log_missing:
|
||||
logger.debug("No legacy channel store at %s", path)
|
||||
return None
|
||||
except OSError:
|
||||
logger.warning(on_corrupt, path, exc_info=True)
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(on_corrupt, path)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
logger.warning(on_corrupt, path)
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _legacy_binding(key: str, entry: Any, now: float) -> ChannelThreadBinding | None:
|
||||
"""One legacy entry as a binding, or ``None`` when it is malformed or would not fit the table.
|
||||
|
||||
The column bounds are checked here because only PostgreSQL enforces
|
||||
``String(n)``: an oversized component would fail the whole import batch there
|
||||
(``value too long for type character varying``) and take every IM channel down
|
||||
with ``ChannelService.start()``, while SQLite silently accepts the same row.
|
||||
"""
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("thread_id"), str) or not entry["thread_id"]:
|
||||
return None
|
||||
channel_name, chat_id, topic_id = split_binding_key(key)
|
||||
if not channel_name or not chat_id:
|
||||
return None
|
||||
thread_id = entry["thread_id"]
|
||||
user_id = entry.get("user_id")
|
||||
user_id = user_id if isinstance(user_id, str) else ""
|
||||
if (
|
||||
len(key) > BINDING_KEY_LENGTH
|
||||
or len(channel_name) > CHANNEL_NAME_LENGTH
|
||||
or len(chat_id) > CHAT_ID_LENGTH
|
||||
or (topic_id is not None and len(topic_id) > TOPIC_ID_LENGTH)
|
||||
or len(thread_id) > THREAD_ID_LENGTH
|
||||
or len(user_id) > USER_ID_LENGTH
|
||||
):
|
||||
return None
|
||||
created_at = _stamp(entry.get("created_at"), now)
|
||||
updated_at = _stamp(entry.get("updated_at"), created_at)
|
||||
return ChannelThreadBinding(key, channel_name, chat_id, topic_id, thread_id, user_id, created_at, updated_at)
|
||||
|
||||
|
||||
def _stamp(value: Any, fallback: float) -> float:
|
||||
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else fallback
|
||||
|
||||
|
||||
def _rename_migrated(path: Path) -> bool:
|
||||
"""Rename ``store.json`` to ``store.json.migrated``; ``False`` when a peer already did."""
|
||||
try:
|
||||
path.replace(path.with_name(path.name + MIGRATED_SUFFIX))
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
logger.warning("Imported legacy channel store %s but could not rename it; the next start finds a populated table and leaves it alone", path, exc_info=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_UNRESOLVED = object()
|
||||
|
||||
|
||||
def resolve_channel_store(app_config: Any | None, *, session_factory: Any = _UNRESOLVED, path: str | Path | None = None) -> ChannelStore:
|
||||
"""Pick the store for ``database.backend``: JSON for ``memory`` (or no config), SQL for a database.
|
||||
|
||||
``session_factory`` defaults to the process engine's
|
||||
(``deerflow.persistence.engine.get_session_factory``); passing ``None``
|
||||
models a configured database whose engine is not initialised, which falls
|
||||
back to the JSON file with a warning so a bare ``ChannelService`` keeps
|
||||
working. ``path`` is the JSON file (default ``{base_dir}/channels/store.json``),
|
||||
also the SQL store's import source and sibling-state directory.
|
||||
"""
|
||||
backend = getattr(getattr(app_config, "database", None), "backend", None)
|
||||
store_path = Path(path) if path is not None else default_store_path()
|
||||
if backend in ("sqlite", "postgres"):
|
||||
if session_factory is _UNRESOLVED:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
|
||||
session_factory = get_session_factory()
|
||||
if session_factory is not None:
|
||||
logger.info("IM chat-to-thread bindings use the shared channel_thread_bindings table (database.backend=%s)", backend)
|
||||
return SqlChannelStore(session_factory, legacy_path=store_path)
|
||||
logger.warning("database.backend=%s but no persistence engine is initialised; IM chat-to-thread bindings fall back to %s for this process", backend, store_path)
|
||||
return JsonChannelStore(store_path)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MIGRATED_SUFFIX",
|
||||
"ChannelStore",
|
||||
"JsonChannelStore",
|
||||
"SqlChannelStore",
|
||||
"binding_key",
|
||||
"default_store_path",
|
||||
"resolve_channel_store",
|
||||
"split_binding_key",
|
||||
]
|
||||
|
||||
@ -604,6 +604,10 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
|
||||
_validate_agent_storage(startup_config)
|
||||
# Warn when login lockouts stay per-process under several Gateway processes.
|
||||
_validate_login_throttle_storage(startup_config)
|
||||
# IM chat-to-thread bindings need no companion warning: ``resolve_channel_store``
|
||||
# keeps them in the shared ``channel_thread_bindings`` table for every
|
||||
# sqlite/postgres database, and the gate above already refuses a memory
|
||||
# database -- the only JSON-file case -- under any multi-process signal.
|
||||
# Warn when a declared multi-instance deployment shares DeerMem's SQLite
|
||||
# retrieval index across instances through the memory volume.
|
||||
_validate_memory_retrieval_index(startup_config)
|
||||
|
||||
@ -589,6 +589,7 @@ Notes:
|
||||
- `GET /health/ready` pings the Redis stream bridge on every probe and answers 503 (`stream_bridge: unreachable`) while Redis is down, so the orchestrator drains that instance instead of routing it runs it cannot publish or stream; the memory bridge reports `not_configured`. When `sandbox.provisioner_url` is set, the body also carries the provisioner's `/health` verdict (`provisioner: ok|unreachable`), which never changes the status code because every instance shares one provisioner. That probe follows the sandbox clients' proxy policy: loopback, private, link-local and cluster-local provisioner addresses bypass `HTTP_PROXY`, external hosts keep the environment's proxy settings.
|
||||
- The declaration also drives the `agent_storage.backend: file` divergence warning, the inbound webhook dedupe warning, the `auth.local.throttle_storage: memory` warning, and the WeChat QR-login guard, which otherwise only look at the worker count.
|
||||
- Login lockouts are shared through the database: `auth.local.throttle_storage` (startup-only) defaults to `auto`, which keeps the per-IP failed-login counters for `POST /api/v1/auth/login/local` in the `login_throttle` table whenever `database.backend` is `sqlite` or `postgres`, so every instance enforces one `max_login_attempts` limit per client IP and a lockout on one instance holds on all of them. `memory` keeps the historical per-process counter (N instances give an attacker N × `max_login_attempts` guesses; a declared multi-instance deployment logs a warning), and `db` forces the table (it falls back to memory with a warning when the database backend is `memory`, and refuses to start when the configured database's engine is unavailable; `auto` falls back with a warning in that case). `max_login_attempts` and `lockout_seconds` stay live-read.
|
||||
- IM chat-to-thread bindings are shared through the database too: with `database.backend: sqlite` or `postgres` the `ChannelManager` keeps the `channel_name:chat_id[:topic_id]` → thread mapping of unbound IM conversations in the `channel_thread_bindings` table (migration `0038_channel_thread_bindings`), so a conversation created on one instance continues on the same thread when its next message lands on another. On the first start after upgrading, an existing `{base_dir}/channels/store.json` is imported once — only into an empty table, with `INSERT … ON CONFLICT DO NOTHING` so concurrently starting instances cannot duplicate a binding — and renamed `store.json.migrated`; a populated table leaves the file untouched. `memory` keeps the per-process JSON file.
|
||||
- The declaration also drives the `agent_storage.backend: file` divergence warning, the inbound webhook dedupe warning, and the WeChat QR-login guard, which otherwise only look at the worker count.
|
||||
- With the default DeerMem backend, the declaration also warns when the derived SQLite retrieval index sits inside the shared `storage_path` (the default `{storage_path}/.retrieval`). Set `memory.backend_config.retrieval_index_path` to an instance-local directory (relative values resolve against `storage_path`): SQLite WAL is unsupported on network filesystems, and the index is rebuilt from the Markdown facts at startup and re-synced per user scope after a peer writes.
|
||||
- Restart-required: the gate runs once at startup. Restart all Gateway instances together after changing it.
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
"""Shared IM chat -> DeerFlow thread bindings (the database successor of ``channels/store.json``)."""
|
||||
|
||||
from deerflow.persistence.channel_thread_bindings.model import (
|
||||
BINDING_KEY_LENGTH,
|
||||
ChannelThreadBinding,
|
||||
ChannelThreadBindingRow,
|
||||
binding_key,
|
||||
split_binding_key,
|
||||
)
|
||||
from deerflow.persistence.channel_thread_bindings.sql import IMPORT_BATCH_SIZE, SqlChannelThreadBindingRepository
|
||||
|
||||
__all__ = [
|
||||
"BINDING_KEY_LENGTH",
|
||||
"IMPORT_BATCH_SIZE",
|
||||
"ChannelThreadBinding",
|
||||
"ChannelThreadBindingRow",
|
||||
"SqlChannelThreadBindingRepository",
|
||||
"binding_key",
|
||||
"split_binding_key",
|
||||
]
|
||||
@ -0,0 +1,116 @@
|
||||
"""ORM model for the shared IM chat -> DeerFlow thread binding table (``channel_thread_bindings``).
|
||||
|
||||
One row per unbound IM conversation the ``ChannelManager`` has routed to a
|
||||
thread, keyed by the legacy composite ``channel_name:chat_id[:topic_id]`` string
|
||||
that ``channels/store.json`` used. Every Gateway replica sharing the application
|
||||
database reads and writes the same rows, so a conversation created on one
|
||||
replica continues on the same thread when the next message lands on another.
|
||||
|
||||
The shape is deliberately 1:1 with the JSON entries so the one-time import is
|
||||
lossless: ``created_at`` / ``updated_at`` stay epoch seconds (the JSON stored
|
||||
``time.time()`` floats), ``user_id`` is the platform sender id the manager
|
||||
recorded (``""`` when unknown, never NULL), and the component columns
|
||||
(``channel_name``, ``chat_id``, ``topic_id``) are what the legacy
|
||||
``list_entries()`` parsed out of the key. Lookups always go through ``key``.
|
||||
|
||||
Connection-bound conversations (``channel_conversations``) are a different
|
||||
table on purpose: those rows belong to a user-owned connection; these are the
|
||||
global fallback mapping for messages without one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Double, Index, PrimaryKeyConstraint, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
#: Longest component values. ``channel_conversations`` already caps external ids
|
||||
#: at 128, so 255 leaves every current provider id well inside the limit.
|
||||
CHANNEL_NAME_LENGTH = 64
|
||||
CHAT_ID_LENGTH = 255
|
||||
TOPIC_ID_LENGTH = 255
|
||||
THREAD_ID_LENGTH = 64
|
||||
USER_ID_LENGTH = 255
|
||||
#: ``channel_name:chat_id:topic_id`` at their maximum lengths plus separators.
|
||||
BINDING_KEY_LENGTH = CHANNEL_NAME_LENGTH + 1 + CHAT_ID_LENGTH + 1 + TOPIC_ID_LENGTH
|
||||
|
||||
|
||||
def binding_key(channel_name: str, chat_id: str, topic_id: str | None = None) -> str:
|
||||
"""The legacy composite key: an empty or missing topic means "no topic"."""
|
||||
if topic_id:
|
||||
return f"{channel_name}:{chat_id}:{topic_id}"
|
||||
return f"{channel_name}:{chat_id}"
|
||||
|
||||
|
||||
def split_binding_key(key: str) -> tuple[str, str, str | None]:
|
||||
"""Split a legacy key into ``(channel_name, chat_id, topic_id)``.
|
||||
|
||||
Mirrors the parsing the JSON store's ``list_entries()`` did: the first two
|
||||
colons separate the components, so a chat id that itself carries a colon
|
||||
lands partly in ``topic_id``. Rows written through the store carry exact
|
||||
components; only imported legacy keys inherit this ambiguity, and lookups
|
||||
never depend on it because they match the full ``key``.
|
||||
"""
|
||||
parts = key.split(":", 2)
|
||||
channel = parts[0]
|
||||
chat = parts[1] if len(parts) > 1 else ""
|
||||
topic = parts[2] if len(parts) > 2 else None
|
||||
return channel, chat, topic
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChannelThreadBinding:
|
||||
"""One binding as the store and the import exchange it with the repository."""
|
||||
|
||||
key: str
|
||||
channel_name: str
|
||||
chat_id: str
|
||||
topic_id: str | None
|
||||
thread_id: str
|
||||
user_id: str
|
||||
created_at: float
|
||||
updated_at: float
|
||||
|
||||
@classmethod
|
||||
def build(cls, channel_name: str, chat_id: str, thread_id: str, *, topic_id: str | None = None, user_id: str = "", now: float) -> ChannelThreadBinding:
|
||||
"""A fresh binding stamped ``now`` for both timestamps (the upsert keeps an existing ``created_at``)."""
|
||||
topic = topic_id or None
|
||||
return cls(binding_key(channel_name, chat_id, topic), channel_name, chat_id, topic, thread_id, user_id, now, now)
|
||||
|
||||
def to_entry(self) -> dict[str, Any]:
|
||||
"""The ``list_entries()`` item shape the JSON store returned (``topic_id`` only when present)."""
|
||||
item: dict[str, Any] = {
|
||||
"channel_name": self.channel_name,
|
||||
"chat_id": self.chat_id,
|
||||
"thread_id": self.thread_id,
|
||||
"user_id": self.user_id,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
if self.topic_id is not None:
|
||||
item["topic_id"] = self.topic_id
|
||||
return item
|
||||
|
||||
|
||||
class ChannelThreadBindingRow(Base):
|
||||
__tablename__ = "channel_thread_bindings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(BINDING_KEY_LENGTH), nullable=False)
|
||||
channel_name: Mapped[str] = mapped_column(String(CHANNEL_NAME_LENGTH), nullable=False)
|
||||
chat_id: Mapped[str] = mapped_column(String(CHAT_ID_LENGTH), nullable=False)
|
||||
topic_id: Mapped[str | None] = mapped_column(String(TOPIC_ID_LENGTH), nullable=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(THREAD_ID_LENGTH), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), nullable=False, default="")
|
||||
# Epoch seconds, exactly what the JSON store recorded with ``time.time()``.
|
||||
created_at: Mapped[float] = mapped_column(Double, nullable=False)
|
||||
updated_at: Mapped[float] = mapped_column(Double, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("key", name="pk_channel_thread_bindings"),
|
||||
# Serves ``list_entries(channel_name)``; the prefix ``remove`` walks the primary key.
|
||||
Index("ix_channel_thread_bindings_channel_chat", "channel_name", "chat_id"),
|
||||
)
|
||||
@ -0,0 +1,139 @@
|
||||
"""SQL repository for ``channel_thread_bindings`` (SQLite or PostgreSQL).
|
||||
|
||||
Concurrency contract, shared with ``app.channels.store.SqlChannelStore``:
|
||||
|
||||
- ``upsert`` is one dialect-native ``INSERT ... ON CONFLICT (key) DO UPDATE``
|
||||
that replaces ``thread_id`` / ``user_id`` / ``updated_at`` and keeps the
|
||||
existing ``created_at`` — the overwrite semantics the JSON store had — so two
|
||||
replicas recording the same conversation never produce two rows and never
|
||||
lose the first stamp.
|
||||
- ``insert_missing`` is the one-time import primitive: ``INSERT ... ON CONFLICT
|
||||
DO NOTHING`` in bounded batches, returning only the rows it actually inserted,
|
||||
so two replicas importing the same ``store.json`` concurrently can neither
|
||||
duplicate nor clobber a binding that already exists (including one a peer
|
||||
wrote live between the emptiness check and the insert).
|
||||
- ``delete_prefix`` removes ``key == prefix`` and every ``key`` starting with
|
||||
``prefix + ":"`` (``startswith(autoescape=True)``, so ``%`` / ``_`` in a chat
|
||||
id match literally) — exact parity with the JSON ``remove()`` that cleared a
|
||||
chat's base mapping and all its topics by key prefix, independent of how a
|
||||
legacy key was split into components.
|
||||
|
||||
Database errors propagate: the manager cannot route an IM message without the
|
||||
database anyway, and failing loudly beats silently splitting a conversation
|
||||
across two threads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.channel_thread_bindings.model import ChannelThreadBinding, ChannelThreadBindingRow
|
||||
|
||||
#: Rows per ``INSERT`` statement during the import; keeps bind-parameter counts small on both dialects.
|
||||
IMPORT_BATCH_SIZE = 500
|
||||
|
||||
_COLUMNS = (
|
||||
ChannelThreadBindingRow.key,
|
||||
ChannelThreadBindingRow.channel_name,
|
||||
ChannelThreadBindingRow.chat_id,
|
||||
ChannelThreadBindingRow.topic_id,
|
||||
ChannelThreadBindingRow.thread_id,
|
||||
ChannelThreadBindingRow.user_id,
|
||||
ChannelThreadBindingRow.created_at,
|
||||
ChannelThreadBindingRow.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _insert_for(session: AsyncSession):
|
||||
dialect = session.get_bind().dialect.name
|
||||
if dialect == "postgresql":
|
||||
return pg_insert
|
||||
if dialect == "sqlite":
|
||||
return sqlite_insert
|
||||
raise ValueError(f"Unsupported channel thread binding database dialect: {dialect}")
|
||||
|
||||
|
||||
def _values(binding: ChannelThreadBinding) -> dict[str, object]:
|
||||
return {
|
||||
"key": binding.key,
|
||||
"channel_name": binding.channel_name,
|
||||
"chat_id": binding.chat_id,
|
||||
"topic_id": binding.topic_id,
|
||||
"thread_id": binding.thread_id,
|
||||
"user_id": binding.user_id,
|
||||
"created_at": binding.created_at,
|
||||
"updated_at": binding.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _binding(row) -> ChannelThreadBinding:
|
||||
key, channel_name, chat_id, topic_id, thread_id, user_id, created_at, updated_at = row
|
||||
return ChannelThreadBinding(key, channel_name, chat_id, topic_id, thread_id, user_id or "", float(created_at), float(updated_at))
|
||||
|
||||
|
||||
class SqlChannelThreadBindingRepository:
|
||||
"""Persistence facade for ``channel_thread_bindings``."""
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def get_thread_id(self, key: str) -> str | None:
|
||||
async with self._sf() as session:
|
||||
return (await session.execute(select(ChannelThreadBindingRow.thread_id).where(ChannelThreadBindingRow.key == key))).scalar_one_or_none()
|
||||
|
||||
async def upsert(self, binding: ChannelThreadBinding) -> None:
|
||||
"""Create or overwrite the binding; an existing row keeps its ``created_at``."""
|
||||
async with self._sf() as session:
|
||||
stmt = _insert_for(session)(ChannelThreadBindingRow).values(**_values(binding))
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[ChannelThreadBindingRow.key],
|
||||
set_={"thread_id": stmt.excluded.thread_id, "user_id": stmt.excluded.user_id, "updated_at": stmt.excluded.updated_at},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(delete(ChannelThreadBindingRow).where(ChannelThreadBindingRow.key == key))
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete_prefix(self, prefix: str) -> int:
|
||||
"""Delete ``prefix`` itself and every ``prefix:*`` key; returns the number of rows removed."""
|
||||
async with self._sf() as session:
|
||||
predicate = or_(ChannelThreadBindingRow.key == prefix, ChannelThreadBindingRow.key.startswith(prefix + ":", autoescape=True))
|
||||
result = await session.execute(delete(ChannelThreadBindingRow).where(predicate))
|
||||
await session.commit()
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
async def list(self, channel_name: str | None = None) -> list[ChannelThreadBinding]:
|
||||
stmt = select(*_COLUMNS).order_by(ChannelThreadBindingRow.created_at, ChannelThreadBindingRow.key)
|
||||
if channel_name:
|
||||
stmt = stmt.where(ChannelThreadBindingRow.channel_name == channel_name)
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return [_binding(row) for row in rows]
|
||||
|
||||
async def count(self) -> int:
|
||||
async with self._sf() as session:
|
||||
return int((await session.execute(select(func.count()).select_from(ChannelThreadBindingRow))).scalar_one())
|
||||
|
||||
async def insert_missing(self, bindings: Iterable[ChannelThreadBinding]) -> int:
|
||||
"""``INSERT ... ON CONFLICT DO NOTHING`` in batches; returns how many rows this call inserted."""
|
||||
rows = [_values(binding) for binding in bindings]
|
||||
if not rows:
|
||||
return 0
|
||||
inserted = 0
|
||||
async with self._sf() as session:
|
||||
insert = _insert_for(session)
|
||||
for start in range(0, len(rows), IMPORT_BATCH_SIZE):
|
||||
batch = rows[start : start + IMPORT_BATCH_SIZE]
|
||||
result = await session.execute(insert(ChannelThreadBindingRow).values(batch).on_conflict_do_nothing(index_elements=[ChannelThreadBindingRow.key]))
|
||||
inserted += int(result.rowcount or 0)
|
||||
await session.commit()
|
||||
return inserted
|
||||
@ -1,6 +1,6 @@
|
||||
### Schema Migrations (`packages/harness/deerflow/persistence/migrations/`)
|
||||
|
||||
DeerFlow's application tables (`runs`, `threads_meta`, `feedback`, `users`, `run_events`, plus the four `channel_*` tables) are owned by alembic via a **hybrid bootstrap** strategy. LangGraph's checkpointer tables (`checkpoints`, `checkpoint_blobs`, `checkpoint_writes`, `checkpoint_migrations`) live in the same database but are owned by LangGraph and excluded from alembic's view via `migrations/_env_filters.py::include_object`.
|
||||
DeerFlow's application tables (`runs`, `threads_meta`, `feedback`, `users`, `run_events`, plus the `channel_*` tables) are owned by alembic via a **hybrid bootstrap** strategy. LangGraph's checkpointer tables (`checkpoints`, `checkpoint_blobs`, `checkpoint_writes`, `checkpoint_migrations`) live in the same database but are owned by LangGraph and excluded from alembic's view via `migrations/_env_filters.py::include_object`.
|
||||
|
||||
**Convention**: every ORM model change (new column, new table, new index) MUST ship as an alembic revision under `migrations/versions/`. The Gateway runs `alembic upgrade head` automatically on startup; routine production upgrades do not require manual Alembic commands. The audited offline recovery below is an exception for the out-of-tree incarnation revision. A new revision must always chain onto the current head — never insert one ahead of an already-shipped revision (re-parenting it): alembic only walks forward from a database's stamped revision, so every database already stamped at or past the insertion point treats the inserted revision as an applied ancestor and never executes it. Inserting `0023_run_change_seq` ahead of the shipped `0023_user_preferences` stranded those databases without the run-change clock schema until `0025_repair_run_change_seq` re-applied it (#5516).
|
||||
|
||||
@ -29,7 +29,8 @@ The empty-DB path keeps using `create_all` because `Base.metadata` is the only a
|
||||
`0028_parked_attempts` → `0029_scheduler_agent_tasks` →
|
||||
`0030_notification_claim_tokens` → `0031_scheduled_streak_boundary` →
|
||||
`0032_activity_and_task_events` → `0033_batch_result_artifact` → `0034_run_event_seq_watermark` →
|
||||
`0035_login_throttle` → `0036_run_idempotency_request` → `0037_project_document_summaries` (current head). The preference
|
||||
`0035_login_throttle` → `0036_run_idempotency_request` → `0037_project_document_summaries` →
|
||||
`0038_channel_thread_bindings` (current head). The preference
|
||||
revision adds a separate owner/key table with a cascading users foreign key and
|
||||
does not alter users; the project-documents revision adds a new owner-scoped
|
||||
shelf table, and the MCP lease-token revision adds two nullable token columns to
|
||||
@ -181,6 +182,7 @@ on installs that never enabled it. The convention is:
|
||||
- `migrations/versions/0035_login_throttle.py` — creates `login_throttle` (`ip` PK, `fail_count`, nullable epoch `locked_at`, nullable `lock_duration_seconds`, `updated_at` + `ix_login_throttle_updated_at` for the sweep's stale-counter predicate), the shared failed-login counter behind `POST /api/v1/auth/login/local` selected by `auth.local.throttle_storage` (`persistence/login_throttle/`: `SqlLoginThrottleStore` increments and starts the lock in one dialect-native upsert, applies check decisions with compare-and-set predicates in a separate write transaction, and sweeps served locks plus 24h-idle counters in bounded batches — the sweep's `DELETE` repeats the expiry predicate on its target, not only `ip IN (SELECT … LIMIT n)`, because PostgreSQL READ COMMITTED re-evaluates only the statement's own WHERE on a row it waited for and would otherwise delete a lock a concurrent `check` just extended). Table create/drop is guarded with a fresh inspector in both directions. New table, so the bootstrap forward-compat floor is unchanged; chains after `0034_run_event_seq_watermark`. Test: `tests/test_migration_0035_login_throttle.py`
|
||||
- `migrations/versions/0036_run_idempotency_request.py` — adds nullable `runs.idempotency_request_json` for versioned keyed-resume identities. The field is private persistence state, never part of `kwargs_json` or a run response; this keeps new-writer/old-reader rolling upgrades from exposing resume digests or synthetic input. Legacy rows remain NULL; new readers accept only the exact old-writer identity shape from `kwargs`, while truly identity-less resume fails closed. The bootstrap forward-compat floor is unchanged. Chains after `0035_login_throttle`. Test: `tests/test_migration_0036_run_idempotency_request.py`
|
||||
- `migrations/versions/0037_project_document_summaries.py` — adds nullable `project_documents.summary` (best-effort LLM one-line description, off by default; no backfill, so legacy rows stay NULL and the bootstrap forward-compat floor is unchanged); chains after `0036_run_idempotency_request`. Test: `tests/test_migration_0037_project_document_summaries.py`
|
||||
- `migrations/versions/0038_channel_thread_bindings.py` — creates `channel_thread_bindings` (`key` PK = the legacy `channel_name:chat_id[:topic_id]` string, parsed `channel_name`/`chat_id`/nullable `topic_id`, `thread_id`, `user_id` (`""` when unknown, never NULL), epoch `Double` `created_at`/`updated_at` + `ix_channel_thread_bindings_channel_chat`), the shared successor of the per-process `channels/store.json` that `ChannelManager` used for unbound IM conversations (`persistence/channel_thread_bindings/`: `SqlChannelThreadBindingRepository` upserts with a dialect-native `ON CONFLICT (key) DO UPDATE` that keeps `created_at`, deletes a chat's base + topic rows by key prefix with `startswith(autoescape=True)`, and imports with batched `ON CONFLICT DO NOTHING` so two replicas importing the same file concurrently neither duplicate nor clobber a row; `app/channels/store.py::SqlChannelStore.import_legacy_json` runs that once from `ChannelService.start()` and renames the file to `store.json.migrated`). The shape is 1:1 with the JSON entries so the import is lossless; `split_binding_key` parses legacy keys on the first two colons exactly as the old `list_entries()` did (lookups always match the full `key`). Table create/drop and the index are guarded with a fresh inspector in both directions. New table, so the bootstrap forward-compat floor is unchanged; chains after `0037_project_document_summaries`. Test: `tests/test_migration_0038_channel_thread_bindings.py`
|
||||
- `persistence/bootstrap.py` — `bootstrap_schema(engine, backend=...)`, the three-branch provisioning decision, locked revision validation, and the narrow 0019 forward-compatibility exception
|
||||
- `extensions/loader.py::load_extensions` — registers each spec's `table_prefix` with `register_extension_table_prefix()`
|
||||
- Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter, including extension-owned tables), `tests/test_extension_loader.py::TestTablePrefixRegistration` (spec-to-filter wiring), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps), `tests/test_migration_0025_repair_run_change_seq.py` (issue #5516 skipped-revision heal)
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
"""Shared IM chat -> DeerFlow thread bindings for multi-replica channel routing.
|
||||
|
||||
Revision ID: 0038_channel_thread_bindings
|
||||
Revises: 0037_project_document_summaries
|
||||
|
||||
Creates ``channel_thread_bindings``: one row per unbound IM conversation the
|
||||
``ChannelManager`` routed to a thread, keyed by the legacy composite
|
||||
``channel_name:chat_id[:topic_id]`` string from ``channels/store.json``, with
|
||||
the parsed components, the ``thread_id``, the platform ``user_id`` and the
|
||||
epoch ``created_at`` / ``updated_at`` stamps the JSON entries carried. Every
|
||||
Gateway replica sharing the application database reads and writes the same
|
||||
rows, replacing the per-process JSON file that made a binding created on one
|
||||
replica invisible to the others (each then created its own thread for the same
|
||||
chat). ``ChannelService.start()`` imports an existing ``store.json`` once and
|
||||
renames it to ``store.json.migrated``.
|
||||
|
||||
``_helpers.py`` only guards columns, so the table and its index are guarded
|
||||
with a fresh inspector in both directions: an upgrade that finds the table (an
|
||||
interrupted earlier attempt, or an empty-database bootstrap that already ran
|
||||
``create_all``) completes, and a downgrade after the table is gone still moves
|
||||
the version row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0038_channel_thread_bindings"
|
||||
down_revision: str | Sequence[str] | None = "0037_project_document_summaries"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_TABLE = "channel_thread_bindings"
|
||||
_INDEX = "ix_channel_thread_bindings_channel_chat"
|
||||
|
||||
|
||||
def _has_table() -> bool:
|
||||
return sa.inspect(op.get_bind()).has_table(_TABLE)
|
||||
|
||||
|
||||
def _has_index() -> bool:
|
||||
if not _has_table():
|
||||
return False
|
||||
return _INDEX in {index["name"] for index in sa.inspect(op.get_bind()).get_indexes(_TABLE)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_table():
|
||||
op.create_table(
|
||||
_TABLE,
|
||||
sa.Column("key", sa.String(length=576), nullable=False),
|
||||
sa.Column("channel_name", sa.String(length=64), nullable=False),
|
||||
sa.Column("chat_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("topic_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("thread_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("created_at", sa.Double(), nullable=False),
|
||||
sa.Column("updated_at", sa.Double(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("key", name="pk_channel_thread_bindings"),
|
||||
)
|
||||
# Serves ``list_entries(channel_name)``; guarded separately so a table
|
||||
# left behind by an interrupted attempt still gets its index.
|
||||
if not _has_index():
|
||||
op.create_index(_INDEX, _TABLE, ["channel_name", "chat_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_index():
|
||||
op.drop_index(_INDEX, table_name=_TABLE)
|
||||
if _has_table():
|
||||
op.drop_table(_TABLE)
|
||||
@ -21,6 +21,7 @@ from deerflow.persistence.channel_connections.model import (
|
||||
ChannelCredentialRow,
|
||||
ChannelOAuthStateRow,
|
||||
)
|
||||
from deerflow.persistence.channel_thread_bindings.model import ChannelThreadBindingRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.login_throttle.model import LoginThrottleRow
|
||||
from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
|
||||
@ -45,6 +46,7 @@ __all__ = [
|
||||
"ChannelConversationRow",
|
||||
"ChannelCredentialRow",
|
||||
"ChannelOAuthStateRow",
|
||||
"ChannelThreadBindingRow",
|
||||
"FeedbackRow",
|
||||
"LoginThrottleRow",
|
||||
"McpTaskRow",
|
||||
|
||||
103
backend/tests/blocking_io/test_channel_thread_binding_store.py
Normal file
103
backend/tests/blocking_io/test_channel_thread_binding_store.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Regression anchors: the chat-to-thread binding store must not block the event loop.
|
||||
|
||||
``ChannelManager`` resolves and records a conversation's thread on the Gateway
|
||||
event loop for every inbound IM message (``_lookup_thread_id`` /
|
||||
``_store_thread_id``), and ``ChannelService.start()`` runs the one-time
|
||||
``store.json`` import there too. The SQL store runs every statement through the
|
||||
async session (aiosqlite / asyncpg); the JSON store keeps the whole file on
|
||||
disk and offloads its reads and atomic rewrites through ``asyncio.to_thread``.
|
||||
If either regresses onto the loop — a synchronous driver call, a file read or
|
||||
rename, a contended ``threading.Lock`` acquired from the loop — the strict
|
||||
Blockbuster gate raises ``BlockingError`` here.
|
||||
|
||||
The engine and the legacy fixture file are built off the loop: that is test
|
||||
fixture work, not the production path under test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||
|
||||
# Pre-import so lazy imports inside the store are cached no-ops under the gate.
|
||||
import deerflow.persistence.models # noqa: F401
|
||||
from app.channels.connection_identity import lookup_thread_id
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.message_bus import InboundMessage, MessageBus
|
||||
from app.channels.store import ChannelStore, JsonChannelStore, SqlChannelStore
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.channel_thread_bindings import ChannelThreadBindingRow
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _build_sqlite_engine(path: Path) -> AsyncEngine:
|
||||
"""Create the schema with a sync engine and return the async one (runs in a worker thread)."""
|
||||
sync_engine = sa.create_engine(f"sqlite:///{path.as_posix()}")
|
||||
try:
|
||||
Base.metadata.create_all(sync_engine, tables=[ChannelThreadBindingRow.__table__])
|
||||
finally:
|
||||
sync_engine.dispose()
|
||||
return create_async_engine(f"sqlite+aiosqlite:///{path.as_posix()}")
|
||||
|
||||
|
||||
def _write_legacy(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"slack:C-legacy": {"thread_id": "legacy-thread", "user_id": "U0", "created_at": 1.0, "updated_at": 1.0}}), encoding="utf-8")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(params=["json", "sql"])
|
||||
async def store(request, tmp_path) -> AsyncIterator[ChannelStore]:
|
||||
engine: AsyncEngine | None = None
|
||||
if request.param == "json":
|
||||
store: ChannelStore = JsonChannelStore(tmp_path / "channels" / "store.json")
|
||||
else:
|
||||
engine = await asyncio.to_thread(_build_sqlite_engine, tmp_path / "bindings.db")
|
||||
store = SqlChannelStore(async_sessionmaker(engine, expire_on_commit=False), legacy_path=tmp_path / "channels" / "store.json")
|
||||
try:
|
||||
yield store
|
||||
finally:
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_store_round_trip_does_not_block_loop(store) -> None:
|
||||
"""get / set / overwrite / list / remove on the loop, including the JSON file's atomic rewrite."""
|
||||
assert await store.get_thread_id("slack", "C1") is None
|
||||
await store.set_thread_id("slack", "C1", "thread-1", user_id="U1")
|
||||
await store.set_thread_id("slack", "C1", "thread-1b", topic_id="171.1", user_id="U1")
|
||||
await store.set_thread_id("slack", "C1", "thread-2", user_id="U2") # overwrite
|
||||
assert await store.get_thread_id("slack", "C1") == "thread-2"
|
||||
assert await store.get_thread_id("slack", "C1", topic_id="171.1") == "thread-1b"
|
||||
assert {entry["thread_id"] for entry in await store.list_entries("slack")} == {"thread-2", "thread-1b"}
|
||||
assert await store.remove("slack", "C1", topic_id="171.1") is True
|
||||
assert await store.remove("slack", "C1") is True
|
||||
assert await store.list_entries() == []
|
||||
|
||||
|
||||
async def test_manager_lookup_and_store_do_not_block_loop(store) -> None:
|
||||
"""The production callers: ``ChannelManager`` resolving and recording a chat's thread."""
|
||||
manager = ChannelManager(bus=MessageBus(), store=store)
|
||||
msg = InboundMessage(channel_name="slack", chat_id="C1", user_id="U1", text="hi", topic_id="171.1")
|
||||
assert await manager._lookup_thread_id(msg) is None
|
||||
assert await lookup_thread_id(msg, repo=None, store=store) is None
|
||||
await manager._store_thread_id(msg, "thread-1")
|
||||
assert await manager._lookup_thread_id(msg) == "thread-1"
|
||||
assert await lookup_thread_id(msg, repo=None, store=store) == "thread-1"
|
||||
|
||||
|
||||
async def test_legacy_import_does_not_block_loop(store, tmp_path) -> None:
|
||||
"""``ChannelService.start()`` imports ``store.json`` on the loop: read, insert, rename all offloaded."""
|
||||
if not isinstance(store, SqlChannelStore):
|
||||
pytest.skip("the import exists only for the SQL store")
|
||||
await asyncio.to_thread(_write_legacy, tmp_path / "channels" / "store.json")
|
||||
assert await store.import_legacy_json() == 1
|
||||
assert await store.get_thread_id("slack", "C-legacy") == "legacy-thread"
|
||||
assert await store.import_legacy_json() == 0 # the file was renamed; the no-op path stays off the loop too
|
||||
@ -41,17 +41,17 @@ pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
"""Stand-in for ChannelStore so the thread-mapping file lands under tmp_path."""
|
||||
"""Stand-in for a ChannelStore so the thread-mapping file lands under tmp_path."""
|
||||
|
||||
def __init__(self, tmp_path: Path) -> None:
|
||||
self._path = tmp_path / "channel_store.json"
|
||||
self.channels_dir = tmp_path
|
||||
|
||||
|
||||
async def test_discord_constructor_is_io_free_on_async_path(tmp_path: Path) -> None:
|
||||
"""``__init__`` must not touch the filesystem — ``_start_channel`` constructs inline."""
|
||||
# Direct construction on the event loop, no asyncio.to_thread wrapper —
|
||||
# mirrors the production _start_channel path. __init__ only resolves paths
|
||||
# (Path.home / store._path.parent); if it regresses to doing exists/read_text
|
||||
# (Path.home / store.channels_dir); if it regresses to doing exists/read_text
|
||||
# the Blockbuster gate raises BlockingError here.
|
||||
channel = DiscordChannel(bus=MessageBus(), config={"bot_token": "t", "channel_store": _FakeStore(tmp_path)})
|
||||
assert channel._bot_token == "t"
|
||||
|
||||
@ -19,7 +19,7 @@ import pytest
|
||||
from app.channels import manager as manager_module
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.message_bus import InboundMessage, MessageBus
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
@ -39,9 +39,9 @@ async def test_resolve_configured_model_name_loads_agent_config_off_loop(monkeyp
|
||||
# would itself perform file reads under the Blockbuster gate.
|
||||
monkeypatch.setattr(manager_module, "load_agent_config", spy_load_agent_config)
|
||||
|
||||
# ChannelStore's constructor mkdirs: setup I/O stays off the loop, same
|
||||
# The JSON store constructor only resolves paths; keep setup I/O off the loop, same
|
||||
# as the file writes above — only the manager call is the unit under test.
|
||||
store = await asyncio.to_thread(ChannelStore, path=tmp_path / "store.json")
|
||||
store = await asyncio.to_thread(JsonChannelStore, path=tmp_path / "store.json")
|
||||
manager = ChannelManager(bus=MessageBus(), store=store)
|
||||
thread_id = "thread-1"
|
||||
# Seeded cache: the assistant resolution needs no client round-trip.
|
||||
|
||||
@ -261,7 +261,7 @@ def test_mention_free_channel_and_thread_follow_pass_without_mention():
|
||||
assert len(captured) == 1
|
||||
|
||||
class FakeStore:
|
||||
def get_thread_id(self, channel_name, chat_id, topic_id=None):
|
||||
async def get_thread_id(self, channel_name, chat_id, topic_id=None):
|
||||
return "thread-1" if topic_id == "aa" * 32 else None
|
||||
|
||||
ch2, captured2 = _started()
|
||||
@ -959,11 +959,11 @@ async def test_bound_pubkey_unmentioned_reply_follows_a_thread_the_manager_mappe
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
|
||||
repo = sql_connection_repo
|
||||
await repo.create_oauth_state(owner_user_id="owner-bound", provider="buzz", state="tok-bind", expires_at=datetime.now(UTC) + timedelta(minutes=10))
|
||||
store = ChannelStore(tmp_path / "store.json")
|
||||
store = JsonChannelStore(tmp_path / "store.json")
|
||||
ch, captured = _started(connection_repo=repo, channel_store=store)
|
||||
ch._transport = FakeTransport()
|
||||
manager = ChannelManager(bus=ch.bus, store=store, connection_repo=repo)
|
||||
@ -978,7 +978,7 @@ async def test_bound_pubkey_unmentioned_reply_follows_a_thread_the_manager_mappe
|
||||
connection_id = captured[0].connection_id
|
||||
assert connection_id
|
||||
await manager._store_thread_id(captured[0], "thread-1")
|
||||
assert store.get_thread_id("buzz", CHANNEL, topic_id=root) is None # the manager wrote the repo only
|
||||
assert await store.get_thread_id("buzz", CHANNEL, topic_id=root) is None # the manager wrote the repo only
|
||||
|
||||
await dispatch(_event(sk=SK_OWNER, content="follow-up", mentions=(), reply_to=root, created_at=1700000300))
|
||||
assert len(captured) == 2
|
||||
@ -995,7 +995,7 @@ def test_bound_pubkey_thread_follow_ignores_a_json_mapping_the_manager_would_not
|
||||
the bind state, so "the bot stopped following my replies" stays triageable."""
|
||||
|
||||
class JsonStoreWithMapping:
|
||||
def get_thread_id(self, channel_name, chat_id, topic_id=None):
|
||||
async def get_thread_id(self, channel_name, chat_id, topic_id=None):
|
||||
return "legacy-thread"
|
||||
|
||||
repo = FakeConnectionRepo(states={"tok-bind": "owner-bound"})
|
||||
|
||||
@ -21,7 +21,7 @@ from app.channels.message_bus import (
|
||||
)
|
||||
from app.channels.service import ChannelService
|
||||
from app.channels.slack import SlackChannel
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
|
||||
|
||||
def _message(index: int, *, with_dedupe_identity: bool = False) -> InboundMessage:
|
||||
@ -119,7 +119,7 @@ async def test_fixed_worker_pool_bounds_handler_and_queue_tasks(tmp_path: Path)
|
||||
bus = MessageBus(inbound_queue_maxsize=3)
|
||||
manager = ChannelManager(
|
||||
bus=bus,
|
||||
store=ChannelStore(path=tmp_path / "store.json"),
|
||||
store=JsonChannelStore(path=tmp_path / "store.json"),
|
||||
max_concurrency=2,
|
||||
)
|
||||
release_handlers = asyncio.Event()
|
||||
@ -164,7 +164,7 @@ async def test_stop_gracefully_drains_every_accepted_message_before_cancelling_w
|
||||
bus = MessageBus(inbound_queue_maxsize=2)
|
||||
manager = ChannelManager(
|
||||
bus=bus,
|
||||
store=ChannelStore(path=tmp_path / "store.json"),
|
||||
store=JsonChannelStore(path=tmp_path / "store.json"),
|
||||
max_concurrency=1,
|
||||
shutdown_grace_period_seconds=0.5,
|
||||
)
|
||||
@ -210,7 +210,7 @@ async def test_stop_cancels_after_grace_drops_queue_and_releases_dedupe(tmp_path
|
||||
bus = MessageBus(inbound_queue_maxsize=2)
|
||||
manager = ChannelManager(
|
||||
bus=bus,
|
||||
store=ChannelStore(path=tmp_path / "store.json"),
|
||||
store=JsonChannelStore(path=tmp_path / "store.json"),
|
||||
max_concurrency=1,
|
||||
shutdown_grace_period_seconds=0,
|
||||
)
|
||||
@ -257,7 +257,7 @@ async def test_successful_stop_waits_for_cancel_resistant_workers_and_watchers(t
|
||||
bus = MessageBus(inbound_queue_maxsize=1)
|
||||
manager = ChannelManager(
|
||||
bus=bus,
|
||||
store=ChannelStore(path=tmp_path / "store.json"),
|
||||
store=JsonChannelStore(path=tmp_path / "store.json"),
|
||||
max_concurrency=1,
|
||||
shutdown_grace_period_seconds=0.01,
|
||||
)
|
||||
@ -317,7 +317,7 @@ async def test_outer_shutdown_cancellation_does_not_start_an_unbounded_second_jo
|
||||
bus = MessageBus(inbound_queue_maxsize=1)
|
||||
manager = ChannelManager(
|
||||
bus=bus,
|
||||
store=ChannelStore(path=tmp_path / "store.json"),
|
||||
store=JsonChannelStore(path=tmp_path / "store.json"),
|
||||
max_concurrency=1,
|
||||
shutdown_grace_period_seconds=60,
|
||||
)
|
||||
|
||||
@ -11,7 +11,7 @@ from langgraph_sdk.client import LangGraphClient
|
||||
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.message_bus import InboundMessage, MessageBus
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
from app.gateway.routers import thread_runs
|
||||
from deerflow.runtime import DisconnectMode, RunRecord, RunStatus
|
||||
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
|
||||
@ -58,7 +58,7 @@ async def test_channel_consumes_error_sse_and_releases_dedupe_after_final_publis
|
||||
app.state.stream_bridge = bridge
|
||||
app.state.run_manager = SimpleNamespace(get=AsyncMock(return_value=record))
|
||||
bus = MessageBus()
|
||||
manager = ChannelManager(bus=bus, store=ChannelStore(path=tmp_path / "channels.json"))
|
||||
manager = ChannelManager(bus=bus, store=JsonChannelStore(path=tmp_path / "channels.json"))
|
||||
inbound = InboundMessage(channel_name="feishu", chat_id="chat-1", user_id="user-1", text="Hello", metadata={"message_id": "message-1"})
|
||||
assert await manager._is_duplicate_inbound(inbound) is False
|
||||
outbound = []
|
||||
|
||||
674
backend/tests/test_channel_thread_binding_store.py
Normal file
674
backend/tests/test_channel_thread_binding_store.py
Normal file
@ -0,0 +1,674 @@
|
||||
"""Chat-to-thread binding store contract: the legacy JSON file and the shared SQL table.
|
||||
|
||||
``ChannelManager`` maps every unbound IM conversation (``channel_name:chat_id[:topic_id]``)
|
||||
to the DeerFlow thread it runs on. Both stores must implement the semantics the
|
||||
manager relied on while the mapping was a JSON file: topic keys are independent of
|
||||
the base chat key, an overwrite keeps ``created_at`` and bumps ``updated_at``,
|
||||
``remove`` without a topic clears the base mapping and every topic of that chat,
|
||||
and ``list_entries`` returns the channel/chat/topic components next to the entry.
|
||||
|
||||
The SQL store additionally shares that state across every Gateway replica using one
|
||||
database, which is the whole point of the change (and the red-on-main reproduction
|
||||
here: two SQL stores over one SQLite file see each other's bindings, two JSON stores
|
||||
over one file do not — each process loads the file once and clobbers the other's
|
||||
writes). The one-time import of ``channels/store.json`` into the table is pinned
|
||||
here too: it runs once, is idempotent, tolerates two replicas importing at the same
|
||||
time, renames the file afterwards and leaves it alone when the table is already
|
||||
populated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||
from support.postgres import asyncpg_test_url
|
||||
|
||||
from app.channels.store import ChannelStore, JsonChannelStore, SqlChannelStore, resolve_channel_store
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.channel_thread_bindings import ChannelThreadBinding, ChannelThreadBindingRow, SqlChannelThreadBindingRepository, binding_key, split_binding_key
|
||||
from deerflow.persistence.channel_thread_bindings.model import CHANNEL_NAME_LENGTH, CHAT_ID_LENGTH, TOPIC_ID_LENGTH, USER_ID_LENGTH
|
||||
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _sqlite_engine(path: Path) -> AsyncEngine:
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{path.as_posix()}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all, tables=[ChannelThreadBindingRow.__table__], checkfirst=True)
|
||||
return engine
|
||||
|
||||
|
||||
def _sql_store(engine: AsyncEngine, *, legacy_path: Path | None = None) -> SqlChannelStore:
|
||||
return SqlChannelStore(async_sessionmaker(engine, expire_on_commit=False), legacy_path=legacy_path)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(params=["json", "sql"])
|
||||
async def store(request, tmp_path) -> AsyncIterator[ChannelStore]:
|
||||
if request.param == "json":
|
||||
yield JsonChannelStore(tmp_path / "store.json")
|
||||
return
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
yield _sql_store(engine)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _write_legacy(path: Path, entries: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(entries, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _legacy_entry(thread_id: str, *, user_id: str = "u", created_at: float = 1_700_000_000.0, updated_at: float | None = None) -> dict:
|
||||
return {"thread_id": thread_id, "user_id": user_id, "created_at": created_at, "updated_at": created_at if updated_at is None else updated_at}
|
||||
|
||||
|
||||
# ── key helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_binding_key_matches_the_legacy_json_key_shape():
|
||||
assert binding_key("slack", "C1") == "slack:C1"
|
||||
assert binding_key("slack", "C1", "171.1") == "slack:C1:171.1"
|
||||
assert binding_key("slack", "C1", "") == "slack:C1" # an empty topic is "no topic", as the JSON store treated it
|
||||
assert binding_key("slack", "C1", None) == "slack:C1"
|
||||
|
||||
|
||||
async def test_split_binding_key_mirrors_the_legacy_list_entries_parsing():
|
||||
assert split_binding_key("slack:C1") == ("slack", "C1", None)
|
||||
assert split_binding_key("slack:C1:171.1") == ("slack", "C1", "171.1")
|
||||
# The legacy format is ambiguous once a chat id carries a colon; the JSON
|
||||
# ``list_entries`` split on the first two colons and so does the import.
|
||||
assert split_binding_key("github:owner/repo:42:coder") == ("github", "owner/repo", "42:coder")
|
||||
assert split_binding_key("broken") == ("broken", "", None)
|
||||
|
||||
|
||||
# ── shared contract ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_set_and_get_thread_id(store):
|
||||
await store.set_thread_id("slack", "ch1", "thread-abc", user_id="u1")
|
||||
assert await store.get_thread_id("slack", "ch1") == "thread-abc"
|
||||
assert await store.get_thread_id("slack", "nonexistent") is None
|
||||
assert await store.get_thread_id("slack", "ch1", topic_id="t") is None # a topic key is not the base key
|
||||
|
||||
|
||||
async def test_topic_mappings_are_independent_of_the_base_mapping(store):
|
||||
await store.set_thread_id("test", "chat1", "base-thread")
|
||||
await store.set_thread_id("test", "chat1", "topic-thread", topic_id="topic-1")
|
||||
await store.set_thread_id("test", "chat1", "topic-thread-2", topic_id="topic-2")
|
||||
assert await store.get_thread_id("test", "chat1") == "base-thread"
|
||||
assert await store.get_thread_id("test", "chat1", topic_id="topic-1") == "topic-thread"
|
||||
assert await store.get_thread_id("test", "chat1", topic_id="topic-2") == "topic-thread-2"
|
||||
# An empty topic id means "no topic", exactly as the JSON key builder treated it.
|
||||
assert await store.get_thread_id("test", "chat1", topic_id="") == "base-thread"
|
||||
|
||||
|
||||
async def test_overwrite_replaces_thread_and_user_but_preserves_created_at(store):
|
||||
await store.set_thread_id("slack", "ch1", "t1", user_id="u1")
|
||||
(entry,) = await store.list_entries()
|
||||
created_at = entry["created_at"]
|
||||
assert entry["updated_at"] == created_at
|
||||
await asyncio.sleep(0.002)
|
||||
await store.set_thread_id("slack", "ch1", "t2", user_id="u2")
|
||||
(entry,) = await store.list_entries()
|
||||
assert entry["thread_id"] == "t2"
|
||||
assert entry["user_id"] == "u2"
|
||||
assert entry["created_at"] == created_at
|
||||
assert entry["updated_at"] > created_at
|
||||
|
||||
|
||||
async def test_remove_specific_topic_leaves_the_rest(store):
|
||||
await store.set_thread_id("slack", "ch1", "base")
|
||||
await store.set_thread_id("slack", "ch1", "topic-a", topic_id="a")
|
||||
await store.set_thread_id("slack", "ch1", "topic-b", topic_id="b")
|
||||
assert await store.remove("slack", "ch1", topic_id="a") is True
|
||||
assert await store.get_thread_id("slack", "ch1", topic_id="a") is None
|
||||
assert await store.get_thread_id("slack", "ch1", topic_id="b") == "topic-b"
|
||||
assert await store.get_thread_id("slack", "ch1") == "base"
|
||||
assert await store.remove("slack", "ch1", topic_id="a") is False
|
||||
|
||||
|
||||
async def test_remove_without_topic_clears_the_base_and_every_topic_of_that_chat_only(store):
|
||||
await store.set_thread_id("slack", "ch1", "base")
|
||||
await store.set_thread_id("slack", "ch1", "topic-a", topic_id="a")
|
||||
await store.set_thread_id("slack", "ch1", "topic-b", topic_id="b:c")
|
||||
await store.set_thread_id("slack", "ch10", "other-chat") # shares the prefix text, not the chat
|
||||
await store.set_thread_id("feishu", "ch1", "other-channel")
|
||||
assert await store.remove("slack", "ch1") is True
|
||||
assert await store.get_thread_id("slack", "ch1") is None
|
||||
assert await store.get_thread_id("slack", "ch1", topic_id="a") is None
|
||||
assert await store.get_thread_id("slack", "ch1", topic_id="b:c") is None
|
||||
assert await store.get_thread_id("slack", "ch10") == "other-chat"
|
||||
assert await store.get_thread_id("feishu", "ch1") == "other-channel"
|
||||
assert await store.remove("slack", "ch1") is False
|
||||
assert await store.remove("slack", "nope") is False
|
||||
|
||||
|
||||
async def test_remove_prefix_treats_like_wildcards_literally(store):
|
||||
await store.set_thread_id("slack", "c_1", "underscore")
|
||||
await store.set_thread_id("slack", "c%1", "percent")
|
||||
await store.set_thread_id("slack", "cx1", "other")
|
||||
assert await store.remove("slack", "c_1") is True
|
||||
assert await store.get_thread_id("slack", "c_1") is None
|
||||
assert await store.get_thread_id("slack", "c%1") == "percent"
|
||||
assert await store.get_thread_id("slack", "cx1") == "other"
|
||||
|
||||
|
||||
async def test_list_entries_all_and_filtered_with_the_legacy_shape(store):
|
||||
await store.set_thread_id("slack", "ch1", "t1", user_id="u1")
|
||||
await store.set_thread_id("feishu", "ch2", "t2", topic_id="om_1", user_id="u2")
|
||||
entries = await store.list_entries()
|
||||
assert len(entries) == 2
|
||||
by_channel = {entry["channel_name"]: entry for entry in entries}
|
||||
slack = by_channel["slack"]
|
||||
assert set(slack) == {"channel_name", "chat_id", "thread_id", "user_id", "created_at", "updated_at"}
|
||||
assert (slack["chat_id"], slack["thread_id"], slack["user_id"]) == ("ch1", "t1", "u1")
|
||||
assert isinstance(slack["created_at"], float) and isinstance(slack["updated_at"], float)
|
||||
feishu = by_channel["feishu"]
|
||||
assert feishu["topic_id"] == "om_1" # present only for topic mappings
|
||||
assert (feishu["chat_id"], feishu["thread_id"], feishu["user_id"]) == ("ch2", "t2", "u2")
|
||||
|
||||
filtered = await store.list_entries(channel_name="slack")
|
||||
assert [entry["channel_name"] for entry in filtered] == ["slack"]
|
||||
assert await store.list_entries(channel_name="telegram") == []
|
||||
assert await store.list_entries() != []
|
||||
|
||||
|
||||
async def test_user_id_defaults_to_an_empty_string(store):
|
||||
await store.set_thread_id("slack", "ch1", "t1")
|
||||
(entry,) = await store.list_entries()
|
||||
assert entry["user_id"] == ""
|
||||
|
||||
|
||||
async def test_entries_survive_a_new_store_instance_over_the_same_storage(tmp_path):
|
||||
path = tmp_path / "store.json"
|
||||
first = JsonChannelStore(path)
|
||||
await first.set_thread_id("slack", "ch1", "t1")
|
||||
assert await JsonChannelStore(path).get_thread_id("slack", "ch1") == "t1"
|
||||
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
await _sql_store(engine).set_thread_id("slack", "ch1", "t1")
|
||||
assert await _sql_store(engine).get_thread_id("slack", "ch1") == "t1"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# ── JSON-specific behaviour kept from the original store ────────────────────
|
||||
|
||||
|
||||
async def test_json_store_corrupt_file_starts_fresh(tmp_path, caplog):
|
||||
path = tmp_path / "store.json"
|
||||
path.write_text("not json", encoding="utf-8")
|
||||
store = JsonChannelStore(path)
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.store"):
|
||||
assert await store.get_thread_id("x", "y") is None
|
||||
assert "Corrupt channel store" in caplog.text
|
||||
await store.set_thread_id("x", "y", "t")
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"x:y": {"thread_id": "t", "user_id": "", "created_at": pytest.approx(time.time(), abs=60), "updated_at": pytest.approx(time.time(), abs=60)}}
|
||||
|
||||
|
||||
async def test_json_store_construction_is_io_free_and_exposes_the_channels_dir(tmp_path):
|
||||
path = tmp_path / "nested" / "channels" / "store.json"
|
||||
store = JsonChannelStore(path)
|
||||
assert store.channels_dir == path.parent
|
||||
assert not path.parent.exists() # nothing is created or read until the first access
|
||||
|
||||
|
||||
async def test_json_store_concurrent_list_and_mutation(tmp_path, monkeypatch):
|
||||
"""``list_entries`` snapshots under the lock, so a concurrent writer cannot resize the dict mid-iteration."""
|
||||
store = JsonChannelStore(tmp_path / "store.json")
|
||||
iteration_started = threading.Event()
|
||||
mutation_requested = threading.Event()
|
||||
mutation_finished = threading.Event()
|
||||
|
||||
class CoordinatedData(dict):
|
||||
def items(self):
|
||||
iterator = iter(super().items())
|
||||
first = next(iterator)
|
||||
iteration_started.set()
|
||||
if store._lock.locked():
|
||||
assert mutation_requested.wait(timeout=5), "mutation thread never requested the store lock"
|
||||
else:
|
||||
assert mutation_finished.wait(timeout=5), "mutation thread never changed the unlocked store"
|
||||
yield first
|
||||
yield from iterator
|
||||
|
||||
store._data = CoordinatedData(
|
||||
{
|
||||
"slack:ch1": {"thread_id": "t1", "user_id": "u1", "created_at": 1.0, "updated_at": 1.0},
|
||||
"feishu:ch2": {"thread_id": "t2", "user_id": "u2", "created_at": 2.0, "updated_at": 2.0},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(store, "_save", lambda: None)
|
||||
|
||||
def mutate():
|
||||
assert iteration_started.wait(timeout=5), "list_entries never started iterating"
|
||||
mutation_requested.set()
|
||||
asyncio.run(store.set_thread_id("test", "new", "t3"))
|
||||
mutation_finished.set()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
list_future = executor.submit(lambda: asyncio.run(store.list_entries()))
|
||||
mutation_future = executor.submit(mutate)
|
||||
mutation_future.result(timeout=5)
|
||||
entries = list_future.result(timeout=5)
|
||||
|
||||
assert {(entry["channel_name"], entry["chat_id"]) for entry in entries} == {("slack", "ch1"), ("feishu", "ch2")}
|
||||
|
||||
|
||||
# ── the multi-replica reproduction ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_two_json_stores_over_one_file_do_not_see_each_other(tmp_path):
|
||||
"""Today's bug: each Gateway process loads ``store.json`` once and rewrites the
|
||||
whole file on every change, so a binding created on replica A is invisible to
|
||||
replica B (B creates a second thread for the same chat) and B's next write
|
||||
clobbers A's binding on disk."""
|
||||
path = tmp_path / "store.json"
|
||||
replica_a = JsonChannelStore(path)
|
||||
replica_b = JsonChannelStore(path)
|
||||
assert await replica_b.get_thread_id("slack", "C1") is None # B has loaded (an empty) file
|
||||
|
||||
await replica_a.set_thread_id("slack", "C1", "thread-from-a")
|
||||
assert await replica_b.get_thread_id("slack", "C1") is None # invisible to B
|
||||
|
||||
await replica_b.set_thread_id("slack", "C2", "thread-from-b")
|
||||
assert json.loads(path.read_text(encoding="utf-8")).keys() == {"slack:C2"} # A's binding clobbered on disk
|
||||
assert await JsonChannelStore(path).get_thread_id("slack", "C1") is None
|
||||
|
||||
|
||||
async def test_two_sql_stores_over_one_database_share_every_binding(tmp_path):
|
||||
engine_a = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
engine_b = create_async_engine(f"sqlite+aiosqlite:///{(tmp_path / 'bindings.db').as_posix()}")
|
||||
try:
|
||||
replica_a, replica_b = _sql_store(engine_a), _sql_store(engine_b)
|
||||
assert await replica_b.get_thread_id("slack", "C1") is None
|
||||
|
||||
await replica_a.set_thread_id("slack", "C1", "thread-from-a", user_id="u1")
|
||||
assert await replica_b.get_thread_id("slack", "C1") == "thread-from-a"
|
||||
|
||||
await replica_b.set_thread_id("slack", "C2", "thread-from-b")
|
||||
assert await replica_a.get_thread_id("slack", "C1") == "thread-from-a"
|
||||
assert await replica_a.get_thread_id("slack", "C2") == "thread-from-b"
|
||||
assert {entry["chat_id"] for entry in await replica_a.list_entries()} == {"C1", "C2"}
|
||||
|
||||
assert await replica_b.remove("slack", "C1") is True
|
||||
assert await replica_a.get_thread_id("slack", "C1") is None
|
||||
finally:
|
||||
await engine_a.dispose()
|
||||
await engine_b.dispose()
|
||||
|
||||
|
||||
async def test_concurrent_set_from_two_sql_stores_keeps_one_row_per_key(tmp_path):
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
stores = [_sql_store(engine) for _ in range(2)]
|
||||
await asyncio.gather(*(store.set_thread_id("slack", "C1", f"thread-{i}", topic_id="t") for i, store in enumerate(stores)))
|
||||
entries = await stores[0].list_entries()
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["thread_id"] in {"thread-0", "thread-1"}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# ── one-time import of channels/store.json ──────────────────────────────────
|
||||
|
||||
|
||||
async def test_import_moves_every_legacy_entry_into_the_table_and_renames_the_file(tmp_path, caplog):
|
||||
path = tmp_path / "channels" / "store.json"
|
||||
_write_legacy(
|
||||
path,
|
||||
{
|
||||
"slack:C1": _legacy_entry("thread-1", user_id="U1", created_at=1_700_000_000.0, updated_at=1_700_000_100.5),
|
||||
"feishu:oc_1:om_1": _legacy_entry("thread-2", user_id="ou_1"),
|
||||
"github:owner/repo:42:coder": _legacy_entry("thread-3", user_id=""),
|
||||
},
|
||||
)
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
store = _sql_store(engine, legacy_path=path)
|
||||
assert store.channels_dir == path.parent
|
||||
with caplog.at_level(logging.INFO, logger="app.channels.store"):
|
||||
assert await store.import_legacy_json() == 3
|
||||
assert "3" in caplog.text and "store.json" in caplog.text
|
||||
|
||||
assert await store.get_thread_id("slack", "C1") == "thread-1"
|
||||
assert await store.get_thread_id("feishu", "oc_1", topic_id="om_1") == "thread-2"
|
||||
assert await store.get_thread_id("github", "owner/repo", topic_id="42:coder") == "thread-3"
|
||||
entries = {binding_key(e["channel_name"], e["chat_id"], e.get("topic_id")): e for e in await store.list_entries()}
|
||||
assert entries["slack:C1"]["user_id"] == "U1"
|
||||
assert entries["slack:C1"]["created_at"] == 1_700_000_000.0
|
||||
assert entries["slack:C1"]["updated_at"] == 1_700_000_100.5 # lossless: the stamps are copied, not regenerated
|
||||
assert entries["feishu:oc_1:om_1"]["topic_id"] == "om_1"
|
||||
assert entries["github:owner/repo:42:coder"]["chat_id"] == "owner/repo"
|
||||
|
||||
assert not path.exists()
|
||||
migrated = path.with_name("store.json.migrated")
|
||||
assert set(json.loads(migrated.read_text(encoding="utf-8"))) == {"slack:C1", "feishu:oc_1:om_1", "github:owner/repo:42:coder"}
|
||||
|
||||
# A second start finds no file: nothing to do.
|
||||
assert await store.import_legacy_json() == 0
|
||||
assert len(await store.list_entries()) == 3
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_import_leaves_the_file_alone_when_the_table_is_already_populated(tmp_path, caplog):
|
||||
path = tmp_path / "channels" / "store.json"
|
||||
_write_legacy(path, {"slack:C1": _legacy_entry("thread-from-file")})
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
store = _sql_store(engine, legacy_path=path)
|
||||
await store.set_thread_id("slack", "C9", "thread-from-db")
|
||||
with caplog.at_level(logging.INFO, logger="app.channels.store"):
|
||||
assert await store.import_legacy_json() == 0
|
||||
assert path.exists() # kept for the operator; a populated table is the source of truth
|
||||
assert not path.with_name("store.json.migrated").exists()
|
||||
assert await store.get_thread_id("slack", "C1") is None
|
||||
assert "already" in caplog.text
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_import_without_a_file_or_without_a_legacy_path_is_a_no_op(tmp_path):
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
assert await _sql_store(engine, legacy_path=tmp_path / "missing" / "store.json").import_legacy_json() == 0
|
||||
assert await _sql_store(engine).import_legacy_json() == 0
|
||||
assert await _sql_store(engine).list_entries() == []
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_import_keeps_a_corrupt_or_malformed_file_and_skips_bad_entries(tmp_path, caplog):
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
corrupt = tmp_path / "corrupt" / "store.json"
|
||||
corrupt.parent.mkdir(parents=True)
|
||||
corrupt.write_text("not json", encoding="utf-8")
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.store"):
|
||||
assert await _sql_store(engine, legacy_path=corrupt).import_legacy_json() == 0
|
||||
assert corrupt.exists()
|
||||
# Import-path wording: nothing "starts fresh" here, the file is kept and the import skipped.
|
||||
assert "Corrupt legacy channel store at" in caplog.text and "skipping the import (file kept)" in caplog.text
|
||||
assert "starting fresh" not in caplog.text
|
||||
|
||||
partial = tmp_path / "partial" / "store.json"
|
||||
_write_legacy(
|
||||
partial,
|
||||
{
|
||||
"slack:C1": _legacy_entry("good"),
|
||||
"slack:C2": {"user_id": "no-thread"}, # no thread_id: skipped
|
||||
"slack:C3": "not-a-dict", # skipped
|
||||
"slack:C4": {"thread_id": "stampless"}, # missing stamps and user_id get defaults
|
||||
},
|
||||
)
|
||||
store = _sql_store(engine, legacy_path=partial)
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.store"):
|
||||
assert await store.import_legacy_json() == 2
|
||||
assert "Skipping 2 malformed or oversized entries" in caplog.text
|
||||
assert await store.get_thread_id("slack", "C1") == "good"
|
||||
assert await store.get_thread_id("slack", "C2") is None
|
||||
assert await store.get_thread_id("slack", "C3") is None
|
||||
(c4,) = [e for e in await store.list_entries() if e["chat_id"] == "C4"]
|
||||
assert c4["user_id"] == "" and c4["created_at"] == pytest.approx(time.time(), abs=60) and c4["updated_at"] == c4["created_at"]
|
||||
assert not partial.exists() and partial.with_name("store.json.migrated").exists()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_import_skips_oversized_components_instead_of_failing_the_whole_import(tmp_path, caplog):
|
||||
"""A key component longer than its column would make PostgreSQL reject the batch
|
||||
(``value too long for type character varying``) and take every IM channel down
|
||||
with the failed ``ChannelService.start()``; SQLite would silently accept it, so
|
||||
the two backends diverged on the same file. Oversized entries are skipped and
|
||||
counted with the malformed ones; the rest import and the file is still renamed."""
|
||||
path = tmp_path / "channels" / "store.json"
|
||||
_write_legacy(
|
||||
path,
|
||||
{
|
||||
"slack:C1": _legacy_entry("thread-1"),
|
||||
f"slack:{'c' * (CHAT_ID_LENGTH + 1)}": _legacy_entry("thread-long-chat"),
|
||||
f"slack:C2:{'t' * (TOPIC_ID_LENGTH + 1)}": _legacy_entry("thread-long-topic"),
|
||||
"slack:C3": _legacy_entry("thread-3"),
|
||||
f"{'x' * (CHANNEL_NAME_LENGTH + 1)}:C4": _legacy_entry("thread-long-channel"),
|
||||
"slack:C5": _legacy_entry("u" * (USER_ID_LENGTH + 1), user_id="u" * (USER_ID_LENGTH + 1)),
|
||||
},
|
||||
)
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
store = _sql_store(engine, legacy_path=path)
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.store"):
|
||||
assert await store.import_legacy_json() == 2
|
||||
assert "Skipping 4 malformed or oversized entries" in caplog.text
|
||||
assert await store.get_thread_id("slack", "C1") == "thread-1"
|
||||
assert await store.get_thread_id("slack", "C3") == "thread-3"
|
||||
assert await store.get_thread_id("slack", "c" * (CHAT_ID_LENGTH + 1)) is None
|
||||
assert await store.get_thread_id("slack", "C2", topic_id="t" * (TOPIC_ID_LENGTH + 1)) is None
|
||||
assert len(await store.list_entries()) == 2
|
||||
assert not path.exists() and path.with_name("store.json.migrated").exists()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_sqlite_accepts_an_oversized_row_so_the_import_validator_is_the_only_guard(tmp_path):
|
||||
"""Why the validator matters: SQLite ignores ``String(n)``, so without it the same
|
||||
file imports cleanly on SQLite and fails on PostgreSQL."""
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
repo = SqlChannelThreadBindingRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||
oversized = ChannelThreadBinding.build("slack", "c" * (CHAT_ID_LENGTH + 1), "thread-x", now=1.0)
|
||||
assert await repo.insert_missing([oversized]) == 1
|
||||
assert await repo.get_thread_id(oversized.key) == "thread-x"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_concurrent_import_from_two_replicas_yields_no_duplicates_and_tolerates_the_lost_rename(tmp_path, caplog):
|
||||
path = tmp_path / "channels" / "store.json"
|
||||
_write_legacy(path, {f"slack:C{i}": _legacy_entry(f"thread-{i}") for i in range(25)})
|
||||
engine_a = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
engine_b = create_async_engine(f"sqlite+aiosqlite:///{(tmp_path / 'bindings.db').as_posix()}")
|
||||
try:
|
||||
replica_a, replica_b = _sql_store(engine_a, legacy_path=path), _sql_store(engine_b, legacy_path=path)
|
||||
with caplog.at_level(logging.INFO, logger="app.channels.store"):
|
||||
counts = await asyncio.gather(replica_a.import_legacy_json(), replica_b.import_legacy_json())
|
||||
assert sum(counts) == 25 # every row inserted exactly once between the two
|
||||
assert len(await replica_a.list_entries()) == 25
|
||||
assert not path.exists()
|
||||
assert path.with_name("store.json.migrated").exists()
|
||||
assert "ERROR" not in caplog.text
|
||||
finally:
|
||||
await engine_a.dispose()
|
||||
await engine_b.dispose()
|
||||
|
||||
|
||||
async def test_repository_insert_missing_never_duplicates_or_overwrites(tmp_path):
|
||||
"""The import primitive: ``INSERT ... ON CONFLICT DO NOTHING`` in batches, reporting only what it inserted."""
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
repo = SqlChannelThreadBindingRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||
store = _sql_store(engine)
|
||||
await store.set_thread_id("slack", "C1", "live-thread", user_id="live")
|
||||
rows = [ChannelThreadBinding.build("slack", f"C{i}", f"thread-{i}", user_id="imported", now=1.0) for i in range(1, 1201)]
|
||||
assert await repo.insert_missing(rows) == 1199
|
||||
assert await repo.insert_missing(rows) == 0
|
||||
assert await repo.count() == 1200
|
||||
assert await store.get_thread_id("slack", "C1") == "live-thread" # DO NOTHING: the live row wins
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# ── selection ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _Config:
|
||||
def __init__(self, backend: str) -> None:
|
||||
self.database = type("Database", (), {"backend": backend})()
|
||||
|
||||
|
||||
async def test_resolve_channel_store_picks_json_for_memory_and_sql_for_a_database(tmp_path, caplog):
|
||||
path = tmp_path / "store.json"
|
||||
assert isinstance(resolve_channel_store(None, path=path), JsonChannelStore)
|
||||
assert isinstance(resolve_channel_store(_Config("memory"), session_factory=object(), path=path), JsonChannelStore)
|
||||
for backend in ("sqlite", "postgres"):
|
||||
store = resolve_channel_store(_Config(backend), session_factory=object(), path=path)
|
||||
assert isinstance(store, SqlChannelStore)
|
||||
assert store.channels_dir == path.parent
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.store"):
|
||||
fallback = resolve_channel_store(_Config("sqlite"), session_factory=None, path=path)
|
||||
assert isinstance(fallback, JsonChannelStore)
|
||||
assert "no persistence engine" in caplog.text
|
||||
|
||||
|
||||
async def test_resolve_channel_store_uses_the_process_engine_by_default(tmp_path, monkeypatch):
|
||||
from deerflow.persistence import engine as engine_module
|
||||
|
||||
path = tmp_path / "store.json"
|
||||
monkeypatch.setattr(engine_module, "get_session_factory", lambda: object())
|
||||
assert isinstance(resolve_channel_store(_Config("postgres"), path=path), SqlChannelStore)
|
||||
monkeypatch.setattr(engine_module, "get_session_factory", lambda: None)
|
||||
assert isinstance(resolve_channel_store(_Config("postgres"), path=path), JsonChannelStore)
|
||||
|
||||
|
||||
# ── ChannelService wiring ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_channel_service_start_imports_the_legacy_file_once_for_the_sql_store(tmp_path):
|
||||
from app.channels.service import ChannelService
|
||||
|
||||
path = tmp_path / "channels" / "store.json"
|
||||
_write_legacy(path, {"slack:C1": _legacy_entry("thread-1", user_id="U1")})
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
service = ChannelService(channels_config={}, store=_sql_store(engine, legacy_path=path))
|
||||
assert service.manager.store is service.store
|
||||
await service.start()
|
||||
try:
|
||||
assert await service.store.get_thread_id("slack", "C1") == "thread-1"
|
||||
assert not path.exists() and path.with_name("store.json.migrated").exists()
|
||||
finally:
|
||||
await service.stop()
|
||||
# A restart of the service (or a peer starting later) imports nothing more.
|
||||
_write_legacy(path, {"slack:C2": _legacy_entry("thread-2")})
|
||||
service = ChannelService(channels_config={}, store=_sql_store(engine, legacy_path=path))
|
||||
await service.start()
|
||||
try:
|
||||
assert await service.store.get_thread_id("slack", "C2") is None
|
||||
assert path.exists()
|
||||
finally:
|
||||
await service.stop()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_channel_service_stop_during_the_legacy_import_invalidates_that_start(tmp_path):
|
||||
"""The import runs under the start lock after the generation check: a ``stop()`` that lands
|
||||
while it is in flight bumps the generation, so the start returns without starting the manager
|
||||
(and a later ``start()`` imports nothing more because the table is populated)."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.channels.service import ChannelService
|
||||
|
||||
path = tmp_path / "channels" / "store.json"
|
||||
_write_legacy(path, {"slack:C1": _legacy_entry("thread-1")})
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
store = _sql_store(engine, legacy_path=path)
|
||||
import_entered = asyncio.Event()
|
||||
release_import = asyncio.Event()
|
||||
real_import = store.import_legacy_json
|
||||
|
||||
async def gated_import():
|
||||
import_entered.set()
|
||||
await release_import.wait()
|
||||
return await real_import()
|
||||
|
||||
store.import_legacy_json = gated_import # type: ignore[method-assign]
|
||||
service = ChannelService(channels_config={}, store=store)
|
||||
manager_start = AsyncMock(wraps=service.manager.start)
|
||||
service.manager.start = manager_start # type: ignore[method-assign]
|
||||
|
||||
start_task = asyncio.create_task(service.start())
|
||||
await asyncio.wait_for(import_entered.wait(), timeout=1)
|
||||
await service.stop() # lands during the import: bumps the shutdown generation
|
||||
release_import.set()
|
||||
await asyncio.wait_for(start_task, timeout=5)
|
||||
|
||||
manager_start.assert_not_awaited() # the invalidated start never started the manager
|
||||
assert service._running is False
|
||||
assert await store.get_thread_id("slack", "C1") == "thread-1" # the import itself completed
|
||||
assert not path.exists()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_channel_service_start_leaves_a_json_store_untouched(tmp_path):
|
||||
from app.channels.service import ChannelService
|
||||
|
||||
path = tmp_path / "channels" / "store.json"
|
||||
_write_legacy(path, {"slack:C1": _legacy_entry("thread-1")})
|
||||
service = ChannelService(channels_config={}, store=JsonChannelStore(path))
|
||||
await service.start()
|
||||
try:
|
||||
assert await service.store.get_thread_id("slack", "C1") == "thread-1"
|
||||
assert path.exists()
|
||||
finally:
|
||||
await service.stop()
|
||||
|
||||
|
||||
async def test_channel_service_defaults_to_the_json_store_without_a_database(tmp_path, monkeypatch):
|
||||
from app.channels import store as store_module
|
||||
from app.channels.service import ChannelService
|
||||
|
||||
monkeypatch.setattr(store_module, "default_store_path", lambda: tmp_path / "channels" / "store.json")
|
||||
service = ChannelService(channels_config={})
|
||||
assert isinstance(service.store, JsonChannelStore)
|
||||
assert service.store.path == tmp_path / "channels" / "store.json"
|
||||
assert isinstance(ChannelService(channels_config={}, app_config=_Config("memory")).store, JsonChannelStore)
|
||||
|
||||
|
||||
# ── PostgreSQL (opt-in) ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_sql_store_round_trips_on_postgres(tmp_path):
|
||||
uri = os.environ.get("TEST_POSTGRES_URI")
|
||||
if not uri:
|
||||
pytest.skip("requires TEST_POSTGRES_URI (real Postgres channel_thread_bindings)")
|
||||
schema = f"ctb_{uuid.uuid4().hex}"
|
||||
engine = create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema))
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
|
||||
await conn.run_sync(Base.metadata.create_all, tables=[ChannelThreadBindingRow.__table__])
|
||||
path = tmp_path / "store.json"
|
||||
_write_legacy(path, {"slack:C0": _legacy_entry("imported")})
|
||||
store = _sql_store(engine, legacy_path=path)
|
||||
assert await store.import_legacy_json() == 1
|
||||
await store.set_thread_id("slack", "C1", "t1", user_id="u1")
|
||||
await store.set_thread_id("slack", "C1", "t2", user_id="u2")
|
||||
await store.set_thread_id("slack", "C1", "topic", topic_id="x")
|
||||
assert await store.get_thread_id("slack", "C0") == "imported"
|
||||
assert await store.get_thread_id("slack", "C1") == "t2"
|
||||
assert len(await store.list_entries(channel_name="slack")) == 3
|
||||
assert await store.remove("slack", "C1") is True
|
||||
assert await store.list_entries() == [{"channel_name": "slack", "chat_id": "C0", "thread_id": "imported", "user_id": "u", "created_at": 1_700_000_000.0, "updated_at": 1_700_000_000.0}]
|
||||
finally:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
|
||||
await engine.dispose()
|
||||
File diff suppressed because it is too large
Load Diff
@ -408,7 +408,7 @@ class TestOnChatbotMessage:
|
||||
def test_p2p_message_without_sender_is_dropped(self, sender_staff_id):
|
||||
"""A P2P chat_id *is* the sender, so an empty one keys every user to one thread.
|
||||
|
||||
``ChannelStore._key`` builds ``f"{channel}:{chat_id}"`` for a topic-less conversation,
|
||||
``binding_key`` builds ``f"{channel}:{chat_id}"`` for a topic-less conversation,
|
||||
so publishing this would put every senderless P2P message under the literal
|
||||
``"dingtalk:"`` — one shared thread, one shared history, across users.
|
||||
"""
|
||||
|
||||
@ -2,6 +2,8 @@ import asyncio
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@ -19,7 +21,7 @@ from app.channels.message_bus import (
|
||||
MessageBus,
|
||||
OutboundMessage,
|
||||
)
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
from deerflow.uploads.manager import PathTraversalError
|
||||
|
||||
|
||||
@ -574,38 +576,410 @@ def test_feishu_on_message_extracts_image_and_file_keys():
|
||||
assert channel._pending_inbound_batches == {}
|
||||
|
||||
|
||||
def test_feishu_on_message_reuses_stored_parent_topic_for_card_replies():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
store.set_thread_id(
|
||||
"feishu",
|
||||
"chat_1",
|
||||
"deer-thread-1",
|
||||
topic_id="om_clarification_card",
|
||||
user_id="user_1",
|
||||
)
|
||||
channel = FeishuChannel(
|
||||
bus,
|
||||
{"app_id": "test", "app_secret": "test", "channel_store": store},
|
||||
)
|
||||
async def _on_message_from_lark_thread(channel: FeishuChannel, event) -> None:
|
||||
"""Drive ``_on_message`` from a plain worker thread (no loop of its own) with the Gateway loop running.
|
||||
|
||||
event = MagicMock()
|
||||
event.event.message.chat_id = "chat_1"
|
||||
event.event.message.message_id = "msg_reply"
|
||||
event.event.message.root_id = "om_unknown_root"
|
||||
event.event.message.parent_id = "om_clarification_card"
|
||||
event.event.message.thread_id = None
|
||||
event.event.sender.sender_id.open_id = "user_1"
|
||||
event.event.message.content = json.dumps({"text": "prod"})
|
||||
The channel store is async, so ``_on_message`` bridges its lookups to
|
||||
``channel._main_loop``; running the callback on the Gateway loop's own
|
||||
thread would be the deadlock the bridge refuses.
|
||||
"""
|
||||
channel._main_loop = asyncio.get_running_loop()
|
||||
await asyncio.to_thread(channel._on_message, event)
|
||||
|
||||
|
||||
async def _on_message_from_sdk_loop(channel: FeishuChannel, event) -> float:
|
||||
"""Drive ``_on_message`` exactly as lark-oapi 1.5.5 does in production.
|
||||
|
||||
``FeishuChannel._run_ws`` gives the SDK thread its own event loop and
|
||||
``ws.Client.start()`` runs it; the SDK's async frame handler then calls
|
||||
``_on_message`` synchronously from inside that *running* loop, while the
|
||||
Gateway loop (``channel._main_loop``) runs in another thread. So
|
||||
``asyncio.get_running_loop()`` succeeds inside the callback, but the loop it
|
||||
returns is not the one the bridge submits to — blocking it briefly is fine
|
||||
(it is the thread that used to block on the JSON rewrite); only a wait on
|
||||
the Gateway loop itself would deadlock.
|
||||
"""
|
||||
channel._main_loop = asyncio.get_running_loop()
|
||||
|
||||
def sdk_thread() -> float:
|
||||
sdk_loop = asyncio.new_event_loop()
|
||||
try:
|
||||
|
||||
async def frame_handler() -> float:
|
||||
assert asyncio.get_running_loop() is sdk_loop
|
||||
started = time.monotonic()
|
||||
channel._on_message(event) # synchronous call from inside the SDK's running loop
|
||||
return time.monotonic() - started # how long the SDK's loop was blocked
|
||||
|
||||
return sdk_loop.run_until_complete(frame_handler())
|
||||
finally:
|
||||
sdk_loop.close()
|
||||
|
||||
return await asyncio.to_thread(sdk_thread)
|
||||
|
||||
|
||||
class _GatedStore:
|
||||
"""A channel store whose database only answers once ``gate`` is set (slow or hung DB)."""
|
||||
|
||||
def __init__(self, inner: JsonChannelStore, gate: asyncio.Event) -> None:
|
||||
self.inner = inner
|
||||
self.gate = gate
|
||||
self.lookups = 0
|
||||
self.channels_dir = inner.channels_dir
|
||||
|
||||
async def get_thread_id(self, channel_name, chat_id, topic_id=None):
|
||||
self.lookups += 1
|
||||
await self.gate.wait()
|
||||
return await self.inner.get_thread_id(channel_name, chat_id, topic_id=topic_id)
|
||||
|
||||
async def set_thread_id(self, channel_name, chat_id, thread_id, *, topic_id=None, user_id=""):
|
||||
await self.gate.wait()
|
||||
await self.inner.set_thread_id(channel_name, chat_id, thread_id, topic_id=topic_id, user_id=user_id)
|
||||
|
||||
|
||||
async def _eventually(predicate, *, timeout: float = 5.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while not predicate():
|
||||
assert time.monotonic() < deadline, "condition not met in time"
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
def _fast_store_budgets(monkeypatch, *, total: float = 5.0) -> None:
|
||||
monkeypatch.setattr(feishu_module, "FEISHU_STORE_LOOKUP_TIMEOUT_SECONDS", 0.2)
|
||||
monkeypatch.setattr(feishu_module, "FEISHU_STORE_BRIDGE_TIMEOUT_SECONDS", 0.3)
|
||||
monkeypatch.setattr(feishu_module, "FEISHU_STORE_RESOLVE_RETRY_BACKOFF_SECONDS", 0.05)
|
||||
monkeypatch.setattr(feishu_module, "FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS", total)
|
||||
|
||||
|
||||
def test_feishu_on_message_reuses_stored_parent_topic_for_card_replies():
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
await store.set_thread_id(
|
||||
"feishu",
|
||||
"chat_1",
|
||||
"deer-thread-1",
|
||||
topic_id="om_clarification_card",
|
||||
user_id="user_1",
|
||||
)
|
||||
channel = FeishuChannel(
|
||||
bus,
|
||||
{"app_id": "test", "app_secret": "test", "channel_store": store},
|
||||
)
|
||||
|
||||
event = MagicMock()
|
||||
event.event.message.chat_id = "chat_1"
|
||||
event.event.message.message_id = "msg_reply"
|
||||
event.event.message.root_id = "om_unknown_root"
|
||||
event.event.message.parent_id = "om_clarification_card"
|
||||
event.event.message.thread_id = None
|
||||
event.event.sender.sender_id.open_id = "user_1"
|
||||
event.event.message.content = json.dumps({"text": "prod"})
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
await _on_message_from_lark_thread(channel, event)
|
||||
|
||||
inbound = mock_make_inbound.return_value
|
||||
assert inbound.topic_id == "om_clarification_card"
|
||||
assert mock_make_inbound.call_args.kwargs["metadata"]["topic_id"] == "om_clarification_card"
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_on_message_drops_a_store_backed_message_without_a_running_gateway_loop(caplog):
|
||||
"""No loop to bridge to (direct construction, no ``start()``): the mapping cannot be
|
||||
consulted, so the message is dropped with a warning — an unresolved lookup is never
|
||||
routed as a miss onto the unknown root (nothing could dispatch it without the loop anyway)."""
|
||||
store = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
bus = MessageBus()
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
event = _make_text_event("prod", message_id="msg_reply", root_id="om_root", parent_id="om_card")
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
channel._on_message(event)
|
||||
schedule = MagicMock()
|
||||
batch = MagicMock()
|
||||
m.setattr(channel, "_schedule_prepare_inbound", schedule)
|
||||
m.setattr(channel, "_queue_file_inbound_batch", batch)
|
||||
with caplog.at_level("WARNING", logger="app.channels.feishu"):
|
||||
channel._on_message(event)
|
||||
|
||||
inbound = mock_make_inbound.return_value
|
||||
assert inbound.topic_id == "om_clarification_card"
|
||||
assert mock_make_inbound.call_args.kwargs["metadata"]["topic_id"] == "om_clarification_card"
|
||||
schedule.assert_not_called()
|
||||
batch.assert_not_called()
|
||||
assert "main loop not running, cannot resolve_topic_mapping" in caplog.text
|
||||
assert "dropping message msg_reply" in caplog.text
|
||||
assert len(bus._inbound_reservations) == 0 # the intake slot taken before routing came back
|
||||
|
||||
|
||||
def test_feishu_slow_store_defers_the_reply_until_it_resolves_to_the_mapped_parent_thread(monkeypatch, caplog):
|
||||
"""Thread 4 of the #6558 review: a lookup the database answers late is *unresolved*, not a
|
||||
miss. The reply (parent card mapped to thread A, root unmapped) is not dispatched until
|
||||
the store answers, then routes to A; no new conversation, no alias overwrite, and the
|
||||
SDK's loop is never blocked past the short bridge bound."""
|
||||
|
||||
async def go():
|
||||
_fast_store_budgets(monkeypatch)
|
||||
inner = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
await inner.set_thread_id("feishu", "chat_1", "thread-a", topic_id="om_card", user_id="user_1")
|
||||
gate = asyncio.Event()
|
||||
store = _GatedStore(inner, gate)
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
dispatched: list = []
|
||||
monkeypatch.setattr(channel, "_schedule_prepare_inbound", lambda msg_id, inbound, **kwargs: dispatched.append(inbound))
|
||||
event = _make_text_event("answer", message_id="msg_reply", root_id="om_unknown_root", parent_id="om_card")
|
||||
|
||||
with caplog.at_level("WARNING", logger="app.channels.feishu"):
|
||||
blocked_for = await _on_message_from_sdk_loop(channel, event)
|
||||
assert blocked_for < feishu_module.FEISHU_STORE_BRIDGE_TIMEOUT_SECONDS + 0.5 # the lark loop is released at the short bound
|
||||
assert dispatched == [] # unresolved: not routed as a miss
|
||||
await asyncio.sleep(0.3)
|
||||
assert dispatched == [] # still waiting for the database, retrying on the Gateway loop
|
||||
|
||||
gate.set()
|
||||
await _eventually(lambda: len(dispatched) == 1)
|
||||
|
||||
inbound = dispatched[0]
|
||||
assert inbound.topic_id == "om_card" and inbound.metadata["topic_id"] == "om_card" # thread A, not the unknown root
|
||||
assert inbound.thread_ts == "msg_reply"
|
||||
assert await inner.get_thread_id("feishu", "chat_1", topic_id="om_card") == "thread-a" # no alias overwrite
|
||||
assert "dropping message" not in caplog.text
|
||||
assert "Gateway loop itself" not in caplog.text
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_store_that_never_answers_drops_the_message_instead_of_opening_a_new_conversation(monkeypatch, caplog):
|
||||
async def go():
|
||||
_fast_store_budgets(monkeypatch, total=0.6)
|
||||
inner = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
await inner.set_thread_id("feishu", "chat_1", "thread-a", topic_id="om_card", user_id="user_1")
|
||||
store = _GatedStore(inner, asyncio.Event()) # never set
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
dispatched: list = []
|
||||
monkeypatch.setattr(channel, "_schedule_prepare_inbound", lambda msg_id, inbound, **kwargs: dispatched.append(inbound))
|
||||
event = _make_text_event("answer", message_id="msg_reply", root_id="om_unknown_root", parent_id="om_card")
|
||||
|
||||
with caplog.at_level("WARNING", logger="app.channels.feishu"):
|
||||
await _on_message_from_sdk_loop(channel, event)
|
||||
await _eventually(lambda: "dropping message msg_reply" in caplog.text)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert dispatched == [] # never routed as a miss
|
||||
assert await inner.get_thread_id("feishu", "chat_1", topic_id="om_card") == "thread-a"
|
||||
assert store.lookups >= 2 # it kept retrying up to the total budget before giving up
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def _reply_event(msg_id: str):
|
||||
return _make_text_event("answer", message_id=msg_id, root_id="om_unknown_root", parent_id="om_card")
|
||||
|
||||
|
||||
def test_feishu_deferred_burst_is_bounded_by_inbound_queue_maxsize(monkeypatch, caplog):
|
||||
"""#6558 review: deferred messages must hold a bus intake slot. A burst during a store
|
||||
outage retains exactly ``inbound_queue_maxsize`` messages, drops the rest immediately
|
||||
with a warning, and returns the capacity once the store answers and the messages are
|
||||
consumed from the bus."""
|
||||
|
||||
async def go():
|
||||
_fast_store_budgets(monkeypatch, total=30.0)
|
||||
inner = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
await inner.set_thread_id("feishu", "chat_1", "thread-a", topic_id="om_card", user_id="user_1")
|
||||
gate = asyncio.Event()
|
||||
store = _GatedStore(inner, gate)
|
||||
bus = MessageBus(inbound_queue_maxsize=3)
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
channel._add_reaction = AsyncMock()
|
||||
channel._ensure_running_card_started = MagicMock()
|
||||
|
||||
with caplog.at_level("WARNING", logger="app.channels.feishu"):
|
||||
for i in range(6):
|
||||
await _on_message_from_sdk_loop(channel, _reply_event(f"msg_{i}"))
|
||||
await asyncio.sleep(0.3)
|
||||
assert bus.inbound_queue.qsize() == 0 # nothing routed as a miss
|
||||
retained = len(bus._inbound_reservations)
|
||||
assert retained == 3 # exactly the configured bound is retained (one slot per deferred message)
|
||||
dropped = [line for line in caplog.text.splitlines() if "dropping message" in line]
|
||||
assert len(dropped) == 3 and all("intake capacity" in line for line in dropped)
|
||||
|
||||
gate.set()
|
||||
delivered = [await asyncio.wait_for(bus.get_inbound(), timeout=5) for _ in range(3)]
|
||||
assert [m.thread_ts for m in delivered] == ["msg_0", "msg_1", "msg_2"] # FIFO, the oldest survivors
|
||||
assert all(m.topic_id == "om_card" for m in delivered)
|
||||
await _eventually(lambda: len(bus._inbound_reservations) == 0)
|
||||
assert bus.inbound_queue.qsize() == 0
|
||||
# Capacity is back: the bound can be reserved again in full.
|
||||
probes = [bus.reserve_inbound(delivered[0]) for _ in range(3)]
|
||||
for probe in probes:
|
||||
probe.release()
|
||||
assert await inner.get_thread_id("feishu", "chat_1", topic_id="om_card") == "thread-a"
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_deferred_message_budget_runs_from_arrival_and_releases_capacity_on_drop(monkeypatch, caplog):
|
||||
"""The retry budget is per message from its arrival, not from when it reaches the queue
|
||||
head: an expired message is dropped without another lookup and its intake slot is freed."""
|
||||
|
||||
async def go():
|
||||
_fast_store_budgets(monkeypatch, total=0.5)
|
||||
inner = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
store = _GatedStore(inner, asyncio.Event()) # never answers
|
||||
bus = MessageBus(inbound_queue_maxsize=4)
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
channel._main_loop = asyncio.get_running_loop()
|
||||
parsed = channel._parse_message_event(_reply_event("msg_expired"))
|
||||
reservation = bus.reserve_inbound(channel._make_inbound(chat_id="chat_1", user_id="user_1", text="x", msg_type=feishu_module.InboundMessageType.CHAT, thread_ts="msg_expired"))
|
||||
attempt = feishu_module._RoutingAttempt(parsed, reservation=reservation, arrived_at=time.monotonic() - 100.0)
|
||||
queue = deque([attempt])
|
||||
channel._deferred_routing["chat_1"] = queue
|
||||
lookups_before = store.lookups
|
||||
|
||||
with caplog.at_level("WARNING", logger="app.channels.feishu"):
|
||||
await channel._drain_deferred_routing("chat_1", queue, None)
|
||||
|
||||
assert store.lookups == lookups_before # expired at the head: no retry spent on it
|
||||
assert "dropping message msg_expired" in caplog.text
|
||||
assert len(bus._inbound_reservations) == 0 # the slot came back
|
||||
assert "chat_1" not in channel._deferred_routing
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_stop_releases_deferred_intake_reservations(monkeypatch, caplog):
|
||||
async def go():
|
||||
_fast_store_budgets(monkeypatch, total=30.0)
|
||||
inner = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
store = _GatedStore(inner, asyncio.Event()) # never answers
|
||||
bus = MessageBus(inbound_queue_maxsize=4)
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
await _on_message_from_sdk_loop(channel, _reply_event("msg_0"))
|
||||
await _on_message_from_sdk_loop(channel, _reply_event("msg_1"))
|
||||
assert len(bus._inbound_reservations) == 2
|
||||
|
||||
with caplog.at_level("WARNING", logger="app.channels.feishu"):
|
||||
await channel._close_and_drain_threadsafe_futures() # what stop() does to in-flight work
|
||||
assert len(bus._inbound_reservations) == 0
|
||||
assert channel._deferred_routing == {}
|
||||
assert "dropping 2 deferred messages for chat chat_1" in caplog.text or "dropping 1 deferred messages for chat chat_1" in caplog.text
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_deferred_messages_keep_per_chat_fifo_order(monkeypatch):
|
||||
"""A later message for the same chat must not overtake a deferred predecessor."""
|
||||
|
||||
async def go():
|
||||
_fast_store_budgets(monkeypatch)
|
||||
inner = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
await inner.set_thread_id("feishu", "chat_1", "thread-a", topic_id="om_card", user_id="user_1")
|
||||
gate = asyncio.Event()
|
||||
store = _GatedStore(inner, gate)
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
dispatched: list = []
|
||||
monkeypatch.setattr(channel, "_schedule_prepare_inbound", lambda msg_id, inbound, **kwargs: dispatched.append(inbound))
|
||||
|
||||
await _on_message_from_sdk_loop(channel, _make_text_event("first", message_id="msg_first", root_id="om_unknown_root", parent_id="om_card"))
|
||||
# Needs no store answer itself (no root/parent/thread), but queues behind the deferred predecessor.
|
||||
blocked_for = await _on_message_from_sdk_loop(channel, _make_text_event("second", message_id="msg_second"))
|
||||
assert blocked_for < 0.2
|
||||
await asyncio.sleep(0.3)
|
||||
assert dispatched == []
|
||||
|
||||
gate.set()
|
||||
await _eventually(lambda: len(dispatched) == 2)
|
||||
assert [inbound.thread_ts for inbound in dispatched] == ["msg_first", "msg_second"]
|
||||
assert dispatched[0].topic_id == "om_card"
|
||||
assert dispatched[1].topic_id == "msg_second"
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_sdk_loop_callback_still_consults_the_stored_parent_mapping(caplog):
|
||||
"""The production shape (#6558 review): the SDK calls ``_on_message`` from inside its own
|
||||
running loop. The bridge must still look the parent card up on the Gateway loop — otherwise
|
||||
a reply whose parent is mapped but whose root is not falls back to the unknown root (a
|
||||
different DeerFlow conversation) and may consume an unrelated pending clarification."""
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
await store.set_thread_id("feishu", "chat_1", "deer-thread-card", topic_id="om_card", user_id="user_1")
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
key = channel._pending_key("chat_1", "user_1")
|
||||
channel._pending_clarifications[key] = [_pending("om_pending", thread_id="deer-thread-pending")]
|
||||
event = _make_text_event("answer", message_id="msg_reply", root_id="om_unknown_root", parent_id="om_card")
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
with caplog.at_level("WARNING", logger="app.channels.feishu"):
|
||||
await _on_message_from_sdk_loop(channel, event)
|
||||
|
||||
metadata = mock_make_inbound.call_args.kwargs["metadata"]
|
||||
assert metadata["topic_id"] == "om_card" # the persisted parent mapping won, via the bridge
|
||||
assert metadata[RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY] is False
|
||||
assert key in channel._pending_clarifications # the unrelated clarification was not consumed
|
||||
assert "event-loop thread" not in caplog.text
|
||||
assert "Gateway loop" not in caplog.text
|
||||
assert "main loop not running" not in caplog.text
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_sdk_loop_callback_restores_the_pending_mapping_through_the_bridge(caplog):
|
||||
"""Same production shape for the bridge's write path: restoring a pending clarification's
|
||||
topic mapping goes through the Gateway loop from inside the SDK's running loop."""
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
key = channel._pending_key("chat_1", "user_1")
|
||||
channel._pending_clarifications[key] = [_pending("om_original", thread_id="deer-thread-1", card_message_id="om_card")]
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
with caplog.at_level("WARNING", logger="app.channels.feishu"):
|
||||
await _on_message_from_sdk_loop(channel, _make_text_event("2", message_id="msg_plain_2"))
|
||||
|
||||
metadata = mock_make_inbound.call_args.kwargs["metadata"]
|
||||
assert metadata["topic_id"] == "om_original"
|
||||
assert metadata[RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY] is True
|
||||
assert key not in channel._pending_clarifications
|
||||
assert await store.get_thread_id("feishu", "chat_1", topic_id="om_original") == "deer-thread-1"
|
||||
assert "event-loop thread" not in caplog.text
|
||||
assert "Gateway loop" not in caplog.text
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_store_bridge_refuses_to_block_the_gateway_loop_itself(caplog):
|
||||
"""Only a wait issued from the Gateway loop itself is refused: it would block the loop
|
||||
that has to run the submitted coroutine. The refusal is reported as *unresolved* (never
|
||||
a miss) and the coroutine factory is never invoked."""
|
||||
|
||||
async def go():
|
||||
store = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
channel._main_loop = asyncio.get_running_loop()
|
||||
factory_calls = 0
|
||||
|
||||
def make_coroutine():
|
||||
nonlocal factory_calls
|
||||
factory_calls += 1
|
||||
return store.get_thread_id("feishu", "chat_1", topic_id="om_card")
|
||||
|
||||
with caplog.at_level("WARNING", logger="app.channels.feishu"):
|
||||
with pytest.raises(feishu_module.StoreLookupUnresolved):
|
||||
channel._run_store_call(make_coroutine, name="resolve_topic_mapping", msg_id="msg_1")
|
||||
|
||||
assert factory_calls == 0
|
||||
assert "resolve_topic_mapping requested on the Gateway loop itself" in caplog.text
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def _make_text_event(
|
||||
@ -810,23 +1184,28 @@ def test_feishu_expired_file_batch_does_not_get_overwritten(monkeypatch):
|
||||
|
||||
|
||||
def test_feishu_plain_reply_consumes_pending_clarification_topic():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
store.set_thread_id("feishu", "chat_1", "deer-thread-1", topic_id="om_original", user_id="user_1")
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
channel._pending_clarifications[channel._pending_key("chat_1", "user_1")] = [_pending("om_original", thread_id="deer-thread-1", card_message_id="om_card")]
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
await store.set_thread_id("feishu", "chat_1", "deer-thread-1", topic_id="om_original", user_id="user_1")
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
channel._pending_clarifications[channel._pending_key("chat_1", "user_1")] = [_pending("om_original", thread_id="deer-thread-1", card_message_id="om_card")]
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
channel._on_message(_make_text_event("2", message_id="msg_plain_2"))
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
await _on_message_from_lark_thread(channel, _make_text_event("2", message_id="msg_plain_2"))
|
||||
|
||||
inbound = mock_make_inbound.return_value
|
||||
metadata = mock_make_inbound.call_args.kwargs["metadata"]
|
||||
assert inbound.topic_id == "om_original"
|
||||
assert metadata["topic_id"] == "om_original"
|
||||
assert metadata[RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY] is True
|
||||
assert channel._pending_key("chat_1", "user_1") not in channel._pending_clarifications
|
||||
inbound = mock_make_inbound.return_value
|
||||
metadata = mock_make_inbound.call_args.kwargs["metadata"]
|
||||
assert inbound.topic_id == "om_original"
|
||||
assert metadata["topic_id"] == "om_original"
|
||||
assert metadata[RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY] is True
|
||||
assert channel._pending_key("chat_1", "user_1") not in channel._pending_clarifications
|
||||
# The pending clarification's mapping was restored through the store on the Gateway loop.
|
||||
assert await store.get_thread_id("feishu", "chat_1", topic_id="om_original") == "deer-thread-1"
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_feishu_pending_clarification_is_consumed_once():
|
||||
@ -947,29 +1326,33 @@ def test_feishu_multiple_pending_clarifications_are_consumed_in_order():
|
||||
|
||||
|
||||
def test_feishu_explicit_reply_prefers_stored_mapping_over_pending():
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
store.set_thread_id("feishu", "chat_1", "deer-thread-card", topic_id="om_card", user_id="user_1")
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
key = channel._pending_key("chat_1", "user_1")
|
||||
channel._pending_clarifications[key] = [_pending("om_pending", thread_id="deer-thread-pending")]
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
store = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||||
await store.set_thread_id("feishu", "chat_1", "deer-thread-card", topic_id="om_card", user_id="user_1")
|
||||
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test", "channel_store": store})
|
||||
key = channel._pending_key("chat_1", "user_1")
|
||||
channel._pending_clarifications[key] = [_pending("om_pending", thread_id="deer-thread-pending")]
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
channel._on_message(
|
||||
_make_text_event(
|
||||
"answer",
|
||||
message_id="msg_reply",
|
||||
root_id="om_unknown",
|
||||
parent_id="om_card",
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
mock_make_inbound = MagicMock()
|
||||
m.setattr(channel, "_make_inbound", mock_make_inbound)
|
||||
await _on_message_from_lark_thread(
|
||||
channel,
|
||||
_make_text_event(
|
||||
"answer",
|
||||
message_id="msg_reply",
|
||||
root_id="om_unknown",
|
||||
parent_id="om_card",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
metadata = mock_make_inbound.call_args.kwargs["metadata"]
|
||||
assert metadata["topic_id"] == "om_card"
|
||||
assert metadata[RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY] is False
|
||||
assert key in channel._pending_clarifications
|
||||
metadata = mock_make_inbound.call_args.kwargs["metadata"]
|
||||
assert metadata["topic_id"] == "om_card"
|
||||
assert metadata[RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY] is False
|
||||
assert key in channel._pending_clarifications
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", sorted(KNOWN_CHANNEL_COMMANDS))
|
||||
|
||||
@ -51,7 +51,7 @@ async def _drain(bus: MessageBus) -> list[InboundMessage]:
|
||||
async def test_fanout_redelivery_progresses_beyond_queue_sized_prefix(base_dir: Path) -> None:
|
||||
"""A redelivery must not keep failing on the same queue-sized prefix."""
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
|
||||
agent_names = {"alpha", "bravo", "charlie"}
|
||||
for name in agent_names:
|
||||
@ -75,7 +75,7 @@ async def test_fanout_redelivery_progresses_beyond_queue_sized_prefix(base_dir:
|
||||
bus = MessageBus(inbound_queue_maxsize=1)
|
||||
manager = ChannelManager(
|
||||
bus=bus,
|
||||
store=ChannelStore(path=base_dir / "fanout-store.json"),
|
||||
store=JsonChannelStore(path=base_dir / "fanout-store.json"),
|
||||
max_concurrency=1,
|
||||
)
|
||||
handled: list[str] = []
|
||||
@ -1240,9 +1240,9 @@ async def test_dedupe_identity_distinguishes_same_agent_name_across_users(base_d
|
||||
# differ: neither user's message is treated as a duplicate of the
|
||||
# other inside the same ChannelManager dedupe window.
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
|
||||
manager = ChannelManager(bus=MessageBus(), store=ChannelStore(path=base_dir / "dedupe-store.json"))
|
||||
manager = ChannelManager(bus=MessageBus(), store=JsonChannelStore(path=base_dir / "dedupe-store.json"))
|
||||
assert await manager._is_duplicate_inbound(by_owner["alice"]) is False
|
||||
assert await manager._is_duplicate_inbound(by_owner["bob"]) is False
|
||||
|
||||
@ -1265,7 +1265,7 @@ async def test_missing_delivery_header_leaves_dedupe_open(base_dir: Path) -> Non
|
||||
(willem-bd, PR #4104 review).
|
||||
"""
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
|
||||
bus = MessageBus()
|
||||
_write_agent(base_dir, "default", "reviewer", {"name": "reviewer", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {"actions": ["opened"]}}}]}})
|
||||
@ -1275,7 +1275,7 @@ async def test_missing_delivery_header_leaves_dedupe_open(base_dir: Path) -> Non
|
||||
"repository": {"full_name": "a/b"},
|
||||
"sender": {"login": "u"},
|
||||
}
|
||||
manager = ChannelManager(bus=MessageBus(), store=ChannelStore(path=base_dir / "dedupe-store.json"))
|
||||
manager = ChannelManager(bus=MessageBus(), store=JsonChannelStore(path=base_dir / "dedupe-store.json"))
|
||||
|
||||
await fanout_event(bus, "pull_request", "", payload)
|
||||
(first,) = await _drain(bus)
|
||||
|
||||
@ -29,7 +29,7 @@ from langgraph_sdk.errors import ConflictError
|
||||
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
from deerflow.sandbox.local.local_sandbox import LocalSandbox
|
||||
from deerflow.sandbox.tools import _github_env_from_runtime, bash_tool
|
||||
|
||||
@ -504,7 +504,7 @@ def _github_msg(installation_id: int | None = 140594274) -> InboundMessage:
|
||||
|
||||
def _new_manager() -> ChannelManager:
|
||||
bus = MessageBus()
|
||||
store = ChannelStore(path=Path("/tmp/nonexistent-store-test.json"))
|
||||
store = JsonChannelStore(path=Path("/tmp/nonexistent-store-test.json"))
|
||||
return ChannelManager(bus=bus, store=store)
|
||||
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ OUTBOX = "0027_notification_deliveries"
|
||||
PARKED = "0028_parked_attempts"
|
||||
SCHEDULER = "0029_scheduler_agent_tasks"
|
||||
CLAIM_TOKENS = "0030_notification_claim_tokens"
|
||||
HEAD = "0037_project_document_summaries"
|
||||
HEAD = "0038_channel_thread_bindings"
|
||||
PREVIOUS = "0026_mcp_task_lease_tokens"
|
||||
TABLE = "notification_deliveries"
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
|
||||
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
|
||||
|
||||
REVISION = "0029_scheduler_agent_tasks"
|
||||
CURRENT_HEAD = "0037_project_document_summaries"
|
||||
CURRENT_HEAD = "0038_channel_thread_bindings"
|
||||
NEXT = "0030_notification_claim_tokens"
|
||||
PREVIOUS = "0028_parked_attempts"
|
||||
TASK_FIELDS = {"origin_thread_id", "goal_objective", "max_runs", "end_at", "standing_notes"}
|
||||
|
||||
@ -21,7 +21,8 @@ NEXT = "0033_batch_result_artifact"
|
||||
AFTER_NEXT = "0034_run_event_seq_watermark"
|
||||
LOGIN_THROTTLE = "0035_login_throttle"
|
||||
IDEMPOTENCY = "0036_run_idempotency_request"
|
||||
CURRENT_HEAD = "0037_project_document_summaries"
|
||||
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
|
||||
CURRENT_HEAD = "0038_channel_thread_bindings"
|
||||
PREVIOUS = "0031_scheduled_streak_boundary"
|
||||
TABLES = {"thread_read_markers", "thread_read_versions", "scheduled_task_events"}
|
||||
INDEXES = {
|
||||
@ -40,7 +41,8 @@ async def test_0032_remains_in_the_single_migration_chain():
|
||||
assert script.get_revision(AFTER_NEXT).down_revision == NEXT
|
||||
assert script.get_revision(LOGIN_THROTTLE).down_revision == AFTER_NEXT
|
||||
assert script.get_revision(IDEMPOTENCY).down_revision == LOGIN_THROTTLE
|
||||
assert script.get_revision(CURRENT_HEAD).down_revision == IDEMPOTENCY
|
||||
assert script.get_revision(DOCUMENT_SUMMARIES).down_revision == IDEMPOTENCY
|
||||
assert script.get_revision(CURRENT_HEAD).down_revision == DOCUMENT_SUMMARIES
|
||||
# alembic_version.version_num is VARCHAR(32).
|
||||
assert len(REVISION) <= 32
|
||||
|
||||
|
||||
@ -21,7 +21,8 @@ REVISION = "0034_run_event_seq_watermark"
|
||||
PREVIOUS = "0033_batch_result_artifact"
|
||||
LOGIN_THROTTLE = "0035_login_throttle"
|
||||
IDEMPOTENCY = "0036_run_idempotency_request"
|
||||
CURRENT_HEAD = "0037_project_document_summaries"
|
||||
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
|
||||
CURRENT_HEAD = "0038_channel_thread_bindings"
|
||||
TABLE = "run_event_thread_seq"
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
@ -32,7 +33,8 @@ async def test_0034_remains_in_the_single_migration_chain():
|
||||
assert script.get_revision(REVISION).down_revision == PREVIOUS
|
||||
assert script.get_revision(LOGIN_THROTTLE).down_revision == REVISION
|
||||
assert script.get_revision(IDEMPOTENCY).down_revision == LOGIN_THROTTLE
|
||||
assert script.get_revision(CURRENT_HEAD).down_revision == IDEMPOTENCY
|
||||
assert script.get_revision(DOCUMENT_SUMMARIES).down_revision == IDEMPOTENCY
|
||||
assert script.get_revision(CURRENT_HEAD).down_revision == DOCUMENT_SUMMARIES
|
||||
assert len(REVISION) <= 32
|
||||
|
||||
|
||||
|
||||
@ -22,7 +22,8 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
|
||||
|
||||
REVISION = "0035_login_throttle"
|
||||
IDEMPOTENCY = "0036_run_idempotency_request"
|
||||
CURRENT_HEAD = "0037_project_document_summaries"
|
||||
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
|
||||
CURRENT_HEAD = "0038_channel_thread_bindings"
|
||||
PREVIOUS = "0034_run_event_seq_watermark"
|
||||
TABLE = "login_throttle"
|
||||
COLUMNS = {"ip", "fail_count", "locked_at", "lock_duration_seconds", "updated_at"}
|
||||
@ -35,7 +36,8 @@ async def test_0035_remains_in_the_single_head_chain_after_0034():
|
||||
assert script.get_heads() == [CURRENT_HEAD]
|
||||
assert script.get_revision(REVISION).down_revision == PREVIOUS
|
||||
assert script.get_revision(IDEMPOTENCY).down_revision == REVISION
|
||||
assert script.get_revision(CURRENT_HEAD).down_revision == IDEMPOTENCY
|
||||
assert script.get_revision(DOCUMENT_SUMMARIES).down_revision == IDEMPOTENCY
|
||||
assert script.get_revision(CURRENT_HEAD).down_revision == DOCUMENT_SUMMARIES
|
||||
# alembic_version.version_num is VARCHAR(32).
|
||||
assert len(REVISION) <= 32
|
||||
|
||||
|
||||
@ -18,12 +18,14 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
|
||||
|
||||
REVISION = "0037_project_document_summaries"
|
||||
PREVIOUS = "0036_run_idempotency_request"
|
||||
CURRENT_HEAD = "0038_channel_thread_bindings"
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_0037_chains_after_0036_and_is_single_head():
|
||||
script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR))
|
||||
assert script.get_heads() == [REVISION]
|
||||
assert script.get_heads() == [CURRENT_HEAD]
|
||||
assert script.get_revision(CURRENT_HEAD).down_revision == REVISION
|
||||
assert script.get_revision(REVISION).down_revision == PREVIOUS
|
||||
# alembic_version.version_num is VARCHAR(32).
|
||||
assert len(REVISION) <= 32
|
||||
|
||||
187
backend/tests/test_migration_0038_channel_thread_bindings.py
Normal file
187
backend/tests/test_migration_0038_channel_thread_bindings.py
Normal file
@ -0,0 +1,187 @@
|
||||
"""Migration 0038: the shared ``channel_thread_bindings`` table (IM chat -> DeerFlow thread)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
from alembic.autogenerate import compare_metadata
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from support.postgres import asyncpg_test_url
|
||||
|
||||
from deerflow.persistence import bootstrap
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.channel_thread_bindings import ChannelThreadBindingRow
|
||||
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
|
||||
|
||||
REVISION = "0038_channel_thread_bindings"
|
||||
PREVIOUS = "0037_project_document_summaries"
|
||||
TABLE = "channel_thread_bindings"
|
||||
COLUMNS = {"key", "channel_name", "chat_id", "topic_id", "thread_id", "user_id", "created_at", "updated_at"}
|
||||
INDEX = "ix_channel_thread_bindings_channel_chat"
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_0038_is_the_single_head_and_chains_after_0037():
|
||||
script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR))
|
||||
assert script.get_heads() == [REVISION]
|
||||
assert script.get_revision(REVISION).down_revision == PREVIOUS
|
||||
# alembic_version.version_num is VARCHAR(32).
|
||||
assert len(REVISION) <= 32
|
||||
|
||||
|
||||
def _engine(tmp_path, backend):
|
||||
if backend == "sqlite":
|
||||
return create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}"), None
|
||||
uri = os.environ.get("TEST_POSTGRES_URI")
|
||||
if not uri:
|
||||
pytest.skip("requires TEST_POSTGRES_URI (real Postgres migration 0038)")
|
||||
schema = f"channel_bindings_{uuid.uuid4().hex}"
|
||||
return create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema)), schema
|
||||
|
||||
|
||||
async def _shape(engine) -> dict:
|
||||
async with engine.connect() as conn:
|
||||
|
||||
def read(sync):
|
||||
inspector = sa.inspect(sync)
|
||||
tables = set(inspector.get_table_names())
|
||||
if TABLE not in tables:
|
||||
return {"tables": tables}
|
||||
return {
|
||||
"tables": tables,
|
||||
"columns": {col["name"]: col for col in inspector.get_columns(TABLE)},
|
||||
"pk": inspector.get_pk_constraint(TABLE)["constrained_columns"],
|
||||
"indexes": {index["name"]: index["column_names"] for index in inspector.get_indexes(TABLE)},
|
||||
}
|
||||
|
||||
return await conn.run_sync(read)
|
||||
|
||||
|
||||
async def _orm_diff(engine) -> list:
|
||||
"""Alembic's own drift check, restricted to the table this revision owns."""
|
||||
|
||||
def only_bindings(obj, name, type_, reflected, compare_to):
|
||||
if type_ == "table":
|
||||
return name == TABLE
|
||||
return getattr(getattr(obj, "table", None), "name", TABLE) == TABLE
|
||||
|
||||
async with engine.connect() as conn:
|
||||
|
||||
def diff(sync):
|
||||
context = MigrationContext.configure(sync, opts={"include_object": only_bindings, "compare_type": True, "compare_server_default": True})
|
||||
return compare_metadata(context, Base.metadata)
|
||||
|
||||
return await conn.run_sync(diff)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
|
||||
async def test_0038_creates_the_table_matching_the_orm_and_downgrades_cleanly(tmp_path, backend):
|
||||
engine, schema = _engine(tmp_path, backend)
|
||||
cfg = bootstrap._get_alembic_config(engine, postgres_schema=schema or "")
|
||||
try:
|
||||
if schema:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
|
||||
await asyncio.to_thread(command.upgrade, cfg, PREVIOUS)
|
||||
assert TABLE not in (await _shape(engine))["tables"]
|
||||
|
||||
await asyncio.to_thread(command.upgrade, cfg, REVISION)
|
||||
shape = await _shape(engine)
|
||||
assert set(shape["columns"]) == COLUMNS
|
||||
assert shape["pk"] == ["key"]
|
||||
assert shape["columns"]["channel_name"]["nullable"] is False
|
||||
assert shape["columns"]["chat_id"]["nullable"] is False
|
||||
assert shape["columns"]["topic_id"]["nullable"] is True
|
||||
assert shape["columns"]["thread_id"]["nullable"] is False
|
||||
assert shape["columns"]["user_id"]["nullable"] is False
|
||||
assert shape["columns"]["created_at"]["nullable"] is False
|
||||
assert shape["columns"]["updated_at"]["nullable"] is False
|
||||
assert shape["indexes"][INDEX] == ["channel_name", "chat_id"] # serves list_entries(channel) and the prefix remove
|
||||
# ``make migrate-rev`` would propose nothing: the revision matches the ORM model.
|
||||
assert await _orm_diff(engine) == []
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.insert(ChannelThreadBindingRow).values(key="slack:C1:171.1", channel_name="slack", chat_id="C1", topic_id="171.1", thread_id="thread-1", user_id="U1", created_at=1_700_000_000.0, updated_at=1_700_000_000.5)
|
||||
)
|
||||
with pytest.raises(sa.exc.IntegrityError):
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(sa.insert(ChannelThreadBindingRow).values(key="slack:C1:171.1", channel_name="slack", chat_id="C1", thread_id="thread-2", user_id="", created_at=1.0, updated_at=1.0))
|
||||
async with engine.connect() as conn:
|
||||
row = (await conn.execute(sa.text(f"SELECT channel_name, chat_id, topic_id, thread_id, user_id, created_at, updated_at FROM {TABLE}"))).one()
|
||||
assert tuple(row) == ("slack", "C1", "171.1", "thread-1", "U1", 1_700_000_000.0, 1_700_000_000.5)
|
||||
|
||||
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
|
||||
shape = await _shape(engine)
|
||||
assert TABLE not in shape["tables"]
|
||||
async with engine.connect() as conn:
|
||||
# The index goes with the table on downgrade (named, so a leftover would be visible).
|
||||
leftover = await conn.run_sync(lambda sync: [name for table in sa.inspect(sync).get_table_names() for name in {index["name"] for index in sa.inspect(sync).get_indexes(table)} if name == INDEX])
|
||||
assert leftover == []
|
||||
|
||||
# Downgrade then upgrade again is clean.
|
||||
await asyncio.to_thread(command.upgrade, cfg, REVISION)
|
||||
shape = await _shape(engine)
|
||||
assert TABLE in shape["tables"]
|
||||
assert INDEX in shape["indexes"]
|
||||
finally:
|
||||
if schema:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
|
||||
async def test_0038_completes_a_partially_applied_upgrade(tmp_path, backend):
|
||||
"""The table already exists (an interrupted earlier attempt): upgrade still lands."""
|
||||
engine, schema = _engine(tmp_path, backend)
|
||||
cfg = bootstrap._get_alembic_config(engine, postgres_schema=schema or "")
|
||||
try:
|
||||
if schema:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
|
||||
await asyncio.to_thread(command.upgrade, cfg, PREVIOUS)
|
||||
async with engine.begin() as conn:
|
||||
# The table exists but its index was never built (an interrupted earlier attempt).
|
||||
await conn.run_sync(Base.metadata.create_all, tables=[ChannelThreadBindingRow.__table__])
|
||||
await conn.execute(sa.text(f"DROP INDEX {INDEX}"))
|
||||
await conn.execute(sa.insert(ChannelThreadBindingRow).values(key="slack:C1", channel_name="slack", chat_id="C1", thread_id="thread-1", user_id="", created_at=1.0, updated_at=1.0))
|
||||
|
||||
await asyncio.to_thread(command.upgrade, cfg, REVISION)
|
||||
assert INDEX in (await _shape(engine))["indexes"] # the partial upgrade is completed
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.text(f"SELECT thread_id FROM {TABLE} WHERE key='slack:C1'"))).scalar_one() == "thread-1"
|
||||
assert (await conn.execute(sa.text("SELECT version_num FROM alembic_version"))).scalar_one() == REVISION
|
||||
|
||||
# A downgrade after someone already dropped the table still completes.
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(sa.text(f"DROP TABLE {TABLE}"))
|
||||
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.text("SELECT version_num FROM alembic_version"))).scalar_one() == PREVIOUS
|
||||
finally:
|
||||
if schema:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_bootstrap_provisions_the_table_on_an_empty_database_and_is_idempotent(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'bootstrap.db'}")
|
||||
try:
|
||||
await bootstrap.bootstrap_schema(engine, backend="sqlite")
|
||||
await bootstrap.bootstrap_schema(engine, backend="sqlite")
|
||||
shape = await _shape(engine)
|
||||
assert set(shape["columns"]) == COLUMNS
|
||||
assert INDEX in shape["indexes"]
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.text("SELECT version_num FROM alembic_version"))).scalar_one() == REVISION
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@ -102,13 +102,13 @@ async def test_manager_injects_shared_store_and_dedupes_cross_pod():
|
||||
loop would (``if await self._is_duplicate_inbound(msg): continue``)."""
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.message_bus import InboundMessage, MessageBus
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
|
||||
sf = get_session_factory()
|
||||
shared_store = PostgresInboundDedupeStore(session_factory=sf)
|
||||
manager_a = ChannelManager(bus=MessageBus(), store=ChannelStore(), inbound_dedupe_store=shared_store)
|
||||
manager_b = ChannelManager(bus=MessageBus(), store=ChannelStore(), inbound_dedupe_store=shared_store)
|
||||
manager_a = ChannelManager(bus=MessageBus(), store=JsonChannelStore(), inbound_dedupe_store=shared_store)
|
||||
manager_b = ChannelManager(bus=MessageBus(), store=JsonChannelStore(), inbound_dedupe_store=shared_store)
|
||||
|
||||
msg = InboundMessage(
|
||||
channel_name="github",
|
||||
|
||||
@ -9,7 +9,7 @@ import pytest_asyncio
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.message_bus import MessageBus
|
||||
from app.channels.qq import QQChannel
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
from deerflow.persistence.channel_connections import ChannelConnectionRepository
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||
|
||||
@ -59,7 +59,7 @@ async def test_binding_transfer_rejects_queued_old_owner(repo, tmp_path, monkeyp
|
||||
channel.send = AsyncMock()
|
||||
manager = ChannelManager(
|
||||
bus,
|
||||
ChannelStore(tmp_path / "threads.json"),
|
||||
JsonChannelStore(tmp_path / "threads.json"),
|
||||
connection_repo=repo,
|
||||
require_bound_identity=True,
|
||||
)
|
||||
|
||||
@ -317,11 +317,10 @@ async def test_cancelled_stop_drains_http_client_cleanup():
|
||||
@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 ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
store = ChannelStore(tmp_path / "channels.json")
|
||||
monkeypatch.setattr(service_module, "ChannelStore", lambda: store)
|
||||
store = JsonChannelStore(tmp_path / "channels.json")
|
||||
config = {
|
||||
"app_id": "fixture-app",
|
||||
"client_secret": "fixture-secret",
|
||||
@ -329,7 +328,7 @@ async def test_service_registers_starts_and_disposes_qq(tmp_path, monkeypatch):
|
||||
}
|
||||
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)
|
||||
service = service_module.ChannelService({"qq": config}, connection_repo=repository, app_config=app_config, store=store)
|
||||
|
||||
async def connected(self, url):
|
||||
self._ready.set()
|
||||
|
||||
@ -357,9 +357,9 @@ async def test_extension_handle_marks_its_threads_and_runs(gateway, monkeypatch,
|
||||
def _manager():
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.message_bus import MessageBus
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
|
||||
return ChannelManager(bus=MessageBus(), store=ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json"))
|
||||
return ChannelManager(bus=MessageBus(), store=JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json"))
|
||||
|
||||
|
||||
def _client(thread_id="im-thread"):
|
||||
|
||||
@ -20,7 +20,7 @@ import pytest
|
||||
|
||||
from app.channels.manager import ChannelManager
|
||||
from app.channels.message_bus import InboundMessage, MessageBus
|
||||
from app.channels.store import ChannelStore
|
||||
from app.channels.store import JsonChannelStore
|
||||
from app.scheduler.service import ScheduledTaskService
|
||||
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
|
||||
from deerflow.trace_context import get_current_trace_id, request_trace_context
|
||||
@ -199,7 +199,7 @@ async def test_inbound_messages_are_handled_under_distinct_trace_scopes(tmp_path
|
||||
bus = MessageBus(inbound_queue_maxsize=4)
|
||||
manager = ChannelManager(
|
||||
bus=bus,
|
||||
store=ChannelStore(path=tmp_path / "store.json"),
|
||||
store=JsonChannelStore(path=tmp_path / "store.json"),
|
||||
max_concurrency=1,
|
||||
)
|
||||
seen: list[str | None] = []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user