初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
"""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(),
)