- 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.
89 lines
3.4 KiB
Python
89 lines
3.4 KiB
Python
import asyncio
|
|
import os
|
|
from dataclasses import replace
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from src.core.config import Config
|
|
from src.storage.runtime import StorageSettings, open_storage
|
|
from .test_persistence import run
|
|
|
|
|
|
@pytest.fixture
|
|
def postgres_settings():
|
|
dsn = os.getenv('AGENT_TEST_POSTGRES_DSN')
|
|
if not dsn:
|
|
pytest.skip('Set AGENT_TEST_POSTGRES_DSN to run PostgreSQL integration tests')
|
|
return StorageSettings(kind='postgresql',postgres_dsn=dsn,auto_migrate=True,pool_size=2)
|
|
|
|
|
|
def test_sqlite_rejects_second_instance_and_releases_guard(tmp_path):
|
|
async def scenario():
|
|
config = StorageSettings(kind='sqlite',sqlite_path=str(tmp_path/'single.db'),auto_migrate=True)
|
|
async with open_storage(config):
|
|
with pytest.raises(RuntimeError, match='active instance'):
|
|
async with open_storage(config): pass
|
|
async with open_storage(config): pass
|
|
run(scenario())
|
|
|
|
|
|
def test_sqlite_requires_migration_and_does_not_fall_back(tmp_path):
|
|
async def scenario():
|
|
config = StorageSettings(kind='sqlite',sqlite_path=str(tmp_path/'empty.db'))
|
|
with pytest.raises(Exception):
|
|
async with open_storage(config):
|
|
pytest.fail('Uninitialized durable storage must not start')
|
|
async with open_storage(replace(config,auto_migrate=True)): pass
|
|
async with open_storage(config) as runtime:
|
|
assert runtime.checkpointer.__class__.__name__=='AsyncSqliteSaver'
|
|
run(scenario())
|
|
|
|
|
|
def test_postgresql_locks_serialize_independent_application_instances(postgres_settings):
|
|
settings = postgres_settings
|
|
async def scenario():
|
|
session = uuid4().hex
|
|
async with open_storage(settings) as first, open_storage(settings) as second:
|
|
entered = asyncio.Event()
|
|
async def waiting():
|
|
async with second.locks.hold(session): entered.set()
|
|
async with first.locks.hold(session):
|
|
task=asyncio.create_task(waiting())
|
|
await asyncio.sleep(0.15)
|
|
assert not entered.is_set()
|
|
# 同一个锁池中,不同会话仍能执行。
|
|
async with second.locks.hold(session+'-other'):
|
|
pass
|
|
await asyncio.wait_for(task,3)
|
|
assert entered.is_set()
|
|
run(scenario())
|
|
|
|
|
|
def test_postgresql_cancelled_lock_holder_releases_server_lock(postgres_settings):
|
|
settings = postgres_settings
|
|
async def scenario():
|
|
session=uuid4().hex
|
|
async with open_storage(settings) as first, open_storage(settings) as second:
|
|
ready=asyncio.Event()
|
|
async def holding():
|
|
async with first.locks.hold(session):
|
|
ready.set()
|
|
await asyncio.Future()
|
|
task=asyncio.create_task(holding())
|
|
await ready.wait()
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError): await task
|
|
async with asyncio.timeout(3):
|
|
async with second.locks.hold(session): pass
|
|
run(scenario())
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['unknown', 'sqlite', 'postgresql'])
|
|
def test_invalid_storage_config_is_rejected(monkeypatch,kind):
|
|
monkeypatch.setattr(Config,'AGENT_BACKEND','langgraph')
|
|
monkeypatch.setattr(Config,'AGENT_STORAGE',kind)
|
|
monkeypatch.setattr(Config,'AGENT_SQLITE_PATH',':memory:')
|
|
monkeypatch.setattr(Config,'AGENT_POSTGRES_DSN','')
|
|
with pytest.raises(ValueError):Config.validate()
|