Skip to content

anyio taskgroup cancellation leading to asyncpg connection leak #12077

Closed

Description

@jraby

Describe the bug

I hit a connection leak when cancelling an http handler task in a fastapi + starlette + BaseHTTPMiddleware + SA + asyncpg setup and managed to reproduce it with the code below.

From my understanding, when cancelling an anyio taskgroup which contains a task that checked out a connection from the pool, SA tries to close the connection, but most (all?) await calls within that code path bail with asyncio.CancelledError, leading to SA and asyncpg thinking that the connection was closed / released, but the socket is still alive and idle from postgres's point of view.

This is related to MagicStack/asyncpg#1156 which doesn't currently have a resolution.

The following code leaks a connection on every loop operation, as observed by running select * from pg_stat_activity where datname='postgres'; in the db while the script is running.

#12076 is an attempt a fixing this behavior by shielding connection termination from asyncio.CancelledError

Optional link from https://docs.sqlalchemy.org which documents the behavior that is expected

No response

SQLAlchemy Version in Use

2.0.36

DBAPI (i.e. the database driver)

asyncpg

Database Vendor and Major Version

postgres 17

Python Version

3.12

Operating system

linux

To Reproduce

import asyncio
import asyncpg
import anyio

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.sql import text

SQLALCHEMY_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
engine = create_async_engine(SQLALCHEMY_DATABASE_URL, pool_size=1, max_overflow=0, echo_pool="debug")
async_session = async_sessionmaker(engine, expire_on_commit=False)

async def anyio_tg_wrapper(func, *args, **kwargs):
    async with anyio.create_task_group() as tg:
        tg.start_soon(func, *args, **kwargs)

async def tg_wrapper(f):
    async with asyncio.TaskGroup() as tg:
        tg.create_task(f)


async def querier():
    async with async_session() as session, session.begin():
        res = await session.execute(text("select 1"))
        print([row for row in res])
        
        # simulate other async io work while holding the session
        await asyncio.sleep(10)

async def main():
    while True:
        t = asyncio.create_task(anyio_tg_wrapper(querier))
        
        # asyncio task group does not lead to a leak
        #t = asyncio.create_task(tg_wrapper(querier()))

        async def timeouter():
            await asyncio.sleep(1)
            t.cancel()

        asyncio.create_task(timeouter())

        try:
            await t
        except asyncio.CancelledError:
            print("cancelled")

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

Error

This is the output from the script above.

2024-11-10 08:07:01,830 DEBUG sqlalchemy.pool.impl.AsyncAdaptedQueuePool Created new connection <AdaptedConnection <asyncpg.connection.Connection object at 0x7e46b7088e60>>
2024-11-10 08:07:01,831 DEBUG sqlalchemy.pool.impl.AsyncAdaptedQueuePool Connection <AdaptedConnection <asyncpg.connection.Connection object at 0x7e46b7088e60>> checked out from pool
[(1,)]
2024-11-10 08:07:02,252 INFO sqlalchemy.pool.impl.AsyncAdaptedQueuePool Invalidate connection <AdaptedConnection <asyncpg.connection.Connection object at 0x7e46b7088e60>> (reason: CancelledError:Cancelled by cancel scope 7e46b705a690)
2024-11-10 08:07:02,253 DEBUG sqlalchemy.pool.impl.AsyncAdaptedQueuePool Hard-closing connection <AdaptedConnection <asyncpg.connection.Connection object at 0x7e46b7088e60>>

at this point, the "hard-closed" connection, is not in the pool anymore. But still alive.

When using an asyncio task group instead of an anyio task group, the session is rolled back and put back in the pool, which is probably a better outcome than closing the connection in this case.

2024-11-10 08:10:56,558 DEBUG sqlalchemy.pool.impl.AsyncAdaptedQueuePool Created new connection <AdaptedConnection <asyncpg.connection.Connection object at 0x7e75c812caa0>>
2024-11-10 08:10:56,564 DEBUG sqlalchemy.pool.impl.AsyncAdaptedQueuePool Connection <AdaptedConnection <asyncpg.connection.Connection object at 0x7e75c812caa0>> checked out from pool
[(1,)]
2024-11-10 08:10:57,526 DEBUG sqlalchemy.pool.impl.AsyncAdaptedQueuePool Connection <AdaptedConnection <asyncpg.connection.Connection object at 0x7e75c812caa0>> being returned to pool
2024-11-10 08:10:57,526 DEBUG sqlalchemy.pool.impl.AsyncAdaptedQueuePool Connection <AdaptedConnection <asyncpg.connection.Connection object at 0x7e75c812caa0>> rollback-on-return

Additional context

No response

Activity

  1. added
    engineengines, connections, transactions, isolation levels, execution options
    great mcveAn issue with a great mcve
    and removed
    requires triageNew issue that requires categorization
    on Nov 11, 2024
  2. sqla-tester commented on Nov 15, 2024

    @sqla-tester
    Collaborator

    Jean Raby has proposed a fix for this issue in the main branch:

    asyncpg: shield connection close in terminate to avoid connection leak https://gerrit.sqlalchemy.org/c/sqlalchemy/sqlalchemy/+/5594

  3. sqla-tester commented on Jan 17, 2025

    @sqla-tester
    Collaborator

    Federico Caselli has proposed a fix for this issue in the rel_2_0 branch:

    asyncpg: shield connection close in terminate to avoid connection leak https://gerrit.sqlalchemy.org/c/sqlalchemy/sqlalchemy/+/5667

  4. added a commit that references this issue on Jan 21, 2025
    9eb87f4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    asyncioengineengines, connections, transactions, isolation levels, execution optionsgreat mcveAn issue with a great mcve

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions