perf: replace RLock with Lock where re-entrant locking is not needed (~11ns saving, -14%) - #796
perf: replace RLock with Lock where re-entrant locking is not needed (~11ns saving, -14%)#796mykaul wants to merge 2 commits into
Conversation
fbb04b2 to
5f8a314
Compare
V2 ChangesFixed: deadlock in
Fix: Restructured Additional cleanup:
Tests: 683 unit tests pass (660 core + 23 IO), 0 failures. |
Follow-up: Skip lock acquisition when no orphaned requests in process_msgCommit: 2e5a6c6 What changedIn Now we check Thread safetyThe unlocked truthiness check on a
Benchmark results (Python 3.14, 2M iterations)
Testing
|
2e5a6c6 to
014e82e
Compare
014e82e to
7d9d6a7
Compare
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Rebased onto current master + full re-entrancy re-verificationRebased Given how much 1.
|
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Improve runtime performance by replacing re-entrant locks (RLock) with standard locks (Lock) where recursion isn’t needed, and add a small hot-path optimization to reduce unnecessary lock acquisitions.
Changes:
- Replace multiple
RLockinstances withLockacross connection/cluster/metadata/pool/cqlengine. - Adjust
Cluster.connect()failure path to avoid deadlock with non-reentrant locking. - Add unit tests and micro-benchmarks to validate lock types and measure overhead.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
cassandra/connection.py |
Switch Connection.lock to Lock and add a fast-path to skip locking in process_msg when possible. |
cassandra/cluster.py |
Switch internal locks to Lock and restructure connect() failure handling to avoid deadlock. |
cassandra/metadata.py |
Switch metadata/token map locks from RLock to Lock. |
cassandra/pool.py |
Switch Host.lock from RLock to Lock. |
cassandra/cqlengine/connection.py |
Switch lazy_connect_lock from RLock to Lock. |
tests/unit/test_rlock_to_lock.py |
Add unit tests validating lock types and basic operations don’t deadlock. |
benchmarks/micro/bench_rlock_vs_lock.py |
Add micro-benchmark comparing Lock vs RLock overhead. |
benchmarks/micro/bench_orphan_lock_skip.py |
Add micro-benchmark for the new orphaned-request “skip lock if empty” approach. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Convert 7 of 8 RLock instances to plain Lock. All verified to use only flat (non-recursive) acquisition patterns: - Connection.lock (hot path: every message send/receive) - Cluster._lock (connect/shutdown) - ControlConnection._lock and _reconnection_lock - Metadata._hosts_lock and TokenMap._rebuild_lock - Host.lock and cqlengine Connection.lazy_connect_lock Session._lock is kept as RLock because run_add_or_renew_pool() uses manual release/acquire inside a 'with' block. Benchmark: RLock 'with' stmt is ~14% slower than plain Lock.
Check orphaned_request_ids only when self._requests.pop(stream_id) raises KeyError, i.e. the request was already removed -- most likely because ResponseFuture._on_timeout marked it as orphaned. This keeps the common, non-orphaned path free of any lock acquisition or access to orphaned_request_ids at all, while still coordinating the orphan check with the same lock the writer (_on_timeout) uses to add to orphaned_request_ids, so a concurrent add for this exact stream_id is never missed. An earlier version of this optimization used an unconditional, unlocked `if self.orphaned_request_ids:` pre-check ahead of the try/except. That pre-check could observe the set as empty at the exact moment ResponseFuture._on_timeout was concurrently adding this same stream_id under the lock, causing process_msg to skip the bookkeeping entirely: the in_flight decrement, the orphaned_request_ids cleanup, and the release notification for that stream were silently lost (a stale entry could even outlive its stream_id being recycled for a brand-new request). See tests/unit/test_connection.py::ProcessMsgOrphanRaceTest for a deterministic regression test covering this. Benchmark (2M iters, Python 3.14): Empty set (common): 80.6 -> 23.2 ns (3.47x, -57.4 ns/response) Non-empty set (rare): 79.7 -> 87.8 ns (+8.1 ns overhead)
7d9d6a7 to
3d626b6
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (4)
cassandra/cluster.py:1842
- Releasing
_lockbefore marking the cluster shut down breaks the previous atomic failure transition. A second thread can enterconnect()in this gap, retry setup, and even create a session that the first thread immediately shuts down. Preserve theis_shutdowntransition under this same lock, then perform cleanup outside it via a non-locking helper so concurrent callers cannot observe a connectable cluster after setup failure.
if connect_exc is not None:
# shutdown() acquires self._lock, so must be called after
# releasing it above to avoid deadlock.
self.shutdown()
benchmarks/micro/bench_orphan_lock_skip.py:48
- This benchmark measures the superseded unlocked truthiness pre-check, not the implementation now used by
process_msg(which only checks the orphan set after_requests.pop()raises). Its reported “new” timing is therefore unrelated to the code being merged and benchmarks the race-prone approach removed earlier in this PR. Update it to model the current try/except path or remove it.
# New: check set first, skip lock if empty
def new_check():
nonlocal in_flight
if orphaned_set:
with lock:
cassandra/pool.py:24
uuidis not referenced anywhere incassandra/pool.py; this newly added import is unrelated to the lock conversion and should be removed.
import uuid
cassandra/connection.py:1425
- This still misses the interleaving where
_on_timeoutpops_requests, pauses before acquiringconnection.lock, and this handler acquires the lock first. The ID is then returned without orphan bookkeeping; afterward_on_timeoutadds a stale orphan entry and leavesin_flightelevated, even though the response has already arrived. The timeout path must make removing the request and publishing its orphan state atomic under the same lock (and add a regression test for this ordering).
with self.lock:
if stream_id in self.orphaned_request_ids:
self.in_flight -= 1
self.orphaned_request_ids.remove(stream_id)
need_notify_of_release = True
self.request_ids.append(stream_id)
Summary
Convert 7 of 8 RLock instances to plain Lock. All verified to use only flat (non-recursive) acquisition patterns:
Connection.lockCluster._lockControlConnection._lockControlConnection._reconnection_lockMetadata._hosts_lockTokenMap._rebuild_lockHost.lockcqlengine.Connection.lazy_connect_lockSession._lockis kept as RLock becauserun_add_or_renew_pool()uses manualrelease()/acquire()inside awithblock, which requires re-entrant semantics.Benchmark
withstmt)RLockLockTests
test_update_host_sequential_lockspecifically validates thatMetadata.update_host()works with plain Lock (sequential, not nested acquisition)