feat(storage): add selectable SQLite and PostgreSQL persistence

- Select one storage backend per instance via AGENT_STORAGE, retaining
  memory as the backward-compatible default. Manage the selected async
  repository, official checkpointer and connection pools in lifespan;
  fail startup on unavailable or uninitialized storage without fallback.
- Add SQLite and PostgreSQL adapters, schema initialization CLI, sample
  PostgreSQL compose configuration and a Windows-compatible serve entry.
  SQLite enforces single-instance file ownership; PostgreSQL uses a
  dedicated pool for session advisory locks plus version/epoch fencing.
- Make repository IO and commit callbacks awaitable. Persist business
  fields, a complete committed graph snapshot and turn result/version in
  one business transaction before success. Rebuild execution checkpoints
  from that snapshot, discarding uncommitted progress after interruption.
- Support optional Idempotency-Key on durable chat requests. Replay
  committed results and return 409 on changed request content; delete
  removes business/history data and replay records while retaining only
  an epoch/version tombstone to reject stale commits.
- Document migration, database switching boundaries, backup/deployment
  expectations and crash recovery. Ignore generated database files.

Validation: 315 tests passed with real SQLite and isolated PostgreSQL,
including abrupt child-process exits before/after business commit;
both backends passed CLI migration and actual HTTP server restart smoke.
Existing API tests were rechecked after isolating them from user storage.
An initial Windows CreateProcess permission failure did not recur in the
full diagnostic run; failure assertions were not skipped or relaxed.
This commit is contained in:
Eric Wang
2026-10-24 13:41:54 +08:00
parent 3caf17494f
commit 97de142e6d
30 changed files with 1368 additions and 333 deletions
+12 -28
View File
@@ -1,55 +1,39 @@
"""会话业务状态 repository(作业 6-1/6-2)。
抽象出 repository 接口,使应用服务不依赖具体存储;
作业 6 用内存实现跑通四接口,作业 7 再替换为 PostgreSQL 实现
(对话 checkpoint 已由 LangGraph checkpointer 承担,本层只管
业务字段——单一事实来源,图每轮读取最新值)。
语义约定:
- 业务状态按外部业务键(hphm1、ywrysw...)扁平存储,值为内部强类型;
- 不存在的会话视为空状态(读取返回 {}),与首轮 checkpoint 无字段一致;
- delete 幂等:重复删除固定成功,不抛异常(行为进测试)。
"""
"""异步业务字段存储接口;字段值保留 bool/int/str 的区别。"""
from typing import Protocol
class CheckpointNotFoundError(Exception):
"""存储适配器明确确认线程不存在;不能用一般存储异常代替。"""
"""存储适配器明确确认线程不存在。"""
class SessionRepository(Protocol):
"""业务字段存储接口(作业 7 由 PostgreSQL 实现替换)。"""
def load(self, session_id: str) -> dict:
"""读取会话业务字段;无记录时返回空 dict。"""
async def load(self, session_id: str) -> dict:
"""读取业务字段;不存在返回空 dict。"""
...
def apply_update(self, session_id: str, patch: dict) -> dict:
"""合并差量并返回合并后的完整业务字段。"""
async def apply_update(self, session_id: str, patch: dict) -> dict:
"""提交差量后返回完整业务字段。"""
...
def delete(self, session_id: str) -> None:
"""删除会话业务状态;幂等,重复删除不抛异常。"""
async def delete(self, session_id: str) -> None:
"""幂等删除;真实存储错误必须传播。"""
...
class InMemorySessionRepository:
"""内存实现:字典存储,仅用于单进程开发与测试。"""
def __init__(self) -> None:
def __init__(self):
self._store: dict[str, dict] = {}
def load(self, session_id: str) -> dict:
async def load(self, session_id: str) -> dict:
return dict(self._store.get(session_id) or {})
def apply_update(self, session_id: str, patch: dict) -> dict:
async def apply_update(self, session_id: str, patch: dict) -> dict:
state = self._store.setdefault(session_id, {})
state.update(patch)
return dict(state)
def delete(self, session_id: str) -> None:
# 幂等:pop 的缺省值吞掉不存在的情况,重复删除行为固定为成功
async def delete(self, session_id: str) -> None:
self._store.pop(session_id, None)