Skip to content

Commit 66221c4

Browse files
ferponsewuliang229
authored andcommitted
feat(sessions): add public prepare_tables() for eager initialization
Merge #4858 Fixes #4857 ## Summary - Rename `_prepare_tables()` to `prepare_tables()`, making it part of the public API so applications can eagerly initialize database tables at startup and avoid the latency spike on the first user request. - All internal callers (`create_session`, `get_session`, `list_sessions`, `delete_session`, `append_event`) updated to call the now-public method. Lazy behavior is fully preserved. - Existing tests updated to use the public method name. - New test (`test_public_prepare_tables_eager_initialization`) verifies eager initialization works correctly. ## Motivation In our production application using `DatabaseSessionService` with PostgreSQL, the first user query after a restart is noticeably slower because `_prepare_tables()` runs schema detection + `metadata.create_all()` inline. By making the method public, applications can call it during startup (e.g. in a FastAPI lifespan handler) to eliminate this cold-start penalty: ```python session_service = DatabaseSessionService(db_url=db_url) await session_service.prepare_tables() # eager initialization at startup ``` ## Test plan - [x] New test `test_public_prepare_tables_eager_initialization` passes - [x] All existing `prepare_tables` tests still pass (4/4) - [x] Fully backward compatible — lazy initialization still works for applications that don't call `prepare_tables()` explicitly Co-authored-by: Liang Wu <wuliang@google.com> COPYBARA_INTEGRATE_REVIEW=#4858 from ferponse:feat/eager-table-preparation aa911af PiperOrigin-RevId: 934013793
1 parent c303c62 commit 66221c4

2 files changed

Lines changed: 53 additions & 24 deletions

File tree

