From 969566965931a1572a95b25f22a747864dd02594 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:30:20 +0530 Subject: [PATCH] fix(sessions): migrate v0 event timestamps as local, not UTC The pickle-to-JSON migration read each v0 event's naive `timestamp` column and forced it to UTC via `timestamp.replace(tzinfo=timezone.utc).timestamp()`. But the v0 schema persisted that column as a naive datetime in local time (`StorageEvent.from_event` uses `datetime.fromtimestamp(event.timestamp)`) and read it back as local (`StorageEvent.to_event` uses a naive `.timestamp()`). Reinterpreting the naive value as UTC shifted every migrated event timestamp by the host's UTC offset on any non-UTC host (for example +19800s under Asia/Kolkata), corrupting event timestamps and ordering in the destination database. Interpret the naive value as local time to match how v0 both wrote and read it. `datetime.timestamp()` treats naive datetimes as local and still honors tzinfo when a driver returns a timezone-aware value. Add a regression test that pins a fixed non-UTC zone and asserts the migrated epoch round-trips exactly. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- .../migrate_from_sqlalchemy_pickle.py | 7 +++- .../sessions/migration/test_migration.py | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/google/adk/sessions/migration/migrate_from_sqlalchemy_pickle.py b/src/google/adk/sessions/migration/migrate_from_sqlalchemy_pickle.py index 65a78c94012..d88c2460563 100644 --- a/src/google/adk/sessions/migration/migrate_from_sqlalchemy_pickle.py +++ b/src/google/adk/sessions/migration/migrate_from_sqlalchemy_pickle.py @@ -220,7 +220,12 @@ def _safe_json_load(val: Any) -> dict[str, Any] | None: author=row.get("author", "agent"), branch=row.get("branch"), actions=actions, - timestamp=timestamp.replace(tzinfo=timezone.utc).timestamp(), + # v0 wrote this column as a naive datetime in local time (via + # datetime.fromtimestamp) and read it back the same way, so interpret a + # naive value as local time here too. Forcing UTC would shift every + # migrated timestamp by the host's UTC offset. datetime.timestamp() + # treats naive datetimes as local and honors tzinfo when present. + timestamp=timestamp.timestamp(), long_running_tool_ids=long_running_tool_ids, partial=row.get("partial"), turn_complete=row.get("turn_complete"), diff --git a/tests/unittests/sessions/migration/test_migration.py b/tests/unittests/sessions/migration/test_migration.py index 9f54a2e0ace..45250f337b9 100644 --- a/tests/unittests/sessions/migration/test_migration.py +++ b/tests/unittests/sessions/migration/test_migration.py @@ -19,6 +19,7 @@ from datetime import timezone import os import pickle +import time from fastapi.openapi.models import HTTPBearer from google.adk.auth.auth_tool import AuthConfig @@ -367,6 +368,38 @@ def test_migrate_from_sqlalchemy_pickle_ignores_non_object_json_fields(): assert event.content is None +def test_migrate_from_sqlalchemy_pickle_reads_naive_timestamp_as_local( + monkeypatch, +): + """Naive v0 event timestamps must migrate as local time, not UTC. + + The v0 schema stored the event ``timestamp`` column as a naive datetime in + local time (``StorageEvent.from_event`` uses ``datetime.fromtimestamp`` and + ``to_event`` reads it back with naive ``.timestamp()``). Forcing UTC on that + naive value shifted every migrated timestamp by the host's UTC offset. Pin a + fixed non-UTC zone so the round trip is exact regardless of the host. + """ + monkeypatch.setenv("TZ", "Asia/Kolkata") + time.tzset() + try: + original_epoch = 1000000.0 + # Exactly what v0.StorageEvent.from_event persisted: naive local time. + naive_local_timestamp = datetime.fromtimestamp(original_epoch) + assert naive_local_timestamp.tzinfo is None + + event = mfsp._row_to_event({ + "id": "event-naive-timestamp", + "invocation_id": "invoke1", + "author": "user", + "actions": EventActions(), + "timestamp": naive_local_timestamp, + }) + + assert event.timestamp == original_epoch + finally: + time.tzset() + + def test_migrate_from_sqlalchemy_pickle_blocks_unsafe_actions_pickle( tmp_path, monkeypatch ):