项目文件夹

文件
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

2055 行
66 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime
from datetime import timezone
import enum
import sqlite3
from unittest import mock
from google.adk.errors.already_exists_error import AlreadyExistsError
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.adk.features import FeatureName
from google.adk.features import override_feature_enabled
from google.adk.sessions import database_session_service
from google.adk.sessions.base_session_service import GetSessionConfig
from google.adk.sessions.database_session_service import DatabaseSessionService
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.sqlite_session_service import SqliteSessionService
from google.adk.sessions.vertex_ai_session_service import VertexAiSessionService
from google.genai import types
import pytest
from sqlalchemy import delete
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import StaticPool
class SessionServiceType(enum.Enum):
IN_MEMORY = 'IN_MEMORY'
IN_MEMORY_WITH_LIGHT_COPY_ENABLED = 'IN_MEMORY_WITH_LIGHT_COPY_ENABLED'
DATABASE = 'DATABASE'
SQLITE = 'SQLITE'
def get_session_service(
service_type: SessionServiceType = SessionServiceType.IN_MEMORY,
tmp_path=None,
):
"""Creates a session service for testing."""
if service_type == SessionServiceType.DATABASE:
return DatabaseSessionService('sqlite+aiosqlite:///:memory:')
if service_type == SessionServiceType.SQLITE:
return SqliteSessionService(str(tmp_path / 'sqlite.db'))
if service_type == SessionServiceType.IN_MEMORY_WITH_LIGHT_COPY_ENABLED:
return InMemorySessionService()
return InMemorySessionService()
@pytest.fixture(
params=[
SessionServiceType.IN_MEMORY,
SessionServiceType.IN_MEMORY_WITH_LIGHT_COPY_ENABLED,
SessionServiceType.DATABASE,
SessionServiceType.SQLITE,
]
)
async def session_service(request, tmp_path):
"""Provides a session service and closes database backends on teardown."""
if request.param == SessionServiceType.IN_MEMORY_WITH_LIGHT_COPY_ENABLED:
override_feature_enabled(
FeatureName.IN_MEMORY_SESSION_SERVICE_LIGHT_COPY, True
)
service = get_session_service(request.param, tmp_path)
yield service
if isinstance(service, DatabaseSessionService):
await service.close()
if request.param == SessionServiceType.IN_MEMORY_WITH_LIGHT_COPY_ENABLED:
override_feature_enabled(
FeatureName.IN_MEMORY_SESSION_SERVICE_LIGHT_COPY, False
)
def test_database_session_service_enables_pool_pre_ping_by_default():
captured_kwargs = {}
def fake_create_async_engine(_db_url: str, **kwargs):
captured_kwargs.update(kwargs)
fake_engine = mock.Mock()
fake_engine.dialect.name = 'postgresql'
fake_engine.sync_engine = mock.Mock()
return fake_engine
with mock.patch.object(
database_session_service,
'create_async_engine',
side_effect=fake_create_async_engine,
):
database_session_service.DatabaseSessionService(
'postgresql+psycopg2://user:pass@localhost:5432/db'
)
assert captured_kwargs.get('pool_pre_ping') is True
@pytest.mark.parametrize(
'dialect_name', ['sqlite', 'postgresql', 'mysql', 'mariadb']
)
def test_database_session_service_uses_naive_datetime_for_dialect(dialect_name):
"""Verifies dialects that store DATETIME WITHOUT TIME ZONE are treated as naive.
SQLite, PostgreSQL, MySQL, and MariaDB all store DATETIME/TIMESTAMP WITHOUT
TIME ZONE, so create_session must strip tzinfo before storing. Otherwise the
marker produced by create_session (with +00:00) mismatches the marker read
back from storage (without +00:00), triggering a false stale-writer error on
the first append_event after create_session.
This exercises the production decision (_uses_naive_datetime) directly rather
than re-implementing the strip logic, so it actually guards create_session.
"""
fake_engine = mock.Mock()
fake_engine.dialect.name = dialect_name
fake_engine.sync_engine = mock.Mock()
service = DatabaseSessionService(db_engine=fake_engine)
assert service._uses_naive_datetime() is True
def test_database_session_service_keeps_timezone_for_spanner():
"""Spanner's TIMESTAMP is timezone-aware, so tzinfo must be preserved."""
fake_engine = mock.Mock()
fake_engine.dialect.name = 'spanner+spanner'
fake_engine.sync_engine = mock.Mock()
service = DatabaseSessionService(db_engine=fake_engine)
assert service._uses_naive_datetime() is False
def test_database_session_service_respects_pool_pre_ping_override():
captured_kwargs = {}
def fake_create_async_engine(_db_url: str, **kwargs):
captured_kwargs.update(kwargs)
fake_engine = mock.Mock()
fake_engine.dialect.name = 'postgresql'
fake_engine.sync_engine = mock.Mock()
return fake_engine
with mock.patch.object(
database_session_service,
'create_async_engine',
side_effect=fake_create_async_engine,
):
database_session_service.DatabaseSessionService(
'postgresql+psycopg2://user:pass@localhost:5432/db',
pool_pre_ping=False,
)
assert captured_kwargs.get('pool_pre_ping') is False
def test_database_session_service_creates_read_only_engine_for_spanner():
captured_binds = []
fake_engine = mock.Mock()
fake_engine.dialect.name = 'spanner+spanner'
fake_engine.sync_engine = mock.Mock()
read_only_engine = mock.Mock()
fake_engine.execution_options.return_value = read_only_engine
def fake_async_sessionmaker(*, bind, expire_on_commit, **kwargs):
del expire_on_commit
del kwargs
captured_binds.append(bind)
return mock.Mock()
with (
mock.patch.object(
database_session_service,
'create_async_engine',
return_value=fake_engine,
),
mock.patch.object(
database_session_service,
'async_sessionmaker',
side_effect=fake_async_sessionmaker,
),
):
database_session_service.DatabaseSessionService(
'spanner+spanner:///projects/test/instances/test/databases/test'
)
assert captured_binds == [fake_engine, read_only_engine]
fake_engine.execution_options.assert_called_once_with(read_only=True)
def test_database_session_service_creates_read_only_engine_for_other_dialects():
captured_binds = []
fake_engine = mock.Mock()
fake_engine.dialect.name = 'postgresql'
fake_engine.sync_engine = mock.Mock()
read_only_engine = mock.Mock()
fake_engine.execution_options.return_value = read_only_engine
def fake_async_sessionmaker(*, bind, expire_on_commit, **kwargs):
del expire_on_commit
del kwargs
captured_binds.append(bind)
return mock.Mock()
with (
mock.patch.object(
database_session_service,
'create_async_engine',
return_value=fake_engine,
),
mock.patch.object(
database_session_service,
'async_sessionmaker',
side_effect=fake_async_sessionmaker,
),
):
database_session_service.DatabaseSessionService(
'postgresql+psycopg2://user:pass@localhost:5432/db'
)
assert captured_binds == [fake_engine, read_only_engine]
fake_engine.execution_options.assert_called_once_with(read_only=True)
@pytest.mark.asyncio
async def test_sqlite_session_service_accepts_sqlite_urls(
tmp_path, monkeypatch
):
monkeypatch.chdir(tmp_path)
service = SqliteSessionService('sqlite+aiosqlite:///./sessions.db')
await service.create_session(app_name='app', user_id='user')
assert (tmp_path / 'sessions.db').exists()
service = SqliteSessionService('sqlite:///./sessions2.db')
await service.create_session(app_name='app', user_id='user')
assert (tmp_path / 'sessions2.db').exists()
@pytest.mark.asyncio
async def test_sqlite_session_service_preserves_uri_query_parameters(
tmp_path, monkeypatch
):
monkeypatch.chdir(tmp_path)
db_path = tmp_path / 'readonly.db'
with sqlite3.connect(db_path) as conn:
conn.execute('CREATE TABLE IF NOT EXISTS t (id INTEGER)')
conn.commit()
service = SqliteSessionService(f'sqlite+aiosqlite:///{db_path}?mode=ro')
# `mode=ro` opens the DB read-only; schema creation should fail.
with pytest.raises(sqlite3.OperationalError, match=r'readonly'):
await service.create_session(app_name='app', user_id='user')
@pytest.mark.asyncio
async def test_sqlite_session_service_accepts_absolute_sqlite_urls(tmp_path):
abs_db_path = tmp_path / 'absolute.db'
abs_url = 'sqlite+aiosqlite:////' + str(abs_db_path).lstrip('/')
service = SqliteSessionService(abs_url)
await service.create_session(app_name='app', user_id='user')
assert abs_db_path.exists()
@pytest.mark.asyncio
async def test_get_empty_session(session_service):
assert not await session_service.get_session(
app_name='my_app', user_id='test_user', session_id='123'
)
@pytest.mark.asyncio
async def test_database_session_service_get_session_uses_read_only_factory():
async with DatabaseSessionService('sqlite+aiosqlite:///:memory:') as service:
service.prepare_tables = mock.AsyncMock()
read_only_session = mock.AsyncMock()
read_only_session.get = mock.AsyncMock(return_value=None)
@asynccontextmanager
async def fake_read_only_session():
yield read_only_session
service.database_session_factory = mock.Mock(
side_effect=AssertionError('write session factory should not be used')
)
service._read_only_database_session_factory = mock.Mock(
return_value=fake_read_only_session()
)
session = await service.get_session(
app_name='my_app', user_id='test_user', session_id='123'
)
assert session is None
service._read_only_database_session_factory.assert_called_once_with()
service.database_session_factory.assert_not_called()
await service.close()
@pytest.mark.asyncio
async def test_database_session_service_list_sessions_uses_read_only_factory():
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
service.prepare_tables = mock.AsyncMock()
read_only_session = mock.AsyncMock()
empty_result = mock.Mock()
empty_result.scalars.return_value.all.return_value = []
read_only_session.execute = mock.AsyncMock(return_value=empty_result)
read_only_session.get = mock.AsyncMock(return_value=None)
@asynccontextmanager
async def fake_read_only_session():
yield read_only_session
service.database_session_factory = mock.Mock(
side_effect=AssertionError('write session factory should not be used')
)
service._read_only_database_session_factory = mock.Mock(
return_value=fake_read_only_session()
)
response = await service.list_sessions(app_name='my_app', user_id='test_user')
assert response.sessions == []
service._read_only_database_session_factory.assert_called_once_with()
service.database_session_factory.assert_not_called()
await service.close()
@pytest.mark.asyncio
async def test_create_get_session(session_service):
app_name = 'my_app'
user_id = 'test_user'
state = {'key': 'value'}
session = await session_service.create_session(
app_name=app_name, user_id=user_id, state=state
)
assert session.app_name == app_name
assert session.user_id == user_id
assert session.id
assert session.state == state
assert (
session.last_update_time
<= datetime.now().astimezone(timezone.utc).timestamp()
)
got_session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
assert got_session == session
assert (
got_session.last_update_time
<= datetime.now().astimezone(timezone.utc).timestamp()
)
session_id = session.id
await session_service.delete_session(
app_name=app_name, user_id=user_id, session_id=session_id
)
assert (
await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
is None
)
@pytest.mark.asyncio
async def test_create_and_list_sessions(session_service):
app_name = 'my_app'
user_id = 'test_user'
session_ids = ['session' + str(i) for i in range(5)]
for session_id in session_ids:
await session_service.create_session(
app_name=app_name,
user_id=user_id,
session_id=session_id,
state={'key': 'value' + session_id},
)
list_sessions_response = await session_service.list_sessions(
app_name=app_name, user_id=user_id
)
sessions = list_sessions_response.sessions
assert len(sessions) == len(session_ids)
assert {s.id for s in sessions} == set(session_ids)
for session in sessions:
assert session.state == {'key': 'value' + session.id}
@pytest.mark.asyncio
async def test_list_sessions_all_users(session_service):
app_name = 'my_app'
user_id_1 = 'user1'
user_id_2 = 'user2'
await session_service.create_session(
app_name=app_name,
user_id=user_id_1,
session_id='session1a',
state={'key': 'value1a'},
)
await session_service.create_session(
app_name=app_name,
user_id=user_id_1,
session_id='session1b',
state={'key': 'value1b'},
)
await session_service.create_session(
app_name=app_name,
user_id=user_id_2,
session_id='session2a',
state={'key': 'value2a'},
)
# List sessions for user1 - should contain merged state
list_sessions_response_1 = await session_service.list_sessions(
app_name=app_name, user_id=user_id_1
)
sessions_1 = list_sessions_response_1.sessions
assert len(sessions_1) == 2
sessions_1_map = {s.id: s for s in sessions_1}
assert sessions_1_map['session1a'].state == {'key': 'value1a'}
assert sessions_1_map['session1b'].state == {'key': 'value1b'}
# List sessions for user2 - should contain merged state
list_sessions_response_2 = await session_service.list_sessions(
app_name=app_name, user_id=user_id_2
)
sessions_2 = list_sessions_response_2.sessions
assert len(sessions_2) == 1
assert sessions_2[0].id == 'session2a'
assert sessions_2[0].state == {'key': 'value2a'}
# List sessions for all users - should contain merged state
list_sessions_response_all = await session_service.list_sessions(
app_name=app_name, user_id=None
)
sessions_all = list_sessions_response_all.sessions
assert len(sessions_all) == 3
sessions_all_map = {s.id: s for s in sessions_all}
assert sessions_all_map['session1a'].state == {'key': 'value1a'}
assert sessions_all_map['session1b'].state == {'key': 'value1b'}
assert sessions_all_map['session2a'].state == {'key': 'value2a'}
@pytest.mark.asyncio
async def test_app_state_is_shared_by_all_users_of_app(session_service):
app_name = 'my_app'
# User 1 creates a session, establishing app:k1
session1 = await session_service.create_session(
app_name=app_name, user_id='u1', session_id='s1', state={'app:k1': 'v1'}
)
# User 1 appends an event to session1, establishing app:k2
event = Event(
invocation_id='inv1',
author='user',
actions=EventActions(state_delta={'app:k2': 'v2'}),
)
await session_service.append_event(session=session1, event=event)
# User 2 creates a new session session2, it should see app:k1 and app:k2
session2 = await session_service.create_session(
app_name=app_name, user_id='u2', session_id='s2'
)
assert session2.state == {'app:k1': 'v1', 'app:k2': 'v2'}
# If we get session session1 again, it should also see both
session1_got = await session_service.get_session(
app_name=app_name, user_id='u1', session_id='s1'
)
assert session1_got.state.get('app:k1') == 'v1'
assert session1_got.state.get('app:k2') == 'v2'
@pytest.mark.asyncio
async def test_user_state_is_shared_only_by_user_sessions(session_service):
app_name = 'my_app'
# User 1 creates a session, establishing user:k1 for user 1
session1 = await session_service.create_session(
app_name=app_name, user_id='u1', session_id='s1', state={'user:k1': 'v1'}
)
# User 1 appends an event to session1, establishing user:k2 for user 1
event = Event(
invocation_id='inv1',
author='user',
actions=EventActions(state_delta={'user:k2': 'v2'}),
)
await session_service.append_event(session=session1, event=event)
# Another session for User 1 should see user:k1 and user:k2
session1b = await session_service.create_session(
app_name=app_name, user_id='u1', session_id='s1b'
)
assert session1b.state == {'user:k1': 'v1', 'user:k2': 'v2'}
# A session for User 2 should NOT see user:k1 or user:k2
session2 = await session_service.create_session(
app_name=app_name, user_id='u2', session_id='s2'
)
assert session2.state == {}
@pytest.mark.asyncio
async def test_session_state_is_not_shared(session_service):
app_name = 'my_app'
# User 1 creates a session session1, establishing sk1 only for session1
session1 = await session_service.create_session(
app_name=app_name, user_id='u1', session_id='s1', state={'sk1': 'v1'}
)
# User 1 appends an event to session1, establishing sk2 only for session1
event = Event(
invocation_id='inv1',
author='user',
actions=EventActions(state_delta={'sk2': 'v2'}),
)
await session_service.append_event(session=session1, event=event)
# Getting session1 should show sk1 and sk2
session1_got = await session_service.get_session(
app_name=app_name, user_id='u1', session_id='s1'
)
assert session1_got.state.get('sk1') == 'v1'
assert session1_got.state.get('sk2') == 'v2'
# Creating another session session1b for User 1 should NOT see sk1 or sk2
session1b = await session_service.create_session(
app_name=app_name, user_id='u1', session_id='s1b'
)
assert session1b.state == {}
@pytest.mark.asyncio
async def test_temp_state_is_not_persisted_in_state_or_events(session_service):
app_name = 'my_app'
user_id = 'u1'
session = await session_service.create_session(
app_name=app_name, user_id=user_id, session_id='s1'
)
event = Event(
invocation_id='inv1',
author='user',
actions=EventActions(state_delta={'temp:k1': 'v1', 'sk': 'v2'}),
)
await session_service.append_event(session=session, event=event)
# Temp state IS available in the in-memory session (same invocation)
assert session.state.get('temp:k1') == 'v1'
assert session.state.get('sk') == 'v2'
# Check event as stored in session does not contain temp keys in state_delta
assert 'temp:k1' not in event.actions.state_delta
assert event.actions.state_delta.get('sk') == 'v2'
@pytest.mark.asyncio
async def test_temp_state_visible_across_sequential_events(session_service):
"""Temp state set by one event should be readable before the next event.
This simulates a SequentialAgent where agent-1 writes output_key='temp:out'
and agent-2 needs to read it from session.state within the same invocation.
"""
app_name = 'my_app'
user_id = 'u1'
session = await session_service.create_session(
app_name=app_name, user_id=user_id, session_id='s_seq'
)
# Agent-1 writes temp state
event1 = Event(
invocation_id='inv1',
author='agent1',
actions=EventActions(state_delta={'temp:output': 'result_from_a1'}),
)
await session_service.append_event(session=session, event=event1)
# Agent-2 should be able to read temp state from the same session object
assert session.state.get('temp:output') == 'result_from_a1'
# But the event delta should NOT contain the temp key (not persisted)
assert 'temp:output' not in event1.actions.state_delta
@pytest.mark.asyncio
async def test_get_session_respects_user_id(session_service):
app_name = 'my_app'
# u1 creates session 's1' and adds an event
session1 = await session_service.create_session(
app_name=app_name, user_id='u1', session_id='s1'
)
event = Event(invocation_id='inv1', author='user')
await session_service.append_event(session1, event)
# u2 creates a session with the same session_id 's1'
await session_service.create_session(
app_name=app_name, user_id='u2', session_id='s1'
)
# Check that getting s1 for u2 returns u2's session (with no events)
# not u1's session.
session2_got = await session_service.get_session(
app_name=app_name, user_id='u2', session_id='s1'
)
assert session2_got.user_id == 'u2'
assert len(session2_got.events) == 0
@pytest.mark.asyncio
async def test_create_session_with_existing_id_raises_error(session_service):
app_name = 'my_app'
user_id = 'test_user'
session_id = 'existing_session'
# Create the first session
await session_service.create_session(
app_name=app_name,
user_id=user_id,
session_id=session_id,
)
# Attempt to create a session with the same ID
with pytest.raises(AlreadyExistsError):
await session_service.create_session(
app_name=app_name,
user_id=user_id,
session_id=session_id,
)
@pytest.mark.asyncio
async def test_append_event_bytes(session_service):
app_name = 'my_app'
user_id = 'user'
session = await session_service.create_session(
app_name=app_name, user_id=user_id
)
test_content = types.Content(
role='user',
parts=[
types.Part.from_bytes(data=b'test_image_data', mime_type='image/png'),
],
)
test_grounding_metadata = types.GroundingMetadata(
search_entry_point=types.SearchEntryPoint(sdk_blob=b'test_sdk_blob')
)
event = Event(
invocation_id='invocation',
author='user',
content=test_content,
grounding_metadata=test_grounding_metadata,
)
await session_service.append_event(session=session, event=event)
assert session.events[0].content == test_content
session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
events = session.events
assert len(events) == 1
assert events[0].content == test_content
assert events[0].grounding_metadata == test_grounding_metadata
@pytest.mark.asyncio
async def test_append_event_complete(session_service):
app_name = 'my_app'
user_id = 'user'
session = await session_service.create_session(
app_name=app_name, user_id=user_id
)
event = Event(
invocation_id='invocation',
author='user',
content=types.Content(role='user', parts=[types.Part(text='test_text')]),
turn_complete=True,
partial=False,
actions=EventActions(
artifact_delta={
'file': 0,
},
transfer_to_agent='agent',
escalate=True,
),
long_running_tool_ids={'tool1'},
error_code='error_code',
error_message='error_message',
interrupted=True,
grounding_metadata=types.GroundingMetadata(
web_search_queries=['query1'],
),
usage_metadata=types.GenerateContentResponseUsageMetadata(
prompt_token_count=1, candidates_token_count=1, total_token_count=2
),
citation_metadata=types.CitationMetadata(),
custom_metadata={'custom_key': 'custom_value'},
timestamp=1700000000.123,
input_transcription=types.Transcription(
text='input transcription',
finished=True,
),
output_transcription=types.Transcription(
text='output transcription',
finished=True,
),
)
await session_service.append_event(session=session, event=event)
assert (
await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
== session
)
@pytest.mark.asyncio
async def test_session_last_update_time_updates_on_event(session_service):
app_name = 'my_app'
user_id = 'user'
session = await session_service.create_session(
app_name=app_name, user_id=user_id
)
original_update_time = session.last_update_time
event_timestamp = original_update_time + 10
event = Event(
invocation_id='invocation',
author='user',
timestamp=event_timestamp,
)
await session_service.append_event(session=session, event=event)
assert session.last_update_time == pytest.approx(event_timestamp, abs=1e-6)
refreshed_session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
assert refreshed_session is not None
assert refreshed_session.last_update_time == pytest.approx(
event_timestamp, abs=1e-6
)
assert refreshed_session.last_update_time > original_update_time
@pytest.mark.asyncio
async def test_append_event_to_stale_session():
session_service = get_session_service(
service_type=SessionServiceType.DATABASE
)
async with session_service:
app_name = 'my_app'
user_id = 'user'
current_time = datetime.now().astimezone(timezone.utc).timestamp()
original_session = await session_service.create_session(
app_name=app_name, user_id=user_id
)
event1 = Event(
invocation_id='inv1',
author='user',
timestamp=current_time + 1,
actions=EventActions(state_delta={'sk1': 'v1'}),
)
await session_service.append_event(original_session, event1)
updated_session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=original_session.id
)
event2 = Event(
invocation_id='inv2',
author='user',
timestamp=current_time + 2,
actions=EventActions(state_delta={'sk2': 'v2'}),
)
await session_service.append_event(updated_session, event2)
# original_session is now stale
assert original_session.last_update_time < updated_session.last_update_time
assert len(original_session.events) == 1
assert 'sk2' not in original_session.state
# Appending another event to stale original_session should be rejected.
event3 = Event(
invocation_id='inv3',
author='user',
timestamp=current_time + 3,
actions=EventActions(state_delta={'sk3': 'v3'}),
)
with pytest.raises(ValueError, match='modified in storage'):
await session_service.append_event(original_session, event3)
# If we fetch session from DB, it should only contain the committed events.
session_final = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=original_session.id
)
assert len(session_final.events) == 2
assert session_final.state.get('sk1') == 'v1'
assert session_final.state.get('sk2') == 'v2'
assert session_final.state.get('sk3') is None
assert [e.invocation_id for e in session_final.events] == [
'inv1',
'inv2',
]
@pytest.mark.asyncio
async def test_append_event_raises_if_app_state_row_missing():
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
session = await service.create_session(
app_name='my_app', user_id='user', session_id='s1'
)
schema = service._get_schema_classes()
async with service.database_session_factory() as sql_session:
await sql_session.execute(
delete(schema.StorageAppState).where(
schema.StorageAppState.app_name == session.app_name
)
)
await sql_session.commit()
event = Event(
invocation_id='inv1',
author='user',
actions=EventActions(state_delta={'k': 'v'}),
)
with pytest.raises(ValueError, match='App state missing'):
await service.append_event(session, event)
finally:
await service.close()
@pytest.mark.asyncio
async def test_append_event_raises_if_user_state_row_missing():
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
session = await service.create_session(
app_name='my_app', user_id='user', session_id='s1'
)
schema = service._get_schema_classes()
async with service.database_session_factory() as sql_session:
await sql_session.execute(
delete(schema.StorageUserState).where(
schema.StorageUserState.app_name == session.app_name,
schema.StorageUserState.user_id == session.user_id,
)
)
await sql_session.commit()
event = Event(
invocation_id='inv1',
author='user',
actions=EventActions(state_delta={'k': 'v'}),
)
with pytest.raises(ValueError, match='User state missing'):
await service.append_event(session, event)
finally:
await service.close()
@pytest.mark.asyncio
async def test_append_event_concurrent_stale_sessions_reject_stale_writer():
session_service = get_session_service(
service_type=SessionServiceType.DATABASE
)
async with session_service:
app_name = 'my_app'
user_id = 'user'
session = await session_service.create_session(
app_name=app_name, user_id=user_id
)
iteration_count = 8
for i in range(iteration_count):
latest_session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
stale_session_1 = latest_session.model_copy(deep=True)
stale_session_2 = latest_session.model_copy(deep=True)
base_timestamp = latest_session.last_update_time + 10.0
event_1 = Event(
invocation_id=f'inv{i}-1',
author='user',
timestamp=base_timestamp + 1.0,
actions=EventActions(state_delta={f'sk{i}-1': f'v{i}-1'}),
)
event_2 = Event(
invocation_id=f'inv{i}-2',
author='user',
timestamp=base_timestamp + 2.0,
actions=EventActions(state_delta={f'sk{i}-2': f'v{i}-2'}),
)
results = await asyncio.gather(
session_service.append_event(stale_session_1, event_1),
session_service.append_event(stale_session_2, event_2),
return_exceptions=True,
)
errors = [result for result in results if isinstance(result, Exception)]
successes = [
result for result in results if not isinstance(result, Exception)
]
assert len(successes) == 1
assert len(errors) == 1
assert isinstance(errors[0], ValueError)
assert 'modified in storage' in str(errors[0])
session_final = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
for i in range(iteration_count):
event_values = {
session_final.state.get(f'sk{i}-1'),
session_final.state.get(f'sk{i}-2'),
}
assert event_values & {f'v{i}-1', f'v{i}-2'}
assert None in event_values
assert len(session_final.events) == iteration_count
@pytest.mark.asyncio
async def test_append_event_allows_timestamp_drift_for_current_session():
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
session = await service.create_session(
app_name='my_app', user_id='user', session_id='s1'
)
event1 = Event(
invocation_id='inv1',
author='user',
timestamp=session.last_update_time + 10,
)
await service.append_event(session, event1)
# Simulate a float round-trip mismatch without changing the persisted
# session revision.
session.last_update_time -= 0.0001
event2 = Event(
invocation_id='inv2',
author='user',
timestamp=event1.timestamp + 10,
)
await service.append_event(session, event2)
refreshed_session = await service.get_session(
app_name='my_app', user_id='user', session_id=session.id
)
assert [event.invocation_id for event in refreshed_session.events] == [
'inv1',
'inv2',
]
finally:
await service.close()
@pytest.mark.asyncio
async def test_append_event_allows_markerless_current_session():
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
session = await service.create_session(
app_name='my_app', user_id='user', session_id='s1'
)
event1 = Event(
invocation_id='inv1',
author='user',
timestamp=session.last_update_time + 10,
)
await service.append_event(session, event1)
session._storage_update_marker = None
session.last_update_time -= 0.0001
event2 = Event(
invocation_id='inv2',
author='user',
timestamp=event1.timestamp + 10,
)
await service.append_event(session, event2)
refreshed_session = await service.get_session(
app_name='my_app', user_id='user', session_id=session.id
)
assert [event.invocation_id for event in refreshed_session.events] == [
'inv1',
'inv2',
]
finally:
await service.close()
@pytest.mark.asyncio
async def test_append_event_when_session_is_same_ref_as_storage_session():
"""Tests that appending an event to a session only appends it once if the user-passed session and the underlying storage session are the same object."""
service = InMemorySessionService()
app_name = 'my_app'
user_id = 'test_user'
# Create a session
session = await service.create_session(app_name=app_name, user_id=user_id)
# Get the actual storage event object from the underlying storage
storage_session = service.sessions[app_name][user_id][session.id]
# Append the event to the storage session directly
event = Event(invocation_id='inv1', author='user')
await service.append_event(session=storage_session, event=event)
# Verify that the storage session has only one event
final_session = await service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
assert len(final_session.events) == 1
@pytest.mark.asyncio
async def test_get_session_with_config(session_service):
app_name = 'my_app'
user_id = 'user'
num_test_events = 5
session = await session_service.create_session(
app_name=app_name, user_id=user_id
)
for i in range(1, num_test_events + 1):
event = Event(author='user', timestamp=i)
await session_service.append_event(session, event)
# No config, expect all events to be returned.
session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
events = session.events
assert len(events) == num_test_events
# Explicitly requesting zero recent events should return no event history.
config = GetSessionConfig(num_recent_events=0)
session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id, config=config
)
assert not session.events
# Only expect the most recent 3 events.
num_recent_events = 3
config = GetSessionConfig(num_recent_events=num_recent_events)
session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id, config=config
)
events = session.events
assert len(events) == num_recent_events
assert events[0].timestamp == num_test_events - num_recent_events + 1
# Only expect events after timestamp 4.0 (inclusive), i.e., 2 events.
after_timestamp = 4.0
config = GetSessionConfig(after_timestamp=after_timestamp)
session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id, config=config
)
events = session.events
assert len(events) == num_test_events - after_timestamp + 1
assert events[0].timestamp == after_timestamp
# Expect no events if none are > after_timestamp.
way_after_timestamp = num_test_events * 10
config = GetSessionConfig(after_timestamp=way_after_timestamp)
session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id, config=config
)
assert not session.events
# Both filters applied, i.e., of 3 most recent events, only 2 are after
# timestamp 4.0, so expect 2 events.
config = GetSessionConfig(
after_timestamp=after_timestamp, num_recent_events=num_recent_events
)
session = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id, config=config
)
events = session.events
assert len(events) == num_test_events - after_timestamp + 1
@pytest.mark.asyncio
async def test_partial_events_are_not_persisted(session_service):
app_name = 'my_app'
user_id = 'user'
session = await session_service.create_session(
app_name=app_name, user_id=user_id
)
event = Event(author='user', partial=True)
await session_service.append_event(session, event)
# Check in-memory session
assert len(session.events) == 0
# Check persisted session
session_got = await session_service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
assert len(session_got.events) == 0
# ---------------------------------------------------------------------------
# Rollback tests – verify _rollback_on_exception_session explicitly rolls back
# on errors
# ---------------------------------------------------------------------------
class _RollbackSpySession:
"""Wraps an AsyncSession to spy on rollback() and optionally fail commit()."""
def __init__(self, real_session, *, fail_commit=False):
self._real = real_session
self._fail_commit = fail_commit
self.rollback_called = False
async def __aenter__(self):
self._real = await self._real.__aenter__()
return self
async def __aexit__(self, *args):
return await self._real.__aexit__(*args)
async def commit(self):
if self._fail_commit:
raise RuntimeError('simulated commit failure')
return await self._real.commit()
async def rollback(self):
self.rollback_called = True
return await self._real.rollback()
def __getattr__(self, name):
return getattr(self._real, name)
@pytest.mark.asyncio
async def test_create_session_calls_rollback_on_commit_failure():
"""Verifies that a commit failure during create_session triggers an explicit
rollback() call via _rollback_on_exception_session, not just a close()."""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
# Ensure tables are initialized.
await service.create_session(
app_name='app', user_id='user', session_id='good'
)
original_factory = service.database_session_factory
spy_sessions = []
def _spy_factory():
spy = _RollbackSpySession(original_factory(), fail_commit=True)
spy_sessions.append(spy)
return spy
service.database_session_factory = _spy_factory
with pytest.raises(RuntimeError, match='simulated commit failure'):
await service.create_session(
app_name='app', user_id='user', session_id='should_fail'
)
# The key assertion: rollback() must have been called explicitly.
assert len(spy_sessions) == 1
assert spy_sessions[0].rollback_called, (
'rollback() was not called – _rollback_on_exception_session is not'
' protecting this path'
)
# Restore and verify the failed session was not persisted.
service.database_session_factory = original_factory
assert (
await service.get_session(
app_name='app', user_id='user', session_id='should_fail'
)
is None
)
finally:
await service.close()
@pytest.mark.asyncio
async def test_append_event_calls_rollback_on_commit_failure():
"""Verifies that a commit failure during append_event triggers an explicit
rollback() call via _rollback_on_exception_session."""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
session = await service.create_session(
app_name='app', user_id='user', session_id='s1'
)
# Successfully append one event first.
event1 = Event(
invocation_id='inv1',
author='user',
actions=EventActions(state_delta={'key1': 'value1'}),
)
await service.append_event(session, event1)
original_factory = service.database_session_factory
spy_sessions = []
def _spy_factory():
spy = _RollbackSpySession(original_factory(), fail_commit=True)
spy_sessions.append(spy)
return spy
service.database_session_factory = _spy_factory
event2 = Event(
invocation_id='inv2',
author='user',
actions=EventActions(state_delta={'key2': 'value2'}),
)
with pytest.raises(RuntimeError, match='simulated commit failure'):
await service.append_event(session, event2)
assert len(spy_sessions) == 1
assert spy_sessions[0].rollback_called, (
'rollback() was not called – _rollback_on_exception_session is not'
' protecting this path'
)
# Restore and verify only the first event was persisted.
service.database_session_factory = original_factory
got = await service.get_session(
app_name='app', user_id='user', session_id='s1'
)
assert len(got.events) == 1
assert got.events[0].invocation_id == 'inv1'
finally:
await service.close()
class _CommitOrderSpySession:
"""SQLAlchemy session spy that marks when commit() has completed."""
def __init__(self, real_session, on_committed):
self._real = real_session
self._on_committed = on_committed
async def __aenter__(self):
self._real = await self._real.__aenter__()
return self
async def __aexit__(self, *args):
return await self._real.__aexit__(*args)
async def commit(self):
result = await self._real.commit()
self._on_committed()
return result
def __getattr__(self, name):
return getattr(self._real, name)
@pytest.mark.asyncio
async def test_append_event_reads_storage_revision_before_commit():
"""append_event captures session revision before commit completes."""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
await service.prepare_tables()
schema = service._get_schema_classes()
original_get_update_timestamp = schema.StorageSession.get_update_timestamp
original_get_update_marker = schema.StorageSession.get_update_marker
revision_read_state = {'committed': False, 'post_commit_reads': 0}
def _track_revision_read(original):
def wrapper(self, *args, **kwargs):
if revision_read_state['committed']:
revision_read_state['post_commit_reads'] += 1
return original(self, *args, **kwargs)
return wrapper
schema.StorageSession.get_update_timestamp = _track_revision_read(
original_get_update_timestamp
)
schema.StorageSession.get_update_marker = _track_revision_read(
original_get_update_marker
)
try:
session = await service.create_session(
app_name='app', user_id='user', session_id='s1'
)
event_timestamp = session.last_update_time + 10
event = Event(
invocation_id='inv1',
author='user',
timestamp=event_timestamp,
)
original_factory = service.database_session_factory
def _spy_factory():
return _CommitOrderSpySession(
original_factory(),
on_committed=lambda: revision_read_state.update({'committed': True}),
)
service.database_session_factory = _spy_factory
await service.append_event(session, event)
assert revision_read_state['post_commit_reads'] == 0
assert session.last_update_time == pytest.approx(event_timestamp, abs=1e-6)
assert session._storage_update_marker is not None
finally:
schema.StorageSession.get_update_timestamp = original_get_update_timestamp
schema.StorageSession.get_update_marker = original_get_update_marker
await service.close()
@pytest.mark.asyncio
async def test_create_session_reads_storage_revision_before_commit():
"""create_session captures session revision before commit completes."""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
await service.prepare_tables()
schema = service._get_schema_classes()
original_get_update_timestamp = schema.StorageSession.get_update_timestamp
original_get_update_marker = schema.StorageSession.get_update_marker
revision_read_state = {'committed': False, 'post_commit_reads': 0}
def _track_revision_read(original):
def wrapper(self, *args, **kwargs):
if revision_read_state['committed']:
revision_read_state['post_commit_reads'] += 1
return original(self, *args, **kwargs)
return wrapper
schema.StorageSession.get_update_timestamp = _track_revision_read(
original_get_update_timestamp
)
schema.StorageSession.get_update_marker = _track_revision_read(
original_get_update_marker
)
try:
original_factory = service.database_session_factory
def _spy_factory():
return _CommitOrderSpySession(
original_factory(),
on_committed=lambda: revision_read_state.update({'committed': True}),
)
service.database_session_factory = _spy_factory
session = await service.create_session(
app_name='app', user_id='user', session_id='s1'
)
assert revision_read_state['post_commit_reads'] == 0
assert session.last_update_time is not None
assert session._storage_update_marker is not None
finally:
schema.StorageSession.get_update_timestamp = original_get_update_timestamp
schema.StorageSession.get_update_marker = original_get_update_marker
await service.close()
@pytest.mark.asyncio
async def test_delete_session_calls_rollback_on_commit_failure():
"""Verifies that a commit failure during delete_session triggers an explicit
rollback() call via _rollback_on_exception_session."""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
await service.create_session(
app_name='app', user_id='user', session_id='s1'
)
original_factory = service.database_session_factory
spy_sessions = []
def _spy_factory():
spy = _RollbackSpySession(original_factory(), fail_commit=True)
spy_sessions.append(spy)
return spy
service.database_session_factory = _spy_factory
with pytest.raises(RuntimeError, match='simulated commit failure'):
await service.delete_session(
app_name='app', user_id='user', session_id='s1'
)
assert len(spy_sessions) == 1
assert spy_sessions[0].rollback_called, (
'rollback() was not called – _rollback_on_exception_session is not'
' protecting this path'
)
# Restore and verify the session still exists (delete was rolled back).
service.database_session_factory = original_factory
got = await service.get_session(
app_name='app', user_id='user', session_id='s1'
)
assert got is not None
finally:
await service.close()
@pytest.mark.asyncio
async def test_service_recovers_after_multiple_failures():
"""After several consecutive commit failures, every single one must trigger
a rollback() call and the service must remain functional afterward."""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
await service.create_session(
app_name='app', user_id='user', session_id='seed'
)
original_factory = service.database_session_factory
spy_sessions = []
def _spy_factory():
spy = _RollbackSpySession(original_factory(), fail_commit=True)
spy_sessions.append(spy)
return spy
service.database_session_factory = _spy_factory
num_failures = 5
for i in range(num_failures):
with pytest.raises(RuntimeError, match='simulated commit failure'):
await service.create_session(
app_name='app', user_id='user', session_id=f'fail_{i}'
)
# Every failure must have triggered a rollback.
assert len(spy_sessions) == num_failures
for i, spy in enumerate(spy_sessions):
assert spy.rollback_called, f'rollback() was not called on failure #{i}'
# Restore and verify the service is still healthy.
service.database_session_factory = original_factory
session = await service.create_session(
app_name='app', user_id='user', session_id='recovered'
)
assert session.id == 'recovered'
finally:
await service.close()
@pytest.mark.asyncio
async def test_concurrent_prepare_tables_no_race_condition():
"""Verifies that concurrent calls to prepare_tables wait for table creation.
Reproduces the race condition where concurrent requests
arrive at startup, prepare_tables must not return before tables exist.
Previously, the early-return guard checked _db_schema_version (set during
schema detection) instead of _tables_created, so a second request could
slip through after schema detection but before table creation finished.
"""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
# Tables haven't been created yet.
assert not service._tables_created
assert service._db_schema_version is None
# Launch several concurrent create_session calls, each with a unique
# app_name to avoid IntegrityError on the shared app_states row.
# Each will call prepare_tables internally. If the race condition
# exists, some of these will fail because the "sessions" table doesn't
# exist yet.
num_concurrent = 5
results = await asyncio.gather(
*[
service.create_session(
app_name=f'app_{i}', user_id='user', session_id=f'sess_{i}'
)
for i in range(num_concurrent)
],
return_exceptions=True,
)
# Every call must succeed – no exceptions allowed.
for i, result in enumerate(results):
assert not isinstance(result, BaseException), (
f'Concurrent create_session #{i} raised {result!r}; tables were'
' likely not ready due to the prepare_tables race condition.'
)
# All sessions should be retrievable.
for i in range(num_concurrent):
session = await service.get_session(
app_name=f'app_{i}', user_id='user', session_id=f'sess_{i}'
)
assert session is not None, f'Session sess_{i} not found after creation.'
assert service._tables_created
finally:
await service.close()
@pytest.mark.asyncio
async def test_prepare_tables_serializes_schema_detection_and_creation():
"""Verifies schema detection and table creation happen atomically under one
lock, so concurrent callers cannot observe a partially-initialized state.
After prepare_tables completes, both _db_schema_version and _tables_created
must be set.
"""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
assert not service._tables_created
assert service._db_schema_version is None
await service.prepare_tables()
# Both must be set after a single prepare_tables call.
assert service._tables_created
assert service._db_schema_version is not None
# Verify tables actually exist by performing a real operation.
session = await service.create_session(
app_name='app', user_id='user', session_id='s1'
)
assert session is not None
assert session.id == 's1'
finally:
await service.close()
@pytest.mark.asyncio
async def test_get_or_create_state_returns_existing_row():
"""_get_or_create_state returns an existing row without inserting."""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
await service.prepare_tables()
schema = service._get_schema_classes()
# Pre-create the app_state row.
async with service.database_session_factory() as sql_session:
sql_session.add(schema.StorageAppState(app_name='app1', state={'k': 'v'}))
await sql_session.commit()
# _get_or_create_state should find and return it.
async with service.database_session_factory() as sql_session:
row = await database_session_service._get_or_create_state(
sql_session=sql_session,
state_model=schema.StorageAppState,
primary_key='app1',
defaults={'app_name': 'app1', 'state': {}},
)
assert row.app_name == 'app1'
assert row.state == {'k': 'v'}
finally:
await service.close()
@pytest.mark.asyncio
async def test_get_or_create_state_creates_new_row():
"""_get_or_create_state creates a row when none exists."""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
await service.prepare_tables()
schema = service._get_schema_classes()
async with service.database_session_factory() as sql_session:
row = await database_session_service._get_or_create_state(
sql_session=sql_session,
state_model=schema.StorageAppState,
primary_key='new_app',
defaults={'app_name': 'new_app', 'state': {}},
)
await sql_session.commit()
assert row.app_name == 'new_app'
assert row.state == {}
# Verify the row was actually persisted.
async with service.database_session_factory() as sql_session:
persisted = await sql_session.get(schema.StorageAppState, 'new_app')
assert persisted is not None
finally:
await service.close()
@pytest.mark.asyncio
async def test_get_or_create_state_handles_race_condition():
"""_get_or_create_state recovers when a concurrent INSERT wins the race.
Simulates the race:
the initial SELECT returns None (another caller hasn't committed yet), but
by the time we INSERT, the other caller has committed — so the INSERT fails
with IntegrityError and we fall back to re-fetching.
"""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
await service.prepare_tables()
schema = service._get_schema_classes()
# Pre-create the row to guarantee the INSERT will fail.
async with service.database_session_factory() as sql_session:
sql_session.add(schema.StorageAppState(app_name='race_app', state={}))
await sql_session.commit()
# Patch session.get to return None on the first call (simulating the
# race window), then fall through to the real implementation.
async with service.database_session_factory() as sql_session:
original_get = sql_session.get
call_count = 0
async def patched_get(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return None # Simulate: row not yet visible
return await original_get(*args, **kwargs)
sql_session.get = patched_get
row = await database_session_service._get_or_create_state(
sql_session=sql_session,
state_model=schema.StorageAppState,
primary_key='race_app',
defaults={'app_name': 'race_app', 'state': {}},
)
assert row.app_name == 'race_app'
# The function should have called get twice: once before the INSERT
# (patched to return None) and once after the IntegrityError.
assert call_count == 2
finally:
await service.close()
@pytest.mark.asyncio
async def test_create_session_sequential_same_app_name():
"""Sequential create_session calls for the same app_name work correctly.
The second call reuses the existing app_states row.
"""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
s1 = await service.create_session(
app_name='shared', user_id='u1', session_id='s1'
)
s2 = await service.create_session(
app_name='shared', user_id='u2', session_id='s2'
)
assert s1.app_name == 'shared'
assert s2.app_name == 'shared'
got1 = await service.get_session(
app_name='shared', user_id='u1', session_id='s1'
)
got2 = await service.get_session(
app_name='shared', user_id='u2', session_id='s2'
)
assert got1 is not None
assert got2 is not None
finally:
await service.close()
@pytest.mark.asyncio
async def test_prepare_tables_idempotent_after_creation():
"""Calling prepare_tables multiple times is safe and idempotent.
After tables are created, subsequent calls should return immediately via
the fast path without errors.
"""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
try:
await service.prepare_tables()
assert service._tables_created
# Call again — should be a no-op via the fast path.
await service.prepare_tables()
assert service._tables_created
# Service should still work.
session = await service.create_session(
app_name='app', user_id='user', session_id='s1'
)
assert session.id == 's1'
finally:
await service.close()
@pytest.mark.asyncio
async def test_public_prepare_tables_eager_initialization():
"""Calling the public prepare_tables() eagerly initializes tables so that
the first real database operation does not pay the setup cost.
"""
async with DatabaseSessionService('sqlite+aiosqlite:///:memory:') as service:
# Before calling prepare_tables, tables are not created.
assert not service._tables_created
assert service._db_schema_version is None
# Eagerly prepare tables via the public API.
await service.prepare_tables()
# Tables should now be ready.
assert service._tables_created
assert service._db_schema_version is not None
# Subsequent operations should work without any additional setup cost.
session = await service.create_session(
app_name='app', user_id='user', session_id='s1'
)
assert session.id == 's1'
@pytest.mark.asyncio
@pytest.mark.parametrize(
'state_delta, expect_app_lock, expect_user_lock',
[
pytest.param(
None,
False,
False,
id='no_state_delta',
),
pytest.param(
{'session_key': 'v'},
False,
False,
id='session_only_delta',
),
pytest.param(
{'app:key': 'v'},
True,
False,
id='app_delta_only',
),
pytest.param(
{'user:key': 'v'},
False,
True,
id='user_delta_only',
),
pytest.param(
{'app:a': '1', 'user:b': '2', 'sk': '3'},
True,
True,
id='all_scopes',
),
],
)
async def test_append_event_locks_only_scopes_with_deltas(
state_delta, expect_app_lock, expect_user_lock
):
"""FOR UPDATE should only be requested for state scopes that have deltas."""
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
lock_requests = []
original_fn = database_session_service._select_required_state
async def tracking_fn(**kwargs):
lock_requests.append({
'model': kwargs['state_model'].__tablename__,
'use_row_level_locking': kwargs['use_row_level_locking'],
})
return await original_fn(**kwargs)
try:
session = await service.create_session(
app_name='app', user_id='user', session_id='s1'
)
database_session_service._select_required_state = tracking_fn
lock_requests.clear()
event_kwargs = {'invocation_id': 'inv', 'author': 'user'}
if state_delta is not None:
event_kwargs['actions'] = EventActions(state_delta=state_delta)
event = Event(**event_kwargs)
await service.append_event(session, event)
app_req = next(
(r for r in lock_requests if r['model'] == 'app_states'), None
)
user_req = next(
(r for r in lock_requests if r['model'] == 'user_states'), None
)
# SQLite doesn't support row-level locking so use_row_level_locking is
# always False. The important check is that locking is not requested
# when there is no delta (it must never be True without a delta).
if not expect_app_lock:
assert (
app_req is None or not app_req['use_row_level_locking']
), 'app_states should not be locked without an app: delta'
if not expect_user_lock:
assert (
user_req is None or not user_req['use_row_level_locking']
), 'user_states should not be locked without a user: delta'
finally:
database_session_service._select_required_state = original_fn
await service.close()
@pytest.mark.asyncio
async def test_get_user_state_returns_empty_dict_when_no_state_exists(
session_service,
):
"""Verifies get_user_state returns empty dict when no state exists."""
state = await session_service.get_user_state(app_name='my_app', user_id='u1')
assert not state
@pytest.mark.asyncio
async def test_get_user_state_returns_state_written_via_append_event(
session_service,
):
"""Verifies get_user_state returns state written via append_event."""
session = await session_service.create_session(
app_name='my_app', user_id='u1'
)
await session_service.append_event(
session,
Event(
author='system',
actions=EventActions(
state_delta={'user:profile': {'name': 'Alice'}, 'session_key': 1}
),
),
)
state = await session_service.get_user_state(app_name='my_app', user_id='u1')
assert state == {'profile': {'name': 'Alice'}}
assert 'session_key' not in state
@pytest.mark.asyncio
async def test_get_user_state_is_not_visible_across_users(session_service):
"""Verifies user state is isolated between users."""
session = await session_service.create_session(
app_name='my_app', user_id='u1'
)
await session_service.append_event(
session,
Event(
author='system',
actions=EventActions(state_delta={'user:secret': 'only-for-u1'}),
),
)
other_state = await session_service.get_user_state(
app_name='my_app', user_id='u2'
)
assert not other_state
@pytest.mark.asyncio
async def test_get_user_state_is_not_visible_across_apps(session_service):
"""Verifies user state is isolated between apps."""
session = await session_service.create_session(
app_name='my_app', user_id='u1'
)
await session_service.append_event(
session,
Event(
author='system',
actions=EventActions(state_delta={'user:data': 'only-app-a'}),
),
)
other_state = await session_service.get_user_state(
app_name='other_app', user_id='u1'
)
assert not other_state
@pytest.mark.asyncio
async def test_get_user_state_available_before_session_is_created(
session_service,
):
"""Verifies user state can be retrieved before a session is created."""
first_session = await session_service.create_session(
app_name='my_app', user_id='u1'
)
await session_service.append_event(
first_session,
Event(
author='system',
actions=EventActions(state_delta={'user:ctx': {'v': 1}}),
),
)
state = await session_service.get_user_state(app_name='my_app', user_id='u1')
assert state == {'ctx': {'v': 1}}
@pytest.mark.asyncio
async def test_get_user_state_reflects_latest_write(session_service):
"""Verifies get_user_state returns the latest state."""
session = await session_service.create_session(
app_name='my_app', user_id='u1'
)
await session_service.append_event(
session,
Event(
author='system',
actions=EventActions(state_delta={'user:counter': 1}),
),
)
await session_service.append_event(
session,
Event(
author='system',
actions=EventActions(state_delta={'user:counter': 2}),
),
)
state = await session_service.get_user_state(app_name='my_app', user_id='u1')
assert state['counter'] == 2
@pytest.mark.asyncio
async def test_vertex_ai_session_service_raises_not_implemented_for_get_user_state():
"""Verifies VertexAiSessionService raises NotImplementedError."""
service = VertexAiSessionService(project='proj', location='us-central1')
with pytest.raises(NotImplementedError):
await service.get_user_state(app_name='my_app', user_id='u1')
def test_database_session_service_visible_in_module_namespace():
"""DatabaseSessionService must be in dir() so Sphinx autodoc renders it.
It is imported lazily via module __getattr__, so without an explicit
__dir__ it drops out of the generated API reference (issue #4331).
"""
import google.adk.sessions as sessions_module
assert 'DatabaseSessionService' in dir(sessions_module)
assert sessions_module.DatabaseSessionService is DatabaseSessionService
@pytest.mark.asyncio
async def test_database_session_service_with_db_url():
"""Test DatabaseSessionService initialization with db_url."""
# Test db_url as positional argument
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
app_name = 'test_app'
user_id = 'test_user'
# Create and retrieve a session
session = await service.create_session(
app_name=app_name, user_id=user_id, state={'key': 'value'}
)
assert session.app_name == app_name
assert session.user_id == user_id
assert session.state == {'key': 'value'}
# Let's check that we can retrieve it
retrieved = await service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
assert retrieved == session
# test db_url as keyword argument
service2 = DatabaseSessionService(db_url='sqlite+aiosqlite:///:memory:')
session2 = await service2.create_session(
app_name=app_name, user_id=user_id, state={'key': 'value2'}
)
assert session2.state == {'key': 'value2'}
@pytest.mark.asyncio
async def test_database_session_service_with_db_engine():
"""Test DatabaseSessionService initialization with db_engine."""
# Create an engine manually with StaticPool to avoid flakes
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
)
# Create service with db_engine
service = DatabaseSessionService(db_engine=engine)
app_name = 'test_app'
user_id = 'test_user'
# Create and retrieve a session
session = await service.create_session(
app_name=app_name, user_id=user_id, state={'key': 'value'}
)
assert session.app_name == app_name
assert session.user_id == user_id
assert session.state == {'key': 'value'}
# Let's check that we can retrieve it
retrieved = await service.get_session(
app_name=app_name, user_id=user_id, session_id=session.id
)
assert retrieved == session
@pytest.mark.asyncio
async def test_database_session_service_caller_owned_engine_not_disposed_on_close():
"""Verifies that a caller-owned engine is not disposed when the service is closed."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
)
service = DatabaseSessionService(db_engine=engine)
# Use the service
session = await service.create_session(app_name='app', user_id='user')
assert session is not None
# Close the service
await service.close()
# Verify engine is still usable by running a query
async with engine.connect() as conn:
result = await conn.execute(text('SELECT 1;'))
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_database_session_service_requires_one_argument():
"""Test that DatabaseSessionService requires exactly one of db_url or db_engine."""
# Neither argument provided
with pytest.raises(
ValueError,
match="Exactly one of 'db_url' or 'db_engine' must be provided",
):
DatabaseSessionService()
# Both arguments provided
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
with pytest.raises(
ValueError,
match="Exactly one of 'db_url' or 'db_engine' must be provided",
):
DatabaseSessionService(
db_url='sqlite+aiosqlite:///:memory:', db_engine=engine
)