Repository navigation
DatabaseSessionService.append_event raises MissingGreenlet with asyncpg after client disconnect (regression from da73e718ef) #5761
Copy link
Copy link
Closed
Labels
request clarification[Status] The maintainer need clarification or more information from the author[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[Component] This issue is related to runtime services, e.g. sessions, memory, artifacts, etc
Description
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
database_session_service.py:208forcespool_pre_ping=Truefor any
non-SQLite URL (added in da73e71 on 2026-02-03 — this is a regression
for asyncpg users).append_eventat L740 doesawait sql_session.commit(), then at L743
accessesstorage_session.update_timevia
storage_session.get_update_timestamp(is_sqlite).- With async engine + commit, that attribute access lazy-loads → pool
checkout →_do_ping_w_event→await_only()outside the greenlet
bridge → MissingGreenlet. - Normally the request completes before this triggers. A client disconnect
raisesGeneratorExitthroughparallel_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)
- DatabaseSessionService with Postgres: sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called #548
- greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s) #1276
- DatabaseSessionService uses sync DB operations - needs async variant #1005, synchronous psycopg2 dependency in postgres database sessionservice , while adk is async all the way #1750 (related)
- Add support for reusing an existing SQLAlchemy AsyncEngine in DatabaseSessionService #3565 (related pool/asyncpg issue, still open)
Suggested fixes
-
Refresh
update_timeexplicitly before commit, so the post-commit
access doesn't lazy-load:await sql_session.refresh(storage_session, ["update_time"]) await sql_session.commit()
-
Read
update_timeinto a local before commit (cheapest fix):updated_at = storage_session.update_time await sql_session.commit() session.last_update_time = updated_at.timestamp()
-
Use
expire_on_commit=Falseconsistently and guard the lazy-load path.
Happy to send a PR if a maintainer can confirm preferred approach.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
request clarification[Status] The maintainer need clarification or more information from the author[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[Component] This issue is related to runtime services, e.g. sessions, memory, artifacts, etc
Activity
KrishnaVemu commentedon May 20, 2026
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-AsyncSessioncontext.Each
await async_session.something()spawns a fresh greenlet viagreenlet_spawn, runs the sync ORM code inside it, and the greenlet exits when that await returns. It does not persist across awaits, andasync with AsyncSession(...)is not what creates it. So "the surrounding task gets cancelled and the nextawait_only()lands without its greenlet" mischaracterizes the bridge. Cancellation between awaits would just give a normalCancelledError, notMissingGreenlet.2.
da73e718efdid 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 indatabase_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:96declaresupdate_timewithonupdate=func.now(). After the UPDATE flush, SQLAlchemy doesn't know what timestamp Postgres wrote, so it marks the attribute as expired — independent ofexpire_on_commit. Thendatabase_session_service.py:743reads it:The read on L743 happens via
__get__on aMappedattribute — plain sync attribute access, noawait— which triggers_load_expired→load_on_pk_identity→ sync pool checkout →pool_pre_ping=True→ asyncpg dialect'sdo_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.CancelledErroraroundappend_event— there's noCancelledErrorin this trace, onlyMissingGreenlet. 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:
(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.
surajksharma07 commentedon May 20, 2026
@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.
fix(sessions): Prevent MissingGreenlet after append_event with asyncpg
DeanChensj commentedon Jun 17, 2026
Fixed by #5814