Skip to content

Commit e32060d

Browse files
committed
Docs: Clarify execution observation contracts and examples
Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent b2e1bf5 commit e32060d

1 file changed

Lines changed: 121 additions & 95 deletions

File tree

Lines changed: 121 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
# Execution observations
22

3-
Execution observations let Python integrations observe native SQLMesh work through
3+
Execution observations expose native SQLMesh work to Python integrations through
44
an optional callback. They do not replace SQL generation, execution, retries,
5-
transactions, or Console progress reporting.
5+
transactions, or `Console` progress reporting.
66

7-
## Register an observer
7+
## Console observer registration
88

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`:
9+
`Console.get_execution_observer()` returns `None` by default. A `Console`
10+
subclass can return an `Observer` callback from
11+
`sqlmesh.core.execution_observation`. The callback receives a phase, a read-only
12+
mapping of facts, and the original exception or `None`.
13+
14+
This console prints the kind, action, and finish error for each observed operation:
1215

1316
```python
1417
from typing import Any, Literal, Mapping, Optional
@@ -19,121 +22,144 @@ from sqlmesh.core.execution_observation import Observer
1922

2023

2124
def observe(
22-
phase: Literal["start", "finish"],
23-
facts: Mapping[str, Any],
24-
error: Optional[BaseException],
25+
phase: Literal["start", "finish"],
26+
facts: Mapping[str, Any],
27+
error: Optional[BaseException],
2528
) -> None:
26-
if phase == "finish":
27-
print(f"{facts['kind']}/{facts['action']}: error={error!r}")
29+
if phase == "finish":
30+
print(f"{facts['kind']}/{facts['action']}: error={error!r}")
2831

2932

3033
class ObservedConsole(TerminalConsole):
31-
def get_execution_observer(self) -> Optional[Observer]:
32-
return observe
34+
def get_execution_observer(self) -> Optional[Observer]:
35+
return observe
3336

3437

