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()
|