Skip to content

Commit b2e1bf5

Browse files
committed
Fix: Preserve execution observation context and propagate worker cancellation
Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent b5e9026 commit b2e1bf5

7 files changed

Lines changed: 612 additions & 197 deletions

File tree

‎docs/reference/execution_observations.md‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,13 @@ Ordinary observer `Exception` failures are suppressed and do not replace the
6767
workload result or error. This guarantee does **not** cover observer-raised
6868
`BaseException` subclasses such as `KeyboardInterrupt` or `SystemExit`: a failure
6969
in start can prevent work and finish, and a failure in finish can replace a
70-
workload error. Blocking callbacks and process termination are also outside the
71-
guarantee. SQLMesh does not recursively log observer failures.
70+
workload error. A worker's `BaseException` can propagate to the caller instead
71+
of leaving parallel completion pending. Once a DAG run records a terminal error,
72+
it stops admitting further nodes and raises that error after already-running
73+
workers exit. The parallel value helper records a worker's `BaseException` in its
74+
result and joins the pool before resolving results. Blocking callbacks and
75+
process termination are also outside the guarantee. SQLMesh does not recursively
76+
log observer failures.
7277

7378
While a callback is being delivered, observations triggered by that callback are
7479
suppressed, and internal metadata updates are ignored. This guard applies only
@@ -117,16 +122,18 @@ consumer associate existing logs with the invocation.
117122
## Worker context
118123

119124
When observation is active, the instrumented scheduler and snapshot DAG dispatch
120-
paths copy the admitting context at execution time. Each node runs in its own
121-
copy, including serial DAG execution. Consumer-owned `ContextVar` values can
122-
therefore carry parent identities into workers; successors inherit the admission
123-
context rather than the preceding worker's changes. Reusing a DAG executor
124-
captures a fresh context for each run.
125+
paths copy the admitting context at execution time. Snapshot schema creation and
126+
data-object listing also copy the context for each admitted value. Each node or
127+
value runs in its own copy, including serial execution. Consumer-owned
128+
`ContextVar` values can therefore carry parent identities into workers;
129+
successors inherit the admission context rather than the preceding worker's
130+
changes. Reusing a DAG executor captures a fresh context for each run.
125131

126132
Context copies are shallow. Mutable objects stored in ContextVars can still be
127133
shared; consumers should use immutable values or replace values instead of
128-
mutating shared state. This behavior applies to the instrumented DAG paths, not
129-
to arbitrary consumer threads or every SQLMesh concurrency helper.
134+
mutating shared state. This behavior applies to these instrumented scheduler,
135+
DAG, schema-creation, and data-object-listing paths, not to arbitrary consumer
136+
threads or every SQLMesh concurrency helper.
130137

131-
With no observer registered, these call sites do not opt into context copying
132-
and retain their existing serial and worker execution behavior.
138+
With no observer registered, these call sites do not opt into context copying;
139+
ContextVars keep the concurrency helper's existing serial and worker behavior.

‎sqlmesh/core/scheduler.py‎

Lines changed: 186 additions & 178 deletions
Large diffs are not rendered by default.

‎sqlmesh/core/snapshot/evaluator.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,7 @@ def _create_schema(
15131513
list(unique_schemas),
15141514
lambda item: _create_schema(item[0], item[1], item[2]),
15151515
self.ddl_concurrent_tasks,
1516+
context_factory=execution_context_factory(),
15161517
)
15171518

15181519
def get_adapter(self, gateway: t.Optional[str] = None) -> EngineAdapter:
@@ -1687,6 +1688,7 @@ def _get_data_objects_in_schema(
16871688
schema=s, object_names=tables_by_schema.get(s), gateway=gateway
16881689
),
16891690
self.ddl_concurrent_tasks,
1691+
context_factory=execution_context_factory(),
16901692
)
16911693

16921694
for schema, objs in zip(schema_list, results):

‎sqlmesh/utils/concurrency.py‎

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,26 +76,40 @@ def run(self) -> t.Tuple[t.List[NodeExecutionFailedError[H]], t.List[H]]:
7676
self._context = None
7777

7878
def _process_node(self, node: H, executor: Executor) -> None:
79+
with self._unprocessed_nodes_lock:
80+
if self._finished_future.done():
81+
return
82+
7983
try:
8084
self.fn(node)
8185

8286
with self._unprocessed_nodes_lock:
87+
if self._finished_future.done():
88+
return
8389
self._unprocessed_nodes_num -= 1
8490
self._submit_next_nodes(executor, node)
8591
except Exception as ex:
8692
error = NodeExecutionFailedError(node)
8793
error.__cause__ = ex
8894

89-
if self.raise_on_error:
90-
self._finished_future.set_exception(error)
91-
return
92-
9395
with self._unprocessed_nodes_lock:
96+
if self._finished_future.done():
97+
return
98+
if self.raise_on_error:
99+
self._finished_future.set_exception(error)
100+
return
94101
self._unprocessed_nodes_num -= 1
95102
self._node_errors.append(error)
96103
self._skip_next_nodes(node)
104+
except BaseException as ex:
105+
with self._unprocessed_nodes_lock:
106+
if not self._finished_future.done():
107+
self._finished_future.set_exception(ex)
97108

98109
def _submit_next_nodes(self, executor: Executor, processed_node: t.Optional[H] = None) -> None:
110+
if self._finished_future.done():
111+
return
112+
99113
if not self._unprocessed_nodes_num:
100114
self._finished_future.set_result(None)
101115
return
@@ -117,6 +131,9 @@ def _submit_next_nodes(self, executor: Executor, processed_node: t.Optional[H] =
117131
)
118132

119133
def _skip_next_nodes(self, parent: H) -> None:
134+
if self._finished_future.done():
135+
return
136+
120137
if not self._unprocessed_nodes_num:
121138
self._finished_future.set_result(None)
122139
return
@@ -280,30 +297,43 @@ def concurrent_apply_to_values(
280297
values: t.Sequence[A],
281298
fn: t.Callable[[A], R],
282299
tasks_num: int,
300+
context_factory: t.Optional[t.Callable[[], Context]] = None,
283301
) -> t.List[R]:
284302
"""Applies a function to the given collection of values concurrently.
285303
286304
Args:
287305
values: Target values.
288306
fn: The function that will be applied concurrently to each value.
289307
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.
290311
291312
Returns:
292313
A list of results.
293314
"""
315+
context = context_factory() if context_factory else None
316+
294317
if tasks_num == 1:
295-
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]
296321

297322
futures: t.List[Future] = [Future() for _ in values]
298323

299324
def _process_value(value: A, index: int) -> None:
300325
try:
301-
futures[index].set_result(fn(value))
302-
except Exception as ex:
326+
result = fn(value)
327+
except BaseException as ex:
303328
futures[index].set_exception(ex)
329+
else:
330+
futures[index].set_result(result)
304331

305332
with ThreadPoolExecutor(max_workers=tasks_num) as pool:
306333
for index, value in enumerate(values):
307-
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)
308338

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

‎tests/core/test_execution_observation.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,110 @@ def run_scheduler(*args):
401401
]
402402
else:
403403
assert observer.events == []
404+
405+
406+
@pytest.mark.parametrize("registration", ["console", "disabled"])
407+
def test_standalone_scheduler_observes_environment_statement_queries(
408+
duck_conn, mocker, registration
409+
):
410+
from contextlib import nullcontext
411+
412+
from sqlmesh.core.engine_adapter import DuckDBEngineAdapter
413+
from sqlmesh.core.environment import EnvironmentNamingInfo, EnvironmentStatements
414+
from sqlmesh.core.scheduler import Scheduler
415+
from sqlmesh.core.snapshot import DeployabilityIndex, SnapshotEvaluator
416+
417+
observer = Observer()
418+
console = mocker.MagicMock()
419+
getter = mocker.patch.object(console, "get_execution_observer", return_value=observer)
420+
state_sync = mocker.MagicMock()
421+
state_sync.get_environment_statements.return_value = [
422+
EnvironmentStatements(
423+
before_all=["CREATE TABLE observed_environment (stage VARCHAR)"],
424+
after_all=["INSERT INTO observed_environment VALUES ('after')"],
425+
python_env={},
426+
)
427+
]
428+
scheduler = Scheduler(
429+
snapshots=[],
430+
snapshot_evaluator=SnapshotEvaluator(DuckDBEngineAdapter(lambda: duck_conn)),
431+
state_sync=state_sync,
432+
default_catalog=None,
433+
console=console,
434+
)
435+
436+
scope = observer_scope(None) if registration == "disabled" else nullcontext()
437+
with scope:
438+
errors, skipped = scheduler.run_merged_intervals(
439+
merged_intervals={},
440+
deployability_index=DeployabilityIndex.all_deployable(),
441+
environment_naming_info=EnvironmentNamingInfo(name="observed"),
442+
run_environment_statements=True,
443+
)
444+
445+
assert not errors and not skipped
446+
assert duck_conn.execute("SELECT stage FROM observed_environment").fetchall() == [("after",)]
447+
if registration == "console":
448+
assert [(phase, facts["kind"], facts["action"]) for phase, facts, _ in observer.events] == [
449+
("start", "query", "execute"),
450+
("finish", "query", "execute"),
451+
("start", "query", "execute"),
452+
("finish", "query", "execute"),
453+
]
454+
assert all(error is None for phase, _, error in observer.events if phase == "finish")
455+
getter.assert_called_once_with()
456+
else:
457+
assert observer.events == []
458+
getter.assert_not_called()
459+
460+
461+
@pytest.mark.parametrize("failing_stage", ["before_all", "after_all"])
462+
def test_standalone_scheduler_restores_observer_scope_after_environment_query_error(
463+
duck_conn, mocker, failing_stage
464+
):
465+
from sqlmesh.core.engine_adapter import DuckDBEngineAdapter
466+
from sqlmesh.core.environment import EnvironmentNamingInfo, EnvironmentStatements
467+
from sqlmesh.core.scheduler import Scheduler
468+
from sqlmesh.core.snapshot import DeployabilityIndex, SnapshotEvaluator
469+
from sqlmesh.utils.errors import SQLMeshError
470+
471+
observer = Observer()
472+
console = mocker.MagicMock()
473+
getter = mocker.patch.object(console, "get_execution_observer", return_value=observer)
474+
state_sync = mocker.MagicMock()
475+
state_sync.get_environment_statements.return_value = [
476+
EnvironmentStatements(
477+
before_all=(
478+
["SELECT * FROM missing_before_all"]
479+
if failing_stage == "before_all"
480+
else ["SELECT 1"]
481+
),
482+
after_all=(["SELECT * FROM missing_after_all"] if failing_stage == "after_all" else []),
483+
python_env={},
484+
)
485+
]
486+
scheduler = Scheduler(
487+
snapshots=[],
488+
snapshot_evaluator=SnapshotEvaluator(DuckDBEngineAdapter(lambda: duck_conn)),
489+
state_sync=state_sync,
490+
default_catalog=None,
491+
console=console,
492+
)
493+
494+
with pytest.raises(SQLMeshError):
495+
scheduler.run_merged_intervals(
496+
merged_intervals={},
497+
deployability_index=DeployabilityIndex.all_deployable(),
498+
environment_naming_info=EnvironmentNamingInfo(name="observed"),
499+
run_environment_statements=True,
500+
)
501+
502+
query_events = [event for event in observer.events if event[1]["kind"] == "query"]
503+
assert len(query_events) == (2 if failing_stage == "before_all" else 4)
504+
assert query_events[-1][0] == "finish"
505+
assert query_events[-1][2] is not None
506+
getter.assert_called_once_with()
507+
state_sync.recycle.assert_not_called()
508+
509+
with action("query", "outside") as facts:
510+
assert facts is None

‎tests/core/test_snapshot_evaluator.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,122 @@ def x(evaluator, y=None) -> None:
248248
)
249249

250250

