Skip to content

Commit 93c4542

Browse files
committed
feat(sessions): add merge_state for race-free server-side atomic state merges
Add a state-only write path, merge_state(app_name, user_id, session_id, delta), that performs a server-side atomic JSON merge of the routed delta into the app/user/session state row with no events row and without advancing the session optimistic-concurrency (OCC) marker. Today the only way to persist state is append_event, which couples a commutative state merge to event-log append + whole-session OCC. Two workers updating independent state keys on the same session therefore race on the session-level marker, and one spuriously fails with "modified in storage" and must retry, even though the keys never collide. merge_state routes the delta via the existing app:/user:/unprefixed convention (temp: rejected) and merges each scope server-side: - PostgreSQL: state || CAST(:delta AS JSONB) - MySQL/MariaDB: JSON_MERGE_PATCH(state, CAST(:delta AS JSON)) - SQLite: json_patch(state, :delta) The session-row merge is issued via sqlalchemy.text touching only the state column, so update_time's onupdate never fires and the OCC marker is preserved. App/user rows are auto-created via the existing _get_or_create_state; a missing session row raises for session-scoped keys. BaseSessionService and VertexAiSessionService raise NotImplementedError, mirroring get_user_state; InMemorySessionService and SqliteSessionService implement it natively. Adds parameterized tests across all backends plus DatabaseSessionService tests proving the OCC marker is not bumped, no events row is written, and concurrent merges to independent keys do not lose updates.
1 parent 50c81eb commit 93c4542

7 files changed

Lines changed: 702 additions & 0 deletions

File tree

src/google/adk/sessions/base_session_service.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,67 @@ async def get_user_state(
151151
'call get_session on each result to access the merged state.'
152152
)
153153

154+
async def merge_state(
155+
self,
156+
*,
157+
app_name: str,
158+
user_id: str,
159+
session_id: str,
160+
delta: dict[str, Any],
161+
) -> None:
162+
"""Atomically merges a state delta without appending an event.
163+
164+
This is a state-only write path that bypasses the event log and the
165+
whole-session optimistic-concurrency (OCC) check that ``append_event``
166+
performs. It is intended for *commutative* updates to independent state
167+
keys (counters, flags, per-user balances, feature toggles), where coupling
168+
the write to event-log append + session-level OCC would force unrelated
169+
writers to serialize and retry on spurious "stale session" errors.
170+
171+
The ``delta`` uses the same prefix convention as ``create_session`` and
172+
event ``state_delta``; keys are routed to the scope-appropriate storage
173+
row:
174+
175+
* ``app:`` -> app-scoped state (keyed by ``app_name``).
176+
* ``user:`` -> user-scoped state (keyed by ``(app_name, user_id)``).
177+
* no prefix -> session-scoped state (keyed by
178+
``(app_name, user_id, session_id)``).
179+
180+
``temp:`` keys are rejected with ``ValueError`` because temp state is never
181+
persisted.
182+
183+
Guarantees (for backends that implement this method):
184+
185+
* No ``events`` row is written.
186+
* The session's OCC revision marker is **not** advanced, so a concurrently
187+
held in-memory ``Session`` does not become stale and its next
188+
``append_event`` still succeeds.
189+
* App- and user-scoped merges do not require a pre-existing session and are
190+
fully decoupled from any session's revision. ``session_id`` is used only
191+
to route session-scoped keys.
192+
193+
Merge semantics for non-scalar values are backend-dependent; see the
194+
concrete implementations (notably ``DatabaseSessionService.merge_state``)
195+
for the dialect-specific caveats around nested objects and ``None`` values.
196+
For flat scalar values all backends behave identically.
197+
198+
Args:
199+
app_name: The name of the app.
200+
user_id: The ID of the user.
201+
session_id: The ID of the session. Required for routing session-scoped
202+
keys; need not exist when the delta has no session-scoped keys.
203+
delta: The state delta to merge. An empty or ``None`` delta is a no-op.
204+
205+
Raises:
206+
ValueError: If ``delta`` contains ``temp:``-prefixed keys, or if a
207+
session-scoped key is supplied for a session that does not exist.
208+
NotImplementedError: When the concrete ``BaseSessionService``
209+
implementation does not support a server-side state merge.
210+
"""
211+
raise NotImplementedError(
212+
f'{type(self).__name__} does not support merge_state.'
213+
)
214+
154215
async def append_event(self, session: Session, event: Event) -> Event:
155216
"""Appends an event to a session object."""
156217
if event.partial:

