Skip to content

Commit d193256

Browse files
sungchun12claude
andcommitted
feat(history): attribute each job to its target table; capture load jobs
Follow-up to `sqlmesh history` addressing how interwoven Python models, hooks, and signals appear: - Add a `Target` column showing the physical table each job wrote to (from BigQuery's INFORMATION_SCHEMA.JOBS destination_table). This makes interleaved Python/SQL model steps and pre/post-hook statements legible instead of an anonymous stream of INSERT/CREATE. Anonymous SELECT result tables are omitted. - Label BigQuery load jobs (LoadJobConfig) with the correlation id, so Python-model dataframe loads and seed loads appear as `LOAD` rows instead of being invisible. Factored the label out into `_correlation_labels`, shared by query and load jobs. - Document that signals (Python readiness checks) and pure-Python model logic run no SQL and don't appear; point to `check_intervals`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 61cb75a commit d193256

6 files changed

Lines changed: 125 additions & 33 deletions

File tree

docs/guides/history.md

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ The `sqlmesh history` command does that correlation for you. Give it a plan id a
1111

1212
## How it works
1313

14-
Every query SQLMesh runs for a plan is tagged with that plan's correlation id &mdash; a BigQuery job label on the query job itself. `sqlmesh history` looks up the plan (either the one you specify or one you pick from a menu), then queries BigQuery's job history for every job carrying that label and renders the results chronologically.
14+
Every job SQLMesh runs for a plan &mdash; both query jobs and the load jobs used to ingest Python-model dataframes and seeds &mdash; is tagged with that plan's correlation id as a BigQuery job label. `sqlmesh history` looks up the plan (either the one you specify or one you pick from a menu), then queries BigQuery's job history for every job carrying that label and renders the results chronologically.
1515

1616
Because it's driven by the query engine's job history rather than SQLMesh state, it only shows what actually executed against the warehouse. It's the fastest way to answer "did this step run, how long did it take, and if it failed, why?" without leaving your terminal.
1717

@@ -55,28 +55,32 @@ You only need to type enough of the plan id to uniquely identify it &mdash; the
5555
```bash linenums="1"
5656
$ sqlmesh history 3f9a2c1e
5757

58-
History · plan 3f9a2c1e · 41 queries · 38 ✓ 2 ✗ 1 running
59-
┏━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━┓
60-
┃ Time ┃ Status ┃ Operation ┃ Duration ┃ Bytes/Rows ┃
61-
┡━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━┩
62-
│ 14:03:11 │ ✓ │ CREATE TABLE │ 1200 ms │ 1,048,576 bytes │
63-
│ 14:03:14 │ ✓ │ INSERT │ 14800 ms │ 8,912,441 bytes │
64-
│ 14:03:29 │ ✗ │ INSERT │ 420 ms │ - │
65-
│ │ │ Syntax error: unexpected identifier 'PRICE' at [1:34] │ │ │
66-
│ 14:04:02 │ … │ MERGE │ - │ - │
67-
└──────────┴────────┴────────────────┴──────────┴──────────────┘
68-
2 failed · re-run with `-o failures.sql` to export the SQL.
58+
History · plan 3f9a2c1e · prod · 9 queries · 7 ✓ 1 ✗ 1 running
59+
60+
Time Status Operation Target Duration Bytes/Rows
61+
──────── ────── ───────────── ──────────────────────────────── ──────── ───────────────
62+
14:03:11 ✓ CREATE TABLE sqlmesh__sushi.orders__2837a1c 1200 ms -
63+
14:03:14 ✓ INSERT sqlmesh__sushi.orders__2837a1c 14800 ms 8,912,441 bytes
64+
14:03:22 ✓ LOAD sqlmesh__sushi.customers__4b90fc 640 ms 12,004 bytes
65+
14:03:29 ✗ INSERT sqlmesh__sushi.items__9f01b2d 420 ms -
66+
└─ Column not found: name PRICE cannot be resolved in sushi.items [at 1:34]
67+
14:03:31 … MERGE sqlmesh__sushi.order_items__7c3a - -
68+
69+
1 failed · re-run with `-o failures.sql` to export the SQL.
6970
```
7071

71-
The header line summarizes the plan: the short plan id, total query count, and a breakdown of how many queries succeeded (``), failed (``), or are still running (``).
72+
(In your terminal this is rendered as a bordered table.) The header line summarizes the plan: the short plan id, environment, total job count, and a breakdown of how many succeeded (``), failed (``), or are still running (``).
7273

73-
- **Time** &mdash; when the query started.
74+
- **Time** &mdash; when the job started.
7475
- **Status** &mdash; `` succeeded, `` failed, `` still running.
75-
- **Operation** &mdash; the leading SQL keyword (`CREATE TABLE`, `INSERT`, `MERGE`, `CREATE VIEW`, `ALTER TABLE`, etc.), so you can scan for the kind of work each query did without reading the full statement.
76-
- **Duration** &mdash; wall-clock time the query took. Shown as `-` for queries that haven't finished.
76+
- **Operation** &mdash; the leading SQL keyword (`CREATE TABLE`, `INSERT`, `MERGE`, `CREATE VIEW`, `ALTER TABLE`, `LOAD`, etc.), so you can scan the kind of work each job did without reading the full statement.
77+
- **Target** &mdash; the physical table the job wrote to, so you can tell which model (and which step) each row belongs to. This is how you follow a Python model whose steps are interleaved with SQL models, or attribute a `LOAD`/`INSERT`/`MERGE` to the right table. Shown as `-` for jobs with no destination (for example a standalone audit `SELECT`).
78+
- **Duration** &mdash; wall-clock time the job took. Shown as `-` for jobs that haven't finished.
7779
- **Bytes/Rows** &mdash; bytes processed if BigQuery reported them, otherwise rows affected, otherwise `-`.
7880

79-
A failed query is followed by an indented line with the error message BigQuery returned, so you can see why it failed without opening the warehouse UI.
81+
A failed job is followed by an indented line with the error message BigQuery returned, so you can see why it failed without opening the warehouse UI.
82+
83+
Because the rows are ordered by execution time, the steps of a **Python model** (its physical `CREATE TABLE`, the `LOAD` of its dataframe, and the `INSERT`/`MERGE` that publishes it) slot chronologically in between the SQL models' steps, and **hooks** &mdash; model pre/post-statements and environment `before_all`/`after_all` statements &mdash; appear as their own rows in position. Use the **Target** column to tell them apart.
8084

8185
## Exporting the SQL
8286

@@ -110,7 +114,7 @@ The file is one statement per entry, in execution order, so it can be read top t
110114
```
111115
112116
- **Ingestion latency and retention.** BigQuery's job history views have some delay before a query appears and don't retain jobs forever, so a plan applied moments ago or a very old plan may show no rows.
113-
- **Labeled query jobs only.** SQLMesh labels the query jobs it runs, but some operations &mdash; like loading a seed model's dataframe into BigQuery &mdash; use a load job rather than a labeled query job, so they won't appear in the history.
117+
- **Signals and pure-Python logic aren't shown.** Signals are Python readiness checks that gate whether a model runs; they execute no SQL, so they don't appear as rows. Their effect shows up as an *absence* &mdash; a signal-blocked model simply has no `INSERT` for that interval. To inspect what a signal is holding back, use [`sqlmesh check_intervals`](../reference/cli.md#check_intervals), which reports pending intervals while respecting signals. Likewise, any purely in-memory work inside a Python model (API calls, dataframe transforms) runs no SQL and won't appear &mdash; only the jobs that touch the warehouse do.
114118
- **Plans only.** Scheduled `sqlmesh run` executions aren't included, only `sqlmesh plan` applications.
115119
116120
## See also

sqlmesh/core/console.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,7 @@ def make_progress_bar(
960960
"ALTER",
961961
"DROP",
962962
"SELECT",
963+
"LOAD",
963964
)
964965

965966

@@ -2840,6 +2841,7 @@ def show_history(
28402841
table.add_column("Time", no_wrap=True)
28412842
table.add_column("Status", no_wrap=True)
28422843
table.add_column("Operation", no_wrap=True)
2844+
table.add_column("Target")
28432845
table.add_column("Duration", justify="right", no_wrap=True)
28442846
table.add_column("Bytes/Rows", justify="right", no_wrap=True)
28452847

@@ -2856,11 +2858,12 @@ def show_history(
28562858
started,
28572859
glyph.get(record.status, record.status),
28582860
_sql_operation(record.sql),
2861+
record.target or "-",
28592862
duration,
28602863
size,
28612864
)
28622865
if record.error:
2863-
table.add_row("", "", f"[red]{record.error}[/red]", "", "")
2866+
table.add_row("", "", f"[red]{record.error}[/red]", "", "", "")
28642867

28652868
self._print(table)
28662869
if failed:

sqlmesh/core/engine_adapter/bigquery.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,16 @@ def bigframe(self) -> t.Optional[BigframeSession]:
132132
return bigframes.connect(context=options)
133133
return None
134134

135+
def _correlation_labels(self) -> t.Dict[str, str]:
136+
"""BigQuery job labels tagging a job with the current correlation id (keys must be lowercase).
137+
138+
Used for both query jobs (`_job_params`) and dataframe/seed load jobs so that everything
139+
SQLMesh runs for a plan is discoverable via `query_history`.
140+
"""
141+
if self.correlation_id:
142+
return {self.correlation_id.job_type.value.lower(): self.correlation_id.job_id}
143+
return {}
144+
135145
@property
136146
def _job_params(self) -> t.Dict[str, t.Any]:
137147
from sqlmesh.core.config.connection import BigQueryPriority
@@ -146,10 +156,9 @@ def _job_params(self) -> t.Dict[str, t.Any]:
146156
params["maximum_bytes_billed"] = self._extra_config.get("maximum_bytes_billed")
147157
if self._extra_config.get("reservation") is not None:
148158
params["reservation"] = self._extra_config.get("reservation")
149-
if self.correlation_id:
150-
# BigQuery label keys must be lowercase
151-
key = self.correlation_id.job_type.value.lower()
152-
params["labels"] = {key: self.correlation_id.job_id}
159+
labels = self._correlation_labels()
160+
if labels:
161+
params["labels"] = labels
153162
return params
154163

155164
@property
@@ -485,11 +494,12 @@ def _query_history_location(self, project: str) -> t.Optional[str]:
485494
def query_history(self, correlation_id: CorrelationId) -> t.List[QueryHistoryRecord]:
486495
"""Return the queries executed under `correlation_id` from BigQuery's INFORMATION_SCHEMA.JOBS.
487496
488-
Jobs are matched on the label SQLMesh attaches to every query it runs (see `_job_params`).
489-
Reading project-wide job history requires the `bigquery.jobs.listAll` permission.
497+
Jobs are matched on the label SQLMesh attaches to every job it runs (see `_correlation_labels`).
498+
Both query jobs and dataframe/seed load jobs are captured. Reading project-wide job history
499+
requires the `bigquery.jobs.listAll` permission.
490500
491-
Note: only labeled query jobs are captured. Dataframe/seed load jobs
492-
(`load_table_from_dataframe`) are not labeled, so they do not appear here.
501+
Note: signals (Python readiness checks) and pure-Python model logic execute no SQL, so they
502+
do not appear here.
493503
"""
494504
from google.api_core.exceptions import Forbidden
495505

@@ -532,6 +542,8 @@ def query_history(self, correlation_id: CorrelationId) -> t.List[QueryHistoryRec
532542
exp.column("message", table="error_result").as_("error_message"),
533543
exp.column("total_bytes_processed"),
534544
exp.column("job_id"),
545+
exp.column("dataset_id", table="destination_table").as_("dest_dataset"),
546+
exp.column("table_id", table="destination_table").as_("dest_table"),
535547
)
536548
.from_(jobs_table)
537549
.where(label_match)
@@ -553,7 +565,30 @@ def query_history(self, correlation_id: CorrelationId) -> t.List[QueryHistoryRec
553565
) from e
554566

555567
records: t.List[QueryHistoryRecord] = []
556-
for start_time, end_time, sql, state, error_message, total_bytes, job_id in rows:
568+
for (
569+
start_time,
570+
end_time,
571+
sql,
572+
state,
573+
error_message,
574+
total_bytes,
575+
job_id,
576+
dest_dataset,
577+
dest_table,
578+
) in rows:
579+
# BigQuery routes SELECTs to anonymous result tables (dataset "_...", table "anon...");
580+
# only surface a real destination the statement actually wrote to.
581+
target = None
582+
if (
583+
dest_table
584+
and dest_dataset
585+
and not dest_dataset.startswith("_")
586+
and not dest_table.startswith("anon")
587+
):
588+
target = f"{dest_dataset}.{dest_table}"
589+
if not sql:
590+
# Load jobs (dataframe/seed loads) carry no SQL text.
591+
sql = f"LOAD INTO {target}" if target else "LOAD"
557592
if error_message:
558593
status = "failed"
559594
elif state in ("RUNNING", "PENDING"):
@@ -574,6 +609,7 @@ def query_history(self, correlation_id: CorrelationId) -> t.List[QueryHistoryRec
574609
bytes_processed=int(total_bytes) if total_bytes is not None else None,
575610
error=error_message,
576611
query_id=job_id,
612+
target=target,
577613
)
578614
)
579615
return records
@@ -690,6 +726,10 @@ def __load_pandas_to_table(
690726
from google.cloud import bigquery
691727

692728
job_config = bigquery.job.LoadJobConfig(schema=self.__get_bq_schema(columns_to_types))
729+
labels = self._correlation_labels()
730+
if labels:
731+
# Label load jobs too, so Python-model dataframe loads and seeds show up in query_history.
732+
job_config.labels = labels
693733
if replace:
694734
job_config.write_disposition = bigquery.WriteDisposition.WRITE_TRUNCATE
695735
logger.info(f"Loading dataframe to BigQuery. Table Path: {table.path}")

sqlmesh/core/engine_adapter/shared.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ class QueryHistoryRecord:
316316
rows: t.Optional[int] = None
317317
error: t.Optional[str] = None
318318
query_id: t.Optional[str] = None
319+
target: t.Optional[str] = None # physical table the statement wrote to, if any
319320

320321

321322
def set_catalog(override_mapping: t.Optional[t.Dict[str, CatalogSupport]] = None) -> t.Callable:

tests/core/engine_adapter/test_bigquery.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,7 +1397,9 @@ def test_query_history_builds_labels_filter_sql(
13971397
executed_sql = executed_query.sql(dialect="bigquery")
13981398
expected_sql = (
13991399
"SELECT start_time, end_time, query, state, error_result.message AS error_message, "
1400-
"total_bytes_processed, job_id FROM `project`.`region-us-central1`.`INFORMATION_SCHEMA.JOBS` AS JOBS "
1400+
"total_bytes_processed, job_id, destination_table.dataset_id AS dest_dataset, "
1401+
"destination_table.table_id AS dest_table "
1402+
"FROM `project`.`region-us-central1`.`INFORMATION_SCHEMA.JOBS` AS JOBS "
14011403
"WHERE EXISTS(SELECT 1 FROM UNNEST(labels) AS l WHERE l.key = 'sqlmesh_plan' AND l.value = 'abc123') "
14021404
"ORDER BY COALESCE(start_time, creation_time)"
14031405
)
@@ -1427,7 +1429,17 @@ def test_query_history_maps_job_status(
14271429
adapter,
14281430
"fetchall",
14291431
return_value=[
1430-
(start_time, end_time, "SELECT 1", state, error_message, 1024, "job-1"),
1432+
(
1433+
start_time,
1434+
end_time,
1435+
"SELECT 1",
1436+
state,
1437+
error_message,
1438+
1024,
1439+
"job-1",
1440+
"sushi",
1441+
"orders__abc",
1442+
),
14311443
],
14321444
)
14331445
mocker.patch.object(adapter, "get_current_catalog", return_value="project")
@@ -1445,6 +1457,33 @@ def test_query_history_maps_job_status(
14451457
assert record.bytes_processed == 1024
14461458
assert record.query_id == "job-1"
14471459
assert record.sql == "SELECT 1"
1460+
assert record.target == "sushi.orders__abc"
1461+
1462+
1463+
def test_query_history_captures_load_jobs_and_skips_anon(
1464+
make_mocked_engine_adapter: t.Callable, mocker: MockerFixture
1465+
):
1466+
start_time = datetime(2024, 1, 1, 10, 0, 0)
1467+
end_time = datetime(2024, 1, 1, 10, 0, 3)
1468+
adapter = make_mocked_engine_adapter(BigQueryEngineAdapter)
1469+
mocker.patch.object(
1470+
adapter,
1471+
"fetchall",
1472+
return_value=[
1473+
# load job: no SQL text, real destination -> becomes a LOAD row with a target
1474+
(start_time, end_time, None, "DONE", None, 4096, "job-load", "sushi", "seed_data__v1"),
1475+
# plain SELECT routed to an anonymous result table -> no target surfaced
1476+
(start_time, end_time, "SELECT 1", "DONE", None, 10, "job-anon", "_scratch", "anon9f8"),
1477+
],
1478+
)
1479+
mocker.patch.object(adapter, "get_current_catalog", return_value="project")
1480+
mocker.patch.object(adapter.client, "location", "us-central1")
1481+
1482+
records = adapter.query_history(CorrelationId.from_plan_id("abc123"))
1483+
1484+
assert records[0].sql == "LOAD INTO sushi.seed_data__v1"
1485+
assert records[0].target == "sushi.seed_data__v1"
1486+
assert records[1].target is None
14481487

14491488

14501489
def test_query_history_forbidden_raises_permission_error(

tests/core/test_console.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
from sqlmesh.utils.errors import SQLMeshError
1212

1313

14-
def _create_test_console() -> t.Tuple[StringIO, TerminalConsole]:
14+
def _create_test_console(width: t.Optional[int] = None) -> t.Tuple[StringIO, TerminalConsole]:
1515
"""Creates a console and buffer for validating console output."""
1616
console_output = StringIO()
17-
console = RichConsole(file=console_output, force_terminal=True)
17+
console = RichConsole(file=console_output, force_terminal=True, width=width)
1818
return console_output, TerminalConsole(console=console)
1919

2020

@@ -149,7 +149,7 @@ def test_markdown_console_error_block():
149149

150150

151151
def test_show_history():
152-
output, console = _create_test_console()
152+
output, console = _create_test_console(width=200)
153153
plan_id = "plan-id-123"
154154
records = [
155155
QueryHistoryRecord(
@@ -158,12 +158,14 @@ def test_show_history():
158158
status="success",
159159
duration_ms=1500,
160160
bytes_processed=2048,
161+
target="sushi.orders__2837",
161162
),
162163
QueryHistoryRecord(
163164
started_at=datetime(2024, 1, 1, 10, 0, 5),
164165
sql="INSERT INTO foo VALUES (1)",
165166
status="failed",
166167
error="Column mismatch",
168+
target="sushi.items__9f01",
167169
),
168170
QueryHistoryRecord(
169171
started_at=None,
@@ -182,6 +184,9 @@ def test_show_history():
182184
assert "MERGE" in printed
183185
assert "Column mismatch" in printed
184186
assert "re-run with" in printed
187+
# target column attributes each step to its physical table
188+
assert "Target" in printed
189+
assert "sushi.orders__2837" in printed
185190

186191

187192
def test_show_history_empty():

0 commit comments

Comments
 (0)