Skip to content

Commit 3bd5972

Browse files
authored
Fix: Propagate terminal worker failures and stop queued DAG work (#6140)
Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent afec213 commit 3bd5972

2 files changed

Lines changed: 240 additions & 6 deletions

File tree

‎sqlmesh/utils/concurrency.py‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,26 +65,40 @@ def run(self) -> t.Tuple[t.List[NodeExecutionFailedError[H]], t.List[H]]:
6565
return self._node_errors, self._skipped_nodes
6666

6767
def _process_node(self, node: H, executor: Executor) -> None:
68+
with self._unprocessed_nodes_lock:
69+
if self._finished_future.done():
70+
return
71+
6872
try:
6973
self.fn(node)
7074

7175
with self._unprocessed_nodes_lock:
76+
if self._finished_future.done():
77+
return
7278
self._unprocessed_nodes_num -= 1
7379
self._submit_next_nodes(executor, node)
7480
except Exception as ex:
7581
error = NodeExecutionFailedError(node)
7682
error.__cause__ = ex
7783

78-
if self.raise_on_error:
79-
self._finished_future.set_exception(error)
80-
return
81-
8284
with self._unprocessed_nodes_lock:
85+
if self._finished_future.done():
86+
return
87+
if self.raise_on_error:
88+
self._finished_future.set_exception(error)
89+
return
8390
self._unprocessed_nodes_num -= 1
8491
self._node_errors.append(error)
8592
self._skip_next_nodes(node)
93+
except BaseException as ex:
94+
with self._unprocessed_nodes_lock:
95+
if not self._finished_future.done():
96+
self._finished_future.set_exception(ex)
8697

8798
def _submit_next_nodes(self, executor: Executor, processed_node: t.Optional[H] = None) -> None:
99+
if self._finished_future.done():
100+
return
101+
88102
if not self._unprocessed_nodes_num:
89103
self._finished_future.set_result(None)
90104
return
@@ -101,6 +115,9 @@ def _submit_next_nodes(self, executor: Executor, processed_node: t.Optional[H] =
101115
executor.submit(self._process_node, submitted_node, executor)
102116

103117
def _skip_next_nodes(self, parent: H) -> None:
118+
if self._finished_future.done():
119+
return
120+
104121
if not self._unprocessed_nodes_num:
105122
self._finished_future.set_result(None)
106123
return
@@ -267,9 +284,11 @@ def concurrent_apply_to_values(
267284

268285
def _process_value(value: A, index: int) -> None:
269286
try:
270-
futures[index].set_result(fn(value))
271-
except Exception as ex:
287+
result = fn(value)
288+
except BaseException as ex:
272289
futures[index].set_exception(ex)
290+
else:
291+
futures[index].set_result(result)
273292

274293
with ThreadPoolExecutor(max_workers=tasks_num) as pool:
275294
for index, value in enumerate(values):

‎tests/utils/test_concurrency.py‎

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import os
2+
import subprocess
3+
import sys
4+
import textwrap
5+
from pathlib import Path
6+
17
import pytest
28
from pytest_mock.plugin import MockerFixture
39

@@ -147,3 +153,212 @@ def test_concurrent_apply(tasks_num: int):
147153
values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
148154
results = concurrent_apply_to_values(values, lambda x: x * 2, tasks_num)
149155
assert results == [x * 2 for x in values]
156+
157+
158+
def _run_bounded_python(program: str) -> None:
159+
repo_root = Path(__file__).resolve().parents[2]
160+
env = os.environ.copy()
161+
env["PYTHONPATH"] = os.pathsep.join(
162+
path for path in (str(repo_root), env.get("PYTHONPATH")) if path
163+
)
164+
result = subprocess.run(
165+
[sys.executable, "-c", textwrap.dedent(program)],
166+
cwd=repo_root,
167+
env=env,
168+
capture_output=True,
169+
text=True,
170+
timeout=8,
171+
)
172+
assert result.returncode == 0, f"{result.stdout}\n{result.stderr}"
173+
174+
175+
def test_concurrent_apply_to_values_propagates_base_exception():
176+
_run_bounded_python(
177+
"""
178+
from sqlmesh.utils.concurrency import concurrent_apply_to_values
179+
180+
error = SystemExit("cancel value execution")
181+
182+
def process(value):
183+
if value == 0:
184+
raise error
185+
return value
186+
187+
try:
188+
concurrent_apply_to_values([0, 1], process, 2)
189+
except SystemExit as raised:
190+
assert raised is error
191+
else:
192+
raise AssertionError("the value worker cancellation was not propagated")
193+
"""
194+
)
195+
196+
197+
@pytest.mark.parametrize("raise_on_error", [True, False])
198+
def test_concurrent_dag_propagates_base_exception_drains_workers_and_can_be_reused(
199+
raise_on_error: bool,
200+
):
201+
_run_bounded_python(
202+
f"""
203+
from threading import Event, Thread
204+
205+
from sqlmesh.utils.concurrency import ConcurrentDAGExecutor
206+
from sqlmesh.utils.dag import DAG
207+
208+
dag = DAG()
209+
dag.add("cancel")
210+
dag.add("sibling")
211+
dag.add("dependent", ["sibling"])
212+
error = KeyboardInterrupt("cancel execution")
213+
release_sibling = Event()
214+
sibling_started = Event()
215+
called = []
216+
217+
def execute(node):
218+
if node == "cancel":
219+
assert sibling_started.wait(timeout=3)
220+
raise error
221+
if node == "sibling":
222+
sibling_started.set()
223+
assert release_sibling.wait(timeout=6)
224+
called.append(node)
225+
226+
executor = ConcurrentDAGExecutor(dag, execute, 2, {raise_on_error!r})
227+
run_errors = []
228+
229+
def run():
230+
try:
231+
executor.run()
232+
except BaseException as ex:
233+
run_errors.append(ex)
234+
235+
runner = Thread(target=run)
236+
runner.start()
237+
try:
238+
assert sibling_started.wait(timeout=3)
239+
try:
240+
executor._finished_future.result(timeout=3)
241+
except KeyboardInterrupt as raised:
242+
assert raised is error
243+
else:
244+
raise AssertionError("the worker cancellation was not propagated")
245+
assert runner.is_alive(), "run returned before the active worker drained"
246+
finally:
247+
release_sibling.set()
248+
249+
runner.join(timeout=3)
250+
assert not runner.is_alive()
251+
assert run_errors == [error]
252+
assert called == ["sibling"]
253+
254+
second_error = SystemExit("cancel reused executor")
255+
256+
def cancel_again(node):
257+
raise second_error
258+
259+
executor.fn = cancel_again
260+
try:
261+
executor.run()
262+
except SystemExit as raised:
263+
assert raised is second_error
264+
else:
265+
raise AssertionError("the reused executor did not propagate cancellation")
266+
"""
267+
)
268+
269+
270+
def test_concurrent_dag_fail_fast_stops_queued_work_while_aggregate_mode_continues():
271+
_run_bounded_python(
272+
"""
273+
from threading import Event, Thread
274+
275+
from sqlmesh.utils.concurrency import ConcurrentDAGExecutor
276+
from sqlmesh.utils.dag import DAG
277+
278+
def make_dag():
279+
dag = DAG()
280+
dag.add("fail")
281+
dag.add("running")
282+
dag.add("queued")
283+
dag.add("failed_child", ["fail"])
284+
dag.add("running_child", ["running"])
285+
dag.add("queued_child", ["queued"])
286+
return dag
287+
288+
failure = RuntimeError("fail node")
289+
release_running = Event()
290+
running_started = Event()
291+
queued_started = Event()
292+
called = []
293+
294+
def execute(node):
295+
if node == "fail":
296+
assert running_started.wait(timeout=3)
297+
raise failure
298+
if node == "running":
299+
running_started.set()
300+
assert release_running.wait(timeout=6)
301+
if node == "queued":
302+
queued_started.set()
303+
called.append(node)
304+
305+
executor = ConcurrentDAGExecutor(make_dag(), execute, 2, True)
306+
run_errors = []
307+
308+
def run_fail_fast():
309+
try:
310+
executor.run()
311+
except BaseException as ex:
312+
run_errors.append(ex)
313+
314+
runner = Thread(target=run_fail_fast)
315+
runner.start()
316+
try:
317+
assert running_started.wait(timeout=3)
318+
try:
319+
executor._finished_future.result(timeout=3)
320+
except Exception as raised:
321+
assert raised.node == "fail"
322+
assert raised.__cause__ is failure
323+
else:
324+
raise AssertionError("fail-fast execution did not raise")
325+
assert runner.is_alive(), "run returned before the active worker drained"
326+
assert not queued_started.is_set(), "queued work started after fail-fast"
327+
finally:
328+
release_running.set()
329+
330+
runner.join(timeout=3)
331+
assert not runner.is_alive()
332+
assert len(run_errors) == 1
333+
assert run_errors[0].node == "fail"
334+
assert run_errors[0].__cause__ is failure
335+
assert called == ["running"]
336+
337+
release_running = Event()
338+
running_started = Event()
339+
queued_started = Event()
340+
called = []
341+
executor = ConcurrentDAGExecutor(make_dag(), execute, 2, False)
342+
run_results = []
343+
344+
def run_aggregate():
345+
run_results.append(executor.run())
346+
347+
runner = Thread(target=run_aggregate)
348+
runner.start()
349+
try:
350+
assert running_started.wait(timeout=3)
351+
assert queued_started.wait(timeout=3), "aggregate mode stopped independent work"
352+
finally:
353+
release_running.set()
354+
355+
runner.join(timeout=3)
356+
assert not runner.is_alive()
357+
errors, skipped = run_results[0]
358+
assert len(errors) == 1
359+
assert errors[0].node == "fail"
360+
assert errors[0].__cause__ is failure
361+
assert skipped == ["failed_child"]
362+
assert set(called) == {"running", "queued", "running_child", "queued_child"}
363+
"""
364+
)

0 commit comments

Comments
 (0)