src/google/adk/sessions/database_session_service.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import copy
1919
from datetime import datetime
2020
from datetime import timezone
21+
import json
2122
import logging
2223
from typing import Any
2324
from typing import AsyncIterator
@@ -34,6 +35,7 @@
3435
from sqlalchemy import event
3536
from sqlalchemy import MetaData
3637
from sqlalchemy import select
38+
from sqlalchemy import text
3739
from sqlalchemy.engine import Connection
3840
from sqlalchemy.engine import make_url
3941
from sqlalchemy.exc import ArgumentError
@@ -187,6 +189,30 @@ def _merge_state(
187189
return merged_state
188190

189191

192+
def _json_merge_set_clause(dialect_name: str) -> Optional[str]:
193+
"""Returns a 'state = <merge>' SET clause for an atomic server-side JSON merge.
194+
195+
The returned SQL fragment merges a ``:delta`` bind parameter into the
196+
existing ``state`` column in place. Only the ``state`` column is referenced,
197+
so when issued via ``sqlalchemy.text`` no column ``onupdate`` callable (such
198+
as the ``update_time`` revision marker) is triggered.
199+
200+
Returns None for dialects that do not provide a known server-side JSON merge
201+
function; the caller raises ``NotImplementedError`` in that case.
202+
"""
203+
if dialect_name == _POSTGRESQL_DIALECT:
204+
# JSONB concatenation: shallow top-level merge; an explicit JSON null is
205+
# stored (key kept), matching append_event's Python dict union.
206+
return "state = state || CAST(:delta AS JSONB)"
207+
if dialect_name in (_MYSQL_DIALECT, _MARIADB_DIALECT):
208+
# RFC 7396: recursive merge; a JSON null deletes the key.
209+
return "state = JSON_MERGE_PATCH(state, CAST(:delta AS JSON))"
210+
if dialect_name == _SQLITE_DIALECT:
211+
# RFC 7396 semantics, like the native SqliteSessionService.
212+
return "state = json_patch(state, :delta)"
213+
return None
214+
215+
190216
class _SchemaClasses:
191217
"""A helper class to hold schema classes based on version."""
192218

@@ -737,6 +763,152 @@ async def get_user_state(
737763
return {}
738764
return dict(storage_user_state.state or {})
739765

766+
@override
767+
async def merge_state(
768+
self,
769+
*,
770+
app_name: str,
771+
user_id: str,
772+
session_id: str,
773+
delta: dict[str, Any],
774+
) -> None:
775+
"""Atomically merges a state delta server-side without appending an event.
776+
777+
Each scoped sub-delta is merged into its storage row with a single atomic
778+
SQL statement (PostgreSQL ``state || delta``, MySQL/MariaDB
779+
``JSON_MERGE_PATCH``, SQLite ``json_patch``) issued via ``text`` so that
780+
only the ``state`` column is written. As a result:
781+
782+
* No ``events`` row is created.
783+
* ``sessions.update_time`` (the optimistic-concurrency revision marker) is
784+
never advanced, so a concurrently held ``Session`` does not go stale.
785+
* Independent keys merged concurrently do not lose updates, because the
786+
merge happens inside the database rather than via Python
787+
read-modify-write.
788+
789+
Merge semantics for non-scalar values differ by dialect. On PostgreSQL the
790+
top-level keys are merged shallowly and an explicit ``None`` is stored as
791+
JSON ``null`` (the key is kept), matching ``append_event``'s Python ``dict``
792+
union. On MySQL, MariaDB and SQLite the merge follows RFC 7396: nested
793+
objects are merged recursively and a ``None`` value deletes the key. For
794+
flat scalar values (counters, flags, balances -- the intended use case) all
795+
dialects behave identically. Use ``append_event`` if you need uniform
796+
Python-``dict``-union semantics for nested objects or ``None`` overwrites.
797+
798+
Args:
799+
app_name: The name of the app.
800+
user_id: The ID of the user.
801+
session_id: The ID of the session. Used to route session-scoped (no
802+
prefix) keys; need not exist when ``delta`` has no session-scoped keys.
803+
delta: The state delta to merge, using the ``app:``/``user:``/no-prefix
804+
convention. An empty or ``None`` delta is a no-op.
805+
806+
Raises:
807+
ValueError: If ``delta`` contains ``temp:`` keys, or if a session-scoped
808+
key targets a session that does not exist.
809+
NotImplementedError: If the active SQL dialect provides no known
810+
server-side JSON merge function.
811+
"""
812+
await self.prepare_tables()
813+
if not delta:
814+
return
815+
if any(key.startswith(State.TEMP_PREFIX) for key in delta):
816+
raise ValueError(
817+
"merge_state does not support temp: keys; temp state is never"
818+
" persisted."
819+
)
820+
821+
merge_clause = _json_merge_set_clause(self.db_engine.dialect.name)
822+
if merge_clause is None:
823+
raise ValueError(
824+
"merge_state is not supported for dialect"
825+
f" {self.db_engine.dialect.name!r}: no server-side JSON merge"
826+
" function is available."
827+
)
828+
829+
state_deltas = _session_util.extract_state_delta(delta)
830+
app_delta = state_deltas["app"]
831+
user_delta = state_deltas["user"]
832+
session_delta = state_deltas["session"]
833+
834+
schema = self._get_schema_classes()
835+
app_table = schema.StorageAppState.__tablename__
836+
user_table = schema.StorageUserState.__tablename__
837+
session_table = schema.StorageSession.__tablename__
838+
use_row_level_locking = self._supports_row_level_locking()
839+
840+
async with self._with_session_lock(
841+
app_name=app_name,
842+
user_id=user_id,
843+
session_id=session_id,
844+
):
845+
async with self._rollback_on_exception_session() as sql_session:
846+
if session_delta:
847+
# A session-scoped key requires the session row to exist; never
848+
# auto-create it (that is create_session's responsibility).
849+
session_exists_stmt = (
850+
select(schema.StorageSession.id)
851+
.filter(schema.StorageSession.app_name == app_name)
852+
.filter(schema.StorageSession.user_id == user_id)
853+
.filter(schema.StorageSession.id == session_id)
854+
)
855+
if use_row_level_locking:
856+
session_exists_stmt = session_exists_stmt.with_for_update()
857+
session_exists = await sql_session.execute(session_exists_stmt)
858+
if session_exists.scalar_one_or_none() is None:
859+
raise ValueError(f"Session {session_id} not found.")
860+
861+
if app_delta:
862+
# Ensure the row exists (handles concurrent inserts), then merge
863+
# server-side. App/user rows have their own update_time which is not
864+
# an OCC marker, so merging here is independent of any session.
865+
await _get_or_create_state(
866+
sql_session=sql_session,
867+
state_model=schema.StorageAppState,
868+
primary_key=app_name,
869+
defaults={"app_name": app_name, "state": {}},
870+
)
871+
await sql_session.execute(
872+
text(
873+
f"UPDATE {app_table} SET {merge_clause} WHERE"
874+
" app_name = :app_name"
875+
),
876+
{"app_name": app_name, "delta": json.dumps(app_delta)},
877+
)
878+
if user_delta:
879+
await _get_or_create_state(
880+
sql_session=sql_session,
881+
state_model=schema.StorageUserState,
882+
primary_key=(app_name, user_id),
883+
defaults={"app_name": app_name, "user_id": user_id, "state": {}},
884+
)
885+
await sql_session.execute(
886+
text(
887+
f"UPDATE {user_table} SET {merge_clause} WHERE"
888+
" app_name = :app_name AND user_id = :user_id"
889+
),
890+
{
891+
"app_name": app_name,
892+
"user_id": user_id,
893+
"delta": json.dumps(user_delta),
894+
},
895+
)
896+
if session_delta:
897+
await sql_session.execute(
898+
text(
899+
f"UPDATE {session_table} SET {merge_clause} WHERE"
900+
" app_name = :app_name AND user_id = :user_id"
901+
" AND id = :session_id"
902+
),
903+
{
904+
"app_name": app_name,
905+
"user_id": user_id,
906+
"session_id": session_id,
907+
"delta": json.dumps(session_delta),
908+
},
909+
)
910+
await sql_session.commit()
911+
740912
@override
741913
async def append_event(self, session: Session, event: Event) -> Event:
742914
await self.prepare_tables()

src/google/adk/sessions/in_memory_session_service.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,46 @@ async def get_user_state(
318318
) -> dict[str, Any]:
319319
return dict(self.user_state.get(app_name, {}).get(user_id, {}))
320320

321+
@override
322+
async def merge_state(
323+
self,
324+
*,
325+
app_name: str,
326+
user_id: str,
327+
session_id: str,
328+
delta: dict[str, Any],
329+
) -> None:
330+
if not delta:
331+
return
332+
if any(key.startswith(State.TEMP_PREFIX) for key in delta):
333+
raise ValueError(
334+
'merge_state does not support temp: keys; temp state is never'
335+
' persisted.'
336+
)
337+
338+
state_deltas = _session_util.extract_state_delta(delta)
339+
app_state_delta = state_deltas['app']
340+
user_state_delta = state_deltas['user']
341+
session_state_delta = state_deltas['session']
342+
343+
if session_state_delta:
344+
storage_session = (
345+
self.sessions.get(app_name, {}).get(user_id, {}).get(session_id)
346+
)
347+
if storage_session is None:
348+
raise ValueError(f'Session {session_id} not found.')
349+
350+
if app_state_delta:
351+
self.app_state.setdefault(app_name, {}).update(app_state_delta)
352+
if user_state_delta:
353+
self.user_state.setdefault(app_name, {}).setdefault(user_id, {}).update(
354+
user_state_delta
355+
)
356+
if session_state_delta:
357+
# Merge into the stored session's state without bumping
358+
# last_update_time, so a concurrently held session does not go stale.
359+
storage_session.state.update(session_state_delta)
360+
321361
@override
322362
async def append_event(self, session: Session, event: Event) -> Event:
323363
if event.partial:

0 commit comments

Comments
 (0)