Skip to content

Commit 29ac4e1

Browse files
committed
Fix RecursionError in execute_concurrent on synchronous errbacks
When a ResponseFuture already has _final_exception set by the time add_callbacks() is called (e.g. the request timeout expired synchronously inside send_request()), the errback fires inline creating an unbounded recursion: _execute -> add_callbacks -> _on_error -> _put_result -> _execute_next -> _execute -> ... The existing _exec_depth guard only protected the except-Exception path (when execute_async itself raises), not this synchronous-callback path. Replace the depth counter with a re-entrancy guard: when _execute is re-entered from a synchronous callback, the work is appended to a pending list and drained iteratively by the outermost invocation. Fixes: #712
1 parent 1206f35 commit 29ac4e1

2 files changed

Lines changed: 77 additions & 18 deletions

File tree

cassandra/concurrent.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,6 @@ def execute_concurrent(session, statements_and_parameters, concurrency=100, rais
9292

9393
class _ConcurrentExecutor(object):
9494

95-
max_error_recursion = 100
96-
9795
def __init__(self, session, statements_and_params, execution_profile):
9896
self.session = session
9997
self._enum_statements = enumerate(iter(statements_and_params))
@@ -103,7 +101,7 @@ def __init__(self, session, statements_and_params, execution_profile):
103101
self._results_queue = []
104102
self._current = 0
105103
self._exec_count = 0
106-
self._exec_depth = 0
104+
self._executing = False
107105

108106
def execute(self, concurrency, fail_fast):
109107
self._fail_fast = fail_fast
@@ -127,22 +125,34 @@ def _execute_next(self):
127125
pass
128126

129127
def _execute(self, idx, statement, params):
130-
self._exec_depth += 1
128+
# When execute_async completes synchronously (e.g. immediate timeout),
129+
# the errback fires inline: _on_error -> _put_result -> _execute_next
130+
# -> _execute. Without protection this recurses once per remaining
131+
# statement and blows the stack.
132+
#
133+
# ``_executing`` marks that we are already inside this method higher up
134+
# the call stack. When a synchronous callback re-enters, we just stash
135+
# the pending work in ``_pending_executions`` and let the outermost
136+
# invocation drain it in a loop -- no recursion.
137+
if self._executing:
138+
self._pending_executions.append((idx, statement, params))
139+
return
140+
141+
self._executing = True
142+
self._pending_executions = [(idx, statement, params)]
131143
try:
132-
future = self.session.execute_async(statement, params, timeout=None, execution_profile=self._execution_profile)
133-
args = (future, idx)
134-
future.add_callbacks(
135-
callback=self._on_success, callback_args=args,
136-
errback=self._on_error, errback_args=args)
137-
except Exception as exc:
138-
# If we're not failing fast and all executions are raising, there is a chance of recursing
139-
# here as subsequent requests are attempted. If we hit this threshold, schedule this result/retry
140-
# and let the event loop thread return.
141-
if self._exec_depth < self.max_error_recursion:
142-
self._put_result(exc, idx, False)
143-
else:
144-
self.session.submit(self._put_result, exc, idx, False)
145-
self._exec_depth -= 1
144+
while self._pending_executions:
145+
p_idx, p_statement, p_params = self._pending_executions.pop(0)
146+
try:
147+
future = self.session.execute_async(p_statement, p_params, timeout=None, execution_profile=self._execution_profile)
148+
args = (future, p_idx)
149+
future.add_callbacks(
150+
callback=self._on_success, callback_args=args,
151+
errback=self._on_error, errback_args=args)
152+
except Exception as exc:
153+
self._put_result(exc, p_idx, False)
154+
finally:
155+
self._executing = False
146156

147157
def _on_success(self, result, future, idx):
148158
future.clear_callbacks()

tests/unit/test_concurrent.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,52 @@ def test_recursion_limited(self):
258258
for r in results:
259259
assert not r[0]
260260
assert isinstance(r[1], TypeError)
261+
262+
def test_no_recursion_on_synchronous_errback(self):
263+
"""
264+
Verify that execute_concurrent does not blow the stack when every
265+
future completes with an error *before* add_callbacks is called
266+
(i.e. the errback fires synchronously inside add_callbacks).
267+
268+
This exercises a different code path from test_recursion_limited:
269+
that test covers execute_async raising an exception, while this one
270+
covers execute_async returning a future whose errback fires inline.
271+
"""
272+
count = sys.getrecursionlimit()
273+
error = Exception("immediate failure")
274+
275+
class AlreadyFailedFuture:
276+
"""A future that already has _final_exception set."""
277+
_query_trace = None
278+
_col_names = None
279+
_col_types = None
280+
has_more_pages = False
281+
282+
def add_callback(self, fn, *args, **kwargs):
283+
pass
284+
285+
def add_errback(self, fn, *args, **kwargs):
286+
# Fire errback synchronously, mimicking a future that
287+
# completed before add_callbacks was called.
288+
fn(error, *args, **kwargs)
289+
290+
def add_callbacks(self, callback, errback,
291+
callback_args=(), callback_kwargs=None,
292+
errback_args=(), errback_kwargs=None):
293+
self.add_callback(callback, *callback_args, **(callback_kwargs or {}))
294+
self.add_errback(errback, *errback_args, **(errback_kwargs or {}))
295+
296+
def clear_callbacks(self):
297+
pass
298+
299+
mock_session = Mock()
300+
mock_session.execute_async.return_value = AlreadyFailedFuture()
301+
302+
statements_and_params = [("SELECT 1", ())] * count
303+
results = execute_concurrent(mock_session, statements_and_params,
304+
raise_on_first_error=False)
305+
306+
assert len(results) == count
307+
for success, result in results:
308+
assert not success
309+
assert result is error

0 commit comments

Comments
 (0)