Skip to content

Commit 1766e52

Browse files
committed
Feat: Add optional per-task context propagation to concurrency helpers
Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent edfb13b commit 1766e52

2 files changed

Lines changed: 102 additions & 10 deletions

File tree

‎sqlmesh/utils/concurrency.py‎

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import typing as t
22
from concurrent.futures import Executor, Future, ThreadPoolExecutor
33
from threading import Lock
4+
from contextvars import Context
45

56
from sqlmesh.core.snapshot import SnapshotId, SnapshotInfoLike
67
from sqlmesh.utils.dag import DAG
@@ -30,6 +31,9 @@ class ConcurrentDAGExecutor(t.Generic[H]):
3031
raise_on_error: If set to True raises an exception on a first encountered error,
3132
otherwises returns a tuple which contains a list of failed nodes and a list of
3233
skipped nodes.
34+
context_factory: Optional factory called at each run admission. Each node runs
35+
in a separate copy of that context, including serial execution. By default
36+
no context is captured or copied.
3337
"""
3438

3539
def __init__(
@@ -38,11 +42,14 @@ def __init__(
3842
fn: t.Callable[[H], None],
3943
tasks_num: int,
4044
raise_on_error: bool,
45+
context_factory: t.Optional[t.Callable[[], Context]] = None,
4146
):
4247
self.dag = dag
4348
self.fn = fn
4449
self.tasks_num = tasks_num
4550
self.raise_on_error = raise_on_error
51+
self.context_factory = context_factory
52+
self._context: t.Optional[Context] = None
4653

4754
self._init_state()
4855

@@ -55,14 +62,18 @@ def run(self) -> t.Tuple[t.List[NodeExecutionFailedError[H]], t.List[H]]:
5562
Returns:
5663
A pair which contains a list of node errors and a list of skipped nodes.
5764
"""
65+
self._context = self.context_factory() if self.context_factory else None
5866
if self._finished_future.done():
5967
self._init_state()
6068

61-
with ThreadPoolExecutor(max_workers=self.tasks_num) as pool:
62-
with self._unprocessed_nodes_lock:
63-
self._submit_next_nodes(pool)
64-
self._finished_future.result()
65-
return self._node_errors, self._skipped_nodes
69+
try:
70+
with ThreadPoolExecutor(max_workers=self.tasks_num) as pool:
71+
with self._unprocessed_nodes_lock:
72+
self._submit_next_nodes(pool)
73+
self._finished_future.result()
74+
return self._node_errors, self._skipped_nodes
75+
finally:
76+
self._context = None
6677

