* feat: data tests for ducklake pipeline materialization Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): data_test count badge on pipeline graph nodes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: surface annotation badges (incl. data_test) on deployed pipeline nodes Backend graph endpoint now parses each pipeline member's deployed body and returns partition/freshness/tag/retry/data_test, so badges render on deployed nodes, not only live drafts. Aligns the TS DataTest.relationships fields to snake_case to match the Rust serde wire shape (the type is now populated from both the parser and the backend JSON). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): keep materialize output edge when editing the producer in the pipeline graph The live-edit overlay re-derived a selected/edited script's lineage from // on inputs + body-inferred assets only, so the // materialize <asset> output (an annotation, not body SQL) was judged stale and its write-edge dropped on select — leaving the materialized asset unlinked (and the node's annotation badges hidden). Include the parsed materialize target in liveRefKeys and the draft writeOuts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: run all data tests in one pass with a structured per-test result Replace the raise-on-first-violation probes with a single materialize summary that embeds every test's violating-row count in a data_tests column (computed in a CTE, since DuckDB rejects subqueries inside struct literals). The worker reads the breakdown and decides pass/fail: a clean run returns the per-test summary in the result; a failing run errors with the FULL list (every test, ✓/✗ + counts), not just the first failure. Verified live (EE) for built-ins + custom, pass and multi-failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): data-test pass/fail checklist in the job result DisplayResult renders a per-test checklist (✓/✗ + violation counts) above the raw result for managed materialize runs — from the structured data_tests on success, and parsed from the worker's breakdown message on failure. Shows in the script editor Test panel, the runs page, and the pipeline asset run pane. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): move data-test badge onto the producer→asset edge with run status The test badge now sits on the write-edge (the transformation link) rather than the producer node, since the tests assert on what the transformation produces. It's tinted by the producer's last-run status (green = passed, red = a test failed) and its hover title lists every declared test. Removes the now-redundant node badge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): render custom data-test scripts as their own clickable graph nodes A // data_test <script_path> custom test now appears as its own node below the asset it validates, joined by a dashed 'tests' edge. Clicking it opens the test script in the detail pane (dispatched like any runnable). Built-in tests stay folded into the edge badge; only script-backed tests become nodes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): type data-test edge field via AssetGraphResponse, not in-scope g BuiltEdge is declared at component scope, outside build(g), so referencing typeof g.runnables in its type failed CI's svelte-check (Cannot find name 'g'). Use the imported AssetGraphResponse type instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): anchor edge badge on routed path + a11y text on test icons Address review: the data-test edge badge anchored on the straight-line midpoint, floating off detoured edges — anchor it at detourX when the edge is routed through a gutter lane. Add sr-only pass/fail text so the checklist icons are distinguishable to screen readers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: close data-test enforcement bypass + gate badges to scripts + reject multi-stmt custom tests Address review (cubic) findings: - P1: managed materialize generates its own summary row carrying data_tests, and enforcement reads that column — but a // result_collection annotation (e.g. a scalar mode) could reshape the row and drop data_tests, silently bypassing a failing test. Force LastStatementAllRows for managed materialize runs so the summary row is always intact. - P2: asset-graph annotation badges were keyed by path only, so a flow sharing a path with a pipeline script inherited its badges. Gate the lookup on usage_kind == Script. - P2: a custom test body is embedded as a subquery, so a multi-statement body produced invalid SQL with an opaque DuckDB error. Validate single-statement up front with an actionable error; align docs/comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: fail loud if fewer data-test outcomes recovered than declared Defense-in-depth from the fresh-context review: enforcement reads per-test outcomes off the materialize summary row, but if the data_tests column were ever dropped/reshaped at the FFI boundary, extract_data_tests would return fewer (or zero) outcomes and the run would silently pass unverified tests. Track the embedded test count on MaterializeExec and abort with a clear error when recovered < declared. Verified: normal run (4==4) unaffected; the scalar-result_collection bypass already fails. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: relationships data test same-lake reuse + schema-qualified target quoting Address Codex/Pi review (two P1s in the relationships codegen): - A relationship into the same ducklake as the materialize target minted a second ATTACH of that lake under _wm_ref_N while _wm_target already held it — DuckDB forbids attaching one database twice, so the test failed before it could run. Reuse _wm_target for same-lake references. - A schema-qualified target (ducklake://warehouse/main.dim_products.sku) emitted FROM _wm_ref_0."main.dim_products" — one quoted identifier with a literal dot — silently querying a nonexistent table. Quote each dotted segment so the dot stays a schema separator. Adds tests for both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): refresh data_test badge on deployed-script drafts + scope to materialize target Address Codex review nits (both P2): - resolveGraph: the existing-runnable draft-overlay branch kept the deployed data_tests, so adding/removing // data_test lines on an already-deployed script left the badge stale until redeploy. Refresh it from the live parse like the new-runnable branch. - AssetGraphCanvas: data tests were attached to every write-edge from a producer. They assert on the // materialize target (always a ducklake asset in v1), so only the ducklake write-edge now carries the badge and custom-test nodes — a producer's other (S3/datatable) outputs no longer show them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
DuckLake-native materialization
Design sketch for "managed, versioned, incremental" assets built on the
DuckLake substrate. This is a companion to pipelines-vs-dbt.md
and extends its Path C (hybrid, partition-first) recommendation. The new
contribution here is leveraging DuckLake's snapshot/time-travel layer, which
the earlier doc's incremental deep-dive did not use. The annotation grammar is
reconciled with that doc — // partitioned + // unique_key + // append
stay canonical; nothing here forks a competing vocabulary.
The core reframe
dbt had to build a materialization engine (compile SQL → CREATE TABLE AS /
incremental MERGE / SCD2 snapshots) because the warehouse gives it nothing
but raw SQL over a mutable table. DuckLake hands us, at the storage layer, the
four things that engine exists to provide:
| Capability | Source |
|---|---|
| ACID multi-statement transactions | DuckLake |
A snapshot per commit + time-travel (AT (VERSION => n) / AT (TIMESTAMP => ...)) |
DuckLake |
Physical partitioning + pruning (ALTER TABLE … SET PARTITIONED BY (…)) |
DuckLake |
| Schema evolution tracked in the catalog DB | DuckLake |
Windmill already attaches DuckLake fully — see transform_attach_ducklake
(backend/windmill-worker/src/duckdb_executor.rs:661), which rewrites a user's
ATTACH 'ducklake://name' AS dl into the real
ATTACH 'ducklake:postgres:…' AS dl (DATA_PATH 's3://…', OVERRIDE_DATA_PATH TRUE, AUTOMATIC_MIGRATION TRUE) at duckdb_executor.rs:730. But today every
write is a destructive overwrite and all four capabilities above are thrown
away — snapshots are never surfaced, partitioning is purely an orchestration
concept disconnected from the physical layout.
So the materialization engine we need is a thin layer — a write-strategy
wrapper + snapshot capture — not a dbt rebuild. This is exactly the
"buy A's 80% without B's dialect-rewriting tax" tradeoff pipelines-vs-dbt.md
argued for; DuckLake is what makes the remaining 20% (versioning, reproducible
reads, materialization history) nearly free instead of a second project.
Annotation grammar (final)
One self-documenting line; managed-by-default. Strategy options live on the
materialize line (they have no meaning without it), while // partitioned
stays separate because it is cross-cutting (cascade + scheduling + materialize).
// materialize ducklake://analytics/orders_daily → managed, replace (default)
// materialize ducklake://analytics/orders_daily key=order_id → managed, merge
// materialize ducklake://analytics/orders_daily append → managed, append
// materialize manual ducklake://analytics/orders_daily → track-only escape hatch
- managed (default) — the script is setup + one trailing
SELECT; Windmill generates the write DDL, captures the DuckLake snapshot, and records state. DuckDB-only; validated at deploy (a non-SELECT script is rejected with a clear error pointing to thewmll.ducklakehelpers). manual— escape hatch: the script writes its own DDL; Windmill only records state (no snapshot capture, no idempotency guarantee). Rare; explicit.key=<col>→ MERGE (dedup within slice);append→ INSERT-only; neither → DELETE-by-partition + INSERT (replace).appendwins overkeyif both are given (deploy warning).// partitioned <kind>— unit of work + state + backfill (separate; cross-cutting). Polyglot / multi-statement writes use thewmll.ducklakehelpers instead of// materialize.
There is no wrap keyword — materialize is "manage the write," so it was
redundant; the only reason for it was to carve out the weak track-only mode,
which is now the explicit manual opt-out.
DuckLake snapshots are orthogonal to all of the above — they apply to every strategy automatically because every write is a DuckLake commit. The user never annotates for versioning; they get it.
Executor codegen
The seam
run_duckdb already splits the script into statement blocks and rewrites
custom ATTACH blocks in a single pass before execution
(duckdb_executor.rs:114-160):
let query_block_list = parse_sql_blocks(&query, true);
// each block: remove_comments → if ducklake/datatable ATTACH, expand; else passthrough
All blocks run in order on one DuckDB connection. Materialize has two modes:
-
Managed (default). The user writes setup + one trailing
SELECT. Windmill replaces that SELECT with generated statements, wrapped in an explicit DuckLake transaction it controls — never textualBEGIN/COMMITinjected around the user's other statements (fragile across their ownATTACHs and multi-statement SQL). For a partitionedreplacethe SELECT block expands to:-- generated for: // partitioned daily ; target = dl.orders_daily ; partition = '2026-06-19' CREATE TABLE IF NOT EXISTS dl.orders_daily AS SELECT *, CAST(NULL AS VARCHAR) AS _wm_partition FROM (<user_select>) WHERE false; -- first-run bootstrap ALTER TABLE dl.orders_daily SET PARTITIONED BY (_wm_partition); BEGIN TRANSACTION; DELETE FROM dl.orders_daily WHERE _wm_partition = '2026-06-19'; INSERT INTO dl.orders_daily SELECT *, '2026-06-19' AS _wm_partition FROM (<user_select>); COMMIT;The strategy variants are all DELETE+INSERT-shaped — no
MERGE INTO, which DuckLake can't reliably run on a fresh partition (it 404s writing the first rows):- whole-table replace (no
// partitioned) → a singleCREATE OR REPLACE TABLE … AS <user_select>(handles schema changes, still snapshots). key=<col>→ `DELETE FROM … WHERE [ AND] IN (SELECT FROM ())` then `INSERT` (upsert within the slice).append→ theDELETEis dropped (insert-only).
- whole-table replace (no
-
manual. The user writes their own DDL inside their ownBEGIN … COMMIT; Windmill injects nothing into the body and only records state (no snapshot capture, no idempotency guarantee).
The _wm_partition column is the physical link the orchestration layer lacks: on
first materialize Windmill runs ALTER TABLE … SET PARTITIONED BY (_wm_partition)
so DuckLake prunes on read and DELETE-by-partition rewrites only that partition's
Parquet files.
Storage: DuckLake writes go to
s3://_default_/through the windmill S3 proxy (/api/w/{ws}/s3_proxy, gated behind theparquet+privatefeatures). The proxy must sign the SigV4 canonical URI with single percent-encoding — the SigV4 default (Double) 401s Hive-partition keys like_wm_partition=2026-06-19(the=double-encodes to%253Dvs the client's%3D).
Run summary capture
After the generated blocks, Windmill appends one read block — it is both the job's result (a useful preview rendered as the materialized table) and the row it records:
SELECT 'ducklake://<name>/<table>' AS materialized,
'<partition>' AS partition, -- only when partitioned
(SELECT count(*) FROM <target> [WHERE _wm_partition = '<partition>']) AS rows,
(SELECT max(snapshot_id) FROM ducklake_snapshots('<target>')) AS snapshot_id;
The snapshot_id and rows are persisted as materialized_partition metadata.
One extra round-trip per materialization, no new infra.
Metadata schema
Extends the materialized_partitions table proposed in pipelines-vs-dbt.md
§"First implementation slice" with the DuckLake snapshot id:
materialized_partition (
workspace_id TEXT,
asset_kind TEXT, -- 'ducklake'
asset_path TEXT, -- 'analytics/orders_daily'
partition TEXT, -- '2026-06-19' (NULL for unpartitioned)
snapshot_id BIGINT, -- DuckLake snapshot produced by this materialize
row_count BIGINT,
job_id UUID,
materialized_at TIMESTAMPTZ,
PRIMARY KEY (workspace_id, asset_kind, asset_path, partition)
)
This one table drives four things at once:
- Observability — "last materialized: snapshot 42, 1.2M rows, 09:14" per asset node (closes the Dagster-catalog gap from the v1-readiness review).
- Run-stale / gap detection — which partitions exist, which are missing.
- Backfill — the missing/failed set is the backfill worklist.
- Snapshot pinning — see below.
Reproducibility — the beyond-dbt part
Because every materialization records the snapshot it produced, you can read any table as of a past version — a capability dbt has no native answer for (dbt models are always "whatever's in the warehouse now"). DuckLake gives us this for the cost of recording one integer per run, and it covers the three things people actually reach for: debugging ("what did this table look like at the failing run"), rollback (re-materialize a consumer from snapshot N), and ad-hoc experimentation on a historical state.
How it's surfaced (shipped): explicit, discoverable time-travel
The version is exposed as a user-driven surface, not hidden plumbing. A consumer pins a read by writing the DuckLake clause directly:
FROM dl.orders_daily AT (VERSION => 42)
The asset node's History tab is a master-detail view: the snapshot list (id
- time) on the left selects the version previewed in a read-only grid on the
right, which surfaces — and copies — the catalog-qualified
FROM lake.<table> AT (VERSION => n)clause. Snapshot ids are captured automatically; the user opts into pinning when they want it, and the clause degrades to "latest" if removed, so the same script still runs standalone. Mechanically this rides on time-travel reads (make_select_query/make_count_queryemit theATclause when aversionis threaded through theWM_INTERNAL_DB_*markers) plus aDUCKLAKE_SNAPSHOTSread for the history list — capabilities DuckLake already has, no new write path.
Deferred: automatic snapshot pinning across the cascade
An earlier sketch had the cascade automatically thread each producer's
snapshot_id into the trigger blob and inject AT (VERSION => $WM_UPSTREAM_SNAPSHOT)
into consumer reads, so a whole run is pinned to upstream state at dispatch time
without anyone asking. This is deliberately not built, for three reasons:
- Not critical. The only thing it adds over the explicit surface above is automatic per-run consistency — protection against an upstream re-materializing in the window between dispatch and a consumer reading. That race only bites high-frequency event-driven cascades (rare today), and the read is always a whole, ACID snapshot regardless — never corruption, just "newer than the triggering version". Debugging and rollback are already covered by the explicit surface.
- Implicit magic. Auto-injecting an
ATclause and stripping it on standalone runs is invisible behaviour to debug when it misfires; the explicit clause is inspectable. - Multi-upstream ambiguity + EE coupling. A consumer reading two ducklake
upstreams needs a per-ref snapshot map accumulated across the AND-join — and
the join-slot logic is EE. A single
$WM_UPSTREAM_SNAPSHOTwould silently pin every read to one (the firing) producer's snapshot.
If a workload ever shows the consistency race in practice, pinning can be layered on top — the capture and the snapshot surfacing built here are its foundation.
It also means we do not build SCD2 snapshots (gap #4 in pipelines-vs-dbt.md):
DuckLake time-travel is a strictly better answer for most of what dbt's
{% snapshot %} is used for. One fewer engine to write.
Data tests (// data_test) — and the extensible-annotation pattern
Data tests are the first dbt-parity gap closed on top of materialization, and
the first deliberately extensible annotation. The design goal was not just
"add five test types" but to establish the convention a sibling family
(column-lineage is the next one) follows, so the annotation vocabulary stops
being a closed hardcoded list (pipelines-vs-dbt.md gap #7).
Grammar
// data_test unique <col>
// data_test not_null <col>
// data_test accepted_values <col> = a,b,c
// data_test relationships <col> -> datatable://other/asset.<col>
// data_test <script_path> ← escape hatch (dbt's singular test)
// data_test lines accumulate (every well-formed line adds one check),
unlike the single-value annotations (// materialize, // partitioned, …)
which are first-write-wins. Malformed lines are dropped fail-safe — a typo
becomes an absent check (visible in the graph), never a mis-parsed one.
The keyword is data_test, not test — there is an unrelated, shipped
// test: CI-test annotation (windmill_common::schema::parse_ci_test_annotation,
tests a script's logic on deploy). data_test tests the data in the
materialized asset at run time. This mirrors dbt 1.8's own tests: →
data_tests: rename, made for exactly this disambiguation.
The pattern: annotation → verifier
The reusable shape, in three layers, each a clean extension seam:
- Parse (
asset_parser.rs+parsePipelineAnnotations.ts, kept in lockstep by the parity corpus). Adata_testline is dispatched on a keyword head to a typed variant (DataTest). A new built-in is one match arm + its sub-parser; theCustomarm is the open fallback. A sibling family reuses this head-keyword dispatch rather than adding a parallel list. - 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;Customsupplies its own (the user's SELECT of violating rows). Referenced assets (relationships) emit anATTACHresolved by the same transform pass as the user's own. - Execute (
duckdb_executor.rs). The materialize summary query embeds every check's count in onedata_testslist-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 (recordFailed, 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.
A new annotation family that produces post-materialize checks (or, for column-lineage, post-materialize metadata reads) plugs into the same three seams: add a parsed variant, emit its check/reader SQL into the summary, read it back in the worker. Nothing about the closed set of today's keywords is load-bearing.
Scoping decisions (v1)
- Partition scope. When
// partitioned, built-in checks are scoped to the slice just written (WHERE _wm_partition = <value>), so a rerun/backfill of one partition is independent of other partitions' (possibly pre-existing) data. Whole-table assertions are a follow-up. - Commit-then-test. Like dbt, the write commits before tests run; a failed
test fails the run (and records
Failed, so downstream cascade stops) but does not roll back the committed snapshot. Time-travel still lets you inspect exactly what failed. - Custom = DuckDB SQL, server worker. The escape hatch fetches the deployed
script's content (a single DuckDB
SELECT/CTE returning the violating rows — it's embedded as a subquery, so a multi-statement body is rejected with a clear error) and inlines it as a check;{partition}is substituted and_wm_targetis in scope. Agent (Http) workers — which have no script cache — get a clear error. Non-DuckDB custom tests (dispatched as sub-jobs, any language) are the natural follow-up and fit the same verifier seam. - Managed only.
// materialize manual+// data_testis rejected with a clear error (we can't know the manual script's target alias / partition col).
Scoping decision: DuckLake vs DataTable
Make DuckLake the materialization/versioning substrate; keep DataTable as the
live operational table with no versioning. DataTable is plain Postgres
(transform_attach_datatable, duckdb_executor.rs:742) — no native snapshots
or time-travel — so giving it the versioned/incremental story means building
MVCC-on-top ourselves (history tables, SCD2), precisely the complexity this
DuckLake approach exists to avoid. Clean split:
ducklake://→ analytics, versioned, reproducible, backfillable.datatable://→ mutable app/operational state; partition idempotency via DELETE+INSERT still works, but no snapshot/time-travel layer.
Don't try to give both the full treatment for v1.
v1 slice (smallest viable)
- Partition runtime context — resolve
(value, start, end)and surface asWM_PARTITION*bind/env (Path C step 1; partly built per the pipeline-partition-runtime work). - Physical partition wiring —
_wm_partitioncolumn +SET PARTITIONED BYon first materialize forducklake://targets. - Strategy templates — DELETE+INSERT default (
CREATE OR REPLACEfor the whole table); delete-by-key + insert whenkey=<col>; INSERT-only whenappend. Managed// materializewraps a single-SELECT DuckDB script behind these templates;// materialize manualopts out. - Snapshot + metadata capture — append
ducklake_snapshotsread, persistmaterialized_partitionrows. - Surface it — last-materialized/snapshot/row-count on the asset node; missing-partition set feeds the backfill UI.
- v1.x — time-travel UX over the captured snapshots: a per-asset History
tab — a master-detail snapshot list + query-at-version preview that copies the
full
FROM lake.<table> AT (VERSION => n)clause. Automatic cascade pinning ($WM_UPSTREAM_SNAPSHOT) is deferred — see §"Reproducibility" for why.
Steps 1–5 are a thin annotation+template layer plus one metadata table and one extra read per run. They deliver managed/incremental/versioned assets, idempotent partitioned materialization, the backfill substrate, and materialization observability together — and stay recognizably Windmill-shaped.
Open decisions
These ride on top of the six in pipelines-vs-dbt.md §"Decisions either path
forces"; DuckLake-specific:
- Bootstrap of
SET PARTITIONED BY. First-materialize detection — table absent vs. present-but-unpartitioned. Idempotent re-apply. - Snapshot retention / compaction. DuckLake snapshots accumulate; when do we expire old ones, and does pinning hold a snapshot alive past retention?
- Pin scope. Pin only direct producers, or the full transitive upstream set per run? Storage and "stale pin" semantics differ.
- Managed multi-statement. Resolved: managed
// materializeaccepts setup statements (ATTACH/SET/…) followed by exactly one trailing SELECT, and rejects anything else at deploy with a clear error pointing to// materialize manual. The classifier (sql_materialize.rs) is the single source of truth.