Skip to content

Commit 4ef4829

Browse files
committed
Feat: Add optional execution observations
Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent 1766e52 commit 4ef4829

11 files changed

Lines changed: 1487 additions & 189 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Execution observations
2+
3+
Execution observations expose native SQLMesh work to Python integrations through
4+
an optional callback. They do not replace SQL generation, execution, retries,
5+
transactions, or `Console` progress reporting.
6+
7+
## Console observer registration
8+
9+
`Console.get_execution_observer()` returns `None` by default. A `Console`
10+
subclass can return an `Observer` callback from `sqlmesh.core.execution_observation`.
11+
The callback receives a
12+
phase, a read-only mapping of facts, and the original exception or `None`.
13+
For general Python integration details, see the [Python API reference](./python.md).
14+
15+
The following console prints the kind, action, and finish error for each observed
16+
operation. The example uses `examples/sushi` relative to the SQLMesh repository root:
17+
18+
```python
19+
from typing import Any, Literal, Mapping, Optional
20+
21+
from sqlmesh.core.console import TerminalConsole, set_console
22+
from sqlmesh.core.context import Context
23+
from sqlmesh.core.execution_observation import Observer
24+
25+
26+
def observe(
27+
phase: Literal["start", "finish"],
28+
facts: Mapping[str, Any],
29+
error: Optional[BaseException],
30+
) -> None:
31+
if phase == "finish":
32+
print(f"{facts['kind']}/{facts['action']}: error={error!r}")
33+
34+
35+
class ObservedConsole(TerminalConsole):
36+
def get_execution_observer(self) -> Optional[Observer]:
37+
return observe
38+
39+
40+
set_console(ObservedConsole())
41+
context = Context(paths=["examples/sushi"])
42+
```
43+
44+
A `Context` uses the `Console` that was current when the context was created.
45+
Unless an explicit `observer_scope()` is active, SQLMesh resolves
46+
`get_execution_observer()` once for each outer plan evaluation or scheduler run.
47+
Nested calls reuse that observer. If the getter raises an ordinary `Exception`,
48+
SQLMesh disables observation for that plan evaluation or scheduler run and
49+
continues native work.
50+
51+
## Observer scopes
52+
53+
`observer_scope(observer)` from `sqlmesh.core.execution_observation` sets the
54+
observer for the duration of the scope. A nested scope restores the previously
55+
active observer when it exits. `observer_scope(None)` disables observation inside
56+
that scope, including automatic Console registration.
57+
58+
## Callback lifecycle
59+
60+
SQLMesh calls `start` before observed work with `error=None`. When `start`
61+
returns or raises an ordinary `Exception`, SQLMesh continues the work and calls
62+
`finish` from a `finally` block afterward. The `finish` callback receives the
63+
original workload exception object or `None`. Both callbacks run synchronously
64+
on the thread that performs the observed work. Callbacks for work dispatched to
65+
a worker run on that worker's thread.
66+
67+
Every invocation that reaches `finish` passes the same read-only mapping object
68+
to `start` and `finish`. Distinct invocations use distinct mapping objects.
69+
Consumers can pair the phases by mapping identity. SQLMesh may add facts while
70+
the work runs, so values in the mapping at `finish` can differ from its values
71+
at `start`. The mapping's supported lifetime ends when the matching `finish`
72+
callback returns.
73+
74+
Ordinary observer `Exception` failures are suppressed and do not replace the
75+
workload result or error. SQLMesh does not suppress observer-raised
76+
`BaseException` subclasses that are not `Exception` subclasses, such as
77+
`KeyboardInterrupt` and `SystemExit`. If `start` raises a `BaseException` that
78+
is not an `Exception`, the work and `finish` callback do not run. If `finish`
79+
raises a `BaseException` that is not an `Exception`, that failure can replace
80+
the workload error.
81+
82+
If a worker raises a `BaseException`, the error can reach the caller after its
83+
worker pool exits. If a DAG run records a terminal error, SQLMesh stops
84+
scheduling nodes and waits for running workers to exit before it raises that
85+
error. Parallel value operations also wait for their workers to finish before
86+
raising a worker's `BaseException`. Blocking callbacks and process termination
87+
are outside the observer failure guarantee. SQLMesh does not log observer
88+
failures.
89+
90+
Nested workload calls produce nested observations. Callbacks for concurrent
91+
workers can interleave, and SQLMesh provides no global callback order. Observers
92+
must support concurrent calls and return promptly. SQLMesh allocates no
93+
execution IDs, timestamps, durations, or parent IDs. Consumers supply those
94+
values when needed.
95+
96+
While SQLMesh delivers a callback, it suppresses observations triggered by that
97+
callback and ignores calls to `update_execution()`. The guard covers callback
98+
delivery only. Normal model work can still contain observations for physical
99+
operations, audits, and queries.
100+
101+
## Native facts and limits
102+
103+
Every facts mapping contains `kind` and `action`. Other fields depend on the
104+
observed operation and can be absent when work exits early or fails.
105+
106+
| Kind | Action | Native facts |
107+
| --- | --- | --- |
108+
| `stage` | Plan-stage class name | `native_plan_id` |
109+
| `model` | `evaluate`, `audit_only` | `snapshot`, `interval`, `batch_index`, `execution_time`, `audit_only` |
110+
| `physical` | `create`, `materialize`, `schema_migration` | `snapshot`; `target` when known; `creating` for materialization; `existed` for schema migration when known |
111+
| `virtual` | `promote`, `demote` | `snapshot`; `target` when known; `source_table` for promotion when known |
112+
| `audit` | `audit` | `audit_name`; `audit_result` at finish if produced |
113+
| `query` | `execute` | `engine` dialect string |
114+
115+
[`Snapshot`](../concepts/architecture/snapshots.md) and `AuditResult` values are
116+
borrowed native objects. Consumers must
117+
treat these objects and other nested values as read-only and must not retain them
118+
beyond the matching `finish` callback. The mapping's read-only wrapper does not
119+
freeze nested objects. Facts are not a JSON event schema. SQLMesh does not
120+
interpret audit results as consumer statuses or inherit model facts into child
121+
mappings. Consumers that need facts after `finish` copy and normalize those
122+
values during the callback.
123+
124+
SQLMesh calls `finish` for an audit before it runs later audits or completes
125+
write-audit-publish (WAP). A later audit or WAP publication can still fail. An audit
126+
query failure can produce a `finish` callback without an `AuditResult`. The
127+
[audits guide](../concepts/audits.md) describes audit behavior in plans and
128+
runs.
129+
130+
For `physical/materialize`, `target` is the logical destination table from the
131+
snapshot. During WAP, SQLMesh writes to a staging destination, but the `target`
132+
fact remains the logical destination. The `finish` callback runs after the
133+
materialization's transaction and session exit. It does not report the result
134+
of later audits or WAP publication.
135+
136+
An observed operation can do no work, issue multiple statements, or trigger
137+
internal driver retries. The observation does not predict how many statements
138+
the operation will issue or report each retry.
139+
140+
The query observation surrounds the base adapter's existing SQL logging and
141+
`_execute` call. It ends before a surrounding transaction commits and before
142+
later fetch or result consumption. Some adapter operations do not call this
143+
query hook. BigQuery session queries, BigQuery dataframe loads, and ClickHouse
144+
dataframe inserts may not produce query observations. These hooks do not provide
145+
a complete warehouse activity history or guarantee transaction success.
146+
147+
SQL logging retains its existing behavior, including VALUES redaction for
148+
non-query expressions. The callback receives no SQL text, adapter objects, or
149+
warehouse query IDs. The `start` callback runs before SQL logging. Consumers can
150+
use that ordering to associate existing log entries with the invocation.
151+
152+
## Worker context
153+
154+
When an observer is active outside callback delivery, SQLMesh copies the current
155+
Python context for each task in scheduler and snapshot DAG work, snapshot schema creation, and
156+
data-object listing. That context carries Python's [context variables](https://docs.python.org/3/library/contextvars.html).
157+
Each task gets its own context copy, including during serial execution. DAG
158+
successors use the context captured for the run. They do not inherit changes
159+
made by earlier workers. Reusing a DAG executor captures a fresh context for
160+
each run.
161+
162+
Context copies are shallow. Mutable objects stored in `ContextVar` values remain
163+
shared across copies. A parent identity stored in a consumer-owned `ContextVar`
164+
is available to each worker. SQLMesh uses these copies in the scheduler and
165+
snapshot DAG, schema creation, and data-object listing. Arbitrary consumer
166+
threads and other concurrency helpers do not receive these copies.
167+
168+
With no observer registered, these operations do not copy contexts. The concurrency
169+
helpers retain their existing `ContextVar` behavior for serial and worker
170+
execution.

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ nav:
106106
- reference/cli.md
107107
- reference/notebook.md
108108
- reference/python.md
109+
- reference/execution_observations.md
109110
- Configuration:
110111
- reference/configuration.md
111112
- 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
@@ -2636,12 +2636,17 @@ def execute(
26362636

26372637
sql = self._attach_correlation_id(sql)
26382638

2639-
self._log_sql(
2640-
sql,
2641-
expression=e if isinstance(e, exp.Expr) else None,
2642-
quote_identifiers=quote_identifiers,
2643-
)
2644-
self._execute(sql, track_rows_processed, **kwargs)
2639+
# The rendered statement and correlation comment are finalized
2640+
# here; observe the exact invocation, including driver errors.
2641+
from sqlmesh.core.execution_observation import action
2642+
2643+
with action("query", "execute", engine=self.dialect):
2644+
self._log_sql(
2645+
sql,
2646+
expression=e if isinstance(e, exp.Expr) else None,
2647+
quote_identifiers=quote_identifiers,
2648+
)
2649+
self._execute(sql, track_rows_processed, **kwargs)
26452650

26462651
def _attach_correlation_id(self, sql: str) -> str:
26472652
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(

0 commit comments

Comments
 (0)