6778
def _process_node(self, node: H, executor: Executor) -> None:
6879
with self._unprocessed_nodes_lock:
@@ -112,7 +123,12 @@ def _submit_next_nodes(self, executor: Executor, processed_node: t.Optional[H] =
112123

113124
for submitted_node in submitted_nodes:
114125
self._unprocessed_nodes.pop(submitted_node)
115-
executor.submit(self._process_node, submitted_node, executor)
126+
if self._context is None:
127+
executor.submit(self._process_node, submitted_node, executor)
128+
else:
129+
executor.submit(
130+
self._context.copy().run, self._process_node, submitted_node, executor
131+
)
116132

117133
def _skip_next_nodes(self, parent: H) -> None:
118134
if self._finished_future.done():
@@ -156,6 +172,7 @@ def concurrent_apply_to_snapshots(
156172
tasks_num: int,
157173
reverse_order: bool = False,
158174
raise_on_error: bool = True,
175+
context_factory: t.Optional[t.Callable[[], Context]] = None,
159176
) -> t.Tuple[t.List[NodeExecutionFailedError[SnapshotId]], t.List[SnapshotId]]:
160177
"""Applies a function to the given collection of snapshots concurrently while
161178
preserving the topological order between snapshots.
@@ -168,6 +185,9 @@ def concurrent_apply_to_snapshots(
168185
raise_on_error: If set to True raises an exception on a first encountered error,
169186
otherwises returns a tuple which contains a list of failed nodes and a list of
170187
skipped nodes.
188+
context_factory: Optional factory called at each run admission. Each node runs
189+
in a separate copy of that context, including serial execution. By default
190+
no context is captured or copied.
171191
172192
Raises:
173193
NodeExecutionFailedError if `raise_on_error` is set to True and execution fails for any snapshot.
@@ -189,6 +209,7 @@ def concurrent_apply_to_snapshots(
189209
lambda s_id: fn(snapshots_by_id[s_id]),
190210
tasks_num,
191211
raise_on_error=raise_on_error,
212+
context_factory=context_factory,
192213
)
193214

194215

@@ -197,6 +218,7 @@ def concurrent_apply_to_dag(
197218
fn: t.Callable[[H], None],
198219
tasks_num: int,
199220
raise_on_error: bool = True,
221+
context_factory: t.Optional[t.Callable[[], Context]] = None,
200222
) -> t.Tuple[t.List[NodeExecutionFailedError[H]], t.List[H]]:
201223
"""Applies a function to the given DAG concurrently while preserving the topological
202224
order between snapshots.
@@ -208,6 +230,9 @@ def concurrent_apply_to_dag(
208230
raise_on_error: If set to True raises an exception on a first encountered error,
209231
otherwises returns a tuple which contains a list of failed nodes and a list of
210232
skipped nodes.
233+
context_factory: Optional factory called at each run admission. Each node runs
234+
in a separate copy of that context, including serial execution. By default
235+
no context is captured or copied.
211236
212237
Raises:
213238
NodeExecutionFailedError if `raise_on_error` is set to True and execution fails for any snapshot.
@@ -219,21 +244,24 @@ def concurrent_apply_to_dag(
219244
raise ConfigError(f"Invalid number of concurrent tasks {tasks_num}")
220245

221246
if tasks_num == 1:
222-
return sequential_apply_to_dag(dag, fn, raise_on_error)
247+
return sequential_apply_to_dag(dag, fn, raise_on_error, context_factory)
223248

224249
return ConcurrentDAGExecutor(
225250
dag,
226251
fn,
227252
tasks_num,
228253
raise_on_error,
254+
context_factory,
229255
).run()
230256

231257

232258
def sequential_apply_to_dag(
233259
dag: DAG[H],
234260
fn: t.Callable[[H], None],
235261
raise_on_error: bool = True,
262+
context_factory: t.Optional[t.Callable[[], Context]] = None,
236263
) -> t.Tuple[t.List[NodeExecutionFailedError[H]], t.List[H]]:
264+
context = context_factory() if context_factory else None
237265
dependencies = dag.graph
238266

239267
node_errors: t.List[NodeExecutionFailedError[H]] = []
@@ -248,7 +276,10 @@ def sequential_apply_to_dag(
248276
continue
249277

250278
try:
251-
fn(node)
279+
if context is None:
280+
fn(node)
281+
else:
282+
context.copy().run(fn, node)
252283
except Exception as ex:
253284
error = NodeExecutionFailedError(node)
254285
error.__cause__ = ex
@@ -266,19 +297,27 @@ def concurrent_apply_to_values(
266297
values: t.Sequence[A],
267298
fn: t.Callable[[A], R],
268299
tasks_num: int,
300+
context_factory: t.Optional[t.Callable[[], Context]] = None,
269301
) -> t.List[R]:
270302
"""Applies a function to the given collection of values concurrently.
271303
272304
Args:
273305
values: Target values.
274306
fn: The function that will be applied concurrently to each value.
275307
tasks_num: The number of concurrent tasks.
308+
context_factory: Optional factory called once when values are admitted. Each value runs
309+
in a separate copy of that context, including serial execution. By default no
310+
context is captured or copied.
276311
277312
Returns:
278313
A list of results.
279314
"""
315+
context = context_factory() if context_factory else None
316+
280317
if tasks_num == 1:
281-
return [fn(value) for value in values]
318+
if context is None:
319+
return [fn(value) for value in values]
320+
return [context.copy().run(fn, value) for value in values]
282321

283322
futures: t.List[Future] = [Future() for _ in values]
284323

@@ -292,6 +331,9 @@ def _process_value(value: A, index: int) -> None:
292331

293332
with ThreadPoolExecutor(max_workers=tasks_num) as pool:
294333
for index, value in enumerate(values):
295-
pool.submit(_process_value, value, index)
334+
if context is None:
335+
pool.submit(_process_value, value, index)
336+
else:
337+
pool.submit(context.copy().run, _process_value, value, index)
296338

297339
return [f.result() for f in futures]

‎tests/utils/test_concurrency.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import subprocess
33
import sys
44
import textwrap
5+
from contextvars import ContextVar, copy_context
56
from pathlib import Path
67

78
import pytest
@@ -362,3 +363,52 @@ def run_aggregate():
362363
assert set(called) == {"running", "queued", "running_child", "queued_child"}
363364
"""
364365
)
366+
367+
368+
@pytest.mark.parametrize("tasks_num", [1, 3])
369+
def test_concurrent_apply_to_values_copies_admission_context(tasks_num: int):
370+
value = ContextVar("admission", default="worker")
371+
token = value.set("admitted")
372+
373+
def process(item: int) -> str:
374+
assert value.get() == "admitted"
375+
value.set(str(item))
376+
return value.get()
377+
378+
try:
379+
assert concurrent_apply_to_values(
380+
[1, 2, 3], process, tasks_num, context_factory=copy_context
381+
) == ["1", "2", "3"]
382+
assert value.get() == "admitted"
383+
default_results = concurrent_apply_to_values([1, 2, 3], lambda _: value.get(), tasks_num)
384+
expected = "admitted" if tasks_num == 1 else "worker"
385+
assert default_results == [expected, expected, expected]
386+
finally:
387+
value.reset(token)
388+
389+
390+
@pytest.mark.parametrize("tasks_num", [1, 2])
391+
def test_context_admission_and_sibling_isolation(tasks_num: int):
392+
from sqlmesh.utils.concurrency import ConcurrentDAGExecutor, concurrent_apply_to_dag
393+
from sqlmesh.utils.dag import DAG
394+
395+
consumer = ContextVar("admission", default="construction")
396+
dag: DAG[str] = DAG()
397+
dag.add("second", {"first"})
398+
seen = []
399+
400+
def execute(node):
401+
seen.append(consumer.get())
402+
consumer.set(node)
403+
404+
executor = ConcurrentDAGExecutor(dag, execute, tasks_num, False, copy_context)
405+
token = consumer.set("first admission")
406+
try:
407+
executor.run()
408+
consumer.set("second admission")
409+
executor.run()
410+
concurrent_apply_to_dag(dag, execute, tasks_num, context_factory=copy_context)
411+
assert consumer.get() == "second admission"
412+
finally:
413+
consumer.reset(token)
414+
assert seen == ["first admission"] * 2 + ["second admission"] * 4

0 commit comments

Comments
 (0)