Files
2026-08-21 16:00:13 +08:00

144 lines
5.7 KiB
Python
Raw Permalink 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.
"""DB 持久化事件存储:写 events 表 + 内存 pub/sub 供 SSE 实时推送。
混合架构:DB 是事实源(持久化、可回放、重启不丢),内存订阅表提供实时推送
(DB 无推送能力)。实现 ``EventStore`` Protocol,与 ``InMemoryEventStore`` 可互换,
调用方无感知(见 events.py 的 Protocol 定义)。
sequence 分配:run 内单调递增。``append`` 时若未指定则查 ``MAX(sequence)+1``,
并发下撞 ``uq_events_run_sequence`` 时重新分配并重试(沙箱场景并发不高,重试足够)。
"""
from __future__ import annotations
import asyncio
import fnmatch
import json
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.models.runtime import Event as EventRow
from app.modules.runtime.events import Event
# 并发 sequence 冲突时的重试上限
_MAX_SEQUENCE_RETRIES = 3
class DbEventStore:
"""DB 持久化 + 内存 pub/sub 的事件存储。"""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._session_factory = session_factory
self._subscribers: dict[str, list[asyncio.Queue[Event]]] = {}
self._pattern_subscribers: dict[str, list[asyncio.Queue[Event]]] = {}
async def append(self, event: Event, *, session: AsyncSession | None = None) -> Event:
# run 内事件分配单调 sequence;run 外事件(run_id=None)sequence 无意义,用 0 占位
if event.sequence is None and event.run_id:
event = await self._assign_sequence(event)
for attempt in range(_MAX_SEQUENCE_RETRIES + 1):
row = _event_to_row(event)
try:
if session is not None:
session.add(row)
# 不提交——调用方管理事务
else:
async with self._session_factory() as s:
s.add(row)
await s.commit()
break
except IntegrityError:
if attempt < _MAX_SEQUENCE_RETRIES and event.run_id:
event = await self._assign_sequence(event)
continue
raise
# 实时通知订阅者(内存 pub/sub,供 SSE 推送)
for queue in self._subscribers.get(event.run_id or "", []):
queue.put_nowait(event)
# pattern 订阅:fnmatch 匹配 event.type(与 InMemoryEventStore 对称)
for pattern, queues in self._pattern_subscribers.items():
if fnmatch.fnmatch(event.type, pattern):
for queue in queues:
queue.put_nowait(event)
return event
async def _assign_sequence(self, event: Event) -> Event:
async with self._session_factory() as session:
result = await session.execute(
select(func.coalesce(func.max(EventRow.sequence), 0)).where(
EventRow.run_id == event.run_id
)
)
current = result.scalar_one()
return event.model_copy(update={"sequence": int(current) + 1})
async def list_by_run(self, run_id: str) -> list[Event]:
async with self._session_factory() as session:
result = await session.execute(
select(EventRow).where(EventRow.run_id == run_id).order_by(EventRow.sequence)
)
rows = result.scalars().all()
return [_row_to_event(r) for r in rows]
def subscribe(self, run_id: str) -> asyncio.Queue[Event]:
queue: asyncio.Queue[Event] = asyncio.Queue()
self._subscribers.setdefault(run_id, []).append(queue)
return queue
def unsubscribe(self, run_id: str, queue: asyncio.Queue[Event]) -> None:
subs = self._subscribers.get(run_id)
if subs is not None:
self._subscribers[run_id] = [q for q in subs if q is not queue]
def subscribe_by_pattern(self, pattern: str) -> asyncio.Queue[Event]:
queue: asyncio.Queue[Event] = asyncio.Queue()
self._pattern_subscribers.setdefault(pattern, []).append(queue)
return queue
def unsubscribe_by_pattern(self, pattern: str, queue: asyncio.Queue[Event]) -> None:
subscribers = self._pattern_subscribers.get(pattern)
if subscribers is not None:
self._pattern_subscribers[pattern] = [q for q in subscribers if q is not queue]
def _event_to_row(event: Event) -> EventRow:
"""Event pydantic → events 表行。sequence 未分配时用 0 占位(run 内会被覆盖)。"""
return EventRow(
id=event.id,
type=event.type,
source=event.source,
payload_json=json.dumps(event.payload, ensure_ascii=False),
visibility=event.visibility,
sensitivity=event.sensitivity,
run_id=event.run_id,
character_id=event.character_id,
trace_id=event.trace_id,
parent_id=event.parent_id,
sequence=event.sequence if event.sequence is not None else 0,
evidence_ids_json=json.dumps(event.evidence_ids, ensure_ascii=False),
created_at=datetime.fromisoformat(event.created_at),
)
def _row_to_event(row: EventRow) -> Event:
"""events 表行 → Event pydantic。"""
return Event(
id=row.id,
type=row.type,
source=row.source,
payload=json.loads(row.payload_json),
visibility=row.visibility,
sensitivity=row.sensitivity,
run_id=row.run_id,
character_id=row.character_id,
trace_id=row.trace_id,
parent_id=row.parent_id,
sequence=row.sequence,
evidence_ids=json.loads(row.evidence_ids_json),
created_at=row.created_at.isoformat(),
)