Skip to content

Commit dd271b8

Browse files
authored
Merge branch 'main' into fix/load-materializations-during-cleanup
2 parents 6fe3da3 + 5a09168 commit dd271b8

16 files changed

Lines changed: 1607 additions & 213 deletions
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

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ dbt = ["dbt-core<2"]
114114
dlt = ["dlt"]
115115
db2 = [
116116
"ibm_db",
117-
"db2-sqlglot-dialect;python_version>=\"3.10\""
117+
"db2-sqlglot-dialect>=1.1.0;python_version>=\"3.10\""
118118
]
119119
duckdb = []
120120
fabric = ["pyodbc>=5.0.0"]

‎sqlmesh/core/audit/builtin.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,10 +318,14 @@ def create_non_blocking_copy(audit: Audit) -> Audit:
318318
# valid_http_method(column=column_name)
319319
valid_http_method_audit = ModelAudit(
320320
name="valid_http_method",
321+
defaults={"condition": exp.true()},
321322
query="""
322323
SELECT *
323324
FROM @this_model
324-
WHERE NOT @column IN ('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT')
325+
WHERE @AND(
326+
NOT @column IN ('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT'),
327+
@condition,
328+
)
325329
""",
326330
)
327331

@@ -432,7 +436,7 @@ def create_non_blocking_copy(audit: Audit) -> Audit:
432436
@column,
433437
(@column - mean_@column) / NULLIF(stddev_@column, 0) AS z_score
434438
FROM @this_model, stats
435-
WHERE ABS((@column - mean_@column) / NULLIF(stddev_@column, 0)) > @threshold
439+
WHERE @AND(ABS((@column - mean_@column) / NULLIF(stddev_@column, 0)) > @threshold, @condition)
436440
""",
437441
)
438442

‎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
@@ -2644,12 +2644,17 @@ def execute(
26442644

26452645
sql = self._attach_correlation_id(sql)
26462646

2647-
self._log_sql(
2648-
sql,
2649-
expression=e if isinstance(e, exp.Expr) else None,
2650-
quote_identifiers=quote_identifiers,
2651-
)
2652-
self._execute(sql, track_rows_processed, **kwargs)
2647+
# The rendered statement and correlation comment are finalized
2648+
# here; observe the exact invocation, including driver errors.
2649+
from sqlmesh.core.execution_observation import action
2650+
2651+
with action("query", "execute", engine=self.dialect):
2652+
self._log_sql(
2653+
sql,
2654+
expression=e if isinstance(e, exp.Expr) else None,
2655+
quote_identifiers=quote_identifiers,
2656+
)
2657+
self._execute(sql, track_rows_processed, **kwargs)
26532658

26542659
def _attach_correlation_id(self, sql: str) -> str:
26552660
if self.ATTACH_CORRELATION_ID and self.correlation_id:

‎sqlmesh/core/engine_adapter/clickhouse.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ def _build_alter_partition_exp(
446446
) -> exp.Alter:
447447
alter_expr = exp.Alter(this=target_table, kind="TABLE")
448448

449-
for partition in partitions_to_replace:
449+
for partition in sorted(partitions_to_replace):
450450
alter_expr.append(
451451
"actions",
452452
exp.ReplacePartition(
@@ -457,7 +457,7 @@ def _build_alter_partition_exp(
457457
),
458458
)
459459

460-
for partition in partitions_to_drop:
460+
for partition in sorted(partitions_to_drop):
461461
alter_expr.append(
462462
"actions",
463463
exp.DropPartition(

0 commit comments

Comments
 (0)