From 8bf60562f46f5e0b3ff5499a6463ca8a6731e00b Mon Sep 17 00:00:00 2001 From: Anmol Jaiswal Date: Sat, 29 Aug 2026 15:59:49 +0530 Subject: [PATCH] fix(sessions): microsecond-safe DatabaseSessionService on SQL Server (mssql) Two defects break DatabaseSessionService on SQL Server today, both hit on the SECOND append_event of a session (i.e. essentially every real conversation's first turn): 1. PreciseTimestamp falls through to the plain DateTime impl, which maps to SQL Server's legacy DATETIME (~3.33ms precision). The optimistic- concurrency update marker is microsecond-precision, so the value read back never equals the value written and the session's own next append is rejected as a stale write. Fix: map to DATETIME2(6), the SQL Server type with microsecond precision (mirrors the existing mysql DATETIME(fsp=6) branch). 2. mssql is missing from _NAIVE_DATETIME_DIALECTS, so create_session writes timezone-aware datetimes while DATETIME2 reads back naive; the marker comparison then renders '...+00:00' vs '...' for the same instant and raises the same false stale-write error. Fix: include mssql, so tzinfo is stripped before storage exactly as for sqlite/postgresql/mysql/ mariadb (Spanner stays excluded - its TIMESTAMP is timezone-aware). Both fixes have been running in production for two months as downstream monkeypatches on Azure SQL (mssql+aioodbc, ~10^5 events); this upstreams them. Added a unit test for the DATETIME2(6) dialect mapping. --- src/google/adk/sessions/database_session_service.py | 2 ++ src/google/adk/sessions/schemas/shared.py | 7 +++++++ tests/unittests/sessions/test_schemas_shared.py | 11 +++++++++++ 3 files changed, 20 insertions(+) diff --git a/src/google/adk/sessions/database_session_service.py b/src/google/adk/sessions/database_session_service.py index a8716d0c9b6..7b1ef27f4ed 100644 --- a/src/google/adk/sessions/database_session_service.py +++ b/src/google/adk/sessions/database_session_service.py @@ -83,6 +83,7 @@ _MARIADB_DIALECT = "mariadb" _MYSQL_DIALECT = "mysql" _POSTGRESQL_DIALECT = "postgresql" +_MSSQL_DIALECT = "mssql" # Dialects whose DATETIME/TIMESTAMP columns do not retain timezone info, so # timezone-aware datetimes must have their tzinfo stripped before storage. This # keeps the value written by create_session consistent with the value read back @@ -94,6 +95,7 @@ _POSTGRESQL_DIALECT, _MYSQL_DIALECT, _MARIADB_DIALECT, + _MSSQL_DIALECT, ) # Tuple key order for in-process per-session lock maps: # (app_name, user_id, session_id). diff --git a/src/google/adk/sessions/schemas/shared.py b/src/google/adk/sessions/schemas/shared.py index acebb369303..6c829aaf478 100644 --- a/src/google/adk/sessions/schemas/shared.py +++ b/src/google/adk/sessions/schemas/shared.py @@ -21,6 +21,7 @@ from sqlalchemy import Dialect from sqlalchemy import Text +from sqlalchemy.dialects import mssql from sqlalchemy.dialects import mysql from sqlalchemy.dialects import postgresql from sqlalchemy.types import DateTime @@ -94,6 +95,12 @@ class PreciseTimestamp(TypeDecorator[datetime.datetime]): # type: ignore[misc] def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]: if dialect.name == "mysql": return dialect.type_descriptor(mysql.DATETIME(fsp=6)) + if dialect.name == "mssql": + # SQL Server's legacy DATETIME has ~3.33ms precision, which destroys + # the microsecond update marker used by the optimistic-concurrency + # check (a session's second append is falsely rejected as stale). + # DATETIME2(6) retains microseconds. + return dialect.type_descriptor(mssql.DATETIME2(precision=6)) return self.impl_instance def result_processor( diff --git a/tests/unittests/sessions/test_schemas_shared.py b/tests/unittests/sessions/test_schemas_shared.py index 16b1d0111b5..c917e15613a 100644 --- a/tests/unittests/sessions/test_schemas_shared.py +++ b/tests/unittests/sessions/test_schemas_shared.py @@ -161,3 +161,14 @@ def test_precise_timestamp_result_processor_delegates_non_numeric_values( process = precise_timestamp.result_processor(_dialect("mysql"), None) assert process("2026-01-02 03:04:05.123456") == expected + + +def test_precise_timestamp_uses_datetime2_on_mssql(): + """SQL Server's legacy DATETIME rounds to ~3.33ms, which destroys the + microsecond update marker used by the optimistic-concurrency check.""" + from sqlalchemy.dialects import mssql as mssql_dialect + + ts = PreciseTimestamp() + impl = ts.load_dialect_impl(mssql_dialect.dialect()) + assert isinstance(impl, mssql_dialect.DATETIME2) + assert impl.precision == 6