Conversation
…pagates Repeatedly cancelling a transaction could keep its pool slot checked out. Two driver-level gaps combine. SQLAlchemy can drive both a graceful close and a stop on the same aiosqlite connection, whose stop is not idempotent and enqueues a second request behind a worker that already exited. And a statement cancelled mid-await leaves its native cursor held by the exception stack, so the rollback that follows fails with "Connection closed" and the slot is never returned. Close the cursor tracked for the connection before retiring it, make the driver stop idempotent per connection, interrupt the statement SQLite is still running, and finish the whole exit inside a managed task so a repeated cancellation cannot strand the cleanup. Only the transaction that owns the connection runs it, so a nested exit cannot close its caller's cursors. A clean exit stays in the caller's task, which the seekdb shutdown ordering depends on. Without the cursor step the new regression fails: the only pool slot is still checked out after the tenth cancelled transaction. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
CI is green on the first attempt (23/23). Worth noting: re-running the same |
Teingi
left a comment
There was a problem hiding this comment.
Found one transaction atomicity issue in the cancellation path.
| if driver._running and driver._connection is not None: | ||
| # Cancelling an await does not stop SQLite's native statement or disconnect its worker. | ||
| driver._connection.interrupt() | ||
| context.is_disconnect = False |
There was a problem hiding this comment.
[P2] Prevent partial commits after an interrupted write
SQLite can roll back the entire native transaction when a write is interrupted. Forcing is_disconnect = False lets SQLAlchemy keep using that transaction: insert row 3, catch TimeoutError around a long UPDATE, then insert row 4. The context exits successfully, but row 3 is lost and only row 4 is committed. The base revision raises PendingRollbackError instead. Please mark the transaction invalid or rollback-only so later work cannot commit after earlier writes have been silently rolled back.
There was a problem hiding this comment.
Thanks — you're right, and the reproduction is exact.
Confirmed before fixing: insert row 3, interrupt a long UPDATE, insert row 4, then exit the
context. Before the change the context returned cleanly and only row 4 was committed; the
unmodified baseline raises PendingRollbackError and commits nothing.
The cause is that sqlite3_interrupt() rolls the whole native transaction back when an
INSERT/UPDATE/DELETE is interrupted, while is_disconnect = False told SQLAlchemy the connection
was still trustworthy. That flag was there for a reason — dropping the connection destroys an
in-memory StaticPool database (verified: the next statement fails with no such table) — so the
fix keeps the connection and makes the transaction itself uncommittable instead: the interruption
is recorded on the connection, and the transaction raises PendingRollbackError when it exits
rather than committing its remains.
test_interrupted_write_cannot_commit_earlier_work covers a file-backed and an in-memory
database. Disabling the guard makes both fail.
…mitting part of it Interrupting a SQLite statement rolls the whole native transaction back, so writes that already reported success are gone while SQLAlchemy still trusts the transaction. Keeping `is_disconnect = False` avoided discarding the connection -- an in-memory StaticPool holds its entire database in it -- but it also let a caller that caught the error keep writing and commit a partial result: insert row 3, interrupt a long UPDATE, insert row 4, and only row 4 survives. Record the interruption on the connection and refuse to commit when the transaction exits, raising PendingRollbackError the way the unmodified baseline does. The connection still returns to the pool, so an in-memory database survives. * `test_interrupted_write_cannot_commit_earlier_work[file|memory]` fails without the guard. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Teingi
left a comment
There was a problem hiding this comment.
The earlier partial-commit issue is fixed. Two regressions remain in cancellation cleanup and connection reuse.
| yield connection | ||
| except BaseException as error: | ||
| if connection.dialect.name == "sqlite": | ||
| connection.info.pop("_powercontext_sqlite_interrupted", None) |
There was a problem hiding this comment.
[P2] Always finish cleanup after the connection is invalidated
With AsyncDatabase.attach(create_async_engine("sqlite+aiosqlite://...")), cancelling an executing statement invalidates the connection through SQLAlchemy's normal cancellation path. This connection.info access then raises PendingRollbackError before _finish_transaction() runs. A real SQLite probe now receives that error instead of CancelledError, leaves one active transaction, and cannot complete database.close() while the operation task remains referenced. The previous commit propagates cancellation and finishes cleanup. Please ensure invalidated or closed connections still reach transaction cleanup and preserve the original cancellation.
| ) | ||
| await _finish_transaction(context, connection, interrupted, sqlite_stop, sqlite_cursors) | ||
| raise interrupted | ||
| await context.__aexit__(None, None, None) |
There was a problem hiding this comment.
[P2] Clear interruption state after COMMIT finishes
The interruption marker is checked before this call performs COMMIT. If cancellation arrives while COMMIT is blocked, the error handler sets the marker afterward and it remains on the pooled connection. With SQLite DELETE journaling and a reader holding a shared lock, cancelling that COMMIT makes the next unrelated INSERT transaction raise PendingRollbackError and roll back. The same probe succeeds on the previous commit. Please scope or clear the marker when its transaction actually finishes, including cancellation during COMMIT or ROLLBACK, so it cannot affect the next borrower.
There was a problem hiding this comment.
Both regressions are fixed, and each now has a test that fails without its fix.
Invalidated connections reached the wrong place. connection.info re-resolves the DBAPI
connection, so once a cancelled statement invalidated it, reading the mark inside the failure path
raised PendingRollbackError and skipped _finish_transaction() — leaving the transaction counted
as active. The transaction now holds the info dict itself, which stays usable once the connection
is gone, so cleanup runs and the caller still sees the original CancelledError.
test_invalidated_connection_still_reaches_transaction_cleanup and
test_invalidated_connection_preserves_the_original_cancellation cover it; both fail before the
change.
The mark outlived its transaction. The check ran before COMMIT, so a commit cancelled while in
flight recorded the mark afterwards and left it on the pooled connection. It is cleared in a
finally around the exit now, which covers cancellation during COMMIT or ROLLBACK.
test_marker_recorded_during_commit_does_not_survive_the_transaction plants the mark from the
commit event — after the check, exactly where your probe found it — and fails without the cleanup.
One note: an earlier run of tests (3.14) failed on
test_cancelling_a_request_during_a_stalled_usage_write_leaves_the_runtime_healthy[storage-failure]
with "cancelled request did not finish". Re-running it went green, and it does not reproduce locally
(10 focused runs, 3 whole-file, 2 full e2e). That test overlaps a 5s busy_timeout with a 5s wait,
so I read it as a timing-sensitive test rather than a code defect — flagging it rather than quietly
re-running until green.
There was a problem hiding this comment.
Two things from the latest CI, separated because they are different problems.
quality failed on 8998b919: I annotated a commit-event callback object and then
read .info off it. Fixed in 545ab669.
tests (3.14) is the one I want your read on.
test_cancelling_a_request_during_a_stalled_usage_write_leaves_the_runtime_healthy[storage-failure]
failed on de8086f5 and again on 8998b919 with "cancelled request did not finish" — the request
task had not completed 5s after pending.cancel(). Both runs show the same tmp_path and the same
task id, so on CI this is deterministic, not a one-off.
I cannot reproduce it locally, and I have ruled out what I can reach from here:
| attempt | result |
|---|---|
| 3.14.4, focused ×20 | pass |
| 3.14.4, whole file ×3, full e2e ×2 | pass |
| 3.14.5rc1 — closest available to CI's 3.14.7 — focused ×5, file ×3 | pass |
| pinned to 1 and to 2 CPUs | pass |
What I did find is that the test's own deadline sits right on top of the configured budget:
busy_timeout_ms=5_000 # app config
done, _ = await asyncio.wait({pending}, timeout=5) # test
Both are 5s. At the point the request is cancelled, the usage write is stalled on release, so the
transaction still holds SQLite's write lock and record_fails has not made a difference yet. If the
request ends up waiting on that lock, it finishes right at the test's deadline, and which side wins
comes down to scheduling.
Two things I would rather ask than guess:
1. Is a 5s window meant to be sufficient here? If a cancelled request can legitimately take up to
busy_timeout to unwind, the test should either wait longer or wait for an explicit completion
signal instead of a fixed deadline.
2. [storage-failure] is a parametrisation I added earlier in this PR. It runs second, and both
failures are on it — could the second run be starting from a different state than the first?
I would rather not loosen a timeout to make the check green; that is how the failure on #1734 got
masked in the first place. If you read this as a real defect rather than a timing-sensitive test,
say so and I will keep digging — I just cannot get 3.14.7 locally to reproduce it.…ransaction The mark lives on the pooled connection, and the usage recorder runs its own transaction without going through `AsyncDatabase.transaction()`, so an interrupt recorded there was never consumed. It survived on the connection until an unrelated transaction picked that connection up and raised PendingRollbackError for a rollback it had nothing to do with -- which is how this first showed up, as a topic-memory supervisor test failing on 3.11. Clear any leftover mark when a transaction starts, so only an interrupt raised inside it can fail it. * `test_interrupt_marked_outside_the_transaction_does_not_fail_it` fails without the cleanup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ection Two follow-ups from review. `connection.info` re-resolves the DBAPI connection, so once a cancelled statement invalidated it through SQLAlchemy's normal path, reading the interruption mark from the failure path raised PendingRollbackError and skipped the rest of `_finish_transaction()`. That left the transaction counted as active and kept `database.close()` from finishing. Hold the info dict itself instead; it stays usable after the connection is gone. The mark was also only checked before COMMIT ran. A commit cancelled while in flight records the mark afterwards, so it survived on the pooled connection and failed the next borrower's transaction. Clear it in a `finally` around the exit, which covers cancellation during COMMIT or ROLLBACK too. * `test_invalidated_connection_still_reaches_transaction_cleanup` fails without the first part. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The earlier fixes addressed review, but two of its requirements were only verified indirectly. Preserving the original cancellation: the invalidation test asserted that cleanup ran, not that the caller still sees CancelledError. Cover that directly. Clearing the mark on a commit that is cancelled: the stale-mark test planted the mark before the transaction started. Plant it from the commit event instead, which is where an error handler records it when the commit itself is interrupted -- after the transaction's own check. That fails without the cleanup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
aee8478 to
8998b91
Compare
… receives `plant_mark` was annotated `object` and then read `.info` off it, which `ty` rejects: `Object of type object has no attribute info`. Name the SQLAlchemy connection type the commit event actually passes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes #1780
Problem
Cancelling a transaction while SQLite is executing a statement could leave its pool slot checked
out. With a one-slot pool, the next
ping()timed out and the runtime stopped accepting work.This is the failure behind the Python 3.14 e2e job on #1734, which passed on a re-run and was
therefore merged without a fix.
Two driver-level gaps combine:
rollback that follows fails with
Connection closedand the connection never returns to thepool.
aiosqlite0.22.1'sstop()is not idempotent. SQLAlchemy can drive both a graceful close anda stop on the same connection, and the second request is enqueued behind a worker that has
already exited, so its completion future never resolves.
The defect is pre-existing: it reproduces on the revision before #1734, and on plain SQLAlchemy
without these wrappers.
Change
Driver-level, per connection (
sqlite/profile.py)the connection is retired.
stop()idempotent for the connection: the original stop runs once, its completion futureis cached, and every waiter gets its own shield so one cancelled waiter cannot poison the
others. Patched per connection instance; the shared
aiosqliteclass is untouched.Transaction exit (
database.py)active-transaction count — inside a managed task, so a repeated cancellation cannot strand the
cleanup. The original cancellation is then re-raised.
its caller's cursors.
(
test_profile_finishes_shutdown_before_propagating_repeated_cancellation).Verification
Reproduce and fix, on the same host:
test_repeated_transaction_cancellation_does_not_exhaust_the_pool[sql]without the cursor stepOperationalError: interrupted)Full runs:
pytest --doctest-modules --ignore=tests/e2emaster, no new onestests/e2eSIGSTOPty checkWindows is Python 3.14.4; the CI job pins 3.14.7, which was not obtainable offline.