Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .github/workflows/test-migrations.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

name: Database Migration Tests

on:
push:
branches: [main]
paths:
- 'src/google/adk/sessions/**'
- 'src/google/adk/cli/cli_tools_click.py'
- 'tests/**/sessions/**'
- '.github/workflows/test-migrations.yml'
pull_request:
branches: [main]
paths:
- 'src/google/adk/sessions/**'
- 'src/google/adk/cli/cli_tools_click.py'
- 'tests/**/sessions/**'
- '.github/workflows/test-migrations.yml'

jobs:
unit-tests:
name: Unit Tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Setup uv and Python
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

- name: Install dependencies
run: uv sync --python ${{ matrix.python-version }} --extra test

- name: Run migration unit tests
run: uv run pytest tests/unittests/sessions/migration/ -v

integration-tests-postgres:
name: Integration (PostgreSQL ${{ matrix.postgres-version }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
postgres-version: ["15", "16", "17"]
python-version: ["3.11", "3.12", "3.13", "3.14"]

services:
postgres:
image: postgres:${{ matrix.postgres-version }}
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: test_adk
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Setup uv and Python
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

- name: Install dependencies
run: |
uv sync --python ${{ matrix.python-version }} --extra test
uv pip install psycopg2-binary

- name: Run integration tests (PostgreSQL)
env:
TEST_POSTGRES_URL: postgresql://testuser:testpass@localhost:5432/test_adk
run: uv run pytest tests/integration/sessions/test_database_migration_integration.py -v

integration-tests-mysql:
name: Integration (MySQL ${{ matrix.mysql-version }}, Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
mysql-version: ["8.0", "8.4", "9.2"]
python-version: ["3.11", "3.12", "3.13", "3.14"]

services:
mysql:
image: mysql:${{ matrix.mysql-version }}
env:
MYSQL_ROOT_PASSWORD: testpass
MYSQL_DATABASE: test_adk
MYSQL_USER: testuser
MYSQL_PASSWORD: testpass
options: >-
--health-cmd "mysqladmin ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 3306:3306

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Setup uv and Python
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

- name: Install dependencies
run: |
uv sync --python ${{ matrix.python-version }} --extra test
uv pip install mysqlclient

- name: Run integration tests (MySQL)
env:
TEST_MYSQL_URL: mysql://testuser:testpass@localhost:3306/test_adk
run: uv run pytest tests/integration/sessions/test_database_migration_integration.py -v
128 changes: 128 additions & 0 deletions docs/helm_migration_guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Database Migrations on Kubernetes

In Kubernetes deployments, run database migrations before application pods start
using Helm `pre-install` and `pre-upgrade` hooks. This ensures all pods see the
same schema and avoids race conditions from concurrent migration attempts.

Disable `ADK_AUTO_MIGRATE_DB` in your application pods when using this approach.

## Migration Job

Add a Job template to your Helm chart that runs `adk migrate upgrade` as a
pre-install and pre-upgrade hook:

```yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-db-migration
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
ttlSecondsAfterFinished: 86400 # Auto-cleanup after 24h
backoffLimit: 3
activeDeadlineSeconds: 300 # Overall job timeout (5 min)
template:
spec:
restartPolicy: Never
serviceAccountName: {{ .Values.serviceAccountName }}
containers:
- name: migration
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["adk", "migrate", "upgrade", "--db_url", "$(DATABASE_URL)"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Values.database.secretName }}
key: url
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
```

Key fields:

- **`activeDeadlineSeconds`**: Overall timeout for the Job. If the migration
hasn't finished within this window the Job is terminated. Adjust based on
your expected migration duration.
- **`ttlSecondsAfterFinished`**: Automatically deletes completed Job resources
after 24 hours to avoid clutter.
- **`backoffLimit`**: Number of retries before the Job is marked as failed.
- **`securityContext`**: Follows least-privilege: non-root user, no privilege
escalation, all Linux capabilities dropped.

The `pre-install,pre-upgrade` annotations ensure the Job runs before any
application pods are created or updated. `helm.sh/hook-delete-policy:
before-hook-creation` cleans up the previous Job before creating a new one on
subsequent upgrades.

The `adk migrate upgrade` command auto-bootstraps databases that predate Alembic
support, so this Job handles both fresh deployments and upgrades from earlier ADK
versions.

## Application Configuration

In your application Deployment, disable auto-migration since the Helm hook
handles it. `false` is the default, so this is optional, but you may want to set it for explicitness:

```yaml
env:
- name: ADK_AUTO_MIGRATE_DB
value: "false" # default
```

## Cloud SQL Proxy

If your database is a Cloud SQL instance on GKE, add the
[Cloud SQL Auth Proxy](https://cloud.google.com/sql/docs/postgres/connect-kubernetes-engine)
as a
[native sidecar container](https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/)
by defining it as an `initContainer` with `restartPolicy: Always`. Kubernetes starts it before the
migration container, keeps it running alongside, and terminates it automatically
when the migration exits:

```yaml
initContainers:
- name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:latest
restartPolicy: Always # Native sidecar (K8s 1.28+)
args:
- "--structured-logs"
- "--port=5432"
- "{{ .Values.database.instanceConnectionName }}"
securityContext:
runAsNonRoot: true
containers:
- name: migration
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["adk", "migrate", "upgrade", "--db_url",
"postgresql://$(DB_USER):$(DB_PASS)@127.0.0.1:5432/$(DB_NAME)"]
env:
- name: DB_USER
valueFrom:
secretKeyRef:
name: {{ .Values.database.secretName }}
key: username
- name: DB_PASS
valueFrom:
secretKeyRef:
name: {{ .Values.database.secretName }}
key: password
- name: DB_NAME
valueFrom:
secretKeyRef:
name: {{ .Values.database.secretName }}
key: database
```

Refer to the
[GKE Cloud SQL connectivity documentation](https://cloud.google.com/sql/docs/postgres/connect-kubernetes-engine)
for Workload Identity and IAM setup.
97 changes: 97 additions & 0 deletions docs/migration_guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Database Migration Guide

ADK uses [Alembic](https://alembic.sqlalchemy.org/) to manage database schema
changes for `DatabaseSessionService`. When you upgrade ADK to a version that
includes schema changes, Alembic applies the necessary migrations to bring your
database up to date.

Migrations can run automatically on application startup or manually via the ADK
CLI.

## Configuration

| Variable | Default | Description |
|----------|---------|-------------|
| `ADK_AUTO_MIGRATE_DB` | `false` | Enable automatic migration on startup |

### Auto-migration (development and simple deployments)

Set the environment variable before starting your application:

```bash
export ADK_AUTO_MIGRATE_DB=true
```

When enabled, `DatabaseSessionService` automatically detects the database schema
version and applies any pending migrations during initialization. This includes
bootstrapping Alembic tracking for databases that predate Alembic support.

### Manual migration (production)

Run migrations explicitly using the CLI before deploying your application:

```bash
adk migrate upgrade --db_url "postgresql://user:pass@host/db"
```

### Kubernetes deployments

For Kubernetes, use Helm hooks to run migrations before application pods start.
See the [Helm Migration Guide](helm_migration_guide.md).

## Upgrading from Earlier ADK Versions

### Existing databases without Alembic tracking

ADK versions up to and including 1.24.0 do not use Alembic for schema tracking.
If you are upgrading from any of these versions, the migration system
automatically detects your database schema version and bootstraps Alembic
tracking.

**Using the CLI:**

```bash
adk migrate upgrade --db_url "postgresql://user:pass@host/db"
```

This command auto-bootstraps: it detects the current schema version, performs any
necessary data migration (e.g., V0 pickle-to-JSON conversion), stamps the
Alembic baseline revision, and applies any pending migrations.

**Using auto-migration:**

When `ADK_AUTO_MIGRATE_DB=true`, `DatabaseSessionService` handles bootstrapping
transparently on startup, including V0-to-V1 migration.

### New databases

No action needed. `DatabaseSessionService` creates tables using the latest
schema and stamps the Alembic baseline automatically.

### Legacy copy-based migration

The existing copy-based migration command remains available:

```bash
adk migrate session \
--source_db_url "postgresql://localhost:5432/v0" \
--dest_db_url "postgresql://localhost:5432/v1"
```

This copies data from a source database to a destination database, converting
the schema in the process. It is an alternative to the in-place migration
performed by `adk migrate upgrade`.

## CLI Reference

| Command | Description |
|---------|-------------|
| `adk migrate upgrade --db_url URL` | Apply pending migrations (auto-bootstraps existing databases) |
| `adk migrate downgrade --db_url URL --revision "-1"` | Rollback one migration step |
| `adk migrate check --db_url URL` | Check if migrations are pending (exit 0 = up-to-date, exit 1 = pending) |
| `adk migrate stamp --db_url URL` | Bootstrap Alembic tracking for an existing database |
| `adk migrate generate --db_url URL --message MSG` | Generate a new migration script (contributors) |
| `adk migrate session --source_db_url URL --dest_db_url URL` | Legacy copy-based migration |

All commands accept an optional `--log_level` flag (`DEBUG`, `INFO`, `WARNING`,
`ERROR`, `CRITICAL`). The default is `INFO`.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
# go/keep-sorted start
"PyYAML>=6.0.2, <7.0.0", # For APIHubToolset.
"aiosqlite>=0.21.0", # For SQLite database
"alembic>=1.18.3, <2.0.0", # For database migrations
"anyio>=4.9.0, <5.0.0", # For MCP Session Manager
"authlib>=1.6.6, <2.0.0", # For RestAPI Tool
"click>=8.1.8, <9.0.0", # For CLI tools
Expand Down
Loading