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
+54
View File
@@ -0,0 +1,54 @@
import asyncio
from contextlib import asynccontextmanager
from dataclasses import replace
import httpx
import pytest
from fastapi import FastAPI
from src.api.endpoints import router
from src.core import fastgpt_client as wiring
from src.core.config import Config
from src.storage.runtime import open_storage
from .test_persistence import RecordingLLM, run, settings
def test_http_restart_replay_headers_and_switching(settings, monkeypatch):
import src.agent.llm as llm_module
monkeypatch.setattr(Config, 'AGENT_BACKEND', 'langgraph')
monkeypatch.setattr(Config, 'LLM_PROVIDER', 'fake')
monkeypatch.setattr(Config, 'AGENT_STORAGE', settings.kind)
monkeypatch.setattr(Config, 'AGENT_SQLITE_PATH', settings.sqlite_path)
monkeypatch.setattr(Config, 'AGENT_POSTGRES_DSN', settings.postgres_dsn)
monkeypatch.setattr(Config, 'AGENT_DB_POOL_SIZE', settings.pool_size)
monkeypatch.setattr(Config, 'AGENT_DB_AUTO_MIGRATE', True)
monkeypatch.setattr(llm_module, 'build_llm_service', lambda: RecordingLLM())
app = FastAPI()
app.include_router(router)
@asynccontextmanager
async def client():
async with wiring.lifespan(app):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as value:
yield value
async def scenario():
import uuid
payload = {'sessionId': uuid.uuid4().hex, 'timeStamp':'20261022120000'}
request = {**payload, 'text':'新对话'}
async with client() as http:
assert (await http.post('/set_info', json={**payload,'key':'hphm1','value':'http-plate'})).json()['code']=='200'
first = (await http.post('/chat', json=request, headers={'Idempotency-Key':'http-key'})).json()
assert first['nextStageCode']=='1001'
monkeypatch.setattr(Config, 'AGENT_DB_AUTO_MIGRATE', False)
async with client() as http:
assert (await http.post('/chat', json=request, headers={'Idempotency-Key':'http-key'})).json()==first
conflict = await http.post('/chat', json={**request,'text':'different'}, headers={'Idempotency-Key':'http-key'})
assert conflict.json()['code']=='409'
stream = await http.post('/chat?stream=true', json={**payload,'text':'继续办理','needFormUpdate':False})
assert 'event: done' in stream.text
assert 'event: formUpdate' not in stream.text
assert '"0"' == (await http.post('/get_info', json={**payload,'key':'jdcsl'})).json()['value']
assert (await http.request('DELETE','/delete_session',json=payload)).json()['code']=='200'
async with client() as http:
assert (await http.post('/get_info',json={**payload,'key':'hphm1'})).json()['value']=='""'
assert (await http.post('/chat',json=request,headers={'Idempotency-Key':'http-key'})).json()['nextStageCode']=='1001'
run(scenario())