Skip to content

DatabaseSessionService.append_event raises MissingGreenlet with asyncpg after client disconnect (regression from da73e718ef) #5761

Closed

Description

@KrishnaVemu

Summary

With postgresql+asyncpg:// and DatabaseSessionService, every SSE/stream
client disconnect mid-run produces:

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called;
can't call await_only() here.

Reproduces 100% of the time when the client closes the stream while
append_event is committing.

Version

  • google-adk: 1.32.0 (verified identical code on main / 1.33 / 1.34 / 2.0)
  • SQLAlchemy: 2.x
  • asyncpg: 0.31.0
  • Python: 3.12
  • Driver recommended by ADK docs: asyncpg

Root cause

  1. database_session_service.py:208 forces pool_pre_ping=True for any
    non-SQLite URL (added in da73e71 on 2026-02-03 — this is a regression
    for asyncpg users).
  2. append_event at L740 does await sql_session.commit(), then at L743
    accesses storage_session.update_time via
    storage_session.get_update_timestamp(is_sqlite).
  3. With async engine + commit, that attribute access lazy-loads → pool
    checkout → _do_ping_w_event → await_only() outside the greenlet
    bridge → MissingGreenlet.
  4. Normally the request completes before this triggers. A client disconnect
    raises GeneratorExit through parallel_agent._merge_agent_run, which
    surfaces the bad code path.

Repro

  • Run any agent under an SSE endpoint with DatabaseSessionService(db_url="postgresql+asyncpg://...") (no extra kwargs — defaults only).
  • Disconnect the client mid-stream (close tab / abort fetch).
  • Server log shows the MissingGreenlet stack from append_event.

Stack trace (abridged)

File ".../google/adk/sessions/database_session_service.py", line 743, in append_event
    session.last_update_time = storage_session.get_update_timestamp(is_sqlite)
File ".../google/adk/sessions/schemas/v1.py", line 131, in get_update_timestamp
    return self.update_time.timestamp()
File ".../sqlalchemy/orm/attributes.py", line 569, in __get__
    return self.impl.get(state, dict_)
...
File ".../sqlalchemy/dialects/postgresql/asyncpg.py", line 816, in ping
    _ = self.await_(self._async_ping())
File ".../sqlalchemy/util/_concurrency_py3k.py", line 123, in await_only
    raise exc.MissingGreenlet(...)
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called;
can't call await_only() here.

Workaround (confirmed)

Pass pool_pre_ping=False — kwargs are forwarded to create_async_engine:

DatabaseSessionService(db_url=url, pool_pre_ping=False)

But pool_pre_ping is the recommended setting for production, so
disabling it is not a real fix.

Prior reports (closed stale, not fixed)

Suggested fixes

  1. Refresh update_time explicitly before commit, so the post-commit
    access doesn't lazy-load:

    await sql_session.refresh(storage_session, ["update_time"])
    await sql_session.commit()
  2. Read update_time into a local before commit (cheapest fix):

    updated_at = storage_session.update_time
    await sql_session.commit()
    session.last_update_time = updated_at.timestamp()
  3. Use expire_on_commit=False consistently and guard the lazy-load path.

Happy to send a PR if a maintainer can confirm preferred approach.

Activity

  1. added
    services[Component] This issue is related to runtime services, e.g. sessions, memory, artifacts, etc
    on May 20, 2026
  2. KrishnaVemu commented on May 20, 2026

    @KrishnaVemu
    Author

    Thanks for digging in — but I think the cancellation framing is leading us away from the actual mechanism. Let me lay out what I've verified, because the specifics matter for the fix.

    1. The greenlet bridge is per-await, not per-AsyncSession context.

    Each await async_session.something() spawns a fresh greenlet via greenlet_spawn, runs the sync ORM code inside it, and the greenlet exits when that await returns. It does not persist across awaits, and async with AsyncSession(...) is not what creates it. So "the surrounding task gets cancelled and the next await_only() lands without its greenlet" mischaracterizes the bridge. Cancellation between awaits would just give a normal CancelledError, not MissingGreenlet.

    2. da73e718ef did not move the commit or swap explicit awaits for implicit ones.

    The diff is one line: engine_kwargs.setdefault("pool_pre_ping", True) for non-SQLite engines. That's it. Verifiable in database_session_service.py:208. The commit shape is neither (a) nor (b) you proposed.

    3. The actual mechanism is pool_pre_ping + onupdate=func.now() + sync attribute access.

    schemas/v1.py:96 declares update_time with onupdate=func.now(). After the UPDATE flush, SQLAlchemy doesn't know what timestamp Postgres wrote, so it marks the attribute as expired — independent of expire_on_commit. Then database_session_service.py:743 reads it:

    await sql_session.commit()                                                   # L740
    session.last_update_time = storage_session.get_update_timestamp(is_sqlite)   # L743

    The read on L743 happens via __get__ on a Mapped attribute — plain sync attribute access, no await — which triggers _load_expired → load_on_pk_identity → sync pool checkout → pool_pre_ping=True → asyncpg dialect's do_ping → await_only(self._async_ping()) → no surrounding greenlet → MissingGreenlet. The full chain is in the trace at the top of this issue.

    Client disconnect is not required — it just changes the pool's checkout timing so the bug surfaces reliably. Without disconnect, the same connection used for the commit is sometimes still hot in the fairy and pre-ping is skipped; with disconnect, the teardown returns it and the next checkout pings.

    4. Why the proposed fixes don't address it:

    • asyncio.shield(session.commit()) — the commit is not what fails. L743 fires after a successful commit. Shielding the commit changes nothing.
    • except asyncio.CancelledError around append_event — there's no CancelledError in this trace, only MissingGreenlet. The bug reproduces without any cancellation.

    5. Cleanest structural fix is at L737–L743, not at cancellation boundaries.

    Either:

    (a) Read into a local before commit so no lazy-load is needed:

    storage_session.update_time = update_time
    await sql_session.flush()
    await sql_session.refresh(storage_session, ["update_time"])  # one await, inside the bridge
    updated_at = storage_session.update_time
    await sql_session.commit()
    session.last_update_time = (
        updated_at.replace(tzinfo=timezone.utc).timestamp() if is_sqlite
        else updated_at.timestamp()
    )

    (b) Drop onupdate=func.now() and trust the Python-assigned value (it's already set on L737), which removes the post-commit refresh entirely.

    Both make the bug structurally impossible regardless of pool_pre_ping or cancellation timing. A regression test should just hit the endpoint normally — no client cancellation choreography needed.

  3. surajksharma07 commented on May 20, 2026

    @surajksharma07
    Collaborator

    @KrishnaVemu the pool_pre_ping default introduced in da73e71 combined with the expired update_time on the post-commit attribute access is a precise root cause.

    Worth trying: move both get_update_timestamp(is_sqlite) and get_update_marker() into locals before the await sql_session.commit() at L747, then assign them to session after.
    That removes any post-commit ORM attribute access entirely and sidesteps the lazy-load/greenlet bridge issue.

    Could you test this thoroughly in your environment and confirm it consistently clears the MissingGreenlet across your use cases.

  4. added a commit that references this issue on May 28, 2026
    08ec380
  5. DeanChensj commented on Jun 17, 2026

    @DeanChensj
    Collaborator

    Fixed by #5814

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

request clarification[Status] The maintainer need clarification or more information from the authorservices[Component] This issue is related to runtime services, e.g. sessions, memory, artifacts, etc

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions