Skip to content

Commit e62b377

Browse files
mday-ioPavan Mylavarapu
andcommitted
Feat: Add optional execution observations
Extract native lifecycle callbacks with isolated observer dispatch and opt-in worker context propagation. Co-authored-by: Pavan Mylavarapu <pavan.mylavarapu@atoms.co> Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent 8d0b4de commit e62b377

11 files changed

Lines changed: 1007 additions & 26 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Execution observations
2+
3+
Execution observations let Python integrations observe native SQLMesh work through
4+
an optional callback. They do not replace SQL generation, execution, retries,
5+
transactions, or Console progress reporting.
6+
7+
## Register an observer
8+
9+
Override `Console.get_execution_observer()` to return a callback accepting
10+
`phase`, `facts`, and `error`. Its default return value is `None`.
11+
Install the console before constructing the SQLMesh `Context`:
12+
13+
```python
14+
from typing import Any, Literal, Mapping, Optional
15+
16+
from sqlmesh.core.console import TerminalConsole, set_console
17+
from sqlmesh.core.context import Context
18+
from sqlmesh.core.execution_observation import Observer
19+
20+
21+
def observe(
22+
phase: Literal["start", "finish"],
23+
facts: Mapping[str, Any],
24+
error: Optional[BaseException],
25+
) -> None:
26+
if phase == "finish":
27+
print(f"{facts['kind']}/{facts['action']}: error={error!r}")
28+
29+
30+
class ObservedConsole(TerminalConsole):
31+
def get_execution_observer(self) -> Optional[Observer]:
32+
return observe
33+
34+
35+
set_console(ObservedConsole())
36+
context = Context(paths=["path/to/project"])
37+
```
38+
39+
Built-in plan-stage evaluation and scheduler dispatch resolve the getter at the
40+
outer observation boundary. Nested dispatch reuses the active registration.
41+
An ordinary `Exception` from the getter disables observation at that boundary;
42+
native work continues.
43+
44+
For an explicitly bounded registration, use `observer_scope(observer)` from
45+
`sqlmesh.core.execution_observation`. Nested scopes restore the preceding
46+
registration on exit. `observer_scope(None)` explicitly disables observation
47+
inside the scope, including automatic Console registration.
48+
49+
## Callback lifecycle
50+
51+
`start` runs before the observed work, with `error=None`. Once the start callback
52+
returns, `finish` runs from `finally` after that work, with its original exception
53+
object or `None`. Both callbacks run synchronously on the invoking thread.
54+
55+
SQLMesh passes the **same read-only mapping object** to start and finish for one
56+
invocation. Distinct invocations have distinct mappings. Consumers may retain the
57+
mapping until its matching finish callback to pair the calls by object identity;
58+
release it afterward. SQLMesh may add facts discovered during execution, so this
59+
mapping is a live view, not an immutable snapshot of the start facts.
60+
61+
Nested workload calls produce nested observations. Callbacks for concurrent
62+
workers can interleave, and there is no global callback ordering. Observers must
63+
support concurrent calls and return promptly. SQLMesh does not allocate execution
64+
IDs, timestamps, durations, or parent IDs; consumers can maintain that information.
65+
66+
Ordinary observer `Exception` failures are suppressed and do not replace the
67+
workload result or error. This guarantee does **not** cover observer-raised
68+
`BaseException` subclasses such as `KeyboardInterrupt` or `SystemExit`: a failure
69+
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.
72+
73+
While a callback is being delivered, observations triggered by that callback are
74+
suppressed, and internal metadata updates are ignored. This guard applies only
75+
during callback delivery: a normal model operation can still contain observed
76+
physical operations, audits, and queries.
77+
78+
## Native facts and boundaries
79+
80+
Every mapping contains `kind` and `action`. Other fields depend on the boundary
81+
and may be absent, particularly when work exits early or fails.
82+
83+
| Kind | Action | Native facts |
84+
| --- | --- | --- |
85+
| `stage` | Native plan-stage name | `native_plan_id` |
86+
| `model` | `evaluate`, `audit_only` | `snapshot`, `interval`, `batch_index`, `execution_time`, `audit_only` |
87+
| `physical` | `create`, `materialize`, `schema_migration` | `snapshot`; `target` when known; `creating` for materialization; `existed` for schema migration when known |
88+
| `virtual` | `promote`, `demote` | `snapshot`; `target` when known; `source_table` for promotion when known |
89+
| `audit` | `audit` | `audit_name`; `audit_result` at finish if produced |
90+
| `query` | `execute` | `engine` dialect string |
91+
92+
`Snapshot` and `AuditResult` values are borrowed native objects. Treat them and
93+
other nested values as read-only, and do not retain them beyond finish. The
94+
mapping's read-only wrapper does not freeze these objects. Normalize any values
95+
needed for later processing during the callback; facts are not a JSON event
96+
schema. SQLMesh does not interpret audit results as consumer statuses or inherit
97+
model facts into child mappings.
98+
99+
Audit observations finish before later audits or WAP publication can fail. An
100+
audit query failure can produce a finish callback without an `AuditResult`.
101+
Physical materialization includes the surrounding transaction/session exit.
102+
An invocation can perform no work, multiple statements, or internal driver
103+
retries; observations are not an expected-work manifest or a retry inventory.
104+
105+
The query boundary surrounds the base adapter's existing SQL logging and
106+
`_execute` call. It does not include a surrounding transaction's commit or later
107+
fetch/result consumption. Adapter paths that bypass this boundary, including
108+
BigQuery session queries and dataframe loads and ClickHouse dataframe inserts,
109+
do not necessarily produce query observations. These hooks therefore do not
110+
provide a complete warehouse activity history or a transaction-success guarantee.
111+
112+
SQL logs retain their existing behavior, including VALUES redaction for
113+
non-query expressions. The callback does not receive SQL text, adapter objects,
114+
or warehouse query IDs. Starting the observation before SQL logging lets a
115+
consumer associate existing logs with the invocation.
116+
117+
## Worker context
118+
119+
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+
126+
Context copies are shallow. Mutable objects stored in ContextVars can still be
127+
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.
130+
131+
With no observer registered, these call sites do not opt into context copying
132+
and retain their existing serial and worker execution behavior.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ nav:
105105
- reference/cli.md
106106
- reference/notebook.md
107107
- reference/python.md
108+
- reference/execution_observations.md
108109
- Configuration:
109110
- reference/configuration.md
110111
- reference/model_configuration.md

