Files
2026-08-13 11:20:33 +09:00

1126 lines
40 KiB
Python

"""Tests for AsyncSQLiteSession functionality."""
from __future__ import annotations
import asyncio
import json
import sqlite3
import sys
import tempfile
from collections.abc import Sequence
from datetime import datetime
from pathlib import Path
from typing import Any, cast
import pytest
pytest.importorskip("aiosqlite") # Skip tests if aiosqlite is not installed
from agents import Agent, Runner, TResponseInputItem
from agents.extensions.memory import AsyncSQLiteSession
from agents.memory import SessionSettings
from agents.testing import ScriptedModel
from tests.test_responses import get_text_message
pytestmark = pytest.mark.asyncio
def _assert_cancel_message(exc: asyncio.CancelledError, expected: str) -> None:
"""Account for Python 3.10 dropping Task cancellation messages when re-awaited."""
expected_args = (expected,) if sys.version_info >= (3, 11) else ()
assert exc.args == expected_args
@pytest.fixture
def agent() -> Agent:
"""Fixture for a basic agent with a scripted model."""
return Agent(name="test", model=ScriptedModel())
def _item_ids(items: Sequence[TResponseInputItem]) -> list[str]:
result: list[str] = []
for item in items:
item_dict = cast(dict[str, Any], item)
result.append(cast(str, item_dict["id"]))
return result
async def test_async_sqlite_session_basic_flow():
"""Test AsyncSQLiteSession add/get/clear behavior."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_basic.db"
session = AsyncSQLiteSession("async_basic", db_path)
items: list[TResponseInputItem] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
await session.add_items(items)
retrieved = await session.get_items()
assert retrieved == items
await session.clear_session()
assert await session.get_items() == []
await session.close()
async def test_async_sqlite_session_pop_item():
"""Test AsyncSQLiteSession pop_item behavior."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_pop.db"
session = AsyncSQLiteSession("async_pop", db_path)
assert await session.pop_item() is None
items: list[TResponseInputItem] = [
{"role": "user", "content": "One"},
{"role": "assistant", "content": "Two"},
]
await session.add_items(items)
popped = await session.pop_item()
assert popped == items[-1]
assert await session.get_items() == items[:-1]
await session.close()
async def test_async_sqlite_session_pop_item_skips_corrupt_most_recent():
"""pop_item skips corrupt newest rows and returns the next valid item."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_pop_corrupt.db"
session = AsyncSQLiteSession("async_pop_corrupt", db_path)
valid_item: TResponseInputItem = {"role": "user", "content": "valid"}
await session.add_items([valid_item])
conn = await session._get_connection()
await conn.execute(
f"INSERT INTO {session.messages_table} (session_id, message_data) VALUES (?, ?)",
(session.session_id, "not valid json {{{"),
)
await conn.commit()
assert await session.pop_item() == valid_item
assert await session.get_items() == []
await session.close()
async def test_async_sqlite_session_pop_item_returns_none_after_dropping_only_corrupt_rows():
"""pop_item removes corrupt rows and returns None when no valid items remain."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_pop_only_corrupt.db"
session = AsyncSQLiteSession("async_pop_only_corrupt", db_path)
conn = await session._get_connection()
await conn.execute(
f"INSERT INTO {session.messages_table} (session_id, message_data) VALUES (?, ?)",
(session.session_id, "not valid json {{{"),
)
await conn.commit()
assert await session.pop_item() is None
assert await session.get_items() == []
await session.close()
async def test_async_sqlite_session_get_items_limit():
"""Test AsyncSQLiteSession get_items limit handling."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_limit.db"
session = AsyncSQLiteSession("async_limit", db_path)
items: list[TResponseInputItem] = [
{"role": "user", "content": "Message 1"},
{"role": "assistant", "content": "Response 1"},
{"role": "user", "content": "Message 2"},
]
await session.add_items(items)
latest = await session.get_items(limit=2)
assert latest == items[-2:]
none = await session.get_items(limit=0)
assert none == []
await session.close()
async def test_async_sqlite_session_get_items_limit_skips_corrupt_newest_rows():
"""limit counts valid items, expanding past corrupt newest rows."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_limit_corrupt.db"
session = AsyncSQLiteSession("async_limit_corrupt", db_path)
await session.add_items(
[
{"role": "user", "content": "valid 0"},
{"role": "assistant", "content": "valid 1"},
{"role": "user", "content": "valid 2"},
]
)
conn = await session._get_connection()
await conn.execute(
f"INSERT INTO {session.messages_table} (session_id, message_data) VALUES (?, ?)",
(session.session_id, "not valid json {{{"),
)
await conn.commit()
limited = await session.get_items(limit=2)
assert [item.get("content") for item in limited] == ["valid 1", "valid 2"]
await session.close()
async def test_async_sqlite_session_session_settings_default():
"""Test that session_settings defaults to empty SessionSettings."""
session = AsyncSQLiteSession("async_default_settings")
assert isinstance(session.session_settings, SessionSettings)
assert session.session_settings.limit is None
await session.close()
@pytest.mark.parametrize("use_dictionary", [False, True], ids=["class", "dictionary"])
async def test_async_sqlite_session_session_settings_constructor(use_dictionary: bool):
"""Test passing session_settings via constructor."""
session = AsyncSQLiteSession(
"async_constructor_settings",
session_settings={"limit": 5} if use_dictionary else SessionSettings(limit=5),
)
assert isinstance(session.session_settings, SessionSettings)
assert session.session_settings.limit == 5
await session.close()
async def test_async_sqlite_session_get_items_uses_session_settings_limit():
"""Test that get_items uses session_settings.limit as default."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_settings_limit.db"
session = AsyncSQLiteSession(
"async_settings_limit",
db_path,
session_settings=SessionSettings(limit=3),
)
items: list[TResponseInputItem] = [
{"role": "user", "content": f"Message {i}"} for i in range(5)
]
await session.add_items(items)
retrieved = await session.get_items()
assert retrieved == items[-3:]
await session.close()
async def test_async_sqlite_session_explicit_limit_overrides_session_settings():
"""Test that explicit limit parameter overrides session_settings."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_settings_override.db"
session = AsyncSQLiteSession(
"async_settings_override",
db_path,
session_settings=SessionSettings(limit=5),
)
items: list[TResponseInputItem] = [
{"role": "user", "content": f"Message {i}"} for i in range(10)
]
await session.add_items(items)
retrieved = await session.get_items(limit=2)
assert retrieved == items[-2:]
no_items = await session.get_items(limit=0)
assert no_items == []
await session.close()
async def test_async_sqlite_session_unicode_content():
"""Test AsyncSQLiteSession stores unicode content."""
session = AsyncSQLiteSession("async_unicode")
items: list[TResponseInputItem] = [
{"role": "user", "content": "こんにちは"},
{"role": "assistant", "content": "Привет"},
]
await session.add_items(items)
retrieved = await session.get_items()
assert retrieved == items
await session.close()
async def test_async_sqlite_session_runner_integration(agent: Agent):
"""Test that AsyncSQLiteSession works correctly with the agent Runner."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_runner_integration.db"
session = AsyncSQLiteSession("runner_integration_test", db_path)
assert isinstance(agent.model, ScriptedModel)
agent.model.enqueue([get_text_message("San Francisco")])
result1 = await Runner.run(
agent,
"What city is the Golden Gate Bridge in?",
session=session,
)
assert result1.final_output == "San Francisco"
agent.model.enqueue([get_text_message("California")])
result2 = await Runner.run(agent, "What state is it in?", session=session)
assert result2.final_output == "California"
last_input = agent.model.calls[-1].input
assert isinstance(last_input, list)
assert len(last_input) > 1
assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input)
await session.close()
async def test_async_sqlite_session_session_isolation(agent: Agent):
"""Test that different session IDs result in isolated conversation histories."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_isolation.db"
session1 = AsyncSQLiteSession("session_1", db_path)
session2 = AsyncSQLiteSession("session_2", db_path)
assert isinstance(agent.model, ScriptedModel)
agent.model.enqueue([get_text_message("I like cats.")])
await Runner.run(agent, "I like cats.", session=session1)
agent.model.enqueue([get_text_message("I like dogs.")])
await Runner.run(agent, "I like dogs.", session=session2)
agent.model.enqueue([get_text_message("You said you like cats.")])
result = await Runner.run(agent, "What animal did I say I like?", session=session1)
assert "cats" in result.final_output.lower()
assert "dogs" not in result.final_output.lower()
await session1.close()
await session2.close()
async def test_async_sqlite_session_add_empty_items_list():
"""Test that adding an empty list of items is a no-op."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_add_empty.db"
session = AsyncSQLiteSession("add_empty_test", db_path)
assert await session.get_items() == []
await session.add_items([])
assert await session.get_items() == []
await session.close()
async def test_async_sqlite_session_pop_from_empty_session():
"""Test that pop_item returns None on an empty session."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_pop_empty.db"
session = AsyncSQLiteSession("empty_session", db_path)
popped = await session.pop_item()
assert popped is None
await session.close()
async def test_async_sqlite_session_get_items_with_limit_more_than_available():
"""Test limit behavior when requesting more items than exist."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_limit_more.db"
session = AsyncSQLiteSession("limit_more_test", db_path)
items: list[TResponseInputItem] = [
{"role": "user", "content": "1"},
{"role": "assistant", "content": "2"},
{"role": "user", "content": "3"},
{"role": "assistant", "content": "4"},
]
await session.add_items(items)
retrieved = await session.get_items(limit=10)
assert retrieved == items
await session.close()
async def test_async_sqlite_session_get_items_same_timestamp_consistent_order():
"""Test that items with identical timestamps keep insertion order."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_same_timestamp.db"
session = AsyncSQLiteSession("same_timestamp_test", db_path)
older_item = cast(
TResponseInputItem, {"id": "older_same_ts", "role": "user", "content": "old"}
)
reasoning_item = cast(TResponseInputItem, {"id": "rs_same_ts", "type": "reasoning"})
message_item = cast(
TResponseInputItem,
{"id": "msg_same_ts", "type": "message", "role": "assistant", "content": []},
)
await session.add_items([older_item])
await session.add_items([reasoning_item, message_item])
conn = await session._get_connection()
cursor = await conn.execute(
f"SELECT id, message_data FROM {session.messages_table} WHERE session_id = ?",
(session.session_id,),
)
rows = await cursor.fetchall()
await cursor.close()
id_map: dict[str, int] = {
cast(str, json.loads(message_json)["id"]): cast(int, row_id)
for row_id, message_json in rows
}
shared = datetime(2025, 10, 15, 17, 26, 39, 132483)
shared_str = shared.strftime("%Y-%m-%d %H:%M:%S.%f")
await conn.execute(
f"""
UPDATE {session.messages_table}
SET created_at = ?
WHERE id IN (?, ?, ?)
""",
(
shared_str,
id_map["older_same_ts"],
id_map["rs_same_ts"],
id_map["msg_same_ts"],
),
)
await conn.commit()
retrieved = await session.get_items()
assert _item_ids(retrieved) == ["older_same_ts", "rs_same_ts", "msg_same_ts"]
latest_two = await session.get_items(limit=2)
assert _item_ids(latest_two) == ["rs_same_ts", "msg_same_ts"]
await session.close()
async def test_async_sqlite_session_pop_item_same_timestamp_returns_latest():
"""Test that pop_item returns the newest item when timestamps tie."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "async_same_timestamp_pop.db"
session = AsyncSQLiteSession("same_timestamp_pop_test", db_path)
reasoning_item = cast(TResponseInputItem, {"id": "rs_pop_same_ts", "type": "reasoning"})
message_item = cast(
TResponseInputItem,
{"id": "msg_pop_same_ts", "type": "message", "role": "assistant", "content": []},
)
await session.add_items([reasoning_item, message_item])
conn = await session._get_connection()
shared = datetime(2025, 10, 15, 17, 26, 39, 132483)
shared_str = shared.strftime("%Y-%m-%d %H:%M:%S.%f")
await conn.execute(
f"UPDATE {session.messages_table} SET created_at = ? WHERE session_id = ?",
(shared_str, session.session_id),
)
await conn.commit()
popped = await session.pop_item()
assert popped is not None
assert cast(dict[str, Any], popped)["id"] == "msg_pop_same_ts"
remaining = await session.get_items()
assert _item_ids(remaining) == ["rs_pop_same_ts"]
await session.close()
async def test_async_sqlite_session_closed_operations_raise_runtime_error():
"""Operations on a closed session must fail instead of reopening the database."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "closed_state.db"
session = AsyncSQLiteSession("closed_state_test", db_path)
await session.add_items([{"role": "user", "content": "before close"}])
await session.close()
with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"):
await session.get_items()
with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"):
await session.add_items([{"role": "user", "content": "after close"}])
with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"):
await session.pop_item()
with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"):
await session.clear_session()
async def test_async_sqlite_session_closed_rejects_empty_add_items():
"""add_items([]) must not bypass the closed check through the empty-list fast path."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "closed_empty_add.db"
session = AsyncSQLiteSession("closed_empty_add_test", db_path)
await session.close()
with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"):
await session.add_items([])
async def test_async_sqlite_session_close_before_use_is_terminal():
"""close() before the connection is opened must still make the session terminal."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "close_before_use.db"
session = AsyncSQLiteSession("close_before_use_test", db_path)
await session.close()
with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"):
await session.get_items()
async def test_async_sqlite_session_close_is_idempotent():
"""Repeated and concurrent close() calls must remain safe no-ops."""
import asyncio
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "close_idempotent.db"
session = AsyncSQLiteSession("close_idempotent_test", db_path)
await session.add_items([{"role": "user", "content": "before close"}])
await asyncio.gather(session.close(), session.close())
await session.close()
with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"):
await session.get_items()
async def test_cancelled_close_finishes_cleanup_and_propagates_cancellation(
monkeypatch: pytest.MonkeyPatch,
):
"""Repeated cancellation must propagate after the owned connection closes."""
close_started = asyncio.Event()
allow_close = asyncio.Event()
session = AsyncSQLiteSession("cancelled_close")
conn: Any = None
real_close: Any = None
close_task: asyncio.Task[None] | None = None
try:
conn = await session._get_connection()
real_close = conn.close
async def controlled_close() -> None:
close_started.set()
await allow_close.wait()
await real_close()
monkeypatch.setattr(conn, "close", controlled_close)
close_task = asyncio.create_task(session.close())
try:
await close_started.wait()
close_task.cancel()
await asyncio.sleep(0)
close_task.cancel()
await asyncio.sleep(0)
allow_close.set()
with pytest.raises(asyncio.CancelledError):
await close_task
finally:
allow_close.set()
if not close_task.done():
close_task.cancel()
await asyncio.gather(close_task, return_exceptions=True)
assert session._closed is True
assert session._connection is None
assert session._quarantined_connections == set()
assert conn._running is False
finally:
allow_close.set()
if close_task is not None and not close_task.done():
close_task.cancel()
await asyncio.gather(close_task, return_exceptions=True)
if conn is not None and real_close is not None:
monkeypatch.setattr(conn, "close", real_close)
try:
await session.close()
finally:
if conn is not None and real_close is not None and conn._running:
await real_close()
@pytest.mark.parametrize("operation", ["add", "pop", "clear"])
async def test_post_commit_cancellation_propagates_after_known_mutation_outcome(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
operation: str,
):
"""Cancellation after async commit must propagate without inviting a retry."""
db_path = tmp_path / f"async_post_commit_{operation}.db"
session = AsyncSQLiteSession(f"async_post_commit_{operation}", db_path)
item: TResponseInputItem = {"role": "user", "content": "once"}
try:
if operation != "add":
await session.add_items([item])
conn = await session._get_connection()
real_commit = conn.commit
commit_finished = asyncio.Event()
allow_return = asyncio.Event()
pause_commit = True
async def controlled_commit() -> None:
nonlocal pause_commit
await real_commit()
if pause_commit:
pause_commit = False
commit_finished.set()
await allow_return.wait()
monkeypatch.setattr(conn, "commit", controlled_commit)
if operation == "add":
mutation: asyncio.Task[Any] = asyncio.create_task(session.add_items([item]))
elif operation == "pop":
mutation = asyncio.create_task(session.pop_item())
else:
mutation = asyncio.create_task(session.clear_session())
try:
await commit_finished.wait()
mutation.cancel()
await asyncio.sleep(0)
mutation.cancel()
await asyncio.sleep(0)
allow_return.set()
with pytest.raises(asyncio.CancelledError):
await mutation
finally:
allow_return.set()
if not mutation.done():
mutation.cancel()
await asyncio.gather(mutation, return_exceptions=True)
if operation == "add":
assert await session.get_items() == [item]
elif operation == "pop":
assert await session.get_items() == []
else:
assert await session.get_items() == []
assert mutation.cancelled()
finally:
await session.close()
def _drop_sqlite_table(db_path: Path, table: str) -> None:
"""Drop a table from an independent connection to make a later statement fail."""
helper = sqlite3.connect(str(db_path))
try:
helper.execute(f"DROP TABLE {table}")
helper.commit()
finally:
helper.close()
def _sqlite_write_lock_is_free(db_path: Path) -> bool:
"""Return whether an independent writer can take the SQLite write lock."""
probe = sqlite3.connect(str(db_path), timeout=0)
try:
probe.execute("CREATE TABLE IF NOT EXISTS probe_lock (x INTEGER)")
probe.commit()
return True
except sqlite3.OperationalError:
return False
finally:
probe.close()
async def test_failed_add_items_rolls_back_and_reuses_connection():
"""A failed add must roll back its partial write and leave the session reusable."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "add_rollback.db"
session = AsyncSQLiteSession("add_rollback", db_path)
unserializable = cast(TResponseInputItem, {"role": "user", "content": object()})
try:
with pytest.raises(TypeError):
await session.add_items([unserializable])
conn = await session._get_connection()
assert conn.in_transaction is False
assert _sqlite_write_lock_is_free(db_path)
await session.add_items([{"role": "user", "content": "after failure"}])
assert [item.get("content") for item in await session.get_items()] == ["after failure"]
finally:
await session.close()
async def test_failed_clear_session_rolls_back():
"""A failed clear must restore earlier statements and release the shared write lock."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "clear_rollback.db"
session = AsyncSQLiteSession("clear_rollback", db_path)
try:
await session.add_items([{"role": "user", "content": "kept"}])
_drop_sqlite_table(db_path, "agent_sessions")
with pytest.raises(sqlite3.OperationalError):
await session.clear_session()
conn = await session._get_connection()
assert conn.in_transaction is False
assert _sqlite_write_lock_is_free(db_path)
finally:
await session.close()
async def test_failed_pop_item_releases_write_lock():
"""A failed pop must not leave a write transaction on the shared connection."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "pop_rollback.db"
session = AsyncSQLiteSession("pop_rollback", db_path)
try:
await session.add_items([{"role": "user", "content": "kept"}])
_drop_sqlite_table(db_path, "agent_messages")
with pytest.raises(sqlite3.OperationalError):
await session.pop_item()
conn = await session._get_connection()
assert conn.in_transaction is False
assert _sqlite_write_lock_is_free(db_path)
finally:
await session.close()
async def test_failed_initialization_closes_candidate_connection():
"""A failed initialization must not retain a half-initialized connection."""
class FailingInitSession(AsyncSQLiteSession):
captured_connection: Any = None
async def _init_db_for_connection(self, conn: Any) -> None:
self.captured_connection = conn
raise RuntimeError("initialization failed")
session = FailingInitSession("failed_init")
try:
with pytest.raises(RuntimeError, match="initialization failed"):
await session.get_items()
assert session._connection is None
assert session.captured_connection._running is False
finally:
try:
await session.close()
finally:
if session.captured_connection is not None and session.captured_connection._running:
await session.captured_connection.close()
async def test_cancelled_add_items_rolls_back_write_transaction(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
"""Cancellation after the first write must release the transaction and database lock."""
db_path = tmp_path / "cancelled_add.db"
session = AsyncSQLiteSession("cancelled_add", db_path)
try:
await session.get_items()
conn = await session._get_connection()
real_execute = conn.execute
real_rollback = conn.rollback
first_write_started = asyncio.Event()
rollback_started = asyncio.Event()
allow_rollback = asyncio.Event()
async def pause_after_first_write(*args: Any, **kwargs: Any) -> Any:
cursor = await real_execute(*args, **kwargs)
first_write_started.set()
await asyncio.Event().wait()
return cursor
async def controlled_rollback() -> None:
rollback_started.set()
await allow_rollback.wait()
await real_rollback()
monkeypatch.setattr(conn, "execute", pause_after_first_write)
monkeypatch.setattr(conn, "rollback", controlled_rollback)
task = asyncio.create_task(session.add_items([{"role": "user", "content": "cancelled"}]))
try:
await first_write_started.wait()
task.cancel()
await rollback_started.wait()
task.cancel()
allow_rollback.set()
with pytest.raises(asyncio.CancelledError):
await task
finally:
allow_rollback.set()
monkeypatch.setattr(conn, "execute", real_execute)
monkeypatch.setattr(conn, "rollback", real_rollback)
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert conn.in_transaction is False
assert _sqlite_write_lock_is_free(db_path)
assert await session.get_items() == []
finally:
await session.close()
async def test_operation_failure_then_cancellation_during_rollback(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
"""Cancellation during rollback must supersede an earlier operation failure."""
db_path = tmp_path / "failure_then_cancelled_rollback.db"
session = AsyncSQLiteSession("failure_then_cancelled_rollback", db_path)
task: asyncio.Task[None] | None = None
try:
await session.get_items()
conn = await session._get_connection()
real_execute = conn.execute
real_rollback = conn.rollback
rollback_started = asyncio.Event()
allow_rollback = asyncio.Event()
async def fail_after_write(*args: Any, **kwargs: Any) -> Any:
await real_execute(*args, **kwargs)
raise RuntimeError("operation failed")
async def controlled_rollback() -> None:
rollback_started.set()
await allow_rollback.wait()
await real_rollback()
monkeypatch.setattr(conn, "execute", fail_after_write)
monkeypatch.setattr(conn, "rollback", controlled_rollback)
task = asyncio.create_task(session.add_items([{"role": "user", "content": "cancelled"}]))
try:
await rollback_started.wait()
task.cancel("first-caller-cancel")
await asyncio.sleep(0)
task.cancel("second-caller-cancel")
allow_rollback.set()
with pytest.raises(asyncio.CancelledError) as exc_info:
await task
finally:
allow_rollback.set()
monkeypatch.setattr(conn, "execute", real_execute)
monkeypatch.setattr(conn, "rollback", real_rollback)
if task is not None and not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
_assert_cancel_message(exc_info.value, "first-caller-cancel")
assert conn.in_transaction is False
assert _sqlite_write_lock_is_free(db_path)
assert await session.get_items() == []
finally:
if task is not None and not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await session.close()
async def test_rollback_failure_closes_and_evicts_connection(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
"""A connection that cannot roll back must not remain cached or retain its write lock."""
db_path = tmp_path / "rollback_failure.db"
session = AsyncSQLiteSession("rollback_failure", db_path)
try:
await session.get_items()
conn = await session._get_connection()
async def fail_rollback() -> None:
raise RuntimeError("rollback failed")
monkeypatch.setattr(conn, "rollback", fail_rollback)
unserializable = cast(TResponseInputItem, {"role": "user", "content": object()})
with pytest.raises(TypeError):
await session.add_items([unserializable])
assert session._connection is None
assert session._closed is False
assert _sqlite_write_lock_is_free(db_path)
await session.add_items([{"role": "user", "content": "after failure"}])
assert [item.get("content") for item in await session.get_items()] == ["after failure"]
finally:
await session.close()
async def test_close_retries_connection_quarantined_after_rollback_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
"""A failed invalidation close must remain owned until a later close succeeds."""
db_path = tmp_path / "close_retry.db"
session = AsyncSQLiteSession("close_retry", db_path)
conn: Any = None
real_close: Any = None
try:
await session.get_items()
conn = await session._get_connection()
real_close = conn.close
async def fail_rollback() -> None:
raise RuntimeError("rollback failed")
async def fail_close() -> None:
raise RuntimeError("close failed")
monkeypatch.setattr(conn, "rollback", fail_rollback)
monkeypatch.setattr(conn, "close", fail_close)
unserializable = cast(TResponseInputItem, {"role": "user", "content": object()})
with pytest.raises(TypeError):
await session.add_items([unserializable])
assert session._closed is True
assert session._connection is None
assert conn in session._quarantined_connections
assert conn._running is True
assert _sqlite_write_lock_is_free(db_path) is False
monkeypatch.setattr(conn, "close", real_close)
await session.close()
assert session._quarantined_connections == set()
assert conn._running is False
assert _sqlite_write_lock_is_free(db_path)
finally:
if conn is not None and real_close is not None:
monkeypatch.setattr(conn, "close", real_close)
try:
await session.close()
finally:
if conn is not None and real_close is not None and conn._running:
await real_close()
async def test_close_retries_quarantined_failed_initialization_candidate(
monkeypatch: pytest.MonkeyPatch,
):
"""A failed initialization candidate close must remain owned for close retry."""
class FailingInitSession(AsyncSQLiteSession):
captured_connection: Any = None
real_close: Any = None
async def _init_db_for_connection(self, conn: Any) -> None:
self.captured_connection = conn
self.real_close = conn.close
async def fail_close() -> None:
raise RuntimeError("close failed")
monkeypatch.setattr(conn, "close", fail_close)
raise RuntimeError("initialization failed")
session = FailingInitSession("failed_init_close_retry")
try:
with pytest.raises(RuntimeError, match="initialization failed"):
await session.get_items()
conn = session.captured_connection
assert session._closed is True
assert conn in session._quarantined_connections
assert conn._running is True
monkeypatch.setattr(conn, "close", session.real_close)
await session.close()
assert session._quarantined_connections == set()
assert conn._running is False
finally:
if session.captured_connection is not None and session.real_close is not None:
monkeypatch.setattr(session.captured_connection, "close", session.real_close)
try:
await session.close()
finally:
if (
session.captured_connection is not None
and session.real_close is not None
and session.captured_connection._running
):
await session.real_close()
async def test_cancelled_initialization_finishes_candidate_close(
monkeypatch: pytest.MonkeyPatch,
):
"""Repeated cancellation must not release initialization ownership before close finishes."""
init_started = asyncio.Event()
close_started = asyncio.Event()
allow_close = asyncio.Event()
class CancelledInitSession(AsyncSQLiteSession):
captured_connection: Any = None
real_close: Any = None
async def _init_db_for_connection(self, conn: Any) -> None:
self.captured_connection = conn
self.real_close = conn.close
async def controlled_close() -> None:
close_started.set()
await allow_close.wait()
await self.real_close()
monkeypatch.setattr(conn, "close", controlled_close)
init_started.set()
await asyncio.Event().wait()
session = CancelledInitSession("cancelled_init")
task = asyncio.create_task(session.get_items())
try:
try:
await init_started.wait()
task.cancel()
await close_started.wait()
task.cancel()
allow_close.set()
with pytest.raises(asyncio.CancelledError):
await task
finally:
allow_close.set()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert session._connection is None
assert session.captured_connection._running is False
finally:
if session.captured_connection is not None and session.real_close is not None:
monkeypatch.setattr(session.captured_connection, "close", session.real_close)
try:
await session.close()
finally:
if (
session.captured_connection is not None
and session.real_close is not None
and session.captured_connection._running
):
await session.real_close()
async def test_initialization_failure_then_cancellation_during_candidate_close(
monkeypatch: pytest.MonkeyPatch,
):
"""Cancellation during candidate close must supersede an initialization failure."""
close_started = asyncio.Event()
allow_close = asyncio.Event()
class FailingInitSession(AsyncSQLiteSession):
captured_connection: Any = None
real_close: Any = None
async def _init_db_for_connection(self, conn: Any) -> None:
self.captured_connection = conn
self.real_close = conn.close
async def controlled_close() -> None:
close_started.set()
await allow_close.wait()
await self.real_close()
monkeypatch.setattr(conn, "close", controlled_close)
raise RuntimeError("initialization failed")
session = FailingInitSession("failed_init_then_cancelled_close")
task = asyncio.create_task(session.get_items())
try:
try:
await close_started.wait()
task.cancel("first-caller-cancel")
await asyncio.sleep(0)
task.cancel("second-caller-cancel")
allow_close.set()
with pytest.raises(asyncio.CancelledError) as exc_info:
await task
finally:
allow_close.set()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
_assert_cancel_message(exc_info.value, "first-caller-cancel")
assert session._connection is None
assert session.captured_connection._running is False
finally:
allow_close.set()
if session.captured_connection is not None and session.real_close is not None:
monkeypatch.setattr(session.captured_connection, "close", session.real_close)
try:
await session.close()
finally:
if (
session.captured_connection is not None
and session.real_close is not None
and session.captured_connection._running
):
await session.real_close()
async def test_cancelled_connect_closes_eventually_acquired_connection(
monkeypatch: pytest.MonkeyPatch,
):
"""Cancellation during connect must wait for and close the eventual connection."""
import aiosqlite
real_connect = aiosqlite.connect
connect_started = asyncio.Event()
allow_connect = asyncio.Event()
created_connections: list[Any] = []
async def controlled_connect(database: str) -> Any:
connect_started.set()
await allow_connect.wait()
conn = await real_connect(database)
created_connections.append(conn)
return conn
monkeypatch.setattr(aiosqlite, "connect", controlled_connect)
session = AsyncSQLiteSession("cancelled_connect")
task = asyncio.create_task(session.get_items())
try:
try:
await connect_started.wait()
task.cancel()
allow_connect.set()
with pytest.raises(asyncio.CancelledError):
await task
finally:
allow_connect.set()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert len(created_connections) == 1
assert created_connections[0]._running is False
assert session._connection is None
finally:
await session.close()
for conn in created_connections:
if conn._running:
await conn.close()