@@ -1370,6 +1370,137 @@ async def test_prepare_tables_serializes_schema_detection_and_creation():
13701370 await service .close ()
13711371
13721372
1373+ @pytest .mark .asyncio
1374+ async def test_get_or_create_state_returns_existing_row ():
1375+ """_get_or_create_state returns an existing row without inserting."""
1376+ service = DatabaseSessionService ('sqlite+aiosqlite:///:memory:' )
1377+ try :
1378+ await service ._prepare_tables ()
1379+ schema = service ._get_schema_classes ()
1380+
1381+ # Pre-create the app_state row.
1382+ async with service .database_session_factory () as sql_session :
1383+ sql_session .add (schema .StorageAppState (app_name = 'app1' , state = {'k' : 'v' }))
1384+ await sql_session .commit ()
1385+
1386+ # _get_or_create_state should find and return it.
1387+ async with service .database_session_factory () as sql_session :
1388+ row = await database_session_service ._get_or_create_state (
1389+ sql_session = sql_session ,
1390+ state_model = schema .StorageAppState ,
1391+ primary_key = 'app1' ,
1392+ defaults = {'app_name' : 'app1' , 'state' : {}},
1393+ )
1394+ assert row .app_name == 'app1'
1395+ assert row .state == {'k' : 'v' }
1396+ finally :
1397+ await service .close ()
1398+
1399+
1400+ @pytest .mark .asyncio
1401+ async def test_get_or_create_state_creates_new_row ():
1402+ """_get_or_create_state creates a row when none exists."""
1403+ service = DatabaseSessionService ('sqlite+aiosqlite:///:memory:' )
1404+ try :
1405+ await service ._prepare_tables ()
1406+ schema = service ._get_schema_classes ()
1407+
1408+ async with service .database_session_factory () as sql_session :
1409+ row = await database_session_service ._get_or_create_state (
1410+ sql_session = sql_session ,
1411+ state_model = schema .StorageAppState ,
1412+ primary_key = 'new_app' ,
1413+ defaults = {'app_name' : 'new_app' , 'state' : {}},
1414+ )
1415+ await sql_session .commit ()
1416+ assert row .app_name == 'new_app'
1417+ assert row .state == {}
1418+
1419+ # Verify the row was actually persisted.
1420+ async with service .database_session_factory () as sql_session :
1421+ persisted = await sql_session .get (schema .StorageAppState , 'new_app' )
1422+ assert persisted is not None
1423+ finally :
1424+ await service .close ()
1425+
1426+
1427+ @pytest .mark .asyncio
1428+ async def test_get_or_create_state_handles_race_condition ():
1429+ """_get_or_create_state recovers when a concurrent INSERT wins the race.
1430+
1431+ Simulates the race from https://github.com/google/adk-python/issues/4954:
1432+ the initial SELECT returns None (another caller hasn't committed yet), but
1433+ by the time we INSERT, the other caller has committed — so the INSERT fails
1434+ with IntegrityError and we fall back to re-fetching.
1435+ """
1436+ service = DatabaseSessionService ('sqlite+aiosqlite:///:memory:' )
1437+ try :
1438+ await service ._prepare_tables ()
1439+ schema = service ._get_schema_classes ()
1440+
1441+ # Pre-create the row to guarantee the INSERT will fail.
1442+ async with service .database_session_factory () as sql_session :
1443+ sql_session .add (schema .StorageAppState (app_name = 'race_app' , state = {}))
1444+ await sql_session .commit ()
1445+
1446+ # Patch session.get to return None on the first call (simulating the
1447+ # race window), then fall through to the real implementation.
1448+ async with service .database_session_factory () as sql_session :
1449+ original_get = sql_session .get
1450+ call_count = 0
1451+
1452+ async def patched_get (* args , ** kwargs ):
1453+ nonlocal call_count
1454+ call_count += 1
1455+ if call_count == 1 :
1456+ return None # Simulate: row not yet visible
1457+ return await original_get (* args , ** kwargs )
1458+
1459+ sql_session .get = patched_get
1460+
1461+ row = await database_session_service ._get_or_create_state (
1462+ sql_session = sql_session ,
1463+ state_model = schema .StorageAppState ,
1464+ primary_key = 'race_app' ,
1465+ defaults = {'app_name' : 'race_app' , 'state' : {}},
1466+ )
1467+ assert row .app_name == 'race_app'
1468+ # The function should have called get twice: once before the INSERT
1469+ # (patched to return None) and once after the IntegrityError.
1470+ assert call_count == 2
1471+ finally :
1472+ await service .close ()
1473+
1474+
1475+ @pytest .mark .asyncio
1476+ async def test_create_session_sequential_same_app_name ():
1477+ """Sequential create_session calls for the same app_name work correctly.
1478+
1479+ The second call reuses the existing app_states row.
1480+ """
1481+ service = DatabaseSessionService ('sqlite+aiosqlite:///:memory:' )
1482+ try :
1483+ s1 = await service .create_session (
1484+ app_name = 'shared' , user_id = 'u1' , session_id = 's1'
1485+ )
1486+ s2 = await service .create_session (
1487+ app_name = 'shared' , user_id = 'u2' , session_id = 's2'
1488+ )
1489+ assert s1 .app_name == 'shared'
1490+ assert s2 .app_name == 'shared'
1491+
1492+ got1 = await service .get_session (
1493+ app_name = 'shared' , user_id = 'u1' , session_id = 's1'
1494+ )
1495+ got2 = await service .get_session (
1496+ app_name = 'shared' , user_id = 'u2' , session_id = 's2'
1497+ )
1498+ assert got1 is not None
1499+ assert got2 is not None
1500+ finally :
1501+ await service .close ()
1502+
1503+
13731504@pytest .mark .asyncio
13741505async def test_prepare_tables_idempotent_after_creation ():
13751506 """Calling _prepare_tables multiple times is safe and idempotent.
0 commit comments