Skip to content

fix(sessions): make SqliteSessionService state merges use dict.update() semantics - #6729

Open
chelsealong wants to merge 2 commits into
google:mainfrom
chelsealong:fix-6728-sqlite-state-merge-semantics
Open

fix(sessions): make SqliteSessionService state merges use dict.update() semantics#6729
chelsealong wants to merge 2 commits into
google:mainfrom
chelsealong:fix-6728-sqlite-state-merge-semantics

Conversation

@chelsealong

Copy link
Copy Markdown
Contributor

Fixes #6728

Problem

SqliteSessionService persists state deltas with SQLite's json_patch(),
which implements RFC 7396 JSON Merge Patch. Every other BaseSessionService
implementation (InMemorySessionService, DatabaseSessionService) applies
dict.update() semantics instead. Two consequences, both silent:

  1. A dict-valued delta is deep-merged into the stored value instead of
    replacing it, so keys written on earlier turns survive a full overwrite.
  2. A None-valued delta deletes the key instead of storing null.

The in-memory Session object is updated via dict.update() by
BaseSessionService._update_session_state, so a single service contradicts
itself: the live object and the row reloaded from SQLite disagree, and the
disagreement only surfaces after a reload or restart. Since
create_session_service_from_options returns the SQLite-backed local
service by default, adk run / adk web hit this out of the box.

Fix

Replace the three json_patch() call sites in sqlite_session_service.py
(_upsert_app_state, _upsert_user_state, _update_session_state_in_db)
with a query built on json_each / json_group_object that merges
delta keys over existing keys — a delta key always wins with its own
value (including SQL/JSON null), and existing keys not present in the
delta are kept as-is. This is a shallow, per-key replace, matching
dict.update(), while remaining a single atomic SQL statement (no
read-modify-write round trip) and using no JSON1 function newer than
json_patch itself.

Follow-up fix (boolean corruption): the first version of this merge
SQL special-cased type IN ('object','array') to re-wrap with
json(value), falling through to the raw value column otherwise.
json_each() reports JSON true/false as type='true'/'false' with
value already coerced to the bare SQL integer 1/0. Those fell into
the ELSE branch and were emitted as raw integers by
json_group_object, silently turning every stored boolean into an int
({"flag": false} -> {"flag": 0}) on the very next unrelated
state_delta applied to that row — including keys that were never part
of the delta, since existing keys go through the same reconstruction.
Fixed by also routing type IN ('true','false') through json(type)
(the type column is literally the string 'true'/'false', so
json(type) yields the JSON literal, not a quoted string).

Testing plan

Added three conformance tests to the shared, four-way parametrized
session_service fixture in tests/unittests/sessions/test_session_service.py
(IN_MEMORY, IN_MEMORY_WITH_LIGHT_COPY_ENABLED, DATABASE, SQLITE), so
SqliteSessionService is checked against the same contract as every other
backend:

  • test_dict_valued_state_delta_replaces_stored_value — a dict-valued
    delta replaces the stored dict rather than deep-merging into it.
  • test_none_valued_state_delta_is_stored_not_dropped — a None-valued
    delta is stored as null, not dropped.
  • test_boolean_state_survives_unrelated_state_delta — a stored boolean
    keeps its type (and value) across an unrelated state_delta.

Confirmed all three fail without their respective fix. For the first two,
reverted the source change with
git checkout HEAD~1 -- src/google/adk/sessions/sqlite_session_service.py
(pre-PR json_patch() code), reran, restored:

FAILED ...test_dict_valued_state_delta_replaces_stored_value[SessionServiceType.SQLITE]
  AssertionError: assert {'name': 'bob', 'role': 'admin'} == {'name': 'bob'}
FAILED ...test_none_valued_state_delta_is_stored_not_dropped[SessionServiceType.SQLITE]
  AssertionError: assert 'flag' in {}
2 failed, 6 passed

For the boolean-corruption test, reverted only the follow-up fix (checked
out this PR's first commit's version of the merge SQL, i.e. without the
type IN ('true','false') branch), reran, restored:

FAILED ...test_boolean_state_survives_unrelated_state_delta[SessionServiceType.SQLITE]
  AssertionError: assert 1 is True
   +  where 1 = {'new_flag': 1, 'flag': 0}.get('new_flag')
1 failed, 3 passed

With the fix applied:

$ pytest tests/unittests/sessions/test_session_service.py -q
180 passed, 2 warnings in 5.70s

$ pytest tests/unittests/sessions/ -q
327 passed, 6 warnings in 7.44s

Also ran formatting/lint tools used by this repo's pre-commit hooks
(all clean, no reformatting needed):

$ isort --settings-path pyproject.toml --check-only src/google/adk/sessions/sqlite_session_service.py tests/unittests/sessions/test_session_service.py
(no output — clean)

$ pyink --config pyproject.toml --diff src/google/adk/sessions/sqlite_session_service.py tests/unittests/sessions/test_session_service.py
All done! (2 files would be left unchanged)

$ ruff check src/google/adk/sessions/sqlite_session_service.py tests/unittests/sessions/test_session_service.py --config pyproject.toml
All checks passed!

Scope

Only sqlite_session_service.py (production fix) and
test_session_service.py (new tests) are touched. No dependency
manifests, CI/workflow files, or generated files were modified.

AI-assistance disclosure

This PR was prepared by an autonomous coding agent (Claude, via an
internal OSS-contribution pipeline), reviewed and pushed under this
account. The reproduction and root-cause analysis in the linked issue
were written by a different AI-assisted contributor and independently
re-verified here (ran the repro cases against the code before writing
the fix); the SQL fix, tests, and this PR are original to this run.

…() semantics

SqliteSessionService persisted state deltas with SQLite's json_patch()
(RFC 7396 JSON Merge Patch), which deep-merges dict values and drops keys
whose delta value is None. Every other BaseSessionService implementation
uses dict.update() semantics: a dict-valued delta replaces the stored
value outright, and a None-valued delta is stored as null. The mismatch
meant the in-memory Session object and the row reloaded from SQLite could
silently disagree after a restart.

Replace the three json_patch() call sites with a query that merges via
json_group_object, preferring delta keys and falling back to existing
keys not present in the delta - shallow replace-per-key, matching
dict.update().

Fixes google#6728
The json_group_object merge introduced for dict.update() semantics
special-cased 'object'/'array' JSON types to re-wrap with json(value),
but json_each() reports JSON true/false as type='true'/'false' with
value already coerced to the bare integer 1/0. Those fell through to
the ELSE branch and were emitted as raw SQL integers, silently turning
every stored boolean into an int on the next unrelated state_delta.
Route 'true'/'false' through json(type) as well, since type is
literally the string 'true' or 'false'.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants