perf: move request submission off event loop thread in execute_concurrent - #827
perf: move request submission off event loop thread in execute_concurrent#827mykaul wants to merge 1 commit into
Conversation
fd9be81 to
b759d4a
Compare
v2 changes: reduce per-request lock overhead in ResponseFutureNew commit Changes
Design notes
Test results
|
|
Correction on test failures: The 6 "pre-existing failures" reported in the v2 comment were all caused by a stale Cython 642 passed, 0 failures, 8 skipped No pre-existing failures. Clean test run. |
d054886 to
11c0191
Compare
11c0191 to
5a0dac7
Compare
5a0dac7 to
8c88381
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 Eventlet/gevent executor-factory issue (the #637 class of bug): not present here. The submitter thread is a plain Thread-safety: mostly sound (single-writer-under-lock for Ordering: unaffected. The returned list is still built from Tests: Amended the fixes into the existing commit and force-pushed ( |
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.
Moves execute_concurrent() follow-up request submission off the event-loop callback thread and reduces per-request synchronization overhead in the hot path.
Changes:
- Adds a dedicated submitter thread that drains completion signals and submits subsequent
execute_async()calls in batches. - Makes the event-loop callback path lighter by replacing per-callback locking/submission work with
deque.append()+Event.set(). - Adds a regression test to ensure user iterables that raise mid-stream surface errors (instead of hanging) when the submitter thread is involved.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
cassandra/concurrent.py |
Introduces submitter thread + signaling to move submission/serialization work off the event-loop callback thread. |
tests/unit/test_concurrent.py |
Adds a test to ensure exceptions from a broken statements_and_parameters iterable propagate instead of causing a hang. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
tests/unit/test_concurrent.py (1)
329-334: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMatch
ResponseFuture.add_callbacks's parameter order in the test double.The real signature is
add_callbacks(self, callback, errback, callback_args=(), ...)(cassandra/cluster.py:5674). The double reorderserrbackaftercallback_args, so it only works becauseconcurrent.pyhappens to pass keywords; a future positional call would mis-bind instead of failing loudly.♻️ Align the stub signature
- def add_callbacks(self, callback, callback_args=(), callback_kwargs=None, - errback=None, errback_args=(), errback_kwargs=None): + def add_callbacks(self, callback, errback, + callback_args=(), callback_kwargs=None, + errback_args=(), errback_kwargs=None):🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/test_concurrent.py` around lines 329 - 334, Update the test double’s add_callbacks method in tests/unit/test_concurrent.py to match ResponseFuture.add_callbacks positional parameter order, placing errback immediately after callback and retaining the remaining argument defaults. Keep the synchronous callback behavior unchanged.cassandra/concurrent.py (1)
254-258: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueHot path still takes a lock via
Event.set().
Event.set()acquires the event's internal lock on every callback, so the "NO lock acquisition" claim in the docstring isn't quite accurate. Skipping the redundantset()is safe because the submitter clears the event before draining, so any append that observesis_set() == Trueis still picked up by the in-flight or next drain.♻️ Optional signaling tweak
self._submit_ready.append(1) - self._submit_event.set() + if not self._submit_event.is_set(): + self._submit_event.set()Also applies to: 286-287
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cassandra/concurrent.py` around lines 254 - 258, Update the hot-path callback logic described near the concurrent submission/draining flow to avoid calling Event.set() on successful non-fail-fast callbacks, since it acquires an internal lock; rely on the submitter’s pre-drain event clearing and existing drain signaling instead. Apply the same change to the corresponding logic around the additionally referenced location, while preserving signaling for fail-fast or otherwise necessary paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cassandra/concurrent.py`:
- Around line 226-233: Update the wait loop in the main result-consumption path
to check _exception and _fail_fast before calling _condition.wait(), while
preserving the existing post-wait check. In _submitter_loop, extend the
pre-dispatch condition to stop scheduling additional statements when _exception
is set under fail-fast mode, so remaining iterable items are not dispatched
after an early failure.
In `@tests/unit/test_concurrent.py`:
- Line 311: Add pytest-timeout to the project’s declared test or development
dependencies so the pytest.mark.timeout decorator in the concurrent test is
provided by an explicitly installed plugin. Preserve the existing timeout marker
usage.
---
Nitpick comments:
In `@cassandra/concurrent.py`:
- Around line 254-258: Update the hot-path callback logic described near the
concurrent submission/draining flow to avoid calling Event.set() on successful
non-fail-fast callbacks, since it acquires an internal lock; rely on the
submitter’s pre-drain event clearing and existing drain signaling instead. Apply
the same change to the corresponding logic around the additionally referenced
location, while preserving signaling for fail-fast or otherwise necessary paths.
In `@tests/unit/test_concurrent.py`:
- Around line 329-334: Update the test double’s add_callbacks method in
tests/unit/test_concurrent.py to match ResponseFuture.add_callbacks positional
parameter order, placing errback immediately after callback and retaining the
remaining argument defaults. Keep the synchronous callback behavior unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 445f3db9-d3e1-44b6-9d82-1197e5dc9569
📒 Files selected for processing (2)
cassandra/concurrent.pytests/unit/test_concurrent.py
…rent ConcurrentExecutorListResults now uses a dedicated submitter thread instead of calling _execute_next inline from the event loop callback. This decouples I/O completion processing from new request serialization and enqueuing, yielding ~6-9% higher write throughput. The callback signals a threading.Event; the submitter thread drains a deque and calls session.execute_async in batches. This avoids blocking the libev event loop thread with request preparation work (query plan, serialization, tablet lookup) that takes ~27us per request. The event-loop callback path is lock-free: it appends to a deque and sets an Event, with no Condition/Lock acquisition in the hot path. Follow-up fixes from a concurrency-focused self-review of this change: - _submitter_loop no longer lets an unexpected exception (e.g. the caller's statements_and_parameters iterable raising something other than StopIteration out of next()) kill the submitter thread silently. Nothing else ever advances _current/_exec_count or wakes _results() after the initial batch, so an unhandled exception there previously hung execute_concurrent() forever instead of raising. The loop body is now wrapped so such failures are recorded and force the waiter to stop waiting instead of hanging; added a regression test (test_submitter_thread_survives_broken_iterable) that hangs against the old code and passes against the fix. - Corrected the docstring in _put_result: it previously claimed the lock-free hot path "assumes a GIL build", which is inconsistent with this driver's CI matrix (which exercises free-threaded Python 3.14t for both the libev and asyncio reactors). The actual cross-thread visibility guarantee comes from the happens-before relationship established by Event.set()/.wait() (both backed by a real lock), not from GIL atomicity of the individual list/deque appends -- documented accordingly. No changes to the Eventlet/gevent story: the submitter thread is a plain threading.Thread signaled with a threading.Event, not concurrent.futures.ThreadPoolExecutor, so it does not hit the Python 3.7+/Eventlet ThreadPoolExecutor hang that Cluster._create_thread_pool_executor() exists to work around (that hang is specific to ThreadPoolExecutor's internal use of queue.SimpleQueue, which is a non-monkey-patchable C implementation). EventletConnection/GeventConnection both require full monkey-patching already, under which threading.Thread/Event become the green equivalents automatically. Ordering is unaffected: the returned list is still built from sorted(self._results_queue) keyed on the original statement index, exactly as before this change.
8c88381 to
e34f76f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
cassandra/concurrent.py:274
- A user iterable can raise an exception whose
__bool__returnsFalse. In that case_submitter_looprecords it as_fatal_exceptionand terminates the work, but this truthiness check skips the raise and returns a truncated result list, contradicting the one-result-per-input contract described above. Check explicitly forNone, as the submitter already does when assigning the field.
if self._fatal_exception:
raise self._fatal_exception
cassandra/concurrent.py:196
- This implementation only changes
ConcurrentExecutorListResults. Calls with the publicresults_generator=Trueoption still useConcurrentExecutorGenResults._put_result, which invokes_execute_next()(and thereforeexecute_async()) directly from the completion callback thread. The PR description and title currently present this as an optimization toexecute_concurrentgenerally, so either the generator path needs equivalent handling or the documented scope should explicitly exclude it.
def execute(self, concurrency, fail_fast):
self._exception = None
self._fatal_exception = None
cassandra/concurrent.py:444
- The submitter is the sole thread that can advance the wait predicate, but this handler excludes
BaseExceptionsubclasses. If a user-supplied iterator (orexecute_async) raisesSystemExit,KeyboardInterrupt, or another directBaseException, the submitter terminates without setting_fatal_exceptionor notifying the condition, leavingexecute_concurrent()blocked forever. CatchBaseExceptionat this thread boundary so it can be relayed to the caller.
except Exception as exc:
cassandra/concurrent.py:289
- This says the hot path acquires no lock, but
_submit_event.set()below acquires theEvent's internal condition lock (and lines 299-302 rely on that synchronization). The optimization avoidsself._condition; it does not eliminate lock acquisition entirely, so the comment should make that distinction.
Hot path (success, not fail-fast): NO lock acquisition. We rely on
the submitter thread to bump ``_current`` from the drained signal
count under the same lock acquisition that bumps ``_exec_count``.
This removes ~0.5-1us of lock cost from every callback on the
event-loop thread.
Summary
execute_async()calls inexecute_concurrentfrom the event-loop callback thread to a dedicated submitter threadexecute_async(), which includes serialization — keeping that CPU work off the event loopv2: Reduce per-request lock overhead in ResponseFuture
Second commit (
7cae6a14e) reduces lock/synchronization cost per request in theexecute_concurrenthot path:Lazy
Eventcreation:ResponseFuture._eventstarts asNoneinstead ofEvent(). The Event is only materialized inresult()(the synchronous path). Forexecute_concurrent, which never callsresult()on individual futures, this eliminates ~620ns per request (351ns Event construction + 267ns Event.set()).Merged
add_callbacks(): Registers both callback and errback under a single_callback_lockacquisition instead of two separate lock/unlock cycles. Saves ~80ns per request._set_final_result/_set_final_exception: Capture_eventreference under_callback_lockbefore calling.set()outside the lock. Skip.set()when Event was never created. Null-check callback/errback lists before buildingto_calltuple._wait_for_result(): Checks result availability under_callback_lockbefore creating Event — avoids Event creation entirely when the result arrived before the caller waits._on_speculative_execute: Checks_final_result/_final_exceptiondirectly instead ofEvent.is_set(), since Event may beNonewith lazy creation.All changes are safe under both GIL and free-threaded (PEP 703) Python. No GIL assumptions.
Benchmark Results
On our vector ingestion benchmark (100K rows, 768-dim float32 vectors, ScyllaDB 2026.1.1):
How It Works
_ConcurrentExecutorBasespawns a daemon submitter thread alongside the existing callback mechanismdeque.append(1); event.set()— minimal work on the hot path_execute_next()in a batchcollections.deque(atomic append/popleft in CPython) +threading.EventNonein deque signals the thread to exit;join()inwait()Testing
test_concurrent.pyunit tests pass