3538
set_console(ObservedConsole())
36-
context = Context(paths=["path/to/project"])
39+
context = Context(paths=["examples/sushi"])
3740
```
3841

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.
42+
A `Context` captures the console returned by `get_console()` when the context is
43+
constructed. `BuiltInPlanEvaluator.evaluate()` and
44+
`Scheduler.run_merged_intervals()` enter `console_observer_scope()` for their
45+
observed work. Unless an `observer_scope()` is already active,
46+
`console_observer_scope()` calls `get_execution_observer()` once. Nested calls
47+
reuse the observer already active in the scope. If `get_execution_observer()`
48+
raises an ordinary `Exception`, SQLMesh disables observation for that scope and
49+
continues native work.
50+
51+
## Observer scopes
4352

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.
53+
`observer_scope(observer)` from `sqlmesh.core.execution_observation` sets the
54+
active observer for the scope. A nested scope restores the previously active
55+
observer when it exits. `observer_scope(None)` disables observation for that
56+
scope, including automatic Console registration.
4857

4958
## Callback lifecycle
5059

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.
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.
5466

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.
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+
A worker's `BaseException` can reach the caller after SQLMesh waits for its
83+
workers. A DAG run that records a terminal error stops scheduling nodes and
84+
raises that error after its running workers exit. In parallel execution,
85+
`concurrent_apply_to_values()` stores a worker's `BaseException` in a future and
86+
waits for the pool to finish before reading results. Blocking callbacks and
87+
process termination are outside the observer failure guarantee. SQLMesh does
88+
not log observer failures.
6089

6190
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.
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.
6595

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. 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.
77-
78-
While a callback is being delivered, observations triggered by that callback are
79-
suppressed, and internal metadata updates are ignored. This guard applies only
80-
during callback delivery: a normal model operation can still contain observed
81-
physical operations, audits, and queries.
82-
83-
## Native facts and boundaries
84-
85-
Every mapping contains `kind` and `action`. Other fields depend on the boundary
86-
and may be absent, particularly when work exits early or fails.
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.
87105

88106
| Kind | Action | Native facts |
89107
| --- | --- | --- |
90-
| `stage` | Native plan-stage name | `native_plan_id` |
108+
| `stage` | Plan-stage class name | `native_plan_id` |
91109
| `model` | `evaluate`, `audit_only` | `snapshot`, `interval`, `batch_index`, `execution_time`, `audit_only` |
92110
| `physical` | `create`, `materialize`, `schema_migration` | `snapshot`; `target` when known; `creating` for materialization; `existed` for schema migration when known |
93111
| `virtual` | `promote`, `demote` | `snapshot`; `target` when known; `source_table` for promotion when known |
94112
| `audit` | `audit` | `audit_name`; `audit_result` at finish if produced |
95113
| `query` | `execute` | `engine` dialect string |
96114

97-
`Snapshot` and `AuditResult` values are borrowed native objects. Treat them and
98-
other nested values as read-only, and do not retain them beyond finish. The
99-
mapping's read-only wrapper does not freeze these objects. Normalize any values
100-
needed for later processing during the callback; facts are not a JSON event
101-
schema. SQLMesh does not interpret audit results as consumer statuses or inherit
102-
model facts into child mappings.
103-
104-
Audit observations finish before later audits or WAP publication can fail. An
105-
audit query failure can produce a finish callback without an `AuditResult`.
106-
Physical materialization includes the surrounding transaction/session exit.
107-
An invocation can perform no work, multiple statements, or internal driver
108-
retries; observations are not an expected-work manifest or a retry inventory.
109-
110-
The query boundary surrounds the base adapter's existing SQL logging and
111-
`_execute` call. It does not include a surrounding transaction's commit or later
112-
fetch/result consumption. Adapter paths that bypass this boundary, including
113-
BigQuery session queries and dataframe loads and ClickHouse dataframe inserts,
114-
do not necessarily produce query observations. These hooks therefore do not
115-
provide a complete warehouse activity history or a transaction-success guarantee.
116-
117-
SQL logs retain their existing behavior, including VALUES redaction for
118-
non-query expressions. The callback does not receive SQL text, adapter objects,
119-
or warehouse query IDs. Starting the observation before SQL logging lets a
120-
consumer associate existing logs with the invocation.
115+
`Snapshot` and `AuditResult` values are borrowed native objects. Consumers must
116+
treat these objects and other nested values as read-only and must not retain them
117+
beyond the matching `finish` callback. The mapping's read-only wrapper does not
118+
freeze nested objects. Facts are not a JSON event schema. SQLMesh does not
119+
interpret audit results as consumer statuses or inherit model facts into child
120+
mappings. Consumers that need facts after `finish` copy and normalize those
121+
values during the callback.
122+
123+
SQLMesh calls `finish` for an audit before it runs later audits or publishes WAP
124+
results. A later audit or publication can still fail. An audit query failure can
125+
produce a `finish` callback without an `AuditResult`. The physical materialization
126+
callback ends after its surrounding transaction and session exit.
127+
128+
An observed operation can do no work, issue multiple statements, or trigger
129+
internal driver retries. The observation does not predict how many statements
130+
the operation will issue or report each retry.
131+
132+
The query observation surrounds the base adapter's existing SQL logging and
133+
`_execute` call. It ends before a surrounding transaction commits and before
134+
later fetch or result consumption. Some adapter operations do not call this
135+
query hook. BigQuery session queries, BigQuery dataframe loads, and ClickHouse
136+
dataframe inserts may not produce query observations. These hooks do not provide
137+
a complete warehouse activity history or guarantee transaction success.
138+
139+
SQL logging retains its existing behavior, including VALUES redaction for
140+
non-query expressions. The callback receives no SQL text, adapter objects, or
141+
warehouse query IDs. The `start` callback runs before SQL logging. Consumers can
142+
use that ordering to associate existing log entries with the invocation.
121143

122144
## Worker context
123145

124-
When observation is active, the instrumented scheduler and snapshot DAG dispatch
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.
131-
132-
Context copies are shallow. Mutable objects stored in ContextVars can still be
133-
shared; consumers should use immutable values or replace values instead of
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.
137-
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.
146+
Outside callback delivery, an active observer makes
147+
`execution_context_factory()` return `contextvars.copy_context`.
148+
`Scheduler.run_merged_intervals()` passes that function to
149+
`concurrent_apply_to_dag()`. `SnapshotEvaluator` passes it to
150+
`concurrent_apply_to_snapshots()` and `concurrent_apply_to_values()`. Each node
151+
or value gets its own context copy, including during serial execution. Successor
152+
nodes start from the context captured for the DAG run. They do not inherit
153+
changes made by earlier workers. `ConcurrentDAGExecutor.run()` captures a fresh
154+
context for each run. Schema creation and data-object listing use
155+
`concurrent_apply_to_values()`, so each value gets its own context copy.
156+
157+
Context copies are shallow. Mutable objects stored in `ContextVar` values remain
158+
shared across copies. A parent identity stored in a consumer-owned `ContextVar`
159+
is available to each worker. These copies apply to the scheduler DAG and to the
160+
`SnapshotEvaluator` calls above. Other SQLMesh concurrency helpers and arbitrary
161+
consumer threads do not receive these copies.
162+
163+
With no observer registered, `execution_context_factory()` returns `None`. The
164+
concurrency helpers retain their existing `ContextVar` behavior for serial and
165+
worker execution.

0 commit comments

Comments
 (0)