feat(pipelines): capture violating-row samples for data tests (#9919)

* feat(pipelines): capture violating-row samples for data tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pipelines): byte-accurate sample cap and leaf-level payload sanitize

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump ee-repo-ref to WAP guard probe adaptation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: WAP failures are counts-only — samples exist only on commit-then-test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: qualify where sample row data appears — job result and failed-job log line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: error handlers receive the full result incl. samples, like any failed job

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 80d309edebb899e36a3bdcdf4ea73c4db070534d

This commit updates the EE repository reference after PR #646 was merged in windmill-ee-private.

Previous ee-repo-ref: 16e916bf11f26381920560b55771fce693e668c6

New ee-repo-ref: 80d309edebb899e36a3bdcdf4ea73c4db070534d

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-07-04 19:10:52 +02:00
committed by GitHub
co-authored by Claude Fable 5 windmill-internal-app[bot]
parent 5ad2de91a2
commit d4b4374de8
7 changed files with 604 additions and 144 deletions
+1 -1
View File
@@ -1 +1 @@
2fab310d4f50ed7c34857d69c9b854f4491bf217
80d309edebb899e36a3bdcdf4ea73c4db070534d
@@ -837,16 +837,29 @@ pub fn materialize_result_sql(
if checks.is_empty() {
return format!("SELECT {base_cols};");
}
// Per-test breakdown. Each check's violating-count is computed once as a CTE
// column (`c0`, `c1`, …); the `data_tests` list-of-struct then references
// those columns — DuckDB rejects scalar subqueries *inside* a struct/list
// literal, hence the CTE. Names are single-quote-escaped. The result row
// carries the whole breakdown so the worker runs every test (no
// abort-on-first) and decides pass/fail itself.
let cte_cols = checks
// Per-test breakdown. Each check's one-row probe `(v, s)` becomes a CTE
// (`_wm_t0`, `_wm_t1`, …); `_wm_tr` cross-joins them (all one-row, so the
// join stays one row) and the `data_tests` list-of-struct references the
// flattened columns — DuckDB rejects scalar subqueries *inside* a
// struct/list literal, hence the CTE lift. Names are single-quote-escaped.
// The result row carries the whole breakdown so the worker runs every
// test (no abort-on-first) and decides pass/fail itself.
let probe_ctes = checks
.iter()
.enumerate()
.map(|(i, c)| format!("{} AS c{i}", c.violating))
.map(|(i, c)| format!("_wm_t{i} AS ({})", c.probe))
.collect::<Vec<_>>()
.join(", ");
let tr_cols = checks
.iter()
.enumerate()
.map(|(i, _)| format!("_wm_t{i}.v AS c{i}, _wm_t{i}.s AS s{i}"))
.collect::<Vec<_>>()
.join(", ");
let tr_from = checks
.iter()
.enumerate()
.map(|(i, _)| format!("_wm_t{i}"))
.collect::<Vec<_>>()
.join(", ");
let list_items = checks
@@ -854,12 +867,12 @@ pub fn materialize_result_sql(
.enumerate()
.map(|(i, c)| {
let name = c.name.replace('\'', "''");
format!("{{'test': '{name}', 'violating': c{i}}}")
format!("{{'test': '{name}', 'violating': c{i}, 'sample': s{i}}}")
})
.collect::<Vec<_>>()
.join(", ");
format!(
"WITH _wm_tr AS (SELECT {cte_cols}) \
"WITH {probe_ctes}, _wm_tr AS (SELECT {tr_cols} FROM {tr_from}) \
SELECT {base_cols}, [{list_items}] AS data_tests FROM _wm_tr;"
)
}
@@ -880,18 +893,19 @@ fn terminate(stmt: &str) -> String {
//
// A data test is the FIRST extensible annotation: the parser yields a
// `DataTest` from a known vocabulary, and this module turns each into a
// *check* — a `(name, violating-row-count query)` pair — that runs against the
// freshly-materialized target after the write commits. The materialize summary
// query embeds every check's count in one `data_tests` column, so all tests
// run in a single pass (no abort-on-first) and the worker, not the SQL,
// decides pass/fail and reports the full per-test breakdown.
// *check* — a `(name, probe)` pair whose probe counts and samples the
// violating rows — that runs against the freshly-materialized target after
// the write commits. The materialize summary query embeds every check's
// outcome in one `data_tests` column, so all tests run in a single pass (no
// abort-on-first) and the worker, not the SQL, decides pass/fail and reports
// the full per-test breakdown.
//
// The pattern is deliberately open: a verifier is just `(name, count query)`.
// Built-ins differ only in their count query; the `Custom` escape hatch
// supplies its own (a user SELECT returning the violating rows). A sibling
// annotation family (column-lineage) can emit its own checks through the same
// `push_check` shape rather than bolting on a parallel mechanism. See
// `docs/ducklake-materialization.md`.
// The pattern is deliberately open: a verifier is just `(name, violating-rows
// query)` handed to `push_check`. Built-ins differ only in their rows query;
// the `Custom` escape hatch supplies its own (a user SELECT returning the
// violating rows). A sibling annotation family (column-lineage) can emit its
// own checks through the same `push_check` shape rather than bolting on a
// parallel mechanism. See `docs/ducklake-materialization.md`.
use crate::asset_parser::{AssetKind, DataTest};
@@ -926,19 +940,31 @@ pub enum DataTestResolved {
Custom { path: String, body: String },
}
/// One compiled data-test check: a human-readable `name` and a scalar SQL
/// expression (`violating`) yielding the number of rows that violate it (0 =
/// pass). The materialize summary query embeds every check's count so the
/// worker gets the whole breakdown in one result — all tests run (no
/// abort-on-first) and the worker, not the SQL, decides pass/fail.
/// One compiled data-test check: a human-readable `name` and a one-row probe
/// query yielding `(v, s)` — the violating-row count (0 = pass) and a bounded
/// `to_json` sample of the violating rows as a VARCHAR (NULL when there are
/// none, or when the serialized sample exceeds the size cap). The materialize
/// summary query embeds every check's outcome so the worker gets the whole
/// breakdown in one result — all tests run (no abort-on-first) and the
/// worker, not the SQL, decides pass/fail. The sample is decoration only:
/// enforcement reads `v`, never `s`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataTestCheck {
pub name: String,
/// Scalar subquery yielding the violating-row count, e.g.
/// `(SELECT count(*) AS v FROM (…))`.
pub violating: String,
/// One-row subquery `SELECT … AS v, … AS s FROM (…)` counting and
/// sampling the check's violating rows in a single scan.
pub probe: String,
}
/// Row cap on a data-test sample. Bounded so the sample stays a debugging aid
/// (the full count is still reported); no ORDER BY on the violating rows, so
/// which rows land in the sample is nondeterministic.
const SAMPLE_MAX_ROWS: usize = 20;
/// Byte cap on one serialized sample. Oversized samples are dropped entirely
/// (NULL), never truncated — truncated JSON would fail parsing downstream
/// after paying the bytes anyway.
const SAMPLE_MAX_BYTES: usize = 51_200;
/// The SQL a set of data tests compiles to: referenced-asset `ATTACH`
/// statements (resolved by the executor's ATTACH-transform pass) and the
/// per-test checks, both in declaration order.
@@ -1002,11 +1028,40 @@ fn partition_scope(ctx: &DataTestCtx, prefix: &str, table_alias: Option<&str>) -
format!("{prefix}{}", conds.join(" AND "))
}
// Record one check: its display `name` plus `count_query` (which yields a
// single-column violating-row count) wrapped as a scalar subquery.
fn push_check(out: &mut DataTestChecks, name: String, count_query: String) {
out.checks
.push(DataTestCheck { name, violating: format!("({count_query})") });
// Record one check: its display `name` plus `rows_query`, the SELECT of its
// violating rows. The probe counts and samples those rows in one scan:
// `_wm_v` (the subquery alias) referenced as a column is the whole row as a
// struct; `to_json(...)::VARCHAR` keeps the sample a JSON *string* through
// the FFI — expanded rows would be visible to the executor's key-recursive
// `extract_i64(result, "rows"/"snapshot_id")` scans, which a user column of
// the same name could corrupt. `list()` over zero rows and an over-cap
// sample both degrade to NULL (`s` is optional by contract).
fn push_check(out: &mut DataTestChecks, name: String, rows_query: String) {
// `strlen` counts bytes (unlike `length`, characters) — the cap bounds
// payload size on the wire, so bytes are the right unit.
let probe = format!(
"SELECT v, CASE WHEN strlen(s_raw) <= {SAMPLE_MAX_BYTES} THEN s_raw END AS s \
FROM (SELECT count(*) AS v, \
to_json(list(_wm_v ORDER BY _wm_rn) FILTER (WHERE _wm_rn <= {SAMPLE_MAX_ROWS}))::VARCHAR AS s_raw \
FROM (SELECT _wm_v, row_number() OVER () AS _wm_rn FROM ({rows_query}) _wm_v))"
);
out.checks.push(DataTestCheck { name, probe });
}
// `SELECT *` for a sample rows-query, excluding the synthetic physical
// partition column on partitioned targets — it's Windmill's storage detail,
// not part of the producer's logical output (same rule as schema capture).
// `qualifier` scopes the star when the query aliases the target (`_wm_src`).
fn sample_star(ctx: &DataTestCtx, qualifier: Option<&str>) -> String {
let star = match qualifier {
Some(q) => format!("{q}.*"),
None => "*".to_string(),
};
if ctx.partitioned {
format!("{star} EXCLUDE ({})", quote_ident(ctx.partition_col))
} else {
star
}
}
/// Compile resolved data tests into ATTACH statements + per-test checks for
@@ -1028,28 +1083,34 @@ pub fn build_data_test_checks(
DataTestResolved::BuiltIn(DataTest::Unique { column }) => {
let c = quote_ident(column);
let scope = partition_scope(ctx, " AND ", None);
// The rows are the GROUP BY result — one `{value, count}` per
// duplicated key — so the count (number of duplicated values,
// not of rows) and the sample share one grain and can't
// contradict each other in the UI.
let q = format!(
"SELECT count(*) AS v FROM (SELECT {c} FROM {t} WHERE {c} IS NOT NULL{scope} \
GROUP BY {c} HAVING count(*) > 1)"
"SELECT {c} AS \"value\", count(*) AS \"count\" FROM {t} \
WHERE {c} IS NOT NULL{scope} GROUP BY {c} HAVING count(*) > 1"
);
push_check(&mut out, format!("unique({column})"), q);
}
DataTestResolved::BuiltIn(DataTest::NotNull { column }) => {
let c = quote_ident(column);
let scope = partition_scope(ctx, " AND ", None);
let q = format!("SELECT count(*) AS v FROM {t} WHERE {c} IS NULL{scope}");
let star = sample_star(ctx, None);
let q = format!("SELECT {star} FROM {t} WHERE {c} IS NULL{scope}");
push_check(&mut out, format!("not_null({column})"), q);
}
DataTestResolved::BuiltIn(DataTest::AcceptedValues { column, values }) => {
let c = quote_ident(column);
let scope = partition_scope(ctx, " AND ", None);
let star = sample_star(ctx, None);
let list = values
.iter()
.map(|v| quote_lit(v))
.collect::<Vec<_>>()
.join(", ");
let q = format!(
"SELECT count(*) AS v FROM {t} WHERE {c} IS NOT NULL AND {c} NOT IN ({list}){scope}"
"SELECT {star} FROM {t} WHERE {c} IS NOT NULL AND {c} NOT IN ({list}){scope}"
);
push_check(&mut out, format!("accepted_values({column})"), q);
}
@@ -1113,8 +1174,9 @@ pub fn build_data_test_checks(
// segment so the dot stays a schema separator, not a literal.
let rt = quote_qualified(ref_table);
let scope = partition_scope(ctx, " AND ", Some("_wm_src"));
let star = sample_star(ctx, Some("_wm_src"));
let q = format!(
"SELECT count(*) AS v FROM {t} _wm_src \
"SELECT {star} FROM {t} _wm_src \
WHERE _wm_src.{c} IS NOT NULL{scope} \
AND NOT EXISTS (SELECT 1 FROM {alias}.{rt} _wm_ref \
WHERE _wm_ref.{rc} = _wm_src.{c})"
@@ -1150,8 +1212,7 @@ pub fn build_data_test_checks(
stmts.len()
));
}
let q = format!("SELECT count(*) AS v FROM ({})", stmts[0]);
push_check(&mut out, format!("custom({path})"), q);
push_check(&mut out, format!("custom({path})"), stmts[0].to_string());
}
}
}
@@ -1678,21 +1739,34 @@ mod tests {
// short, asset-free names (the asset is shown once by the breakdown).
assert_eq!(sql.checks[0].name, "unique(order_id)");
assert_eq!(sql.checks[1].name, "not_null(user_id)");
// each `violating` is a scalar count subquery.
// each probe counts and samples the violating rows in one scan, with
// the size guard on the serialized sample.
for c in &sql.checks {
assert!(c
.probe
.starts_with("SELECT v, CASE WHEN strlen(s_raw) <= 51200 THEN s_raw END AS s"));
assert!(c.probe.contains("count(*) AS v"));
assert!(c.probe.contains("FILTER (WHERE _wm_rn <= 20)"));
}
// unique: groups non-null keys within the slice, having count>1; the
// sample is `{value, count}` pairs at the same grain as the count.
assert!(sql.checks[0]
.violating
.starts_with("(SELECT count(*) AS v FROM"));
// unique: groups non-null keys within the slice, having count>1
assert!(sql.checks[0]
.violating
.probe
.contains("GROUP BY \"order_id\" HAVING count(*) > 1"));
assert!(sql.checks[0]
.violating
.probe
.contains("SELECT \"order_id\" AS \"value\", count(*) AS \"count\""));
assert!(sql.checks[0]
.probe
.contains("\"order_id\" IS NOT NULL AND \"_wm_partition\" = '2026-06-19'"));
// not_null: null rows in the slice
// not_null: null rows in the slice; the sample excludes the synthetic
// partition column (storage detail, not producer output).
assert!(sql.checks[1]
.violating
.probe
.contains("WHERE \"user_id\" IS NULL AND \"_wm_partition\" = '2026-06-19'"));
assert!(sql.checks[1]
.probe
.contains("SELECT * EXCLUDE (\"_wm_partition\") FROM"));
}
#[test]
@@ -1701,8 +1775,9 @@ mod tests {
column: "id".into(),
})];
let sql = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap();
assert!(sql.checks[0].violating.contains("WHERE \"id\" IS NULL"));
assert!(!sql.checks[0].violating.contains("_wm_partition"));
assert!(sql.checks[0].probe.contains("WHERE \"id\" IS NULL"));
assert!(!sql.checks[0].probe.contains("_wm_partition"));
assert!(!sql.checks[0].probe.contains("EXCLUDE"));
}
#[test]
@@ -1719,14 +1794,14 @@ mod tests {
];
let sql = build_data_test_checks(&tests, &ctx_scd2()).unwrap();
assert!(sql.checks[0]
.violating
.probe
.contains("WHERE \"customer_id\" IS NOT NULL AND is_current"));
assert!(sql.checks[1]
.violating
.probe
.contains("WHERE \"tier\" IS NULL AND is_current"));
assert!(sql.checks[2].violating.contains("AND is_current"));
assert!(sql.checks[2].probe.contains("AND is_current"));
// no partition scope leaks in (scd2 is unpartitioned in v1)
assert!(!sql.checks[0].violating.contains("_wm_partition"));
assert!(!sql.checks[0].probe.contains("_wm_partition"));
}
#[test]
@@ -1736,10 +1811,8 @@ mod tests {
values: vec!["paid".into(), "o'brien".into()],
})];
let sql = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap();
assert!(sql.checks[0]
.violating
.contains("NOT IN ('paid', 'o''brien')"));
assert!(sql.checks[0].violating.contains("\"status\" IS NOT NULL"));
assert!(sql.checks[0].probe.contains("NOT IN ('paid', 'o''brien')"));
assert!(sql.checks[0].probe.contains("\"status\" IS NOT NULL"));
}
#[test]
@@ -1763,14 +1836,19 @@ mod tests {
assert_eq!(sql.attaches.len(), 1, "same db attached once");
assert_eq!(sql.attaches[0], "ATTACH 'datatable://prod' AS _wm_ref_0;");
assert!(sql.checks[0]
.violating
.probe
.contains("NOT EXISTS (SELECT 1 FROM _wm_ref_0.\"users\""));
assert!(sql.checks[1]
.violating
.probe
.contains("NOT EXISTS (SELECT 1 FROM _wm_ref_0.\"buyers\""));
assert!(sql.checks[0]
.violating
.probe
.contains("_wm_src.\"_wm_partition\" = '2026-06-19'"));
// sample rows come from the aliased target and drop the synthetic
// partition column.
assert!(sql.checks[0]
.probe
.contains("SELECT _wm_src.* EXCLUDE (\"_wm_partition\") FROM"));
assert_eq!(
sql.checks[0].name,
"relationships(user_id -> prod/users.id)"
@@ -1807,7 +1885,7 @@ mod tests {
"same-lake ref must not ATTACH again"
);
assert!(sql.checks[0]
.violating
.probe
.contains("NOT EXISTS (SELECT 1 FROM _wm_target.\"users\""));
}
@@ -1828,10 +1906,10 @@ mod tests {
);
assert!(
sql.checks[0]
.violating
.probe
.contains("FROM _wm_ref_0.\"main\".\"dim_products\""),
"schema-qualified target should be quoted per segment: {}",
sql.checks[0].violating
sql.checks[0].probe
);
}
@@ -1853,10 +1931,11 @@ mod tests {
body: "SELECT * FROM _wm_target.orders WHERE amount < 0;".into(),
}];
let sql = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap();
// trailing ; stripped, wrapped as a count subquery
assert!(sql.checks[0].violating.contains(
"SELECT count(*) AS v FROM (SELECT * FROM _wm_target.orders WHERE amount < 0)"
));
// trailing ; stripped, body embedded as the probe's rows query
assert!(sql.checks[0]
.probe
.contains("FROM (SELECT * FROM _wm_target.orders WHERE amount < 0) _wm_v"));
assert!(sql.checks[0].probe.contains("count(*) AS v"));
assert_eq!(sql.checks[0].name, "custom(f/tests/amount)");
}
@@ -1883,14 +1962,8 @@ mod tests {
#[test]
fn materialize_result_sql_embeds_data_tests_breakdown() {
let checks = vec![
DataTestCheck {
name: "unique(order_id)".into(),
violating: "(SELECT count(*) AS v FROM q0)".into(),
},
DataTestCheck {
name: "custom(f/t)".into(),
violating: "(SELECT count(*) AS v FROM q1)".into(),
},
DataTestCheck { name: "unique(order_id)".into(), probe: "SELECT v, s FROM q0".into() },
DataTestCheck { name: "custom(f/t)".into(), probe: "SELECT v, s FROM q1".into() },
];
let sql = materialize_result_sql(
"_wm_target.orders",
@@ -1900,10 +1973,17 @@ mod tests {
false,
&checks,
);
// counts computed once in a CTE, referenced by the list-of-struct.
assert!(sql.starts_with("WITH _wm_tr AS (SELECT (SELECT count(*) AS v FROM q0) AS c0,"));
assert!(sql.contains("[{'test': 'unique(order_id)', 'violating': c0}, "));
assert!(sql.contains("{'test': 'custom(f/t)', 'violating': c1}] AS data_tests"));
// each probe runs once as a one-row CTE; _wm_tr cross-joins them and
// the list-of-struct references the flattened count/sample columns.
assert!(sql.starts_with(
"WITH _wm_t0 AS (SELECT v, s FROM q0), _wm_t1 AS (SELECT v, s FROM q1), \
_wm_tr AS (SELECT _wm_t0.v AS c0, _wm_t0.s AS s0, _wm_t1.v AS c1, _wm_t1.s AS s1 \
FROM _wm_t0, _wm_t1)"
));
assert!(sql.contains("[{'test': 'unique(order_id)', 'violating': c0, 'sample': s0}, "));
assert!(
sql.contains("{'test': 'custom(f/t)', 'violating': c1, 'sample': s1}] AS data_tests")
);
assert!(sql.contains("FROM _wm_tr;"));
// no tests -> plain summary, no CTE / data_tests column.
let plain = materialize_result_sql(
@@ -794,6 +794,155 @@ mod temporal_json_tests {
assert_eq!(json_of(3), serde_json::json!("2026-07-01"));
assert_eq!(json_of(4), serde_json::json!("10:30:00"));
}
// The data-test sample probe shape emitted by
// `windmill-parser::sql_materialize::build_data_test_checks`: one scan
// yielding the violating-row count plus a bounded `to_json` sample of the
// rows as a VARCHAR. These tests gate that design against the *bundled*
// engine (json extension availability, row-as-struct alias reference,
// NULL degrade on zero rows / oversized samples, exotic column types).
fn sample_probe_sql(rows_query: &str, max_len: usize) -> String {
format!(
"SELECT v, CASE WHEN strlen(s_raw) <= {max_len} THEN s_raw END AS s \
FROM (SELECT count(*) AS v, \
to_json(list(_wm_v ORDER BY _wm_rn) FILTER (WHERE _wm_rn <= 20))::VARCHAR AS s_raw \
FROM (SELECT _wm_v, row_number() OVER () AS _wm_rn FROM ({rows_query}) _wm_v))"
)
}
fn run_sample_probe(
conn: &duckdb::Connection,
rows_query: &str,
max_len: usize,
) -> (i64, Option<String>) {
let mut stmt = conn
.prepare(&sample_probe_sql(rows_query, max_len))
.unwrap();
let mut rows = stmt.query([]).unwrap();
let row = rows.next().unwrap().unwrap();
(row.get(0).unwrap(), row.get(1).unwrap())
}
#[test]
fn data_test_sample_probe_counts_and_samples() {
let conn = duckdb::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE t (id INT, name VARCHAR); \
INSERT INTO t VALUES (1, 'a'), (2, NULL), (3, NULL);",
)
.unwrap();
let (v, s) = run_sample_probe(&conn, "SELECT * FROM t WHERE name IS NULL", 51200);
assert_eq!(v, 2);
let parsed: serde_json::Value = serde_json::from_str(&s.unwrap()).unwrap();
assert_eq!(
parsed,
serde_json::json!([{"id": 2, "name": null}, {"id": 3, "name": null}])
);
}
#[test]
fn data_test_sample_probe_zero_rows_yields_null_sample() {
let conn = duckdb::Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE t (id INT); INSERT INTO t VALUES (1);")
.unwrap();
let (v, s) = run_sample_probe(&conn, "SELECT * FROM t WHERE id IS NULL", 51200);
assert_eq!(v, 0);
assert!(s.is_none());
}
#[test]
fn data_test_sample_probe_caps_at_20_rows_but_counts_all() {
let conn = duckdb::Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE t AS SELECT range AS id FROM range(50);")
.unwrap();
let (v, s) = run_sample_probe(&conn, "SELECT * FROM t", 51200);
assert_eq!(v, 50);
let parsed: serde_json::Value = serde_json::from_str(&s.unwrap()).unwrap();
assert_eq!(parsed.as_array().unwrap().len(), 20);
}
#[test]
fn data_test_sample_probe_oversized_sample_degrades_to_null() {
let conn = duckdb::Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE t AS SELECT repeat('x', 1000) AS big FROM range(5);")
.unwrap();
let (v, s) = run_sample_probe(&conn, "SELECT * FROM t", 100);
assert_eq!(v, 5);
assert!(s.is_none());
}
#[test]
fn data_test_sample_probe_codegen_row_query_shapes() {
// The exact rows-query shapes emitted by `build_data_test_checks`:
// unique's `{value, count}` grain and the star-EXCLUDE forms used on
// partitioned targets (plain and `_wm_src.`-qualified).
let conn = duckdb::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE t (id INT, name VARCHAR, _wm_partition VARCHAR); \
INSERT INTO t VALUES (1, 'a', 'p'), (1, 'b', 'p'), (2, NULL, 'p');",
)
.unwrap();
let (v, s) = run_sample_probe(
&conn,
"SELECT \"id\" AS \"value\", count(*) AS \"count\" FROM t \
WHERE \"id\" IS NOT NULL GROUP BY \"id\" HAVING count(*) > 1",
51200,
);
assert_eq!(v, 1);
let parsed: serde_json::Value = serde_json::from_str(&s.unwrap()).unwrap();
assert_eq!(parsed, serde_json::json!([{"value": 1, "count": 2}]));
let (v, s) = run_sample_probe(
&conn,
"SELECT * EXCLUDE (\"_wm_partition\") FROM t WHERE name IS NULL",
51200,
);
assert_eq!(v, 1);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&s.unwrap()).unwrap(),
serde_json::json!([{"id": 2, "name": null}])
);
let (v, s) = run_sample_probe(
&conn,
"SELECT _wm_src.* EXCLUDE (\"_wm_partition\") FROM t _wm_src WHERE _wm_src.name IS NULL",
51200,
);
assert_eq!(v, 1);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&s.unwrap()).unwrap(),
serde_json::json!([{"id": 2, "name": null}])
);
}
#[test]
fn data_test_sample_probe_survives_exotic_types() {
let conn = duckdb::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE t AS SELECT \
INTERVAL 3 DAY AS iv, \
12345678901234567890123456789::HUGEINT AS hi, \
'\\xDE\\xAD'::BLOB AS bl, \
[1, 2, 3] AS li, \
{'a': 1, 'b': 'x'} AS st, \
TIMESTAMPTZ '2026-07-01 23:13:42+00' AS tstz, \
DECIMAL '12.34' AS dec;",
)
.unwrap();
let (v, s) = run_sample_probe(&conn, "SELECT * FROM t", 51200);
assert_eq!(v, 1);
let parsed: serde_json::Value = serde_json::from_str(&s.unwrap()).unwrap();
let row = &parsed.as_array().unwrap()[0];
// Exact renderings are DuckDB's to_json choices — the gate is only
// that every type serializes without error and parses back as JSON.
assert!(row.get("iv").is_some());
assert!(row.get("hi").is_some());
assert!(row.get("bl").is_some());
assert_eq!(row["li"], serde_json::json!([1, 2, 3]));
assert_eq!(row["st"], serde_json::json!({"a": 1, "b": "x"}));
assert!(row.get("tstz").is_some());
assert!(row.get("dec").is_some());
}
}
fn json_value_to_duckdb_value(
+133 -15
View File
@@ -11,7 +11,7 @@ use serde_json::{json, Value};
use uuid::Uuid;
use windmill_common::error::{to_anyhow, Error, Result};
use windmill_common::utils::sanitize_string_from_password;
use windmill_common::worker::{get_memory, Connection, SqlResultCollectionStrategy};
use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy};
use windmill_common::workspaces::{
get_datatable_resource_from_db_unchecked, get_ducklake_from_db_unchecked,
DucklakeCatalogResourceType,
@@ -773,10 +773,14 @@ fn extract_i64(result: &RawValue, field: &str) -> Option<i64> {
}
// One data test's outcome as carried by the materialize summary's `data_tests`
// column: its display name and how many rows violated it (0 = pass).
// column: its display name, how many rows violated it (0 = pass), and an
// optional bounded sample of the violating rows. The sample is decoration
// only — enforcement reads `violating`, never `sample` — so a NULL, dropped
// (over the size cap) or unparseable sample must never affect pass/fail.
struct DataTestOutcome {
name: String,
violating: i64,
sample: Option<Value>,
}
// Pull the per-test breakdown out of the materialize summary result. The
@@ -793,7 +797,21 @@ fn extract_data_tests(result: &RawValue) -> Vec<DataTestOutcome> {
.get("violating")
.and_then(|x| x.as_i64().or_else(|| x.as_f64().map(|f| f as i64)))
.unwrap_or(0);
out.push(DataTestOutcome { name: name.clone(), violating });
// The probe serializes the sample as a JSON *string*
// (a VARCHAR through the FFI), deliberately — parse it
// only here, so sampled user columns named `rows` /
// `snapshot_id` stay invisible to the key-recursive
// `extract_i64` scans over the summary result. Accept
// a native array too (FFI JSON-column quirk); anything
// else (NULL, size-capped, garbage) degrades to None.
let sample = o.get("sample").and_then(|s| match s {
arr @ Value::Array(_) => Some(arr.clone()),
Value::String(txt) => serde_json::from_str::<Value>(txt)
.ok()
.filter(|v| v.is_array()),
_ => None,
});
out.push(DataTestOutcome { name: name.clone(), violating, sample });
}
}
}
@@ -1304,7 +1322,42 @@ pub async fn do_duckdb(
Some(&breakdown),
)
.await;
return Err(Error::ExecutionErr(breakdown));
// Structured failure payload: the queue wraps it as
// `{"error": {...}}` (`WrappedError`) and result_processor
// derives the run description from the top-level `message`,
// so keep `message` at the top and add no `error` nesting of
// our own. Samples ride only on failed tests, only in this
// structured result — the message text stays counts-only.
let has_samples = tests.iter().any(|t| t.violating > 0 && t.sample.is_some());
let message = if has_samples {
// Wording must not match the UI's breakdown-line parsing
// (no ✓/✗, no `— N violating`), which older-result
// rendering still relies on.
format!(
"{breakdown}\n\nSamples of the violating rows are \
attached to this run's result (error.data_tests)."
)
} else {
breakdown
};
let data_tests = tests
.iter()
.map(|t| {
let mut o = json!({ "test": t.name, "violating": t.violating });
if t.violating > 0 {
if let Some(s) = &t.sample {
o["sample"] = s.clone();
}
}
o
})
.collect::<Vec<_>>();
return Err(Error::ExecutionRawError(to_raw_value(&json!({
"message": message,
"name": "ExecutionErr",
"step_id": job.flow_step_id,
"data_tests": data_tests,
}))));
}
record_mat(
conn,
@@ -1347,14 +1400,46 @@ pub async fn do_duckdb(
match result {
Ok(result) => Ok(result),
Err(e) => {
// Passwords might appear in the error message
let mut err_str = e.to_string();
for pwd in hidden_passwords.lock().unwrap().iter() {
if let Some(sanitized) = sanitize_string_from_password(&err_str, &pwd.clone()) {
err_str = sanitized;
// Passwords might appear in the error message — and, for the
// structured data-test failure, in sampled row data read from an
// attached database — so every outgoing error is sanitized here.
let sanitize = |mut s: String| {
for pwd in hidden_passwords.lock().unwrap().iter() {
if let Some(sanitized) = sanitize_string_from_password(&s, &pwd.clone()) {
s = sanitized;
}
}
s
};
match e {
// The structured payload must keep its variant: flattening it
// to a string (the arm below) would strip the data-test
// samples result_processor places verbatim into the failed
// job's result. Sanitize its string *leaves*, not the
// serialized text: a secret containing `"` or `\` is
// JSON-escaped in the text, so a plain-substring pass would
// miss it — and samples carry raw row data from attached
// databases.
Error::ExecutionRawError(raw) => {
fn walk(v: &mut Value, f: &dyn Fn(String) -> String) {
match v {
Value::String(s) => *s = f(std::mem::take(s)),
Value::Array(a) => a.iter_mut().for_each(|x| walk(x, f)),
Value::Object(o) => o.values_mut().for_each(|x| walk(x, f)),
_ => {}
}
}
Err(match serde_json::from_str::<Value>(raw.get()) {
Ok(mut v) => {
walk(&mut v, &sanitize);
Error::ExecutionRawError(to_raw_value(&v))
}
// Not valid JSON (shouldn't happen) — redact as text.
Err(_) => Error::ExecutionErr(sanitize(raw.get().to_string())),
})
}
e => Err(Error::ExecutionErr(sanitize(e.to_string()))),
}
Err(Error::ExecutionErr(err_str))
}
}
}
@@ -2908,15 +2993,22 @@ mod tests {
// `data_tests` array (how the FFI serialises the list-of-struct).
let r = raw(
r#"[{"rows":3,"snapshot_id":17,"materialized":"ducklake://a/b",
"data_tests":[{"test":"unique(order_id)","violating":0},
{"test":"accepted_values(status)","violating":2}]}]"#,
"data_tests":[{"test":"unique(order_id)","violating":0,"sample":null},
{"test":"accepted_values(status)","violating":2,
"sample":"[{\"id\":1,\"status\":\"bad\"}]"}]}]"#,
);
let out = extract_data_tests(&r);
assert_eq!(out.len(), 2);
assert_eq!(out[0].name, "unique(order_id)");
assert_eq!(out[0].violating, 0);
assert!(out[0].sample.is_none());
assert_eq!(out[1].name, "accepted_values(status)");
assert_eq!(out[1].violating, 2);
// The probe emits the sample as a JSON string; it parses to rows here.
assert_eq!(
out[1].sample,
Some(serde_json::json!([{"id": 1, "status": "bad"}]))
);
}
#[test]
@@ -2927,10 +3019,30 @@ mod tests {
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "not_null(x)");
assert_eq!(out[0].violating, 1);
assert!(out[0].sample.is_none());
// Absent column (no tests) -> empty, no panic.
assert!(extract_data_tests(&raw(r#"[{"rows":3}]"#)).is_empty());
}
#[test]
fn extract_data_tests_sample_degrades_to_none_never_flips_outcome() {
// sample is optional by contract: native array accepted; non-array
// JSON, garbage text, and absence all degrade to None without
// touching `violating`.
let r = raw(r#"[{"data_tests":[
{"test":"a","violating":1,"sample":[{"id":9}]},
{"test":"b","violating":2,"sample":"{\"not\":\"an array\"}"},
{"test":"c","violating":3,"sample":"not json at all"},
{"test":"d","violating":4}]}]"#);
let out = extract_data_tests(&r);
assert_eq!(out.len(), 4);
assert_eq!(out[0].sample, Some(serde_json::json!([{"id": 9}])));
for (i, t) in out.iter().enumerate().skip(1) {
assert!(t.sample.is_none(), "test {} should have no sample", t.name);
assert_eq!(t.violating, i as i64 + 1);
}
}
#[test]
fn extract_schema_parses_nested_and_string_encoded() {
// Real shape: the summary row carries a nested `output_schema`
@@ -2959,9 +3071,15 @@ mod tests {
#[test]
fn format_data_test_breakdown_lists_all_with_marks() {
let tests = vec![
DataTestOutcome { name: "unique(order_id)".into(), violating: 1 },
DataTestOutcome { name: "not_null(user_id)".into(), violating: 0 },
DataTestOutcome { name: "accepted_values(status)".into(), violating: 2 },
DataTestOutcome { name: "unique(order_id)".into(), violating: 1, sample: None },
DataTestOutcome { name: "not_null(user_id)".into(), violating: 0, sample: None },
DataTestOutcome {
name: "accepted_values(status)".into(),
violating: 2,
// The breakdown is counts-only by design — samples never
// appear in the error text.
sample: Some(serde_json::json!([{"status": "bad"}])),
},
];
let msg = format_data_test_breakdown("analytics/orders", &tests);
assert_eq!(
+66 -11
View File
@@ -327,18 +327,73 @@ The reusable shape, in three layers, each a clean extension seam:
match arm + its sub-parser; the `Custom` arm is the open fallback. A sibling
family reuses this head-keyword dispatch rather than adding a parallel list.
2. **Compile** (`sql_materialize.rs::build_data_test_checks`). Each test becomes
a **check**: `(name, violating-row-count query)`. Built-ins differ only in
their count query; `Custom` supplies its own (the user's SELECT of violating
rows). Referenced assets (relationships) emit an `ATTACH` resolved by the
same transform pass as the user's own.
a **check**: `(name, probe)`, where the probe is a one-row query counting
*and sampling* the test's violating rows in a single scan. Built-ins differ
only in their violating-rows query; `Custom` supplies its own (the user's
SELECT of violating rows). Referenced assets (relationships) emit an
`ATTACH` resolved by the same transform pass as the user's own.
3. **Execute** (`duckdb_executor.rs`). The materialize summary query embeds
every check's count in one `data_tests` list-of-struct column (computed in a
CTE, since DuckDB rejects subqueries inside struct literals), so **all tests
run in a single pass** against the freshly-materialized slice — no
abort-on-first. The worker reads the breakdown from the result and decides
pass/fail: any violation **fails the run** (record `Failed`, propagate up the
cascade) with an error listing *every* test (✓/✗ + counts); a clean run
returns the per-test summary so the UI can render a checklist.
every check's outcome in one `data_tests` list-of-struct column (probes are
lifted into one-row CTEs and cross-joined, since DuckDB rejects subqueries
inside struct literals), so **all tests run in a single pass** against the
freshly-materialized slice — no abort-on-first. The worker reads the
breakdown from the result and decides pass/fail: any violation **fails the
run** (record `Failed`, propagate up the cascade) with an error listing
*every* test (✓/✗ + counts); a clean run returns the per-test summary so
the UI can render a checklist.
### Violating-row samples
Each check's probe also captures a bounded **sample of its violating rows**
(≤20 rows, ≤50KB serialized per test — over-cap samples are *dropped*, never
truncated, since truncated JSON fails parsing after paying the bytes). The
sample is **optional by contract** at every layer: enforcement reads only the
count; NULL / dropped / unparseable samples degrade to "no sample" and can
never flip pass/fail. Where they surface:
- **Failed run**: the worker returns a structured error payload
(`Error::ExecutionRawError`), so the failed job's result carries
`error.data_tests: [{test, violating, sample?}]` with samples on failed
tests only. The error *message* stays counts-only, plus a pointer at the
payload — so run descriptions and `materialized_partition.error` carry no
row data. The full payload is the job *result*, and follows every failed
job's result wherever that already goes: the worker's failed-job log line
(`add_completed_job_error`) and, on EE, the args of configured
global/workspace error handlers — the same pre-existing paths that carry
e.g. a failed process's own `result.json`. A handler that formats only
`error.message` (the common alert shape) stays counts-only; one that wants
the offending rows can read `error.data_tests`. The duckdb executor's
password-sanitize catch-all must special-case `ExecutionRawError` —
flattening it to a string would silently strip the payload.
- **UI**: `DataTestsResult.svelte` renders a per-failed-test expandable
`AutoDataTable`; `DisplayResult.svelte` prefers the structured payload and
falls back to text-parsing the breakdown for results predating it.
- **Enterprise (write-audit-publish)**: samples only exist on the
commit-then-test path. Under the EE in-transaction guard a violation aborts
pre-commit and the slice rolls back, so the violating rows cease to exist —
WAP failures are counts-only by construction (the guard's in-SQL ✓/✗
message), and the guard projects only the probe's count column
(`pipeline_advanced_ee.rs::data_test_guard_sql`).
Sample mechanics worth knowing:
- The sample rides as a **JSON string** (`to_json(...)::VARCHAR`) through the
summary row, deliberately: expanded rows would be visible to the executor's
key-recursive `extract_i64(result, "rows"/"snapshot_id")` scans, which a
user column of the same name could corrupt. It is parsed only inside
`extract_data_tests`.
- No `ORDER BY` on the violating rows — *which* rows land in the sample is
nondeterministic (labelled "sample" in the UI for that reason).
- `unique` samples `{value, count}` pairs at its count's grain (number of
duplicated *values*), so count and sample can't contradict each other.
- On partitioned targets the synthetic `_wm_partition` column is `EXCLUDE`d
from samples (same rule as schema capture).
- A custom body joining with `SELECT *` can yield duplicate column names —
duplicate JSON keys keep the last value; harmless but visible.
- The probe shape is gated by in-memory tests against the bundled engine in
`windmill-duckdb-ffi-internal` (json extension availability, zero-row NULL
degrade, exotic types) — a sample-expr runtime error would fail the summary
read after the write committed, so keep those green when touching the shape.
A new annotation family that produces post-materialize checks (or, for
column-lineage, post-materialize *metadata reads*) plugs into the same three
@@ -1,15 +1,22 @@
<script lang="ts">
// Renders the per-test breakdown a managed `// materialize` run attaches to
// its result as `data_tests: [{ test, violating }]`. Shown above the raw
// result so "which tests ran, and which passed/failed" is clear at a glance.
// (On a failed run the job result is the error message — which already lists
// every test — so this success-path checklist and that error text together
// cover both outcomes.)
import { CheckCircle2, FlaskConical, XCircle } from 'lucide-svelte'
// its result as `data_tests: [{ test, violating, sample? }]`. Shown above
// the raw result so "which tests ran, and which passed/failed" is clear at
// a glance. A failed test may carry `sample` — a bounded, unordered sample
// of its violating rows — rendered as an expandable table. The sample is
// optional by contract: its absence only means no rows to show.
import { CheckCircle2, ChevronDown, ChevronRight, FlaskConical, XCircle } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import AutoDataTable from '$lib/components/table/AutoDataTable.svelte'
import { SvelteSet } from 'svelte/reactivity'
let { tests }: { tests: Array<{ test: string; violating: number }> } = $props()
let {
tests
}: { tests: Array<{ test: string; violating: number; sample?: Record<string, any>[] }> } =
$props()
let failed = $derived(tests.filter((t) => t.violating > 0).length)
let expanded = new SvelteSet<string>()
</script>
<div
@@ -32,18 +39,40 @@
</div>
<ul class="divide-y divide-border">
{#each tests as t (t.test)}
<li class="flex items-center gap-1.5 px-2 py-1 font-mono">
{#if t.violating > 0}
<XCircle size={13} class="shrink-0 text-red-600 dark:text-red-400" />
<span class="sr-only">failed:</span>
<span class="text-primary">{t.test}</span>
<span class="text-red-600 dark:text-red-400"
>{t.violating} violating row{t.violating === 1 ? '' : 's'}</span
>
{:else}
<CheckCircle2 size={13} class="shrink-0 text-emerald-600 dark:text-emerald-400" />
<span class="sr-only">passed:</span>
<span class="text-secondary">{t.test}</span>
<li class="px-2 py-1">
<div class="flex items-center gap-1.5 font-mono">
{#if t.violating > 0}
<XCircle size={13} class="shrink-0 text-red-600 dark:text-red-400" />
<span class="sr-only">failed:</span>
<span class="text-primary">{t.test}</span>
<span class="text-red-600 dark:text-red-400"
>{t.violating} violating row{t.violating === 1 ? '' : 's'}</span
>
{#if t.sample && t.sample.length > 0}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: expanded.has(t.test) ? ChevronDown : ChevronRight }}
onclick={() => {
if (!expanded.delete(t.test)) expanded.add(t.test)
}}
>
{expanded.has(t.test) ? 'hide sample' : `view sample (${t.sample.length})`}
</Button>
{/if}
{:else}
<CheckCircle2 size={13} class="shrink-0 text-emerald-600 dark:text-emerald-400" />
<span class="sr-only">passed:</span>
<span class="text-secondary">{t.test}</span>
{/if}
</div>
{#if t.violating > 0 && t.sample && t.sample.length > 0 && expanded.has(t.test)}
<div class="mt-1 mb-1">
<div class="text-2xs text-tertiary mb-1">
sample of the violating rows ({t.sample.length} of {t.violating}, unordered)
</div>
<AutoDataTable objects={t.sample} />
</div>
{/if}
</li>
{/each}
@@ -574,24 +574,53 @@
// formats are produced by this repo's worker (see duckdb_executor.rs); the
// derivation is inert (undefined) for every other DisplayResult use.
let dataTests = $derived.by(() => {
// Both structured shapes carry `[{ test, violating, sample? }]`; the
// sample (bounded violating-row rows) may arrive as a JSON string (the
// worker keeps it string-typed through the summary row) and is optional
// by contract — anything malformed degrades to no sample, never to a
// dropped checklist.
const normalize = (
dt: any
): Array<{ test: string; violating: number; sample?: Record<string, any>[] }> | undefined => {
if (typeof dt === 'string') {
try {
dt = JSON.parse(dt)
} catch {
return undefined
}
}
if (
!Array.isArray(dt) ||
dt.length === 0 ||
!dt.every((x) => x && typeof x.test === 'string' && typeof x.violating === 'number')
) {
return undefined
}
return dt.map((x) => {
let sample = x.sample
if (typeof sample === 'string') {
try {
sample = JSON.parse(sample)
} catch {
sample = undefined
}
}
if (!Array.isArray(sample) || !sample.every((r) => r && typeof r === 'object')) {
sample = undefined
}
return { test: x.test, violating: x.violating, sample }
})
}
// Success: structured column on the summary row.
const row = Array.isArray(result) ? (result as any)?.[0] : (result as any)
let dt = row?.data_tests
if (typeof dt === 'string') {
try {
dt = JSON.parse(dt)
} catch {
dt = undefined
}
}
if (
Array.isArray(dt) &&
dt.length > 0 &&
dt.every((x) => x && typeof x.test === 'string' && typeof x.violating === 'number')
) {
return dt as Array<{ test: string; violating: number }>
}
// Failure: parse the worker's breakdown out of the error message.
const fromRow = normalize(row?.data_tests)
if (fromRow) return fromRow
// Failure: the worker attaches the same structured breakdown (plus
// per-failed-test samples) to the error payload.
const fromError = normalize((result as any)?.error?.data_tests)
if (fromError) return fromError
// Failure fallback for results predating the structured error payload:
// parse the worker's breakdown out of the error message.
const msg = (result as any)?.error?.message
if (typeof msg === 'string' && msg.includes('data tests failed on')) {
const out: Array<{ test: string; violating: number }> = []