sqlmesh/core/console.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,20 @@ class Console(
407407

408408
INDIRECTLY_MODIFIED_DISPLAY_THRESHOLD = 10
409409

410+
def get_execution_observer(
411+
self,
412+
) -> t.Optional[
413+
t.Callable[
414+
[t.Literal["start", "finish"], t.Mapping[str, t.Any], t.Optional[BaseException]], None
415+
]
416+
]:
417+
"""Optional start/finish hook receiving native facts and original errors.
418+
419+
May run concurrently. No IDs, clocks or product status are supplied.
420+
Implementations must return promptly; ordinary hook errors are isolated.
421+
"""
422+
return None
423+
410424
@abc.abstractmethod
411425
def start_plan_evaluation(self, plan: EvaluatablePlan) -> None:
412426
"""Indicates that a new evaluation has begun."""

sqlmesh/core/engine_adapter/base.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2624,12 +2624,17 @@ def execute(
26242624

26252625
sql = self._attach_correlation_id(sql)
26262626

2627-
self._log_sql(
2628-
sql,
2629-
expression=e if isinstance(e, exp.Expr) else None,
2630-
quote_identifiers=quote_identifiers,
2631-
)
2632-
self._execute(sql, track_rows_processed, **kwargs)
2627+
# The rendered statement and correlation comment are finalized
2628+
# here; observe the exact invocation, including driver errors.
2629+
from sqlmesh.core.execution_observation import action
2630+
2631+
with action("query", "execute", engine=self.dialect):
2632+
self._log_sql(
2633+
sql,
2634+
expression=e if isinstance(e, exp.Expr) else None,
2635+
quote_identifiers=quote_identifiers,
2636+
)
2637+
self._execute(sql, track_rows_processed, **kwargs)
26332638

26342639
def _attach_correlation_id(self, sql: str) -> str:
26352640
if self.ATTACH_CORRELATION_ID and self.correlation_id:
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Optional native lifecycle hooks, independent of telemetry storage or policy.
3+
4+
Call sites report start before native work and finish in finally, including the
5+
original error. Consumers must return promptly. Ordinary observer exceptions never
6+
replace workload results. The scope only routes hooks and late native metadata;
7+
execution IDs, timing, parent correlation and status mapping belong to consumers.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import contextlib
13+
import contextvars
14+
import functools
15+
import typing as t
16+
from types import MappingProxyType
17+
18+
Observer = t.Callable[
19+
[t.Literal["start", "finish"], t.Mapping[str, t.Any], t.Optional[BaseException]], None
20+
]
21+
_dispatching: contextvars.ContextVar[bool] = contextvars.ContextVar(
22+
"execution_observer_dispatch", default=False
23+
)
24+
_scoped: contextvars.ContextVar[bool] = contextvars.ContextVar(
25+
"execution_observer_scoped", default=False
26+
)
27+
_observer: contextvars.ContextVar[t.Optional[Observer]] = contextvars.ContextVar(
28+
"execution_observer", default=None
29+
)
30+
_metadata: contextvars.ContextVar[t.Optional[t.Dict[str, t.Any]]] = contextvars.ContextVar(
31+
"execution_observation_metadata", default=None
32+
)
33+
34+
35+
def update_execution(**metadata: t.Any) -> None:
36+
"""Supply native facts discovered inside the current hook boundary."""
37+
current = _metadata.get()
38+
if current is not None and not _dispatching.get():
39+
current.update(metadata)
40+
41+
42+
@contextlib.contextmanager
43+
def observer_scope(observer: t.Optional[Observer]) -> t.Iterator[None]:
44+
token = _observer.set(observer)
45+
scope_token = _scoped.set(True)
46+
metadata_token = _metadata.set(None)
47+
try:
48+
yield
49+
finally:
50+
_metadata.reset(metadata_token)
51+
_scoped.reset(scope_token)
52+
_observer.reset(token)
53+
54+
55+
def _notify(
56+
observer: Observer,
57+
phase: t.Literal["start", "finish"],
58+
metadata: t.Mapping[str, t.Any],
59+
error: t.Optional[BaseException] = None,
60+
) -> None:
61+
token = _dispatching.set(True)
62+
try:
63+
observer(phase, metadata, error)
64+
except Exception:
65+
# Hooks are observational: do not alter execution or recursively log a
66+
# telemetry failure. Consumers own their capture-loss reporting.
67+
pass
68+
finally:
69+
_dispatching.reset(token)
70+
71+
72+
@contextlib.contextmanager
73+
def action(kind: str, name: str, **metadata: t.Any) -> t.Iterator[t.Optional[t.Dict[str, t.Any]]]:
74+
observer = _observer.get()
75+
if observer is None or _dispatching.get():
76+
yield None
77+
return
78+
data = {**metadata, "kind": kind, "action": name}
79+
view = MappingProxyType(data)
80+
token = _metadata.set(data)
81+
try:
82+
_notify(observer, "start", view)
83+
error: t.Optional[BaseException] = None
84+
try:
85+
yield data
86+
except BaseException as ex:
87+
error = ex
88+
raise
89+
finally:
90+
_notify(observer, "finish", view, error)
91+
finally:
92+
_metadata.reset(token)
93+
94+
95+
def observe_snapshot(kind: str, name: str) -> t.Callable:
96+
"""Hook evaluator methods whose first argument after self is snapshot.
97+
98+
Verified callers pass Snapshot as that argument (positional or keyword).
99+
Pass the native object unchanged; no reflection, identity formatting, skip
100+
classification or snapshot interpretation is performed by this hook.
101+
"""
102+
103+
def decorate(function: t.Callable) -> t.Callable:
104+
@functools.wraps(function)
105+
def wrapped(self: t.Any, snapshot: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any:
106+
with action(kind, name, snapshot=snapshot):
107+
return function(self, snapshot, *args, **kwargs)
108+
109+
return wrapped
110+
111+
return decorate
112+
113+
114+
def execution_context_factory() -> t.Optional[t.Callable[[], contextvars.Context]]:
115+
"""Opt in to worker context propagation only during active observation."""
116+
return (
117+
contextvars.copy_context if _observer.get() is not None and not _dispatching.get() else None
118+
)
119+
120+
121+
@contextlib.contextmanager
122+
def console_observer_scope(console: t.Any) -> t.Iterator[None]:
123+
"""Resolve once at the outer boundary; an explicit None scope disables hooks."""
124+
if _scoped.get():
125+
yield
126+
return
127+
try:
128+
observer = console.get_execution_observer()
129+
except Exception:
130+
observer = None
131+
with observer_scope(observer):
132+
yield

sqlmesh/core/plan/evaluator.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import logging
1919
import typing as t
2020
from sqlmesh.core import analytics
21+
from sqlmesh.core.execution_observation import action, console_observer_scope
2122
from sqlmesh.core import constants as c
2223
from sqlmesh.core.console import Console, get_console
2324
from sqlmesh.core.environment import EnvironmentNamingInfo, execute_environment_statements
@@ -102,7 +103,8 @@ def evaluate(
102103

103104
try:
104105
plan_stages = stages.build_plan_stages(plan, self.state_sync, self.default_catalog)
105-
self._evaluate_stages(plan_stages, plan)
106+
with console_observer_scope(self.console):
107+
self._evaluate_stages(plan_stages, plan)
106108
except Exception as e:
107109
analytics.collector.on_plan_apply_end(plan_id=plan.plan_id, error=e)
108110
raise
@@ -122,7 +124,8 @@ def _evaluate_stages(
122124
raise SQLMeshError(f"Unexpected plan stage: {stage_name}")
123125
logger.info("Evaluating plan stage %s", stage_name)
124126
handler = getattr(self, handler_name)
125-
handler(stage, plan)
127+
with action("stage", stage_name, native_plan_id=plan.plan_id):
128+
handler(stage, plan)
126129

127130
def visit_before_all_stage(self, stage: stages.BeforeAllStage, plan: EvaluatablePlan) -> None:
128131
execute_environment_statements(

sqlmesh/core/scheduler.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
from sqlglot import exp
99
from sqlmesh.core import constants as c
1010
from sqlmesh.core.console import Console, get_console
11+
from sqlmesh.core.execution_observation import (
12+
action,
13+
console_observer_scope,
14+
execution_context_factory,
15+
)
1116
from sqlmesh.core.environment import EnvironmentNamingInfo, execute_environment_statements
1217
from sqlmesh.core.macros import RuntimeStage
1318
from sqlmesh.core.model.definition import AuditResult
@@ -518,6 +523,21 @@ def run_merged_intervals(
518523
)
519524

520525
def run_node(node: SchedulingUnit) -> None:
526+
if isinstance(node, EvaluateNode):
527+
with action(
528+
"model",
529+
"audit_only" if audit_only else "evaluate",
530+
snapshot=self.snapshots_by_name[node.snapshot_name],
531+
interval=node.interval,
532+
batch_index=node.batch_index,
533+
execution_time=execution_time,
534+
audit_only=audit_only,
535+
):
536+
execute_node(node)
537+
else:
538+
execute_node(node)
539+
540+
def execute_node(node: SchedulingUnit) -> None:
521541
if circuit_breaker and circuit_breaker():
522542
raise CircuitBreakerError()
523543
if isinstance(node, DummyNode):
@@ -596,12 +616,13 @@ def run_node(node: SchedulingUnit) -> None:
596616
)
597617

598618
try:
599-
with self.snapshot_evaluator.concurrent_context():
619+
with console_observer_scope(self.console), self.snapshot_evaluator.concurrent_context():
600620
errors, skipped_intervals = concurrent_apply_to_dag(
601621
dag,
602622
run_node,
603623
self.max_workers,
604624
raise_on_error=False,
625+
context_factory=execution_context_factory(),
605626
)
606627
self.console.stop_evaluation_progress(success=not errors)
607628

0 commit comments

Comments
 (0)