aiml
SearchMy learning
Menu

Lesson 5 of 20Module 1

Rewind a session and migrate its database

Undo turns in a Google ADK 2.0 session with rewind_async, see what it restores, and migrate a legacy pickle session database with adk migrate session.

Prerequisites

Python async functions, session state and user: state from Building Agents with Google ADK 2.0. This lesson introduces the artifact version needed for its rewind example.

By the end, you can

  • Rewind a session to before an invocation and predict which state, artifacts and events change.
  • Explain how rewind computes the restored state, and why user: and app: state are left alone.
  • Migrate a v0 session database with adk migrate session and check the result before switching to it.
Runtime
Python 3.12+, a Gemini API key (or Ollama with gemma4:e2b)
Dependencies
google-adk[db] 2.12.0 (SQLAlchemy for DatabaseSessionService and adk migrate); litellm 1.102.4 for the Ollama alternative

Lab code, tests and fixtures (ZIP, 16 KB)Quiz check your understanding

Progress is saved in this browser.

A session is an append-only log of events, and its state is what those events changed. ADK gives you two operations on that log for the day something goes wrong. Runner.rewind_async undoes a user’s turn and everything after it, so a conversation can return to a known good point. adk migrate session copies a session database written by an older ADK release into the current schema. Here you rewind a reading-list session for the docs assistant and look at exactly what came back and what did not, then migrate a legacy database and catch the one event the migration quietly dropped. The rewind run is recorded on Gemini gemini-3.8-flash; the migration runs need no model.

When you need this. A developer tells the docs assistant to pin a page and switch teams, then realizes it was the wrong session. The docs team wants an “undo last turn” button rather than a new session that loses the history. Separately, the assistant’s sessions have lived in a database since an ADK 1.x release, and ADK now warns that the v0 pickle schema will stop being supported. Both tasks rewrite stored state, so you need to know their edges before you run them on real data.

