Repository navigation
🐛 fix(async-rw): drop holds left by failed or abandoned calls - #833
Merged
Merged
Conversation
gaborbernat
force-pushed
the
fix/async-rw-close
branch
from
October 16, 2026 02:09
cc6ce9a to
49e4feb
Compare
gaborbernat
force-pushed
the
fix/async-hold-after-failure
branch
from
October 16, 2026 02:09
16007e0 to
f7f24eb
Compare
gaborbernat
force-pushed
the
fix/async-rw-close
branch
from
October 16, 2026 02:36
49e4feb to
e941530
Compare
gaborbernat
force-pushed
the
fix/async-hold-after-failure
branch
from
October 16, 2026 02:36
f7f24eb to
34b8bb4
Compare
gaborbernat
force-pushed
the
fix/async-rw-close
branch
from
October 16, 2026 04:52
e941530 to
2557200
Compare
gaborbernat
force-pushed
the
fix/async-hold-after-failure
branch
3 times, most recently
from
October 16, 2026 05:52
646dd13 to
a79bd7d
Compare
gaborbernat
force-pushed
the
fix/async-hold-after-failure
branch
4 times, most recently
from
October 16, 2026 13:43
16300d4 to
66fc803
Compare
gaborbernat
force-pushed
the
fix/async-rw-close
branch
from
October 16, 2026 13:45
54b9513 to
1cfa37b
Compare
gaborbernat
force-pushed
the
fix/async-hold-after-failure
branch
from
October 16, 2026 13:45
66fc803 to
10155fb
Compare
gaborbernat
force-pushed
the
fix/async-rw-close
branch
2 times, most recently
from
October 16, 2026 17:01
67fe0f1 to
770733b
Compare
gaborbernat
force-pushed
the
fix/async-hold-after-failure
branch
from
October 16, 2026 17:02
10155fb to
6629211
Compare
gaborbernat
force-pushed
the
fix/async-rw-close
branch
from
October 16, 2026 19:57
770733b to
14fcde9
Compare
gaborbernat
force-pushed
the
fix/async-hold-after-failure
branch
from
October 16, 2026 19:57
6629211 to
4c2bf09
Compare
A ReadWriteLock release ends the SQLite transaction and then resets the lock level in a separate step. When something other than a sqlite3.Error stopped it in between, such as an exception raised by a signal handler, the level stayed set with no transaction behind it. The next acquire on that instance, the per-path singleton by default, then nested into the lost hold and returned without taking any database lock, while another process could take the write lock. The release now clears the level for any exception once the transaction is over, and an acquire that finds a level whose transaction ended drops it and locks again instead of nesting.
When the backend release of an async read-write lock failed, the task table put the task's hold back so a retried release could find it. The sync SoftReadWriteLock drops its hold before the filesystem step that can fail (a full disk, for example), so the restored entry stood for a lock nobody held: later readers joined it without touching the lock files, beside a writer in another process. The table now restores the hold only while the sync lock still has the level, so a failure after the backend let go leaves the table empty and the next acquire takes the lock again.
Every task waiting in an async read-write lock's task table waited on one shared future, and a task that gave up (by timeout or cancellation) could not detach from it. Each give-up left a callback and a future reachable from the lock until the table next changed state, so a service answering requests with read_lock(timeout=...) grew by about a kilobyte per timed-out request for as long as a writer held or waited. Each waiting call now gets its own ticket, taken out of the table when the call stops waiting.
An async file lock serializes acquire() and release() through a chain of tickets. A call that gave up could not leave the chain, so it asked its predecessor to settle its ticket, and the holder's exit settled the whole chain recursively. About 250 calls giving up behind one waiting acquire() were enough to raise RecursionError inside a future callback; the rest of the chain stayed pending, release() never returned and the lock file stayed held until the process was killed. The gate now keeps its tickets in a queue. A call that gives up removes its own ticket, and the holder hands the gate to the next ticket directly.
for more information, see https://pre-commit.ci
The interrupted-release, failed-release and abandoned-waiter cases lived in files of their own. ReadWriteLock, AsyncReadWriteLock and AsyncSoftReadWriteLock each already have a test file, so they belong there.
One sentence per idea in the gate docstring, the retained-hold comment and the interrupted-release comment, and a changelog entry in the same style.
After an interrupted forced release, the sync lock keeps a level whose transaction already ended. The sync acquire path reconciles that, but the async wrappers asked _holds_level_of(), which only read the depth tables, and admitted readers into the dead level. Ask whether the transaction ended as well, without taking the internal lock, so the next task enters and takes a real lock.
A signal handler's exception delivered while a call joined the transition gate's queue skipped the block that removes the ticket. The orphan ticket then blocked every later acquire and release on the lock. Enqueue the ticket inside that block, let leaving tolerate a ticket that never joined, and have a parked caller settle a head whose hand-off was cut short.
gaborbernat
force-pushed
the
fix/async-hold-after-failure
branch
from
October 17, 2026 03:08
4c2bf09 to
bf4b55c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four failure and give-up paths in the read-write locks either left a lock believing in a hold that no longer existed, or kept state for calls that had already left. This PR ties
ReadWriteLock's level (its count of nested acquisitions) to its SQLite transaction, makes the async task table trust the sync lock after a failed release, gives every waiting task its own ticket, and turns the async file locks' ticket chain into a queue. It builds on the task-table model from #832.An interrupted
ReadWriteLockrelease kept a level with no transactionReadWriteLock.release()rolls the SQLite transaction back and resets the lock level in two steps. The handler between them cleared the level only forsqlite3.Error, a shape that came in with #652. Python runs a signal handler between any two bytecodes. An exception from one, such as aSIGALRMjob timeout or aKeyboardInterruptthat a retry loop catches, left_lock_levelat 1 after the rollback had run. The nextwrite_lock()on that instance, the per-path singleton by default, took the re-entrant shortcut and returned with no database lock. Another process acquired the same write lock meanwhile. The object raised and logged nothing until its next release.stateDiagram-v2 direction LR classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332 classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000 classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a Held: level 1, transaction open Rolled: level 1, transaction gone Free: level 0 Held --> Rolled: rollback ran Rolled --> Free: level reset Rolled --> Rolled: interrupt between the two (before) Rolled --> Free: next acquire sees no transaction (after) class Held info class Rolled bad class Free okThe release now clears the level for any exception once
_transaction_ended()reports the rollback done. That check asks the connection'sin_transaction, so it also covers an interrupt between the rollback and the flag that records it. An interrupt can still land on the way out of the cleanup, so an acquire that finds a level whose transaction ended drops it and locks again instead of nesting. The async wrappers ask the same question before they let a task join the level taken for tasks, so a phantom level does not admit readers through the table either. An interrupt before the rollback leaves level and transaction together, and the check leaves that alone.A failed sync release put a hold back that the sync lock had dropped
When the sync release raised,
_TaskOwners.release()restored the task's hold so a retried release could find it, which #789 kept on purpose. ForAsyncSoftReadWriteLockthat assumption does not hold.SoftReadWriteLock.release()clears its hold and stops the heartbeat (the thread that refreshes the holder record) beforeParticipant.leave()commits the successor snapshot. That commit is the step that fails on a full filesystem withENOSPC. From then on the table admitted every later reader, and the task whose release failed, into a hold with nothing on disk, beside a writer in another process. In-process writers waited forever.sequenceDiagram participant T as task participant O as task table participant S as SoftReadWriteLock participant D as lock files T->>O: release() O->>S: release the level (executor) S->>S: hold = None, heartbeat stopped S->>D: commit successor snapshot D-->>S: ENOSPC S-->>O: OSError rect rgb(255, 214, 214) Note over O: before: restore the hold, readers join it with no lock behind end rect rgb(216, 243, 220) Note over O: after: sync lock holds nothing, so the table is left empty endrelease()asks the wrapper, through itsstill_heldcallback, whether the sync lock still has the level. It restores the hold only while it does; otherwise it finishes the transition as released and lets the error propagate.BaseAsyncFileLockalready asks its backend before deciding what a failed release means. A release that fails while the sync lock still holds keeps its hold for a retry, as before.Every give-up behind a writer kept a future alive
All tasks waiting in the table parked on one shared
concurrent.futures.Futurethroughasyncio.wrap_future(). That registers a done callback which aconcurrent.futures.Futureoffers no way to remove. A read that gave up throughtimeoutorasyncio.timeout()left its callback and wrapper future reachable from the lock until the table next changed state, about 1.2 KiB each. Clearing a large backlog ran every stale callback on the event loop at once. A service answering requests withread_lock(timeout=...)while one task waited for a long hold grew at its own request rate.flowchart LR W[task waits] --> B[shared future] B -. give-up leaves callback .-> B W2[task waits] --> T[own ticket] T -- give-up --> X[ticket removed] classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332; classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000; classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; class W,W2 info; class B bad; class T,X ok;Each waiting call now gets its own ticket.
_admit()creates the ticket under the table lock, in the same critical section as the admission decision, so no wakeup is lost. Afinallyaround the wait takes it out of the table, which coversTimeoutand cancellation alike._notify()settles only the tickets still registered, in arrival order.Abandoned acquires settled their tickets by recursion
AsyncFileLockandAsyncSoftFileLockserializeacquire()andrelease()through a chain of tickets. Since #652 a call that gave up could not leave the chain, so it asked its predecessor to settle its ticket. When the polling task took the lock, the chain unwound asset_result→ callback →set_result..., four frames per abandoned call. Past about 250 give-ups behind one waitingacquire(), at the default recursion limit,concurrent.futurescaught aRecursionErrorinside a callback. The rest of the chain stayed pending.release()did not return,async with lock:did not exit, and the lock file stayed held until the process exited.flowchart LR H[holder] --> A1[abandoned 1] --> A2[abandoned 2] --> AN[abandoned ~250] --> R[RecursionError: tail never settled] H2[holder] --> Q[queue] --> N[next ticket] classDef ok fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332; classDef bad fill:#ffd6d6,stroke:#c1121f,color:#5c0000; classDef info fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; class H,H2 info; class A1,A2,AN,R bad; class Q,N ok;The gate now keeps its tickets in a
dequeguarded by its lock. A call that gives up removes its own ticket. The holder hands the gate to the next ticket itself, so no ticket settles on another ticket's stack. Admission stays first in, first out. A ticket enters the queue inside the block that removes it, so an exception from a signal handler delivered while a call joins the queue does not leave a ticket behind, and a parked caller that finds itself at the head of the queue takes the gate itself.Behavior changes
A
release()retried after a failure that already dropped the sync-lock hold raises the existing "not held by this task"RuntimeErrorinstead of reporting success. AReadWriteLock.release()interrupted after its rollback leaves the object unheld. A retried release then raises "not held", as it already did after a failed rollback whose transaction ended.