Skip to content

Commit 0feada2

Browse files
committed
feat(py): default FirestoreSessionStore to AsyncClient with sync fallback
1 parent 49c8ae0 commit 0feada2

3 files changed

Lines changed: 141 additions & 17 deletions

File tree

py/plugins/google-cloud/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ classifiers = [
4444
]
4545
dependencies = [
4646
"genkit",
47+
"google-cloud-firestore>=2.20.0",
4748
"google-cloud-logging>=3.10.0",
4849
"opentelemetry-exporter-gcp-trace>=1.9.0",
4950
"opentelemetry-exporter-gcp-monitoring>=1.9.0",

py/plugins/google-cloud/src/genkit/plugins/google_cloud/session_store/firestore.py

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,16 @@
1717
from __future__ import annotations
1818

1919
import asyncio
20+
import inspect
2021
import json
2122
from collections.abc import Callable
2223
from datetime import datetime, timezone
23-
from typing import Any, Generic, TypeVar
24+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
25+
26+
from google.cloud import firestore
27+
28+
if TYPE_CHECKING:
29+
from google.cloud.firestore import AsyncClient, Client
2430

2531
from genkit._ai._agents._session import SessionStore, SnapshotSubscriber
2632
from genkit._ai._agents._session_stores import (
@@ -58,23 +64,37 @@ class FirestoreSessionStore(SessionStore[StateT], SnapshotSubscriber, Generic[St
5864
def __init__(
5965
self,
6066
*,
61-
client: Any | None = None, # noqa: ANN401 — google.cloud.firestore.Client
67+
client: AsyncClient | Client | None = None,
6268
collection: str = DEFAULT_COLLECTION,
6369
snapshot_path_prefix: Callable[[], str] | None = None,
6470
reject_ambiguous_session: bool = False,
6571
) -> None:
6672
"""Initialize the Firestore session store."""
6773
if client is None:
68-
from google.cloud import firestore # noqa: PLC0415
69-
70-
client = firestore.Client()
74+
client = firestore.AsyncClient()
7175
self._client = client
7276
self._collection = collection
7377
self._prefix_fn = snapshot_path_prefix or (lambda: DEFAULT_PREFIX)
7478
self._reject_ambiguous = reject_ambiguous_session
7579
self._lock = asyncio.Lock()
7680
self._subs: Subs = {}
7781

82+
def _is_async_client(self) -> bool:
83+
"""True if the client is AsyncClient or returns coroutines (`doc.get()`)."""
84+
if hasattr(self._client, '__dict__') and '_is_async' in self._client.__dict__:
85+
return bool(getattr(self._client, '_is_async', False))
86+
cls = type(self._client)
87+
cls_name = cls.__name__
88+
cls_module = cls.__module__
89+
if 'async_client' in cls_module or 'AsyncClient' in cls_name or 'AsyncMock' in cls_name:
90+
return True
91+
if cls_name == 'MagicMock' or cls_name == 'Client' or 'firestore_v1.client' in cls_module:
92+
return False
93+
try:
94+
return inspect.isawaitable(self._client.collection('a').document('b').get())
95+
except Exception:
96+
return False
97+
7898
def _prefix(self) -> str:
7999
return self._prefix_fn()
80100

@@ -99,26 +119,24 @@ async def get_snapshot(
99119
"""Retrieve a session snapshot by snapshot ID or session ID."""
100120
_require_one_selector(snapshot_id, session_id)
101121
if snapshot_id is not None:
102-
snap = await asyncio.to_thread(self._read_snapshot_sync, snapshot_id)
122+
snap = await self._read_snapshot(snapshot_id)
103123
return snap.model_copy(deep=True) if snap is not None else None
104124

105125
assert session_id is not None
106-
pointer = await asyncio.to_thread(self._read_pointer_sync, session_id)
126+
pointer = await self._read_pointer(session_id)
107127
if pointer is not None:
108-
snap = await asyncio.to_thread(self._read_snapshot_sync, pointer['currentSnapshotId'])
128+
snap = await self._read_snapshot(pointer['currentSnapshotId'])
109129
return snap.model_copy(deep=True) if snap is not None else None
110130

111131
# Fallback: scan snapshots for this session (dev/small deployments).
112-
owned = await asyncio.to_thread(self._list_session_snapshots_sync, session_id)
132+
owned = await self._list_session_snapshots(session_id)
113133
leaf = _select_leaf(owned, session_id, reject_ambiguous=self._reject_ambiguous)
114134
return leaf.model_copy(deep=True) if leaf is not None else None
115135

116136
async def save_snapshot(self, snapshot_id: str | None, fn: SaveFn) -> SessionSnapshot | None:
117137
"""Save or update a session snapshot in Firestore."""
118138
async with self._lock:
119-
existing = (
120-
await asyncio.to_thread(self._read_snapshot_sync, snapshot_id) if snapshot_id is not None else None
121-
)
139+
existing = await self._read_snapshot(snapshot_id) if snapshot_id is not None else None
122140
next_snapshot = _apply_save(existing, snapshot_id, fn)
123141
if next_snapshot is None:
124142
return None
@@ -131,9 +149,8 @@ async def save_snapshot(self, snapshot_id: str | None, fn: SaveFn) -> SessionSna
131149
message="FirestoreSessionStore requires 'sessionId' on the snapshot.",
132150
)
133151

134-
await asyncio.to_thread(self._write_snapshot_sync, next_snapshot)
135-
await asyncio.to_thread(
136-
self._maybe_update_pointer_sync,
152+
await self._write_snapshot(next_snapshot)
153+
await self._maybe_update_pointer(
137154
session_id,
138155
sid,
139156
is_new=existing is None,
@@ -144,12 +161,67 @@ async def save_snapshot(self, snapshot_id: str | None, fn: SaveFn) -> SessionSna
144161
async def on_snapshot_status_change(self, snapshot_id: str) -> asyncio.Queue[SnapshotStatus | None]:
145162
"""Subscribe to status changes for a session snapshot."""
146163
async with self._lock:
147-
current = await asyncio.to_thread(self._read_snapshot_sync, snapshot_id)
164+
current = await self._read_snapshot(snapshot_id)
148165
q = await _subscribe(self._subs, snapshot_id, current)
149166
# Firestore listener keeps cross-instance abort working for detached turns.
150167
self._start_listener(snapshot_id, q)
151168
return q
152169

170+
async def _read_snapshot(self, snapshot_id: str) -> SessionSnapshot | None:
171+
if self._is_async_client():
172+
doc = await self._snapshot_ref(snapshot_id).get()
173+
if not doc.exists:
174+
return None
175+
return SessionSnapshot.model_validate(doc.to_dict())
176+
return await asyncio.to_thread(self._read_snapshot_sync, snapshot_id)
177+
178+
async def _write_snapshot(self, snapshot: SessionSnapshot) -> None:
179+
if self._is_async_client():
180+
assert snapshot.snapshot_id is not None
181+
await self._snapshot_ref(snapshot.snapshot_id).set(
182+
_sanitize(snapshot.model_dump(by_alias=True, exclude_none=True))
183+
)
184+
else:
185+
await asyncio.to_thread(self._write_snapshot_sync, snapshot)
186+
187+
async def _read_pointer(self, session_id: str) -> dict[str, str] | None:
188+
if self._is_async_client():
189+
doc = await self._pointer_ref(session_id).get()
190+
if not doc.exists:
191+
return None
192+
data = doc.to_dict() or {}
193+
current = data.get('currentSnapshotId')
194+
return {'currentSnapshotId': current} if current else None
195+
return await asyncio.to_thread(self._read_pointer_sync, session_id)
196+
197+
async def _maybe_update_pointer(self, session_id: str, snapshot_id: str, *, is_new: bool) -> None:
198+
if self._is_async_client():
199+
ref = self._pointer_ref(session_id)
200+
existing = await ref.get()
201+
pointer = existing.to_dict() if existing.exists else None
202+
if is_new or not pointer or pointer.get('currentSnapshotId') == snapshot_id:
203+
await ref.set(
204+
_sanitize({
205+
'currentSnapshotId': snapshot_id,
206+
'updatedAt': datetime.now(timezone.utc).isoformat(),
207+
})
208+
)
209+
else:
210+
await asyncio.to_thread(self._maybe_update_pointer_sync, session_id, snapshot_id, is_new=is_new)
211+
212+
async def _list_session_snapshots(self, session_id: str) -> list[SessionSnapshot]:
213+
if self._is_async_client():
214+
snaps: list[SessionSnapshot] = []
215+
async for doc in self._snapshots_col().stream():
216+
try:
217+
snap = SessionSnapshot.model_validate(doc.to_dict())
218+
except (ValueError, TypeError):
219+
continue
220+
if _session_id_of(snap) == session_id:
221+
snaps.append(snap)
222+
return snaps
223+
return await asyncio.to_thread(self._list_session_snapshots_sync, session_id)
224+
153225
def _read_snapshot_sync(self, snapshot_id: str) -> SessionSnapshot | None:
154226
doc = self._snapshot_ref(snapshot_id).get()
155227
if not doc.exists:

py/plugins/google-cloud/tests/firestore_session_store_test.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33

44
"""Tests for FirestoreSessionStore."""
55

6-
from unittest.mock import MagicMock
6+
from unittest.mock import AsyncMock, MagicMock
77

88
import pytest
9+
910
from genkit._core._typing import SessionSnapshot
1011
from genkit.plugins.google_cloud.session_store.firestore import FirestoreSessionStore
1112

@@ -57,3 +58,53 @@ def save_fn(existing: SessionSnapshot | None) -> SessionSnapshot:
5758
assert saved is not None
5859
assert saved.session_id == 'sess-456'
5960
assert isinstance(saved.snapshot_id, str)
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_firestore_session_store_save_and_get_async() -> None:
65+
"""Test saving and getting a session snapshot using an AsyncMock Firestore client."""
66+
mock_client = MagicMock()
67+
mock_client._is_async = True
68+
mock_doc_snapshot = MagicMock()
69+
70+
mock_doc_ref = MagicMock()
71+
mock_doc_ref.get = AsyncMock(return_value=mock_doc_snapshot)
72+
mock_doc_ref.set = AsyncMock()
73+
74+
mock_col = MagicMock()
75+
mock_col.document.return_value = mock_doc_ref
76+
77+
mock_doc_ref.collection.return_value = mock_col
78+
mock_client.collection.return_value = mock_col
79+
80+
store = FirestoreSessionStore(client=mock_client)
81+
assert store._is_async_client() is True
82+
83+
snapshot_data = {
84+
'snapshotId': 'snap-async-123',
85+
'sessionId': 'sess-async-456',
86+
'createdAt': '2026-07-03T00:00:00Z',
87+
}
88+
mock_doc_snapshot.exists = True
89+
mock_doc_snapshot.to_dict.return_value = snapshot_data
90+
91+
retrieved = await store.get_snapshot(snapshot_id='snap-async-123')
92+
assert retrieved is not None
93+
assert retrieved.snapshot_id == 'snap-async-123'
94+
assert retrieved.session_id == 'sess-async-456'
95+
96+
mock_doc_snapshot.exists = False
97+
mock_doc_snapshot.to_dict.return_value = None
98+
99+
def save_fn(existing: SessionSnapshot | None) -> SessionSnapshot:
100+
return SessionSnapshot(
101+
snapshot_id='snap-async-789',
102+
session_id='sess-async-456',
103+
created_at='2026-07-03T00:00:01Z',
104+
)
105+
106+
saved = await store.save_snapshot(None, save_fn)
107+
assert saved is not None
108+
assert saved.session_id == 'sess-async-456'
109+
assert isinstance(saved.snapshot_id, str)
110+
mock_doc_ref.set.assert_called()

0 commit comments

Comments
 (0)