Skip to content

Commit ba39e55

Browse files
committed
feat: add PostgreSQL TIMESTAMPTZ migration script
Adds a migration script that converts TIMESTAMP WITHOUT TIME ZONE columns to TIMESTAMP WITH TIME ZONE for existing PostgreSQL databases. Usage: python -m google.adk.sessions.migration.migrate_postgresql_timestamptz \ --db_url postgresql+asyncpg://user:pass@host:port/dbname The script checks each ADK table column, skips non-PostgreSQL databases and columns already using TIMESTAMPTZ, and migrates only what's needed.
1 parent 1c57f8f commit ba39e55

File tree

1 file changed

+150
-0
lines changed

1 file changed

+150
-0
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Migration script to convert TIMESTAMP to TIMESTAMPTZ for PostgreSQL.
15+
16+
Starting from ADK v1.24.0, DatabaseSessionService creates timezone-aware
17+
datetime objects (with tzinfo=UTC). When using PostgreSQL with asyncpg,
18+
this causes a conflict if existing timestamp columns are defined as
19+
TIMESTAMP WITHOUT TIME ZONE, resulting in:
20+
21+
asyncpg.exceptions.DataError: can't subtract offset-naive and
22+
offset-aware datetimes
23+
24+
This migration alters all timestamp columns in ADK tables to use
25+
TIMESTAMP WITH TIME ZONE. It is safe to run on existing data as
26+
PostgreSQL will interpret existing naive timestamps as being in the
27+
server's timezone (typically UTC).
28+
29+
Usage:
30+
python -m google.adk.sessions.migration.migrate_postgresql_timestamptz \
31+
--db_url postgresql+asyncpg://user:pass@host:port/dbname
32+
"""
33+
34+
from __future__ import annotations
35+
36+
import argparse
37+
import logging
38+
import sys
39+
40+
from sqlalchemy import create_engine
41+
from sqlalchemy import text
42+
43+
from . import _schema_check_utils
44+
45+
logger = logging.getLogger("google_adk." + __name__)
46+
47+
# Columns to migrate: (table_name, column_name)
48+
_TIMESTAMP_COLUMNS = [
49+
("sessions", "create_time"),
50+
("sessions", "update_time"),
51+
("events", "timestamp"),
52+
("app_states", "update_time"),
53+
("user_states", "update_time"),
54+
]
55+
56+
57+
def migrate(db_url: str) -> None:
58+
"""Migrates TIMESTAMP columns to TIMESTAMP WITH TIME ZONE for PostgreSQL.
59+
60+
Args:
61+
db_url: The database URL (sync or async format).
62+
"""
63+
sync_url = _schema_check_utils.to_sync_url(db_url)
64+
engine = create_engine(sync_url)
65+
66+
try:
67+
with engine.begin() as conn:
68+
# Only run on PostgreSQL
69+
if engine.dialect.name != "postgresql":
70+
logger.info(
71+
"Skipping TIMESTAMPTZ migration: not a PostgreSQL database"
72+
" (dialect=%s).",
73+
engine.dialect.name,
74+
)
75+
return
76+
77+
migrated = 0
78+
for table_name, column_name in _TIMESTAMP_COLUMNS:
79+
# Check if table exists
80+
result = conn.execute(
81+
text(
82+
"SELECT data_type FROM information_schema.columns "
83+
"WHERE table_schema = 'public' "
84+
"AND table_name = :table_name "
85+
"AND column_name = :column_name"
86+
),
87+
{"table_name": table_name, "column_name": column_name},
88+
).fetchone()
89+
90+
if result is None:
91+
logger.debug(
92+
"Skipping %s.%s: column not found.", table_name, column_name
93+
)
94+
continue
95+
96+
if result[0] == "timestamp with time zone":
97+
logger.debug(
98+
"Skipping %s.%s: already TIMESTAMP WITH TIME ZONE.",
99+
table_name,
100+
column_name,
101+
)
102+
continue
103+
104+
logger.info(
105+
"Migrating %s.%s from %s to TIMESTAMP WITH TIME ZONE.",
106+
table_name,
107+
column_name,
108+
result[0],
109+
)
110+
conn.execute(
111+
text(
112+
f"ALTER TABLE {table_name} "
113+
f"ALTER COLUMN {column_name} "
114+
f"TYPE TIMESTAMP WITH TIME ZONE"
115+
)
116+
)
117+
migrated += 1
118+
119+
if migrated > 0:
120+
logger.info(
121+
"Successfully migrated %d column(s) to TIMESTAMP WITH TIME ZONE.",
122+
migrated,
123+
)
124+
else:
125+
logger.info("No columns needed migration.")
126+
127+
finally:
128+
engine.dispose()
129+
130+
131+
def main():
132+
parser = argparse.ArgumentParser(
133+
description=(
134+
"Migrate PostgreSQL TIMESTAMP columns to TIMESTAMP WITH TIME ZONE"
135+
" for ADK DatabaseSessionService."
136+
)
137+
)
138+
parser.add_argument(
139+
"--db_url",
140+
required=True,
141+
help="Database URL (e.g., postgresql+asyncpg://user:pass@host:port/db)",
142+
)
143+
args = parser.parse_args()
144+
145+
logging.basicConfig(level=logging.INFO)
146+
migrate(args.db_url)
147+
148+
149+
if __name__ == "__main__":
150+
main()

0 commit comments

Comments
 (0)