Files
hunjing/backend/app/services/dispatcher.py
T
2026-08-21 16:00:13 +08:00

116 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""一炁进程内事件订阅分发器。
订阅 EventStore 全量事件(``subscribe_by_pattern("*")``),按 glob pattern 分发给注册的
handler。每个 ``(pattern, handler)`` 注册项 = 一个独立 subscription(独立 queue + consumer
task),handler 异常隔离(``try/except`` + ``logger.exception``),at-most-once(失败不重试)。
设计见 ``docs/architecture/data-and-security-architecture.md`` §17「进程内订阅范式」。
"""
from __future__ import annotations
import asyncio
import contextlib
import fnmatch
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from loguru import logger
from app.modules.runtime.events import Event, EventStore
AsyncHandler = Callable[[Event], Awaitable[None]]
@dataclass
class _Subscription:
pattern: str
handler: AsyncHandler
queue: asyncio.Queue[Event] = field(default_factory=asyncio.Queue)
class Dispatcher:
"""一炁事件订阅分发器。
用法:
dispatcher = Dispatcher(event_store)
dispatcher.register("time.*", handler) # 必须在 start 前注册
await dispatcher.start() # lifespan startup
...
await dispatcher.stop() # lifespan shutdown
"""
def __init__(self, event_store: EventStore) -> None:
self._event_store = event_store
self._subscriptions: list[_Subscription] = []
self._all_queue: asyncio.Queue[Event] | None = None
self._pump_task: asyncio.Task[None] | None = None
self._consumer_tasks: list[asyncio.Task[None]] = []
self._started = False
def register(self, pattern: str, handler: AsyncHandler) -> None:
"""注册 handler(必须在 start 前调用)。
pattern: glob 如 ``'time.reunion'`` / ``'time.*'`` / ``'*'``(fnmatch 语义)。
同一 handler 注册多 pattern = 多个独立 subscription(v1 不去重)。
"""
if self._started:
raise RuntimeError(
"Dispatcher 已启动,不能注册 handler(应在 lifespan startup 前注册)"
)
self._subscriptions.append(_Subscription(pattern=pattern, handler=handler))
async def start(self) -> None:
"""lifespan startup:subscribe_by_pattern('*') + 起 pump task + 各 subscription consumer task。"""
if self._started:
return
self._all_queue = self._event_store.subscribe_by_pattern("*")
self._pump_task = asyncio.create_task(self._pump(), name="dispatcher-pump")
for sub in self._subscriptions:
task = asyncio.create_task(
self._run_consumer(sub), name=f"dispatcher-consumer-{sub.pattern}"
)
self._consumer_tasks.append(task)
self._started = True
logger.info("Dispatcher 启动:{} 个 subscription", len(self._subscriptions))
async def stop(self) -> None:
"""lifespan shutdown:cancel 所有 task + await 取消 + unsubscribe all_queue。"""
if not self._started:
return
tasks = [t for t in [self._pump_task, *self._consumer_tasks] if t is not None]
for task in tasks:
task.cancel()
for task in tasks:
with contextlib.suppress(asyncio.CancelledError):
await task
if self._all_queue is not None:
self._event_store.unsubscribe_by_pattern("*", self._all_queue)
self._pump_task = None
self._consumer_tasks = []
self._all_queue = None
self._started = False
logger.info("Dispatcher 停止")
async def _pump(self) -> None:
"""读 all_queue → glob 匹配 subscription → put 到 per-subscription queue(无 IO,不抛)。"""
assert self._all_queue is not None
while True:
event = await self._all_queue.get()
for sub in self._subscriptions:
if fnmatch.fnmatch(event.type, sub.pattern):
sub.queue.put_nowait(event)
async def _run_consumer(self, sub: _Subscription) -> None:
"""per-subscription consumer:读 queue → try/except 调 handler(at-most-once 隔离)。"""
while True:
event = await sub.queue.get()
try:
await sub.handler(event)
except Exception:
logger.exception(
"Dispatcher handler 异常(at-most-once,跳过): pattern={} type={}",
sub.pattern,
event.type,
)