251+
def test_evaluate_reports_materialization_through_transaction_exit(
252+
duck_conn, mocker: MockerFixture, make_snapshot
253+
):
254+
from sqlmesh.core.engine_adapter import DuckDBEngineAdapter
255+
from sqlmesh.core.execution_observation import observer_scope
256+
257+
model = SqlModel(
258+
name="test_schema.test_model",
259+
kind=FullKind(),
260+
query=parse_one("SELECT 1 AS value"),
261+
)
262+
snapshot = make_snapshot(model)
263+
snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
264+
duck_conn.execute("CREATE SCHEMA sqlmesh__test_schema")
265+
evaluator = SnapshotEvaluator(DuckDBEngineAdapter(lambda: duck_conn))
266+
observations: t.List[t.Tuple[str, t.Dict[str, t.Any], t.Optional[BaseException]]] = []
267+
materialize_active = False
268+
269+
def observer(phase, facts, error):
270+
nonlocal materialize_active
271+
observations.append((phase, dict(facts), error))
272+
if facts["action"] == "materialize":
273+
materialize_active = phase == "start"
274+
275+
target = snapshot.table_name()
276+
transaction_failure = RuntimeError("transaction exit failed")
277+
transaction_depth = 0
278+
transaction = evaluator.adapter.transaction
279+
280+
@contextlib.contextmanager
281+
def fail_outer_transaction(*args: t.Any, **kwargs: t.Any) -> t.Iterator[None]:
282+
nonlocal transaction_depth
283+
outermost = transaction_depth == 0
284+
fail_on_exit = outermost and materialize_active
285+
transaction_depth += 1
286+
try:
287+
with transaction(*args, **kwargs):
288+
yield
289+
if fail_on_exit:
290+
raise transaction_failure
291+
finally:
292+
transaction_depth -= 1
293+
294+
mocker.patch.object(evaluator.adapter, "transaction", new=fail_outer_transaction)
295+
with observer_scope(observer), pytest.raises(RuntimeError) as raised:
296+
evaluator.evaluate(
297+
snapshot,
298+
start="2020-01-01",
299+
end="2020-01-02",
300+
execution_time="2020-01-02",
301+
snapshots={},
302+
target_table_exists=False,
303+
)
304+
305+
assert raised.value is transaction_failure
306+
assert evaluator.adapter.table_exists(target)
307+
materialize_events = [event for event in observations if event[1]["action"] == "materialize"]
308+
assert [phase for phase, _, _ in materialize_events] == ["start", "finish"]
309+
assert all(facts["kind"] == "physical" for _, facts, _ in materialize_events)
310+
assert all(facts["target"] == target for _, facts, _ in materialize_events)
311+
assert all(facts["creating"] is True for _, facts, _ in materialize_events)
312+
assert materialize_events[0][2] is None
313+
assert materialize_events[1][2] is transaction_failure
314+
315+
316+
@pytest.mark.parametrize("workers", [1, 2])
317+
def test_create_physical_schemas_preserves_observation_context(duck_conn, make_snapshot, workers):
318+
from contextvars import ContextVar
319+
320+
from sqlmesh.core.engine_adapter import DuckDBEngineAdapter
321+
from sqlmesh.core.execution_observation import observer_scope
322+
323+
snapshots = []
324+
for index in ("one", "two"):
325+
snapshot = make_snapshot(
326+
SqlModel(
327+
name=f"schema_{index}.model",
328+
kind=FullKind(),
329+
query=parse_one("SELECT 1 AS value"),
330+
)
331+
)
332+
snapshot.categorize_as(SnapshotChangeCategory.BREAKING)
333+
snapshots.append(snapshot)
334+
evaluator = SnapshotEvaluator(
335+
DuckDBEngineAdapter(lambda: duck_conn), ddl_concurrent_tasks=workers
336+
)
337+
consumer = ContextVar("schema_consumer", default=None)
338+
observed = []
339+
340+
def observer(phase, facts, error):
341+
observed.append((phase, dict(facts), error, consumer.get()))
342+
343+
token = consumer.set("parent")
344+
try:
345+
with observer_scope(observer):
346+
evaluator.create_physical_schemas(snapshots, DeployabilityIndex.all_deployable())
347+
finally:
348+
consumer.reset(token)
349+
350+
query_events = [event for event in observed if event[1]["kind"] == "query"]
351+
assert len(query_events) == 4
352+
assert sum(phase == "start" for phase, _, _, _ in query_events) == 2
353+
assert sum(phase == "finish" for phase, _, _, _ in query_events) == 2
354+
assert all(facts["action"] == "execute" for _, facts, _, _ in query_events)
355+
assert all(context == "parent" for _, _, _, context in query_events)
356+
assert all(error is None for phase, _, error, _ in query_events if phase == "finish")
357+
358+
created_schemas = {
359+
row[0]
360+
for row in duck_conn.execute(
361+
"SELECT schema_name FROM information_schema.schemata"
362+
).fetchall()
363+
}
364+
assert {"sqlmesh__schema_one", "sqlmesh__schema_two"} <= created_schemas
365+
366+
251367
def test_runtime_stages(capsys, mocker, adapter_mock, make_snapshot):
252368
evaluator = SnapshotEvaluator(adapter_mock)
253369

0 commit comments

Comments
 (0)