From 00da0bb31907a5344e28d57efbb3fa00e57fffc1 Mon Sep 17 00:00:00 2001 From: abhiramArise Date: Fri, 10 Jul 2026 02:47:42 +0530 Subject: [PATCH 1/4] fix(sessions): add process_result_value to PreciseTimestamp for SQLite float timestamps Fixes #6352. SQLite stores DateTime columns as REAL (Unix epoch float). Without a result-value converter, reading them back raised TypeError: fromisoformat: argument must be str. This adds the missing converter, matching the naive local-time convention already used on the write side in StorageEvent.from_event. --- src/google/adk/sessions/schemas/shared.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/google/adk/sessions/schemas/shared.py b/src/google/adk/sessions/schemas/shared.py index 25d4ea9e958..6e53de8d82e 100644 --- a/src/google/adk/sessions/schemas/shared.py +++ b/src/google/adk/sessions/schemas/shared.py @@ -13,6 +13,7 @@ # limitations under the License. from __future__ import annotations +from datetime import datetime import json from sqlalchemy import Dialect @@ -65,3 +66,10 @@ def load_dialect_impl(self, dialect): if dialect.name == "mysql": return dialect.type_descriptor(mysql.DATETIME(fsp=6)) return self.impl + + def process_result_value(self, value, dialect: Dialect): + if value is None: + return None + if isinstance(value, (int, float)): + return datetime.fromtimestamp(value) + return value From ae669b77ab18321bcf7c5c75b6069185261f1490 Mon Sep 17 00:00:00 2001 From: abhiramArise Date: Fri, 10 Jul 2026 12:07:50 +0530 Subject: [PATCH 2/4] fix(sessions): override result_processor instead of process_result_value on PreciseTimestamp TypeDecorator.process_result_value runs after the underlying impl's own result processor, which already fails when it receives a raw float before process_result_value can intercept it. Overriding result_processor directly runs before the impl's processor, allowing floats to be handled explicitly while delegating to the impl's own processor otherwise. Also adds a regression test that forces a raw REAL-affinity float into the events table via raw SQL to reproduce the original failure, since values written through the normal ORM path are already serialized as text and do not trigger the bug. Thanks to @surajksharma07 for catching the process_result_value issue in review on #6352. --- src/google/adk/sessions/schemas/shared.py | 19 ++++-- .../sessions/test_session_service.py | 61 +++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/google/adk/sessions/schemas/shared.py b/src/google/adk/sessions/schemas/shared.py index 6e53de8d82e..d1a8dc0c281 100644 --- a/src/google/adk/sessions/schemas/shared.py +++ b/src/google/adk/sessions/schemas/shared.py @@ -67,9 +67,16 @@ def load_dialect_impl(self, dialect): return dialect.type_descriptor(mysql.DATETIME(fsp=6)) return self.impl - def process_result_value(self, value, dialect: Dialect): - if value is None: - return None - if isinstance(value, (int, float)): - return datetime.fromtimestamp(value) - return value + def result_processor(self, dialect, coltype): + impl_processor = self.impl.result_processor(dialect, coltype) + + def process(value): + if value is None: + return None + if isinstance(value, (int, float)): + return datetime.fromtimestamp(value) + if impl_processor: + return impl_processor(value) + return value + + return process diff --git a/tests/unittests/sessions/test_session_service.py b/tests/unittests/sessions/test_session_service.py index 157e4fb21aa..ddbfbedd617 100644 --- a/tests/unittests/sessions/test_session_service.py +++ b/tests/unittests/sessions/test_session_service.py @@ -18,6 +18,7 @@ from datetime import timezone import enum import sqlite3 +import time from unittest import mock from google.adk.errors.already_exists_error import AlreadyExistsError @@ -2052,3 +2053,63 @@ async def test_database_session_service_requires_one_argument(): DatabaseSessionService( db_url='sqlite+aiosqlite:///:memory:', db_engine=engine ) + + +@pytest.mark.asyncio +async def test_database_session_service_sqlite_file_timestamp_read_after_reopen( + tmp_path, +): + """Regression test for #6352. + + SQLite REAL-affinity columns can end up storing raw Unix epoch floats + instead of the text format SQLAlchemy's DateTime type normally writes + (for example, if the row was written by a different code path than the + SQLAlchemy ORM). Reading such a row back must not raise TypeError, since + TypeDecorator.process_result_value runs after the impl's own result + processor, which chokes on a float before process_result_value can run. + This test forces that condition directly via raw SQL. + """ + db_path = tmp_path / 'timestamp_regression.db' + db_url = f'sqlite+aiosqlite:///{db_path}' + app_name = 'my_app' + user_id = 'user' + + service = DatabaseSessionService(db_url) + try: + session = await service.create_session( + app_name=app_name, user_id=user_id + ) + event = Event(author='user', timestamp=time.time()) + await service.append_event(session, event) + finally: + await service.close() + + # Directly overwrite the stored timestamp with a raw float via raw SQL, + # simulating a REAL-affinity column value that bypassed SQLAlchemy's + # normal text-based DateTime serialization. + raw_epoch_float = time.time() + conn = sqlite3.connect(str(db_path)) + try: + conn.execute( + 'UPDATE events SET timestamp = ? WHERE session_id = ?', + (raw_epoch_float, session.id), + ) + conn.commit() + finally: + conn.close() + + # Read it back with a fresh service instance; this must not raise + # TypeError: fromisoformat: argument must be str. + service2 = DatabaseSessionService(db_url) + try: + retrieved_session = await service2.get_session( + app_name=app_name, user_id=user_id, session_id=session.id + ) + finally: + await service2.close() + + assert retrieved_session is not None + assert len(retrieved_session.events) == 1 + assert retrieved_session.events[0].timestamp == pytest.approx( + raw_epoch_float, abs=1.0 + ) From 771442116c8571e20813e492c6a095cdd81690f3 Mon Sep 17 00:00:00 2001 From: abhiramArise Date: Fri, 10 Jul 2026 12:30:32 +0530 Subject: [PATCH 3/4] style: apply pyink formatting to regression test --- tests/unittests/sessions/test_session_service.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unittests/sessions/test_session_service.py b/tests/unittests/sessions/test_session_service.py index ddbfbedd617..b75004854b0 100644 --- a/tests/unittests/sessions/test_session_service.py +++ b/tests/unittests/sessions/test_session_service.py @@ -2076,9 +2076,7 @@ async def test_database_session_service_sqlite_file_timestamp_read_after_reopen( service = DatabaseSessionService(db_url) try: - session = await service.create_session( - app_name=app_name, user_id=user_id - ) + session = await service.create_session(app_name=app_name, user_id=user_id) event = Event(author='user', timestamp=time.time()) await service.append_event(session, event) finally: From 85b87904f6fda78f935d91be9e0c773d2b719fbe Mon Sep 17 00:00:00 2001 From: abhiramArise Date: Sat, 11 Jul 2026 23:41:36 +0530 Subject: [PATCH 4/4] fix(sessions): add type annotations to result_processor for mypy Adds type hints to PreciseTimestamp.result_processor and its nested process() function, and uses impl_instance instead of impl to satisfy mypy's expectation of an instance rather than a class reference when calling result_processor on the underlying type. Verified this introduces zero new mypy errors: the 11 remaining errors on this file are identical (same messages) on unmodified main, all in unrelated pre-existing methods this change doesn't touch. --- src/google/adk/sessions/schemas/shared.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/google/adk/sessions/schemas/shared.py b/src/google/adk/sessions/schemas/shared.py index d1a8dc0c281..8faccebe740 100644 --- a/src/google/adk/sessions/schemas/shared.py +++ b/src/google/adk/sessions/schemas/shared.py @@ -15,6 +15,8 @@ from datetime import datetime import json +from typing import Any +from typing import Callable from sqlalchemy import Dialect from sqlalchemy import Text @@ -67,10 +69,12 @@ def load_dialect_impl(self, dialect): return dialect.type_descriptor(mysql.DATETIME(fsp=6)) return self.impl - def result_processor(self, dialect, coltype): - impl_processor = self.impl.result_processor(dialect, coltype) + def result_processor( + self, dialect: Dialect, coltype: object + ) -> Callable[[Any], Any] | None: + impl_processor = self.impl_instance.result_processor(dialect, coltype) - def process(value): + def process(value: Any) -> Any: if value is None: return None if isinstance(value, (int, float)):