Recorded run of pins.pyRecorded output · textDownload
$ python pins.py
after turn 1: {'pinned': ['rollback'], 'user:team': 'payments', 'artifact_versions': [0], 'artifact': '- rollback', 'events': 4}
after turn 2: {'pinned': ['rollback', 'tokens'], 'user:team': 'platform', 'artifact_versions': [0, 1], 'artifact': '- rollback\n- tokens', 'events': 8}
after rewind: {'pinned': ['rollback'], 'user:team': 'platform', 'artifact_versions': [0, 1, 2], 'artifact': '- rollback', 'events': 9}
turn 3: You are on the payments team, and the rollback page is currently pinned.
pins.py · Runner.rewind_async · adk migrate session · recorded in recorded-runs.json
data flowreturnartifact hand-offfailurerewind · pins.pysessions.dbevents with state_delta,session and user stateRunner.rewind_asyncreplay deltas before the turn,skip user:, app:, temp: keysearlier eventsrewind eventreading-list.mdnew versionold contentunknown invocation IDInvocationNotFoundErrorRecorded snapshotswhenpinneduser:teamartifact versionseventsafter turn 1rollbackpayments14after turn 2rollback, tokensplatform28after rewindrollbackplatform39migrate · adk migrate sessionlegacy.dbschema v0, pickled actionsadk migrate sessionrestricted unpicklerrowsmigrated.dbschema v1, JSON event_dataJSON rowsCounter refusedactions dropped,WARNING, exit 0Event e-2 after migrationrestricted (default)state_delta={}--allow-unsafe-unpicklingstate_delta={'pinned': ['rollb…check_migrated.pydata flowreturnartifact hand-offfailurerewindsessions.dbevents, state deltasrewind_asyncskip user:, app:, temp:earlier eventsrewind eventreading-listnew versionold contentunknown IDNotFoundRecorded snapshotsafter turn 1pinned: rollbackuser:team paymentsafter turn 2pinned: rollback, tokensuser:team platformafter rewindpinned: rollbackuser:team platformmigratelegacy.dbv0, pickledadk migrate sessionrestricted unpicklerrowsCounterrefused,exit 0migratedv1, JSONJSONEvent e-2 deltacheckrestrictedstate_delta={}unsafe unpicklingpinned, page_views kept
Rewind replays the state deltas of the events before the chosen invocation and appends one event that sets session state back, here {"pinned":["rollback"]}, plus a new artifact version with the old content. The rewound events stay in the log, and user: state keeps its newer value (dashed chip). The migration reads every legacy row through a restricted unpickler; a value it may not load drops that event's actions with only a warning, so check the migrated deltas before you switch databases.

Lab setup. Download the complete lab ZIP, extract it and open a terminal in adk-rewind-migrate/. Keep the bundled code, tests and fixtures together. Create the pinned environment before running the commands below:

Prepare the lab environmentTerminal · bash
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt

1. Keep session state, user state and an artifact

An ADK artifact is a versioned file managed by an artifact service. Here it holds a Markdown reading list. pins.py gives the assistant two tools that write three kinds of data: pin_page updates pinned in session state and saves a reading-list.md artifact, and set_team writes user:team, which the user: prefix shares across all of that user’s sessions.

pins.pySource · pythonDownload
"""Pin docs pages for a developer, then rewind the session to undo a turn.

Run from this folder with GOOGLE_API_KEY set: python pins.py
Sessions live in SQLite through DatabaseSessionService. Set DOCS_MODEL=ollama_chat/gemma4:e2b for Gemma.
"""
import asyncio
import os
from pathlib import Path

from google.adk import Agent, Runner
from google.adk.artifacts import InMemoryArtifactService
from google.adk.models.lite_llm import LiteLlm
from google.adk.sessions import DatabaseSessionService
from google.adk.tools.tool_context import ToolContext
from google.genai import types

HERE = Path(__file__).resolve().parent
MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")
DB_URL = os.environ.get("DOCS_DB", f"sqlite+aiosqlite:///{HERE / 'sessions.db'}")
APP, USER = "docs_assistant", "dev-1"
PAGES = ("config", "deploy", "rollback", "tokens")


async def pin_page(page: str, tool_context: ToolContext) -> dict:
    """Pin a docs page to the developer's reading list for this session.

    Args:
        page: The page name: config, deploy, rollback or tokens.
    """
    if page not in PAGES:
        return {"error": f"No page {page!r}."}
    pinned = [*tool_context.state.get("pinned", []), page]
    tool_context.state["pinned"] = pinned  # session-scoped state
    note = types.Part.from_bytes(data="\n".join(f"- {p}" for p in pinned).encode(), mime_type="text/markdown")
    version = await tool_context.save_artifact("reading-list.md", note)  # session-scoped artifact
    return {"pinned": pinned, "artifact_version": version}


def set_team(team: str, tool_context: ToolContext) -> dict:
    """Record the developer's team. It is kept for the user across all sessions.

    Args:
        team: The team name, for example payments or platform.
    """
    tool_context.state["user:team"] = team  # user-scoped state: the user: prefix
    return {"team": team}


root_agent = Agent(
    name=APP,
    model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
    instruction="""You keep a reading list of docuctl docs pages for a developer.
Use pin_page to pin pages and set_team when the developer names their team.
Current pins: {pinned?}. Developer's team: {user:team?}.
Answer in one short sentence.""",
    tools=[pin_page, set_team],
    generate_content_config=types.GenerateContentConfig(
        temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)


def build_runner(db_url: str = DB_URL) -> Runner:
    return Runner(app_name=APP, agent=root_agent, session_service=DatabaseSessionService(db_url=db_url),
                  artifact_service=InMemoryArtifactService())


async def ask(runner: Runner, session_id: str, text: str) -> list:
    message = types.Content(role="user", parts=[types.Part(text=text)])
    return [e async for e in runner.run_async(user_id=USER, session_id=session_id, new_message=message)]


async def snapshot(runner: Runner, session_id: str) -> dict:
    """What the services hold now: session and user state, artifact versions, event count."""
    session = await runner.session_service.get_session(app_name=APP, user_id=USER, session_id=session_id)
    versions = await runner.artifact_service.list_versions(app_name=APP, user_id=USER, session_id=session_id,
                                                           filename="reading-list.md")
    latest = await runner.artifact_service.load_artifact(app_name=APP, user_id=USER, session_id=session_id,
                                                         filename="reading-list.md")
    return {"pinned": session.state.get("pinned"), "user:team": session.state.get("user:team"),
            "artifact_versions": versions, "artifact": latest.inline_data.data.decode() if latest else None,
            "events": len(session.events)}


async def main() -> None:
    runner = build_runner()
    session = await runner.session_service.create_session(app_name=APP, user_id=USER)
    try:
        first = await ask(runner, session.id, "I'm on the payments team. Pin the rollback page.")
        print("after turn 1:", await snapshot(runner, session.id))
        second = await ask(runner, session.id, "Pin the tokens page too. And I moved to the platform team.")
        print("after turn 2:", await snapshot(runner, session.id))
        await runner.rewind_async(user_id=USER, session_id=session.id,
                                  rewind_before_invocation_id=second[0].invocation_id)
        print("after rewind:", await snapshot(runner, session.id))
        third = await ask(runner, session.id, "Which pages are pinned, and which team am I on?")
        print("turn 3:", third[-1].content.parts[-1].text)
    finally:
        await runner.close()


if __name__ == "__main__":
    asyncio.run(main())

The instruction injects {pinned?} and {user:team?}, so the model sees the current values of both on every call.

2. Rewind to before the second turn

Turn 1 pinned rollback and set the team to payments; turn 2 pinned tokens and changed the team to platform. rewind_async takes the invocation ID of the first turn to undo, here the second turn’s, read from its first event. After the rewind, pinned was back to ["rollback"] and the artifact’s latest content was back to the turn 1 list. The session had 9 events, one more than before: the rewind added an event and deleted nothing.

user:team stayed platform. Rewind restores session-level state and artifacts only; app-level and user-level state are shared beyond this session, so the docs list them as not restored. The model then saw “payments” in the remaining history and “platform” in the injected state, and its turn 3 answer named the team from the history. If a rewind must also undo a user-level change, write the old value back yourself.

User state that rewind does not restore. user:team kept the value from the rewound turn, and the next answer mixed old history with new state. Treat user: and app: writes as outside the undo; restore them yourself when they must follow.

An invocation ID that is not in the session. rewind_async raises InvocationNotFoundError and changes nothing. Read the ID from the stored events of that session.

Recorded rewind failure on an unknown invocation IDRecorded output · textDownload
# runner.rewind_async(..., rewind_before_invocation_id='e-0000')
InvocationNotFoundError: Invocation ID not found: e-0000

3. Build a legacy session database

ADK releases before 1.22 stored event actions as Python pickles: the v0 schema. ADK 2.12 still reads and writes it, and includes the v0 schema classes, so legacy.py builds one the way an old deployment would have. Its second event stores a collections.Counter in state. JSON can represent its entries as a mapping, but not preserve the Python Counter type; the restricted unpickler rejects that type:

legacy.pySource · pythonDownload
"""Build a v0 (pickle) session database like one written by ADK releases before 1.22.

Run: python legacy.py legacy.db
It uses ADK's own v0 schema classes; DatabaseSessionService detects the v0 tables and writes
event actions as pickles. The page_views Counter is session state that is not plain JSON data.
"""
import asyncio
import collections
import sys

from google.adk.events import Event, EventActions
from google.adk.sessions import DatabaseSessionService
from google.adk.sessions.schemas import v0
from google.genai import types
from sqlalchemy import create_engine


async def build(path: str) -> None:
    engine = create_engine(f"sqlite:///{path}")
    v0.Base.metadata.create_all(engine)  # the legacy tables, with no schema-version row
    engine.dispose()
    sessions = DatabaseSessionService(db_url=f"sqlite+aiosqlite:///{path}")
    session = await sessions.create_session(app_name="docs_assistant", user_id="dev-1", session_id="pins-2025",
                                            state={"user:team": "payments"})
    turns = [("Pin the rollback page.", {"pinned": ["rollback"]}),
             ("Pin the tokens page.", {"pinned": ["rollback", "tokens"],
                                       "page_views": collections.Counter(rollback=3, tokens=1)})]
    for n, (text, delta) in enumerate(turns, start=1):
        await sessions.append_event(session, Event(invocation_id=f"e-{n}", author="user",
                                                   content=types.Content(role="user", parts=[types.Part(text=text)])))
        await sessions.append_event(session, Event(invocation_id=f"e-{n}", author="docs_assistant",
                                                   content=types.Content(role="model", parts=[types.Part(text="Pinned.")]),
                                                   actions=EventActions(state_delta=delta)))


if __name__ == "__main__":
    asyncio.run(build(sys.argv[1]))
Recorded build of legacy.dbRecorded output · textDownload
$ python legacy.py legacy.db
The database is using the legacy v0 schema, which uses Pickle to serialize event actions. The v0 schema will not be supported going forward and will be deprecated in a few rollouts. Please migrate to the v1 schema which uses JSON serialization for event data.  …
This database uses the legacy schema, which stores an event as one column per field. 16 event fields are dropped on write and read back as unset: avg_logprobs, cache_metadata, environment_id, finish_reason, go_away, interaction_id, interaction_status, isolatio …
exit 0

The second warning names 16 fields that v0 cannot store. This does not mean every event had meaningful values in all of them; inspect the fields your application uses before continuing to write to v0.

4. Migrate and check the result

adk migrate session reads from one URL and writes a new database at another; the command rejects an in-place migration. Its log for this database, without the routine INFO lines:

Recorded migrationRecorded output · textDownload
$ adk migrate session --source_db_url sqlite:///legacy.db --dest_db_url sqlite:///migrated.db
WARNING - _schema_check_utils.py:81 - The database is using the legacy v0 schema, which uses Pickle to serialize event actions. The v0 schema will not be supported going forward and will be deprecated in a few rollouts. Please migrate to the v1 schema which us …
WARNING - migrate_from_sqlalchemy_pickle.py:85 - Failed to unpickle actions for event 91f82cf5-09df-4b83-a664-3cc2ffce62ee: Refusing to load collections.Counter from a legacy pickled `events.actions` value: it is not a type that `EventActions` can hold. This v …
INFO - migrate_from_sqlalchemy_pickle.py:351 - Migrated 4 events.
INFO - migrate_from_sqlalchemy_pickle.py:356 - Migration completed successfully.
Migration check and upgrade process finished.
exit 0

The command reported success and exited 0, but one line is a warning that it refused to load the Counter. check_migrated.py opens the new database with DatabaseSessionService and prints each event’s state delta:

check_migrated.pySource · pythonDownload
"""Open a migrated session database and print each event's state delta.

Run: python check_migrated.py migrated.db
A migration that dropped an event's actions shows up here as an empty state_delta.
"""
import asyncio
import sys

from google.adk.sessions import DatabaseSessionService


async def check(path: str) -> list[str]:
    sessions = DatabaseSessionService(db_url=f"sqlite+aiosqlite:///{path}")
    lines = []
    for listed in (await sessions.list_sessions(app_name="docs_assistant", user_id="dev-1")).sessions:
        session = await sessions.get_session(app_name="docs_assistant", user_id="dev-1", session_id=listed.id)
        lines.append(f"session {session.id}: {len(session.events)} events, state {dict(session.state)}")
        lines += [f"event {e.invocation_id} {e.author:<15} state_delta={dict(e.actions.state_delta)}" for e in session.events]
    return lines


if __name__ == "__main__":
    print("\n".join(asyncio.run(check(sys.argv[1]))))
Recorded check of migrated.dbRecorded output · textDownload
$ python check_migrated.py migrated.db
session pins-2025: 4 events, state {'pinned': ['rollback', 'tokens'], 'page_views': {'rollback': 3, 'tokens': 1}, 'user:team': 'payments'}
event e-1 user            state_delta={}
event e-1 docs_assistant  state_delta={'pinned': ['rollback']}
event e-2 user            state_delta={}
event e-2 docs_assistant  state_delta={}
exit 0

Event e-2 lost its delta. The session’s current state is intact, because the migration copies it from the sessions table, but a rewind recomputes state from event deltas, so a later rewind on this database would compute the wrong state. This database is our own, so the fix is to rerun with --allow-unsafe-unpickling, which uses Python’s full unpickler:

Recorded check after --allow-unsafe-unpicklingRecorded output · textDownload
$ python check_migrated.py migrated-full.db
session pins-2025: 4 events, state {'pinned': ['rollback', 'tokens'], 'page_views': {'rollback': 3, 'tokens': 1}, 'user:team': 'payments'}
event e-1 user            state_delta={}
event e-1 docs_assistant  state_delta={'pinned': ['rollback']}
event e-2 user            state_delta={}
event e-2 docs_assistant  state_delta={'pinned': ['rollback', 'tokens'], 'page_views': {'rollback': 3, 'tokens': 1}}
exit 0

Unpickling can run code named in the data, so use that flag only on a database no one else could have written to. Afterward, point DatabaseSessionService at the new URL.

An event the migration cannot unpickle. A Counter in state is refused with a warning, the event’s actions are dropped, and the command still exits 0. Search the log for Failed to unpickle, check deltas with check_migrated.py, and rerun with --allow-unsafe-unpickling only for a trusted database.

Migrating in place. Passing the same source and destination URL fails with In-place migration is not supported, exit 1. Write to a new database, then switch the service URL.

A misspelled source path. sqlite:///legacy-db.db does not exist, so SQLite creates an empty file, the command reports it is already at version 1 and exits 0, and no destination is written. Check that the source file exists and that the destination appears; compare session and event counts.

Recorded migration failuresRecorded output · textDownload
$ adk migrate session --source_db_url sqlite:///legacy.db --dest_db_url sqlite:///legacy.db
Migration failed: In-place migration is not supported. Please provide a different URL for dest_db_url.
exit 1

$ adk migrate session --source_db_url sqlite:///legacy-db.db --dest_db_url sqlite:///out.db
INFO - migration_runner.py:84 - Database sqlite:///legacy-db.db is already at latest version 1. No migration needed.
Migration check and upgrade process finished.
exit 0

How rewind applies state and artifact deltas

Rewind does not edit history. It finds the event index where the chosen invocation starts, replays the state_delta of every earlier event to rebuild the session state at that point, skipping keys with the user:, app: and temp: prefixes, and compares the result with the current state. The differences become one new event:

recorded-runs.json: the rewind event's actionsRecorded output · jsonDownload
{
  "state_delta": {
    "pinned": [
      "rollback"
    ]
  },
  "artifact_delta": {
    "reading-list.md": 2
  },
  "rewind_before_invocation_id": "e-b8177353-3d3f-4715-9724-2e23162b7f81"
}

Its artifact delta points at the restored version of reading-list.md. When the runner next builds the model’s history, it skips the rewound invocations, so the model no longer sees turn 2. The docs add that the steps are not one atomic transaction, so do not rewind a session that is running a turn.

The migration has two parts: it copies sessions and app and user state as JSON, then reads each event row and unpickles its actions through a restricted unpickler that admits only the types EventActions can hold plus common standard-library data types. A row it refuses keeps its content but loses its actions, and the run continues.

Test rewind and migration offline

test_pins.py replays the recorded model replies for the rewind, and runs the real adk migrate session command on a fresh legacy.db in a temporary folder.

Terminal commandTerminal · bash
python -m unittest -v test_pins.py
Recorded test outputRecorded output · textDownload
test_a_misspelled_source_path_reports_no_migration_needed (test_pins.Migrate.test_a_misspelled_source_path_reports_no_migration_needed) ... ok
test_a_restricted_migration_drops_an_event_it_cannot_unpickle (test_pins.Migrate.test_a_restricted_migration_drops_an_event_it_cannot_unpickle) ... ok
test_allow_unsafe_unpickling_keeps_the_event (test_pins.Migrate.test_allow_unsafe_unpickling_keeps_the_event) ... ok
test_in_place_migration_is_refused (test_pins.Migrate.test_in_place_migration_is_refused) ... ok
test_an_unknown_invocation_id_raises (test_pins.Rewind.test_an_unknown_invocation_id_raises) ... ok
test_rewind_restores_session_state_and_the_artifact (test_pins.Rewind.test_rewind_restores_session_state_and_the_artifact) ... ok
test_rewound_events_stay_in_the_log (test_pins.Rewind.test_rewound_events_stay_in_the_log) ... ok
test_user_state_is_not_restored (test_pins.Rewind.test_user_state_is_not_restored) ... ok

----------------------------------------------------------------------
Ran 8 tests in 3.310s

OK

All 8 tests pass.

Read the test and recording scripts
test_pins.pySource · pythonDownload
"""Replay the rewind demo offline, and run the session migration CLI on a fresh legacy database.

Run: python -m unittest -v test_pins.py
The model is replaced by its recorded responses; the Runner, DatabaseSessionService, rewind and
`adk migrate session` are real. Nothing here calls a model or the network.
"""
import asyncio
import json
import logging
import subprocess
import sys
import tempfile
import unittest
import warnings
from pathlib import Path
from unittest import mock

warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)

from google.adk.models.base_llm import BaseLlm  # noqa: E402
from google.adk.models.llm_response import LlmResponse  # noqa: E402
from google.genai import types  # noqa: E402

import check_migrated  # noqa: E402
import legacy  # noqa: E402
import pins  # noqa: E402

HERE = Path(__file__).resolve().parent
RECORD = json.loads((HERE / "recorded-runs.json").read_text(encoding="utf-8"))
ADK = str(Path(sys.executable).with_name("adk"))


class ReplayLlm(BaseLlm):
    turns: list

    async def generate_content_async(self, llm_request, stream=False):
        yield LlmResponse(content=types.Content.model_validate(self.turns.pop(0)["content"]))


class Rewind(unittest.TestCase):
    def setUp(self):
        warnings.simplefilter("ignore")
        self.tmp = tempfile.TemporaryDirectory()
        model = ReplayLlm(model="replay", turns=list(RECORD["rewind"]["model_turns"]))
        self.agent = pins.root_agent.clone(update={"model": model})

    def tearDown(self):
        self.tmp.cleanup()

    def run_demo(self, rewind_to_unknown: bool = False):
        with mock.patch.object(pins, "root_agent", self.agent):
            runner = pins.build_runner(f"sqlite+aiosqlite:///{Path(self.tmp.name) / 'sessions.db'}")

        async def go():
            session = await runner.session_service.create_session(app_name=pins.APP, user_id=pins.USER)
            try:
                await pins.ask(runner, session.id, RECORD["rewind"]["turns"][0])
                second = await pins.ask(runner, session.id, RECORD["rewind"]["turns"][1])
                target = "e-0000" if rewind_to_unknown else second[0].invocation_id
                await runner.rewind_async(user_id=pins.USER, session_id=session.id, rewind_before_invocation_id=target)
                return await pins.snapshot(runner, session.id)
            finally:
                await runner.close()

        return asyncio.run(go())

    def test_rewind_restores_session_state_and_the_artifact(self):
        snap = self.run_demo()
        expected = RECORD["rewind"]["snapshots"]["after rewind"]
        self.assertEqual((snap["pinned"], snap["artifact"]), (expected["pinned"], expected["artifact"]))
        self.assertEqual(snap["artifact_versions"], expected["artifact_versions"])  # restored as a new version

    def test_user_state_is_not_restored(self):
        snap = self.run_demo()
        self.assertEqual(snap["user:team"], "platform")
        self.assertEqual(RECORD["rewind"]["snapshots"]["after turn 1"]["user:team"], "payments")

    def test_rewound_events_stay_in_the_log(self):
        snap = self.run_demo()
        self.assertEqual(snap["events"], RECORD["rewind"]["snapshots"]["after turn 2"]["events"] + 1)

    def test_an_unknown_invocation_id_raises(self):
        from google.adk.errors._invocation_not_found_error import InvocationNotFoundError
        with self.assertRaisesRegex(InvocationNotFoundError, "Invocation ID not found: e-0000"):
            self.run_demo(rewind_to_unknown=True)


class Migrate(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.tmp = tempfile.TemporaryDirectory()
        cls.dir = Path(cls.tmp.name)
        asyncio.run(legacy.build(str(cls.dir / "legacy.db")))

    @classmethod
    def tearDownClass(cls):
        cls.tmp.cleanup()

    def setUp(self):
        warnings.simplefilter("ignore")

    def migrate(self, source: str, dest: str, *extra: str) -> subprocess.CompletedProcess:
        return subprocess.run([ADK, "migrate", "session", "--source_db_url", f"sqlite:///{source}",
                               "--dest_db_url", f"sqlite:///{dest}", *extra], cwd=self.dir, capture_output=True, text=True)

    def deltas(self, name: str) -> list[str]:
        return [l for l in asyncio.run(check_migrated.check(str(self.dir / name))) if l.startswith("event e-2 docs")]

    def test_a_restricted_migration_drops_an_event_it_cannot_unpickle(self):
        proc = self.migrate("legacy.db", "migrated.db")
        self.assertEqual(proc.returncode, 0)
        self.assertIn("Refusing to load collections.Counter", proc.stderr)
        self.assertEqual(self.deltas("migrated.db"), ["event e-2 docs_assistant  state_delta={}"])

    def test_allow_unsafe_unpickling_keeps_the_event(self):
        proc = self.migrate("legacy.db", "migrated-full.db", "--allow-unsafe-unpickling")
        self.assertEqual(proc.returncode, 0)
        self.assertIn("page_views", self.deltas("migrated-full.db")[0])

    def test_in_place_migration_is_refused(self):
        proc = self.migrate("legacy.db", "legacy.db")
        self.assertEqual(proc.returncode, 1)
        self.assertIn("In-place migration is not supported", proc.stdout + proc.stderr)

    def test_a_misspelled_source_path_reports_no_migration_needed(self):
        proc = self.migrate("legacy-db.db", "out.db")
        self.assertEqual(proc.returncode, 0)
        self.assertIn("already at latest version 1. No migration needed.", proc.stderr)
        self.assertTrue((self.dir / "legacy-db.db").exists())
        self.assertFalse((self.dir / "out.db").exists())


if __name__ == "__main__":
    unittest.main()
record.pySource · pythonDownload
"""Record the rewind demo on Gemini and the session-database migration commands.

Run from this folder with GOOGLE_API_KEY set: python record.py
Writes run-output.txt and recorded-runs.json. Databases are created in a temporary folder.
"""
import asyncio
import json
import logging
import os
import re
import subprocess
import sys
import tempfile
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)

TMP = Path(tempfile.mkdtemp())
os.environ["DOCS_DB"] = f"sqlite+aiosqlite:///{TMP / 'sessions.db'}"

import check_migrated  # noqa: E402
import legacy  # noqa: E402
import pins  # noqa: E402

HERE = Path(__file__).resolve().parent
ADK = str(Path(sys.executable).with_name("adk"))
TURNS = ["I'm on the payments team. Pin the rollback page.",
         "Pin the tokens page too. And I moved to the platform team.",
         "Which pages are pinned, and which team am I on?"]


async def rewind_demo() -> dict:
    runner = pins.build_runner()
    session = await runner.session_service.create_session(app_name=pins.APP, user_id=pins.USER)
    rec = {"snapshots": {}, "turns": TURNS}
    try:
        await pins.ask(runner, session.id, TURNS[0])
        rec["snapshots"]["after turn 1"] = await pins.snapshot(runner, session.id)
        second = await pins.ask(runner, session.id, TURNS[1])
        rec["snapshots"]["after turn 2"] = await pins.snapshot(runner, session.id)
        rec["rewound_invocation"] = second[0].invocation_id
        await runner.rewind_async(user_id=pins.USER, session_id=session.id, rewind_before_invocation_id=second[0].invocation_id)
        rec["snapshots"]["after rewind"] = await pins.snapshot(runner, session.id)
        third = await pins.ask(runner, session.id, TURNS[2])
        rec["answer"] = third[-1].content.parts[-1].text
        stored = await runner.session_service.get_session(app_name=pins.APP, user_id=pins.USER, session_id=session.id)
        rec["rewind_event"] = next(e.actions.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
                                   for e in stored.events if e.actions.rewind_before_invocation_id)
        rec["model_turns"] = [{"content": {"role": "model", "parts": [
            {k: v for k, v in p.model_dump(mode="json", exclude_none=True).items() if k != "thought_signature"}
            for p in e.content.parts]}} for e in stored.events if e.content and e.content.role == "model"]
        try:
            await runner.rewind_async(user_id=pins.USER, session_id=session.id, rewind_before_invocation_id="e-0000")
        except Exception as exc:  # recorded failure
            rec["unknown_invocation"] = f"{type(exc).__name__}: {exc}"
    finally:
        await runner.close()
    return rec


def sh(cmd: list[str]) -> dict:
    proc = subprocess.run(cmd, cwd=TMP, capture_output=True, text=True, timeout=120)
    lines = [re.sub(r"^\d{4}-\d\d-\d\d [\d:,]+ - ", "", l) for l in (proc.stderr + proc.stdout).splitlines()
             if l.strip() and not l.startswith(" ")]
    lines = [l[:260] + (" …" if len(l) > 260 else "") for l in lines]
    shown = ["adk", *cmd[1:]] if cmd[0] == ADK else ["python", *[Path(a).name if a.endswith(".py") else a for a in cmd[1:]]]
    return {"command": " ".join(shown), "lines": lines, "exit": proc.returncode}


def migrations() -> dict:
    py = sys.executable
    runs = {
        "build": sh([py, str(HERE / "legacy.py"), "legacy.db"]),
        "migrate": sh([ADK, "migrate", "session", "--source_db_url", "sqlite:///legacy.db", "--dest_db_url", "sqlite:///migrated.db"]),
        "check": sh([py, str(HERE / "check_migrated.py"), "migrated.db"]),
        "migrate_unsafe": sh([ADK, "migrate", "session", "--source_db_url", "sqlite:///legacy.db",
                              "--dest_db_url", "sqlite:///migrated-full.db", "--allow-unsafe-unpickling"]),
        "check_unsafe": sh([py, str(HERE / "check_migrated.py"), "migrated-full.db"]),
        "in_place": sh([ADK, "migrate", "session", "--source_db_url", "sqlite:///legacy.db", "--dest_db_url", "sqlite:///legacy.db"]),
        "typo": sh([ADK, "migrate", "session", "--source_db_url", "sqlite:///legacy-db.db", "--dest_db_url", "sqlite:///out.db"]),
    }
    runs["typo"]["created"] = sorted(p.name for p in TMP.glob("*.db"))
    return runs


def main() -> None:
    rec = {"model": pins.MODEL, "rewind": asyncio.run(rewind_demo()), "migrate": migrations()}
    r = rec["rewind"]
    out = ["$ python pins.py", *[f"{k}: {v}" for k, v in r["snapshots"].items()], f"turn 3: {r['answer']}", "",
           "# runner.rewind_async(..., rewind_before_invocation_id='e-0000')", r["unknown_invocation"], ""]
    for run in rec["migrate"].values():
        out += [f"$ {run['command']}", *run["lines"], f"exit {run['exit']}", ""]
    out.append(f"files now: {rec['migrate']['typo']['created']}")
    text = "\n".join(out)
    (HERE / "run-output.txt").write_text(text + "\n", encoding="utf-8")
    (HERE / "recorded-runs.json").write_text(json.dumps(rec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print(text)


if __name__ == "__main__":
    main()

Check your understanding

After turn two changes session pins and user:team, you rewind to before that turn. Which state boundary should you expect?

Choose one answer, then check your reasoning.

Read the explanation

The rewind restores session-scoped state while leaving user- and app-scoped state alone.

References

Official sources checked October 21, 2026, against google-adk 2.12.0. How rewind computes state comes from the installed google.adk.sessions._rewind_utils module.

Previously on the blog: Google ADK Sessions, State & Memory Explained (with Code) covers session state in ADK 1.x.

Written by Arjun Prabhulal.

Module 1

Run agents and manage sessions