fix: Per-session locking and row-level locking in DatabaseSessionService.append_event

This change introduces an in-process `asyncio.Lock` per session to serialize `append_event` calls for the same session ID within a single process. For supported database dialects (MySQL, PostgreSQL, MariaDB), it also uses `SELECT ... FOR UPDATE` to acquire row-level locks on the session, app state, and user state records, preventing race conditions across different processes or database connections. A new test case verifies that concurrent updates to stale session objects correctly merge all state changes.

Close #1049

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 867752676
This commit is contained in:
George Weale
2026-02-09 13:38:38 -08:00
committed by Copybara-Service
parent 32ee07df01
commit f50847460f
2 changed files with 292 additions and 66 deletions
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from datetime import datetime
from datetime import timezone
import enum
@@ -28,6 +29,7 @@ from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.sqlite_session_service import SqliteSessionService
from google.genai import types
import pytest
from sqlalchemy import delete
class SessionServiceType(enum.Enum):
@@ -613,6 +615,110 @@ async def test_append_event_to_stale_session():
]
@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_preserve_all_state():
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'}),
)
await asyncio.gather(
session_service.append_event(stale_session_1, event_1),
session_service.append_event(stale_session_2, event_2),
)
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):
assert session_final.state.get(f'sk{i}-1') == f'v{i}-1'
assert session_final.state.get(f'sk{i}-2') == f'v{i}-2'
assert len(session_final.events) == iteration_count * 2
@pytest.mark.asyncio
async def test_get_session_with_config(session_service):
app_name = 'my_app'