* feat: column-level lineage for ducklake pipelines via // column annotation Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: auto-derive column lineage from DuckDB SQL AST (annotation as override) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: clarify column-lineage inference is server-side; drafts use annotations Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(frontend): surface inferred column lineage in live pipeline drafts Threads the DuckDB SQL-AST column lineage (from the WASM asset parser) through ScriptEditor -> details pane -> page -> resolveGraph, merged with // column annotations (annotation wins) so the live preview matches the deployed graph. Takes effect once windmill-parser-wasm-asset is republished with the inference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(frontend): bump windmill-parser-wasm-asset to 1.740.0 for SQL column-lineage inference Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: column-lineage inference now runs live (WASM) too, merged with annotations Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(frontend): transitive column-lineage trace (impact analysis) Stitches every producer's column_lineage into a pipeline-wide column graph (columnLineageGraph.ts) and replaces the single-hop diagram with an interactive ColumnLineageTrace: select an asset to see its columns' full upstream/downstream lineage across scripts; click any column to highlight its complete transitive impact set (forward + backward) and dim the rest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address CI review on column lineage (parse-fallback, node-id, perf, leak) - backend: DuckDB SQL parse failure now falls back to `// column` annotation lineage instead of dropping it (Codex P1) - columnLineageGraph: collision-proof JSON node ids; deterministic first-write output anchoring when a producer has multiple ducklake writes (cubic P2 ×2) - pipeline page: gate buildColumnGraph to a ducklake-asset selection so it doesn't rebuild on every editor keystroke (cubic P2) - ScriptEditor: clear inferredColumnLineage on parse error so it can't leak across a script switch (cubic P2) - AssetGraphEdge: widen badge stacking offset 12px->18px to fully clear (cubic P3) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: resolve JOIN inputs + anchor column lineage to // materialize target Addresses the second Codex review pass (two P1s): - SQL inference now walks JOINed tables: build_from_maps maps every FROM entry AND its joins into the alias map, and single-table attribution requires no joins. `SELECT o.x, c.y FROM a o JOIN b c` now resolves c.y (was dropped). - The column graph anchors a producer's lineage to its declared // materialize target (surfaced on the runnable node) instead of guessing a ducklake write-edge, which is unordered for deployed graphs and ambiguous for multi-output scripts. Falls back to a write-edge when no materialize target. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: gate column-lineage badge to the // materialize target write-edge The canvas badge keyed on `e.asset_kind === 'ducklake'`, so a multi-output producer showed the same column mapping on every ducklake write-edge. Use the same materialize-target anchor as buildColumnGraph: the badge lands only on the declared output's edge, falling back to the ducklake write-edge when there's no materialize annotation. (Codex P1) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: build column trace from displayGraph so View hides draft lineage The transitive column trace was built from graphWithDraft regardless of mode, so in View with drafts hidden it could surface draft `// column` lineage the deployed canvas doesn't show. Build it from `displayGraph` (the graph the canvas actually renders) so the trace matches: draft overlays in edit / show-drafts, deployed-only in plain View. (Codex P2) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: don't infer column lineage for local/temp staging CTAS A CTAS into a local/temp staging table isn't the materialized output, but its projection was inferred and (flat) column_lineage anchored to the script's // materialize target — so staging columns showed up as the final asset's. Gate inference to the actual output: a top-level managed-materialize SELECT, or a CTAS/CREATE VIEW whose target resolves to a real asset. (Codex P1) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: scope inferred column lineage to one output asset Inference accumulated columns from every output-producing query into one flat list, all anchored (frontend) to the script's // materialize target — so an auxiliary CTAS into a different asset showed its columns on the materialized one. Tag each inferred entry with its output asset and, in parse_assets, scope the list to the // materialize target (keeping untagged top-level-SELECT entries); with no declared target, drop inference when entries span multiple output assets rather than attribute them to an arbitrary one. Parser-internal — no wire change. (Codex P1) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: treat CREATE TEMP TABLE/VIEW as local even under an active USE A one-part temp name under `USE dl` resolved to an asset (ducklake://…/tmp) before being registered local, so a final SELECT reading it invented `final.total <- warehouse/tmp.amt` (a phantom DuckLake column) and recorded a phantom asset. track_table_definition now registers any temporary table/view as local up front, bypassing active-asset resolution; CreateTable/CreateView pass their `temporary` flag. (Codex P1) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
18 KiB
Pipelines vs. dbt
Positioning analysis and architectural notes for the data-pipeline abstraction
currently landing on feat/asset-graph-view. Covers what we're building, how
it differs from dbt, which dbt features are real gaps vs. TODO, and a focused
deep-dive on incremental materialization — including a recommendation to
collapse it into partitioning rather than ship it as a separate concept.
What we're building
Asset-centric, polyglot, annotation-driven, event-aware:
- Assets (
datatable,ducklake,s3object,volume) are graph nodes; scripts are edges that produce/consume them. Seebackend/parsers/windmill-parser/src/asset_parser.rs:25. - Lineage comes from two sources: parsed annotations (
// pipeline,// on datatable://...,// partitioned daily,// freshness 1h,// trigger any,// debounce,// tag,// retry) and body-inferred reads/writes via the asset parser. - Triggers are first-class: schedule, webhook, email, kafka, mqtt, nats,
postgres, sqs, gcp — all wired into the same DAG view
(
frontend/src/lib/components/assets/AssetGraph/types.ts:56). - Per-language scaffolds (DuckDB ATTACH, Postgres, Python, TS, Bash) generate
starter code per
PipelineOutputKind(datatable | ducklake | s3_parquet | s3_object | none). Seefrontend/src/lib/components/assets/AssetGraph/pipelineTemplates.ts:11.
Differentiators vs. dbt
- Event-driven + batch in one DAG. dbt is batch-on-warehouse. Kafka → Python normalize → DuckDB aggregate → Postgres view → Slack notify is native here; in dbt land it's "use Airflow/Prefect for the non-SQL parts."
- Polyglot, not SQL+Jinja. Python/TS/Bash/Duck/PG transformations live in the same graph. No Jinja templating language; annotations are real comments parsed strictly.
- Multi-substrate by design.
datatable(Postgres),ducklake(lakehouse),s3_parquet,s3_objectare peers. dbt's universe is "tables in your warehouse." - One platform. Same runtime as workflows, internal apps, background jobs, RBAC, secrets, schedules. dbt is single-purpose.
- Inferred lineage from code. Body parser picks up
CREATE TABLE/ S3 writes — annotations are not strictly required to get edges. dbt requires explicit{{ ref() }}everywhere.
Where dbt wins today
| Gap | Architectural blocker? | Verdict |
|---|---|---|
| Data tests | No | Shipped (// data_test) |
| Incremental materializations | No, but pick a philosophy | TODO with design decision |
| Column lineage | No | Shipped (// column); docs site still TODO |
| Snapshots / SCD2 | No | New output kind |
| Selective execution grammar | No | UI/CLI surface |
| Schema contracts | No, but design metadata model | TODO with design work |
| Packages / community | Closed annotation parser starts to bind | Decide extensibility model |
| Semantic layer / metrics | No | Large additive scope |
The three items where the current abstraction needs deliberate decisions before more weight lands on it: incremental philosophy, schema metadata, and annotation extensibility. The rest is execution.
1. Data tests
dbt: unique, not_null, accepted_values, custom generic tests, plus
singular tests. Run as SELECT statements that pass when they return 0 rows.
Shipped via the // data_test annotation (built on materialization):
// data_test unique <col>, not_null, accepted_values <col> = a,b,c,
relationships <col> -> <asset>.<col>, and // data_test <script_path> for
the custom escape hatch (dbt's singular test). Each compiles to a SQL
verifier probe that runs against the freshly-materialized asset and raises on
violation, riding the existing failure-propagation path. This is also the first
extensible annotation — see ducklake-materialization.md §"Data tests" for
the annotation→verifier pattern that column-lineage will reuse. The keyword is
data_test, not test, to stay clear of the unrelated // test: CI-test
annotation.
2. Incremental materializations
See Incremental deep-dive below.
3. Column lineage + docs
dbt: SQL-AST parsing for column-level deps; dbt docs serve produces a
static lineage site with descriptions.
Shipped, inferred-first. For DuckDB scripts the column lineage is derived
automatically from the SQL AST — windmill-parser-sql-asset walks each
output-producing query's projection and maps every output column to the source
columns its expression reads (passthroughs and computed columns like
amount + tax AS total), resolving each input to its asset via the same
ATTACH/alias machinery the asset parser already uses. This is the dbt-style
AST lineage, and it needs no annotation.
The // column <out_col> <- <asset-uri>.<col>[, …] annotation is the
override / escape hatch, for the cases inference can't reach: polyglot
transforms (Python/TS/Bash — no SQL AST), dynamic SQL (${sql.raw(...)}, flagged
by SqlQueryDetails.has_raw_interpolation), or correcting a mis-inferred edge.
Inferred and annotated lineage are merged per output column with the annotation
winning (merge_column_lineage). The annotation is the second extensible
annotation family after // data_test — same head-then-tail parse shape (see
ColumnLineage/ColumnRef in asset_parser.rs) — and is pure metadata: it
drives the graph surface, never a runtime probe.
Surfaced two ways in the asset graph: a count badge on the
producer→materialized-asset write-edge, and a transitive column-lineage
trace (ColumnLineageTrace.svelte, over the cross-script graph built by
columnLineageGraph.ts) in the asset details pane — select an asset to see its
columns and their full upstream/downstream lineage, click any column to
highlight its complete impact set across the pipeline (forward + backward).
SQL-AST inference runs both server-side (the graph endpoint + deploy via
parse_assets, for deployed members) and in the live editor — the same
parser compiled to WASM (windmill-parser-wasm-asset) runs on the open draft's
buffer, and resolveGraph merges its column_lineage with the buffer's
// column annotations under the same annotation-wins precedence, so the draft
preview matches what deploys. The dbt docs serve-style static lineage site
is still TODO.
4. Snapshots / SCD2
dbt: {% snapshot %} blocks with strategy='timestamp' or 'check'.
Today: nothing. Add as a new PipelineOutputKind + // snapshot strategy= timestamp updated_at=updated_at unique_key=id annotation. Same shape as
other output kinds.
5. Selective execution grammar
dbt: --select tag:nightly+ state:modified+ +my_model+.
Today: requestRunCascadeSignal in the canvas, // tag annotation parsed.
Graph + tags + last-run state has all the inputs. UI/CLI surface, not
abstraction work.
6. Schema contracts
dbt: contract: enforced + columns: [{name, data_type}]. Compile-time
check that model output matches the declaration.
Today: // on datatable://users/active is a string. Rename a column
upstream → downstream breaks at runtime, silently.
This is the item where the current asset abstraction is thinnest. To do contracts well: capture output schemas after a run (substrate-specific DESCRIBE), persist them as asset metadata, validate consumer references at save time. The asset-as-typed-node model accommodates it — but where schemas live (asset row, sidecar?), when they're captured (post-run? edit-time?), and how versioning works are non-trivial design choices. Worth doing intentionally now while the asset surface is still young.
7. Packages / community
dbt: dbt deps, dbt-utils, dbt-expectations. Whole ecosystem on Jinja
macros.
Today: closed-vocabulary annotation parser — parsePipelineAnnotations
hardcodes pipeline, partitioned, freshness, trigger, debounce,
tag, retry, on. No way for a package to register
// test rows_between 100 1000000 or // hook on_failure my_alert.
This is the one place the current abstraction starts to bind. Macros are also dbt's biggest pain source — we don't have to replicate them. Possible shapes:
- Hooks-as-scripts:
// on_failure f/lib/alert,// pre_run f/lib/setup. Value is a script path. Stays inside the closed annotation set; new hook types still require parser changes but third-party behavior ships as scripts. - Test types as scripts: a test is a script that returns 0/1, packaged via the hub like anything else.
- Materialization plugins: harder; template-generator would need to be extensible.
Doing this after you've shipped 30 hardcoded annotations is much harder than doing it now.
8. Semantic layer / metrics
dbt: metrics: blocks, MetricFlow, BI-tool query API. Large scope,
additive. Lowest priority of the eight.
Incremental deep-dive
How dbt incremental works
-- models/marts/orders_daily.sql
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge',
on_schema_change='append_new_columns'
) }}
SELECT order_id, user_id, amount, created_at
FROM {{ ref('orders_raw') }}
{% if is_incremental() %}
WHERE created_at > (SELECT MAX(created_at) FROM {{ this }})
{% endif %}
- First run (target doesn't exist):
CREATE TABLE orders_daily AS SELECT ...— full build, no WHERE. - Subsequent runs: stage to temp table, then MERGE on
unique_key.
Knobs: incremental_strategy ∈ {merge, append, delete+insert,
insert_overwrite, microbatch}. on_schema_change ∈ {fail, ignore,
append_new_columns, sync_all_columns}. --full-refresh forces rebuild.
Pain points: watermark + unique_key interaction is subtle (late-arriving
rows past the watermark are silently dropped); on_schema_change defaults
to ignore (silent column drop); Jinja is_incremental() runs at compile,
not runtime — debugging requires dbt compile; cross-warehouse MERGE
dialect is dbt's biggest internal complexity.
Where Windmill stands today
// partitioned daily|hourly|weekly|monthly|dynamic key=...parsed intoPartitionSpecatbackend/parsers/windmill-parser/src/asset_parser.rs:172.// freshness 1hparsed.- Templates emit
CREATE TABLE IF NOT EXISTS ... AS SELECT *— full refresh, every run, no partition substitution. - No
WM_PARTITION_*context flowing into scripts. - No materialized-partition state per asset.
Annotations are present but metadata-only. Nothing actually executes incrementally yet.
Path A — Literal templates ("script is the truth")
Philosophy: WYSIWYG. Windmill never wraps. Templates scaffold boilerplate, partition context is injected as bind / env vars, the user owns the SQL.
-- pipeline
-- on datatable://prod/orders_raw
-- partitioned daily
-- unique_key order_id
ATTACH 'datatable://prod' AS pg;
CREATE TABLE IF NOT EXISTS pg.orders_daily (
order_id BIGINT PRIMARY KEY,
user_id BIGINT,
amount NUMERIC,
created_at TIMESTAMPTZ
);
CREATE OR REPLACE TEMP TABLE _stage AS
SELECT order_id, user_id, amount, created_at
FROM pg.orders_raw
WHERE created_at >= $WM_PARTITION_START
AND created_at < $WM_PARTITION_END;
BEGIN;
DELETE FROM pg.orders_daily
WHERE created_at >= $WM_PARTITION_START
AND created_at < $WM_PARTITION_END;
INSERT INTO pg.orders_daily SELECT * FROM _stage;
COMMIT;
Runtime: resolve (value, start, end) from scheduler tick / trigger event
/ backfill range → bind as SQL params → execute script as-is → record
(asset_path, partition_value) on success.
Pros: no compile step; backfill is trivial (idempotent DELETE+INSERT); late-arriving data → just re-run the affected partition; no dialect rewriting in core; Python/TS/Bash/SQL all fit the same model.
Cons: boilerplate per script; materialization changes require script edits; user owns dialect specifics.
Path B — dbt-style wrapping
Philosophy: separate intent (SELECT) from execution (DDL). User declares what; Windmill compiles to per-substrate DDL.
User writes:
SELECT order_id, user_id, amount, created_at
FROM pg.orders_raw
WHERE created_at >= $WM_PARTITION_START
AND created_at < $WM_PARTITION_END
Runtime parses, looks up target schema, wraps per output kind + strategy + first-run/subsequent-run state.
Pros: concise; materialization is a config flip; automatic schema-drift handling; cross-substrate consistency.
Cons: two-layer execution ("what ran?" needs a compile-output view);
SELECT-only restricts pre/post-statement work (dbt's answer: pre_hook /
post_hook — more surface); doesn't generalize to Python/TS (you end up
with two execution models); cross-substrate MERGE dialect is where dbt has
burned the most engineering — we'd inherit that tax forever; schema
introspection per substrate is its own project.
Path C — Hybrid (recommended)
- Literal-by-default: scaffolds emit full DDL with
WM_PARTITION_*substitution. WYSIWYG for all languages. - Helper library (e.g.
wmll.partition,wmll.datatable.upsert_partition): lifts boilerplate into library calls without hiding semantics — readable source. - Opt-in wrapping for single-SELECT SQL scripts via
// materialized incremental wrap=true. Limit to DuckDB first; add others as needed. Always log the compiled SQL. - State + backfill UI: persist materialized partitions per asset; UI to backfill a range with concurrency cap.
Ships A's 80% case first without committing to B's dialect-rewriting tax. Wrapping becomes opt-in convenience for users who want dbt-style ergonomics.
See
ducklake-materialization.mdfor the DuckLake-native realization of this path: how snapshots make the assets versioned/reproducible for free, the executor codegen seam, and the materialization-metadata schema.
Decisions either path forces
- Partition window provenance. Scheduler tick? Trigger event time
(Kafka
event_timeheader)? Explicit backfill? Default = "now's bucket"? - Surface. Bind params (
$WM_PARTITION_START), env vars (WM_PARTITION_START), helper library — probably all three for different languages, but pick canonical names. - First-run bootstrap. Template scaffolds
CREATE TABLE IF NOT EXISTS(A), or runtime detects "table missing → full refresh" (B). - State tracking.
materialized_partitionskeyed by(workspace, asset_kind, asset_path). Drives "run stale," backfill gap detection, downstream waiting. - Backfill execution. N partitions → serial? Parallel with concurrency cap per asset?
- Idempotency contract.
// partitionedshould imply "re-running the same partition is safe." Templates and helpers must enforce.
Partitioning vs. incremental: the reframing
Partitioning covers ~80% of what dbt's incremental does. What it gives for free:
- Unit of work (one partition per run)
- Idempotency (DELETE-by-partition + INSERT is safe to rerun)
- State (track which partitions are materialized)
- Backfill (re-run a range)
- First-run vs. subsequent-run (every run is "process partition P" — no special case)
- "Process only new data" (the partition window IS the filter)
dbt itself has been migrating toward partition-first thinking via
microbatch strategy — essentially incremental with mandatory partition
key.
What partitioning alone doesn't address
Dedup within a partition by a separate key. Example: partition by
created_at daily, but orders_raw is mutable — the same order_id can
appear multiple times in one partition (initial create, then amendments).
You want orders_daily to hold the latest version per order_id.
DELETE-by-partition + INSERT works only if you reprocess from a
source-of-truth source. If you're consuming amendments and need dedup
within the slice, you need MERGE on order_id, not DELETE on partition.
This is what dbt's unique_key does. Orthogonal to partitioning:
partitioned answers "which slice?"; unique_key answers "how do I dedup
inside the slice?"
Pure watermark-based incremental. Mostly subsumed by
// partitioned dynamic key=updated_at — a partition becomes "everything
since the last seen value of key."
Recommended annotation shape
Don't build "incremental" as a concept. Build:
// partitioned <kind>— unit of work + state + backfill (already exists).// unique_key <col>— opt-in dedup-within-partition. Drives MERGE template vs. DELETE+INSERT template.// append— opt-out of dedup entirely (INSERT-only, no DELETE).
This collapses dbt's materialized=incremental + incremental_strategy +
unique_key into orthogonal annotations that compose. Partition-first is
the better mental model.
Schema drift handling (on_schema_change) is genuinely separate — applies
to full-refresh too — and belongs with the schema-contracts work (gap #6).
First implementation slice
Sequencing if we go with the hybrid + partition-first reframing:
- Partition runtime context — resolve
(value, start, end)from scheduler / trigger / backfill, surface as bind vars + env vars. No materialization change yet. - Helper library —
wmll.partition.window(),wmll.datatable.upsert_partition()for Python/TS, SQL macros for DuckDB/PG. - Template updates — when
// partitioned Xis present, scaffold DELETE+INSERT (or MERGE when// unique_keyalso present, or INSERT when// append). - Materialized-partition state — new table keyed by
(workspace, asset_kind, asset_path, partition_value). Asset metadata read API exposes it. - Backfill UI — date range picker on the pipeline folder page; fans out runs with concurrency cap.
- Later, behind a flag: opt-in wrap mode for single-SELECT DuckDB.
Delivers dbt's pragmatic value (incremental, backfill, idempotent reruns) without buying the compile-layer maintenance, and keeps Windmill recognizably Windmill-shaped.