Add support for summary metrics in Lakeflow Declarative Pipelines / DLT#1301
Add support for summary metrics in Lakeflow Declarative Pipelines / DLT#1301mwojtyczka wants to merge 7 commits into
Conversation
Add DQEngine.compute_summary_metrics, which computes the same summary metrics as the observer/listener path but as a lazy aggregation over the result columns, returning a DataFrame with the OBSERVATION_TABLE_SCHEMA. Unlike observe()/streaming-listener metrics (which require the caller to own the write action), this can back a materialized view inside a Spark Declarative Pipeline, where the pipeline runtime owns the write. DQEngineCore._observe_metrics now detects an SDP/Lakeflow/DLT context via is_dlt_pipeline and skips wiring observe() there (returning the DataFrame unchanged) so apply_checks* can be returned directly from a @dlt.table definition even with an observer configured. compute_summary_metrics uses the engine's observer (incl. its custom_metrics) and raises InvalidParameterError when none is configured. Includes two DLT demos (report-as-columns and quarantine), docs updates, and unit/integration/e2e tests. Co-authored-by: Isaac
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1301 +/- ##
==========================================
- Coverage 92.56% 92.22% -0.35%
==========================================
Files 102 102
Lines 10429 10461 +32
==========================================
- Hits 9654 9648 -6
- Misses 775 813 +38
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
✅ 805/805 passed, 42 skipped, 5h39m46s total Running from acceptance #5170 |
|
✅ 178/178 passed, 2 skipped, 6h48m51s total Running from anomaly #1284 |
test_run_dqx_row_anomaly_detection_demo is the slowest e2e demo (trains two IsolationForest models and scores with contributions + AI explanations) and can exceed the previous 30-minute SDK job-wait on a cold serverless start, causing spurious TimeoutError failures. Raise both waits to 45 minutes, still well within the e2e CI job's 2h wrapper. Co-authored-by: Isaac
Note in the Snapshot vs. history admonition that a materialized view is single-query/single-pipeline and cannot be shared: unionByName the compute_summary_metrics results inside one MV to centralize several tables in a pipeline, and use the companion-job append into a plain Delta table (safe concurrent appends) to centralize across pipelines. Co-authored-by: Isaac
… run overwrites Add an integration test exercising build_metrics_df_from_aggregation's two previously-uncovered branches: the current_timestamp() run_time fallback (no run_time_overwrite) and the user_metadata map. These are the only new lines the integration suite missed for compute_summary_metrics. Also document that, as for DQX result materialized views, run_time_overwrite and run_id_overwrite must be set as static values in ExtraParams for the summary-metrics materialized view to be incrementally refreshable in Lakeflow (otherwise run_time defaults to current_timestamp() and run_id to a random UUID, so every update looks new). Co-authored-by: Isaac
ghanse
left a comment
There was a problem hiding this comment.
Overall LGTM, left a few comments regarding documentation and usage patterns.
| df = dlt.read_stream("bronze_dq_check") | ||
| return dq_engine.get_invalid(df) No newline at end of file | ||
| def dq_summary_metrics(): | ||
| df = dlt.read("silver") |
There was a problem hiding this comment.
This defines an MV on the entire silver table with all of its historical data and does not scope the metrics to the latest run or micro-batch. Should we show a foreachBatch sink that reads data with the streaming APIs instead? This seems more scalable.
There was a problem hiding this comment.
You are right, although foreachBatch is more complex and is for streaming pipelines only. I guess we should document all 3 approaches:
- MV — simple, fully managed, broad runtime support, gives current-state snapshot (scalable when incrementalizable). Default for a current-quality view and the simplest starting point.
- foreach_batch sink — recommended for large/streaming/incremental tables and when you want per-run/per-batch history; needs a streaming source, a recent runtime, and an idempotent write.
- windowed streaming table — per-time-window history.
As for the demo, I'm not sure, maybe we can have a separate one with foreach_batch? the MV one will definitely be simpler.
| # One row per metric (input / error / warning / valid row counts and per-check breakdown). | ||
| @dlt.table | ||
| def dq_summary_metrics(): | ||
| df = dlt.read("bronze_dq_check") |
| Inside a Lakeflow Pipeline (Spark Declarative Pipeline / DLT), do the following: | ||
|
|
||
| 1. Persist the checked data as a table (so the result columns are available downstream). | ||
| 2. Add a materialized view that calls `compute_summary_metrics` on that table, passing the same `checks` you applied. |
There was a problem hiding this comment.
An MV will recompute metrics over the entire checked table. There are 2 possible challenges with this:
- It might not be scalable for large tables where data is checked incrementally
- MV will not have per-run or per-micro-batch quality results, the metrics will be recomputed every time
Should we instruct users to use a foreachBatch sink in these scenarios?
| **Streaming pipelines (recommended):** append per-window metrics from a windowed streaming table. Add a watermark on a timestamp column and aggregate the observer's metric expressions per time window, so each window's metrics are appended as it completes: | ||
|
|
||
| ```python | ||
| import pyspark.sql.functions as F | ||
| from databricks.labs.dqx.metrics_observer import DQMetricsObserver | ||
|
|
||
| observer = DQMetricsObserver() # pass check names to get_metrics(check_names) to also include the per-check breakdown | ||
|
|
||
| @dlt.table | ||
| def dq_summary_metrics(): | ||
| df = dlt.read_stream("silver").withWatermark("event_time", "10 minutes") | ||
| metric_exprs = [F.expr(m) for m in observer.get_metrics()] | ||
| return df.groupBy(F.window("event_time", "1 hour")).agg(*metric_exprs) | ||
| ``` | ||
|
|
||
| This needs a timestamp column to window on (an event or ingestion time) and a watermark; each window's metrics are emitted only once the watermark passes it, and very late rows are dropped. The output carries a `window` column plus the metric columns (it is not the long `OBSERVATION_TABLE_SCHEMA` shape) — so it is **not** schema-compatible with the companion-job output below, and the two cannot be appended into the same metrics table. Pick one history strategy per table. |
There was a problem hiding this comment.
ForeachBatch sink may be useful to avoid recomputing the results for large tables.
Reconcile the "Snapshot vs. history" note with the "Incremental materialized views" admonition: the metrics MV refreshes incrementally when the query is deterministic (static run_time_overwrite / run_id_overwrite) and the pipeline engine can incrementalize the aggregation, otherwise it fully recomputes — and either way it is a cumulative snapshot, not per-run. Add the same caveat to both DLT demos, pointing at the windowed streaming-table option for per-run / per-window metrics on large or incrementally-checked tables. Co-authored-by: Isaac
Changes
Adds aggregation-based data quality summary metrics that work inside Spark Declarative Pipelines (SDP / Lakeflow / DLT), where the existing
observe()-based observer and the streaming metrics listener cannot be used because the pipeline runtime — not the caller — owns the write action.DQEngine.compute_summary_metrics(...)— computes the same metrics as the observer path (row counts, per-check breakdown, and observer custom metrics) but as a lazy aggregation over the result columns, returning a DataFrame with theOBSERVATION_TABLE_SCHEMA. This can back a materialized view over the checked table inside a pipeline. It uses the engine'sDQMetricsObserver(including itscustom_metrics) and raisesInvalidParameterErrorwhen no observer is configured.DQEngineCore._observe_metricsnow detects a pipeline context (viais_dlt_pipeline) and skips wiringobserve()there, returning the DataFrame unchanged. This keepsreturn engine.apply_checks*(df, checks)usable directly as a@dlt.tabledefinition even with an observer configured; metrics are computed downstream bycompute_summary_metrics.DQMetricsObserver.build_metrics_df_from_aggregation— reshapes the one-row wide aggregation into the longOBSERVATION_TABLE_SCHEMAwithout triggering an action; robust to dotted custom-metric aliases.summary_metrics.mdx,quality_checks_apply.mdx,demos.mdx) and two DLT demos added (report-as-columns and quarantine variants).Quarantine scenario:

Annotation scenario:

Linked issues
Resolves #1291
Resolves #947
Tests
Documentation and Demos