src/google/adk/sessions/database_session_service.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -330,12 +330,17 @@ async def _with_session_lock(
330330
else:
331331
self._session_lock_ref_count[lock_key] = remaining
332332

333-
async def _prepare_tables(self) -> None:
333+
async def prepare_tables(self) -> None:
334334
"""Ensure database tables are ready for use.
335335
336336
This method is called lazily before each database operation. It checks the
337337
DB schema version to use and creates the tables (including setting the
338338
schema version metadata) if needed.
339+
340+
It can also be called eagerly right after construction to pay the
341+
table-creation cost upfront (e.g. during application startup) instead of
342+
on the first database operation. It is safe to call more than once and
343+
is recommended for latency-sensitive applications.
339344
"""
340345
# Early return if tables are already created
341346
if self._tables_created:
@@ -434,7 +439,7 @@ async def create_session(
434439
# 3. Add the object to the table
435440
# 4. Build the session object with generated id
436441
# 5. Return the session
437-
await self._prepare_tables()
442+
await self.prepare_tables()
438443
has_user_provided_id = session_id is not None
439444
if session_id is None:
440445
session_id = platform_uuid.new_uuid()
@@ -510,7 +515,7 @@ async def get_session(
510515
session_id: str,
511516
config: Optional[GetSessionConfig] = None,
512517
) -> Optional[Session]:
513-
await self._prepare_tables()
518+
await self.prepare_tables()
514519
# 1. Get the storage session entry from session table
515520
# 2. Get all the events based on session id and filtering config
516521
# 3. Convert and return the session
@@ -574,7 +579,7 @@ async def get_session(
574579
async def list_sessions(
575580
self, *, app_name: str, user_id: Optional[str] = None
576581
) -> ListSessionsResponse:
577-
await self._prepare_tables()
582+
await self.prepare_tables()
578583
schema = self._get_schema_classes()
579584
async with self._rollback_on_exception_session(
580585
read_only=True
@@ -631,7 +636,7 @@ async def list_sessions(
631636
async def delete_session(
632637
self, app_name: str, user_id: str, session_id: str
633638
) -> None:
634-
await self._prepare_tables()
639+
await self.prepare_tables()
635640
schema = self._get_schema_classes()
636641
async with self._rollback_on_exception_session() as sql_session:
637642
stmt = delete(schema.StorageSession).where(
@@ -646,7 +651,7 @@ async def delete_session(
646651
async def get_user_state(
647652
self, *, app_name: str, user_id: str
648653
) -> dict[str, Any]:
649-
await self._prepare_tables()
654+
await self.prepare_tables()
650655
schema = self._get_schema_classes()
651656
async with self._rollback_on_exception_session(
652657
read_only=True
@@ -660,7 +665,7 @@ async def get_user_state(
660665

661666
@override
662667
async def append_event(self, session: Session, event: Event) -> Event:
663-
await self._prepare_tables()
668+
await self.prepare_tables()
664669
if event.partial:
665670
return event
666671

tests/unittests/sessions/test_session_service.py

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ async def test_get_empty_session(session_service):
285285
@pytest.mark.asyncio
286286
async def test_database_session_service_get_session_uses_read_only_factory():
287287
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
288-
service._prepare_tables = mock.AsyncMock()
288+
service.prepare_tables = mock.AsyncMock()
289289

290290
read_only_session = mock.AsyncMock()
291291
read_only_session.get = mock.AsyncMock(return_value=None)
@@ -315,7 +315,7 @@ async def fake_read_only_session():
315315
@pytest.mark.asyncio
316316
async def test_database_session_service_list_sessions_uses_read_only_factory():
317317
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
318-
service._prepare_tables = mock.AsyncMock()
318+
service.prepare_tables = mock.AsyncMock()
319319

320320
read_only_session = mock.AsyncMock()
321321
empty_result = mock.Mock()
@@ -1281,7 +1281,7 @@ def __getattr__(self, name):
12811281
async def test_append_event_reads_storage_revision_before_commit():
12821282
"""append_event captures session revision before commit completes."""
12831283
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
1284-
await service._prepare_tables()
1284+
await service.prepare_tables()
12851285
schema = service._get_schema_classes()
12861286
original_get_update_timestamp = schema.StorageSession.get_update_timestamp
12871287
original_get_update_marker = schema.StorageSession.get_update_marker
@@ -1339,7 +1339,7 @@ def _spy_factory():
13391339
async def test_create_session_reads_storage_revision_before_commit():
13401340
"""create_session captures session revision before commit completes."""
13411341
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
1342-
await service._prepare_tables()
1342+
await service.prepare_tables()
13431343
schema = service._get_schema_classes()
13441344
original_get_update_timestamp = schema.StorageSession.get_update_timestamp
13451345
original_get_update_marker = schema.StorageSession.get_update_marker
@@ -1470,10 +1470,10 @@ def _spy_factory():
14701470

14711471
@pytest.mark.asyncio
14721472
async def test_concurrent_prepare_tables_no_race_condition():
1473-
"""Verifies that concurrent calls to _prepare_tables wait for table creation.
1473+
"""Verifies that concurrent calls to prepare_tables wait for table creation.
14741474
Reproduces the race condition from
14751475
https://github.com/google/adk-python/issues/4445: when concurrent requests
1476-
arrive at startup, _prepare_tables must not return before tables exist.
1476+
arrive at startup, prepare_tables must not return before tables exist.
14771477
Previously, the early-return guard checked _db_schema_version (set during
14781478
schema detection) instead of _tables_created, so a second request could
14791479
slip through after schema detection but before table creation finished.
@@ -1486,7 +1486,7 @@ async def test_concurrent_prepare_tables_no_race_condition():
14861486

14871487
# Launch several concurrent create_session calls, each with a unique
14881488
# app_name to avoid IntegrityError on the shared app_states row.
1489-
# Each will call _prepare_tables internally. If the race condition
1489+
# Each will call prepare_tables internally. If the race condition
14901490
# exists, some of these will fail because the "sessions" table doesn't
14911491
# exist yet.
14921492
num_concurrent = 5
@@ -1504,7 +1504,7 @@ async def test_concurrent_prepare_tables_no_race_condition():
15041504
for i, result in enumerate(results):
15051505
assert not isinstance(result, BaseException), (
15061506
f'Concurrent create_session #{i} raised {result!r}; tables were'
1507-
' likely not ready due to the _prepare_tables race condition.'
1507+
' likely not ready due to the prepare_tables race condition.'
15081508
)
15091509

15101510
# All sessions should be retrievable.
@@ -1523,17 +1523,17 @@ async def test_concurrent_prepare_tables_no_race_condition():
15231523
async def test_prepare_tables_serializes_schema_detection_and_creation():
15241524
"""Verifies schema detection and table creation happen atomically under one
15251525
lock, so concurrent callers cannot observe a partially-initialized state.
1526-
After _prepare_tables completes, both _db_schema_version and _tables_created
1526+
After prepare_tables completes, both _db_schema_version and _tables_created
15271527
must be set.
15281528
"""
15291529
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
15301530
try:
15311531
assert not service._tables_created
15321532
assert service._db_schema_version is None
15331533

1534-
await service._prepare_tables()
1534+
await service.prepare_tables()
15351535

1536-
# Both must be set after a single _prepare_tables call.
1536+
# Both must be set after a single prepare_tables call.
15371537
assert service._tables_created
15381538
assert service._db_schema_version is not None
15391539

@@ -1552,7 +1552,7 @@ async def test_get_or_create_state_returns_existing_row():
15521552
"""_get_or_create_state returns an existing row without inserting."""
15531553
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
15541554
try:
1555-
await service._prepare_tables()
1555+
await service.prepare_tables()
15561556
schema = service._get_schema_classes()
15571557

15581558
# Pre-create the app_state row.
@@ -1579,7 +1579,7 @@ async def test_get_or_create_state_creates_new_row():
15791579
"""_get_or_create_state creates a row when none exists."""
15801580
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
15811581
try:
1582-
await service._prepare_tables()
1582+
await service.prepare_tables()
15831583
schema = service._get_schema_classes()
15841584

15851585
async with service.database_session_factory() as sql_session:
@@ -1612,7 +1612,7 @@ async def test_get_or_create_state_handles_race_condition():
16121612
"""
16131613
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
16141614
try:
1615-
await service._prepare_tables()
1615+
await service.prepare_tables()
16161616
schema = service._get_schema_classes()
16171617

16181618
# Pre-create the row to guarantee the INSERT will fail.
@@ -1680,17 +1680,17 @@ async def test_create_session_sequential_same_app_name():
16801680

16811681
@pytest.mark.asyncio
16821682
async def test_prepare_tables_idempotent_after_creation():
1683-
"""Calling _prepare_tables multiple times is safe and idempotent.
1683+
"""Calling prepare_tables multiple times is safe and idempotent.
16841684
After tables are created, subsequent calls should return immediately via
16851685
the fast path without errors.
16861686
"""
16871687
service = DatabaseSessionService('sqlite+aiosqlite:///:memory:')
16881688
try:
1689-
await service._prepare_tables()
1689+
await service.prepare_tables()
16901690
assert service._tables_created
16911691

16921692
# Call again — should be a no-op via the fast path.
1693-
await service._prepare_tables()
1693+
await service.prepare_tables()
16941694
assert service._tables_created
16951695

16961696
# Service should still work.
@@ -1702,6 +1702,30 @@ async def test_prepare_tables_idempotent_after_creation():
17021702
await service.close()
17031703

17041704

1705+
@pytest.mark.asyncio
1706+
async def test_public_prepare_tables_eager_initialization():
1707+
"""Calling the public prepare_tables() eagerly initializes tables so that
1708+
the first real database operation does not pay the setup cost.
1709+
"""
1710+
async with DatabaseSessionService('sqlite+aiosqlite:///:memory:') as service:
1711+
# Before calling prepare_tables, tables are not created.
1712+
assert not service._tables_created
1713+
assert service._db_schema_version is None
1714+
1715+
# Eagerly prepare tables via the public API.
1716+
await service.prepare_tables()
1717+
1718+
# Tables should now be ready.
1719+
assert service._tables_created
1720+
assert service._db_schema_version is not None
1721+
1722+
# Subsequent operations should work without any additional setup cost.
1723+
session = await service.create_session(
1724+
app_name='app', user_id='user', session_id='s1'
1725+
)
1726+
assert session.id == 's1'
1727+
1728+
17051729
@pytest.mark.asyncio
17061730
@pytest.mark.parametrize(
17071731
'state_delta, expect_app_lock, expect_user_lock',

0 commit comments

Comments
 (0)