Files
windmill/backend/windmill-api
Ruben FiszelandClaude Opus 5 0139467b01 feat: ingest dbt column lineage and real column schemas from the engine's parquet index (#10977)
* feat: column-level lineage for dbt from the engine's parquet index

`manifest.json` carries no column-to-column edges, which is why decision 14
recorded column lineage as unavailable. The edges live in a different artifact:
`dbt compile --static-analysis strict --write-index` writes `target/index/`,
whose `dbt.column_lineage.parquet` holds them and whose
`dbt.node_columns.parquet` holds every column of every node, typed and ordered
rather than only the ones an author documented.

Strict analysis rejects SQL the default accepts, so this is a separate compile
with its own `--target-path`, opt-in per project via `column_lineage: true`, and
best-effort throughout: a project it cannot analyze keeps exactly the graph it
had, with the engine's own diagnostics in the job log.

Storage mirrors `dbt_edge`: `dbt_column_edge` keyed by (path, version, job) with
the same composite FK to `script` and the same sweeps. The typed column list
lands in `dbt_node.column_schema`, beside `columns` rather than merged into it,
so `columns` stays what the author declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ

* fix: address review findings on the dbt column-lineage pass

- The workspace fork copied every other dbt sidecar table and not this one, so
  a fork lost its column lineage silently and could not recover it: the cloned
  digest covers the column edges, so a dynamic run in the fork matched it and
  stored nothing.
- The parquet was collected whole before the edge cap applied, which is exactly
  the input the cap exists for — a project whose `scan` lineage is quadratic in
  its widest model could take the worker process down. Decoded a row at a time
  with the bound enforced during the decode.
- The pass swallowed every error from the runner, including the job poller's
  cancellation and deadline, so a run that blew its timeout inside an optional
  annotation could still publish a graph and report success. `run_captured`
  now carries the exit status in its value, so only a failed COMPILE is
  downgraded, and the pass may spend at most half the remaining wall clock so
  it cannot starve the build that follows it.
- `scan` edges are stored but no longer served: they are most of a project's
  lineage, nothing renders them, and the graph endpoint is polled by the run
  page. They are also the first thing the storage cap gives up now, rather than
  evicting the direct edges the trace draws.
- `column_schema` and the column edges take the same gate as the model's SQL. A
  column-level view is the shape of what the author wrote, one level finer than
  the `ref()` graph, which is ungated only because it draws relations the
  caller already sees.
- `graph_digest` hashes the new section only when it has edges, so a project
  that never asked for the pass keeps the digest it has instead of
  re-snapshotting on every dynamic run until it is redeployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ

* fix: the editor buffer's column lineage, and three bounds that were wrong

Round-2 review found four defects, all of them introduced by the round-1 fixes.

- The `script_visible` gate on the column edges was copied from the node query
  without its `script_hash IS NULL` arm. `= NULL` is never true, so every
  version-less row was filtered out and an editor buffer's parse rendered its
  typed columns and none of their lineage — the one place the feature is meant
  to be used. Pinned by an assertion in `dbt_pinned_graph.rs`, which is where
  this class of bug already had a home.
- The phase budget was handed to the poller, whose expiry is an `Err`
  indistinguishable from a cancellation or the job's own deadline, so a slow
  but valid analysis aborted the build it exists to annotate. The runner gets
  the full deadline again — those two must still fail the job — and the budget
  is a race around the whole pass, where expiring is this budget and nothing
  else.
- The decode cap counted parquet ROWS, so `scan` and out-of-graph rows could
  spend it before a single drawn edge was read. It now counts what is kept,
  takes direct kinds in a first pass, and is handed the graph's own nodes so
  the budget cannot go on rows that could never be stored.
- Hashing the new digest section conditionally did not preserve old digests,
  because an absent `column_schema` still serialized as `null` inside the
  nodes. It is skipped when absent instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ

* refactor: split the lineage pass by error contract, and read it in one query

Round 3's findings were all consequences of round 1 and 2's fixes, clustered in
the same two files, so this reshapes those two seams rather than patching again.

The worker pass was one function being three things at once — a subprocess
runner with job-lifecycle error semantics, a bounded decoder, and a best-effort
degrader — which is why each fix to one perturbed another. It is now
`compile_index`, which owns the JOB's semantics (only a cancellation or the
job's deadline can `Err`; a non-zero exit, the output ceiling and the phase
budget are outcomes), and `read_index`, which owns the ARTIFACT's and knows
nothing about the job. The budget wraps the compile alone, so a decode can no
longer outlive the timeout that reported the build would get the rest. The
output ceiling likewise becomes a value rather than a job error, for the caller
that can carry on without the tail of a compile's stdout.

The column edges were read by a fourth hand-written copy of the `live`/`chosen`
CTEs and the version/editor-buffer join conditions, and copying them is what
dropped the `script_hash IS NULL` arm and hid every buffer parse's lineage. Both
kinds of edge now come from ONE statement over a `UNION ALL`'d edge source, so
those conditions exist once. The union is at the source rather than a join
because column lineage can name a node pair `dbt_edge` has no row for: a model
reading `{{ this }}` gets edges from itself to itself, and `parent_map` has no
self-loop.

The cap on the column half now sits after the scope filter, the visibility
check and the graph joins — the scope moved into SQL via the existing
`ScopePathFilter` — so a row the caller may not read can no longer spend it and
leave an allowed project's trace short.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ

* refactor: serve dbt column lineage from its own endpoint

The column edges rode on the folder-wide asset graph, which a run page polls,
while the trace is drawn for one selected relation. That needed a cap, and a cap
has to be applied after every filter that can drop a row.

Keyed to the asset there is no cap: `assets/column_lineage` answers for one
relation, and the caller's `scripts:read` scope and the project's visibility are
decided once, for the script that owns it. Pinning to a run's snapshot or the
editor's parse of its buffer costs the job-read gate, so that form is
`jobs/dbt_column_lineage/{id}` — the same shape `jobs/dbt_graph/{id}` has.

The worker's decode now bounds work and memory separately, and a compile stopped
by the output ceiling reports as truncated rather than complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve the owning dbt version the way the graph does

The unpinned arm picked the newest live version at the path without narrowing to
dbt, so a path since redeployed in another language answered with no lineage
while the graph beside it still drew that project's stale nodes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate pinned column lineage on reading the project, and answer the component

Four things round 5 found, three of them in code this branch rewrote:

- The pinned arm resolved the version from the job and stopped there, so a
  share-link viewer entitled to a run got the project's column names and edges
  while the graph beside it still redacted `raw_code` and `column_schema`.
  Resolving WHICH version answers is not deciding whether the caller may read
  it; the version-less editor buffer keeps its exemption, having no `script` row
  to ask.
- The answer was the whole owning project's edges. The canvas lays out the
  connected component of the selected relation's columns, so the rest was
  unrenderable weight; a recursive walk over both directions returns exactly
  what is drawn, and the project key travels with it so a `unique_id` two
  projects share cannot walk from one graph into the other.
- The decode had no exit but the 4M-row backstop once its buckets were full,
  spending wall clock the build below does not get.
- An unreadable index was reported as a missing one, sending the reader to look
  at their engine rather than at the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stitch the two column graphs, and walk the component in Rust

Round 6's two findings, both regressions this branch introduced:

- The decode returned `Continue` on the edge that FILLED the direct-edge
  budget, so a `scan`-only tail after it decoded to the 4M-row backstop with
  nowhere to put anything. The read now ends on that edge.
- Seam 3 made the pipeline page choose between the dbt graph and the producer
  one. They share node ids — `// column total <- dbt://wh/analytics/orders.amount`
  mints the same `(dbt, path, column)` node dbt's own lineage does — so choosing
  ended a trace at the boundary in both directions. They are merged again, and
  a ducklake selection asks about the dbt relation its producers name so the
  chain continues past it. The dbt editor gets the same merge.

Also: the component is walked in Rust rather than by a recursive CTE. A CTE has
no index, so the recursive term rescanned the doubled edge set once per level —
1243ms against 59ms for the query alone on a 3000-model project, 11.7M rows in
the plan. Same answers, same tests; end to end 1.48s to 0.73s there and 1.60s to
0.26s on a 1000-deep chain. The client stops re-asking for a component it
already holds, which is most clicks within one project.

The four doc sites that described a whole-project answer are rewritten around
what it now is, rather than edited where they disagreed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: expand every dbt boundary a selection reaches, and only skip what was asked

Round 7's findings, all in the frontend seam this branch added:

- A ducklake selection seeded the dbt fetch from the FIRST boundary relation it
  found, so a table derived from two unconnected dbt relations expanded one and
  left the other a leaf — the same "stops at the boundary" symptom the round-6
  fix removed, one hop further along. Every distinct boundary is fetched now and
  the components merged.
- The component cache skipped a relation merely PRESENT in the graph in hand.
  A relation two projects describe has an owner row in each, and a component
  fetched for one carries it as an endpoint without the other's half, so that
  skipped the request that would have resolved the second owner. Only a relation
  actually asked about under this pin is skipped.
- A comment still called the producer graph gated to ducklake selections after
  it was widened to dbt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: land dbt column lineage as storage and ingest only

The API surface that draws a column trace moves to a follow-up PR, on
`dbt-column-lineage-surface`. It kept generating findings — a client cache
whose premise was wrong for a two-owner relation, then staleness and a lost
retry from tightening it, and a seed walk that stopped at the first boundary —
and the fix for the last of them is a transitive owner expansion, which has to
re-apply the caller's gate to every newly discovered project. That is the same
shape as the leak four reviewers caught in the pinned arm, and it wants its own
review rather than being the fourth fix at the end of this one.

What lands here stands on its own: the analysis pass, `dbt_column_edge`,
`dbt_node.column_schema`, the engine gating and the error-contract split — plus
the one user-visible half, the typed and ordered column list, which rides the
asset graph the details pane already fetches and replaces a panel that could
only show the columns an author had documented.

Also fixes a real bug in the pass, found in review: it compiled without the
build's `--full-refresh`. `is_incremental()` branches on that flag, so an
incremental model reading `{{ this }}` compiles its self-join — and any `ref()`
inside that branch — only when the flag is absent, and the pass was storing
lineage for SQL a full-refresh run never executed. The flag now comes from one
place shared with the build, and a run that overrides it gets its own graph
rather than standing as the version's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: say why direct kinds get the budget without naming a view

The bucketing comments explained the priority by what a trace draws, which is
a forward reference now that the surface moved out. The reason stands on its
own: `copy`/`mod` say the value travelled, `scan` says the column was read to
produce the row and so reaches every output column of its model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: round-9 findings on the descoped PR

- The `full_refresh` helper was inserted between `selection_is_overridden` and
  its doc comment, so thirteen lines about `select`/`exclude` echoes documented
  the wrong function and the one they were written for had none. Moved below it.
- The parse path ran the analysis compile and the parquet decode BEFORE the
  guard that returns when there is no warehouse identity, paying for both and
  dropping the result. Moved after it.
- Three sites still described a `/column_lineage` endpoint this branch no longer
  has, and two user-facing strings promised a column trace it no longer renders:
  the panel's hint and the descriptor template now say what the flag actually
  buys, which is the typed column schema.
- Dropped test scaffolding the removed suite left behind: a `raw_orders` node
  and `dbt_edge` whose only assertion re-tested pre-existing graph behaviour,
  and a second editor-buffer node nothing asserts on.

Documented rather than fixed: an incremental model has two shapes, and which one
the index holds depends on whether the target existed when the pass ran.
`is_incremental()` is false with no target as well as under `--full-refresh`, and
dbt has no mode that emits both — so a version's graph describes the compile that
produced it, and only a re-ingesting run describes its own run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep lineage_kind in the edge key, and one answer for --full-refresh

- Both unique indexes omitted `lineage_kind`, so a column that is projected AND
  used as a predicate for the same output column — an ordinary shape — had its
  `copy` and `scan` edges collapse under `ON CONFLICT DO NOTHING`, while the
  digest counted both. The kind is part of the fact, so it is part of the key.
  Edited in the migration rather than added as a second one: it has not landed.
- `full_refresh` was shared between the build and the analysis pass without the
  `command != "test"` condition that sat at the build's call site, so the two
  disagreed for exactly the runs that build nothing. The condition moved inside
  the function, which is the point of sharing it, and the command is threaded to
  the pass.
- The "what a trace draws" rewrite missed the copy in `dbt_manifest.rs`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop the unreachable full_refresh threading, test the uniqueness key

`DBT_COMMANDS` is `["build", "retry", "show", "parse"]` and `default_command`
returns `build` in every arm, so `command == "test"` cannot happen — the guard
the last commit moved into `full_refresh` was already inert where it came from.
Threading the command through five signatures to preserve it bought nothing, so
it is gone; the build and the pass call one function of the descriptor and the
invocation, which is what the sharing was for.

The uniqueness-key fix now has a test: a column projected AND used as a
predicate for the same output column stores both its `copy` and its `scan` row.
Verified against the old key, where it returns 1 instead of 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: restore the dbt test --full-refresh guard I removed on a wrong premise

The previous commit removed it after reading `DBT_COMMANDS` and concluding
`"test"` was unreachable. That is only true of the command a CALLER can name:
`run_dbt` is invoked with `"test"` directly for the `after_all` test phase, so
an `after_all` project with `full_refresh: true` reached it — and dbt rejects
`--full-refresh` on `test`, failing the phase. Both reviewers caught it.

The guard is back inside the shared function, where the build and the pass get
one answer, and its doc now records why reading the allowlist alone is
misleading. The test covering the `test` case is restored with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: notice a job that ended during the decode, and name truncation as the cause

- The parquet decode runs on a blocking thread with no poller watching it, so a
  cancellation or an expired deadline during it was invisible: `dbt_dep` went on
  to publish the graph and the job returned success. The job's state is checked
  once the decode returns, before the caller publishes anything, and an ended
  job `Err`s — which this module may always do for the job's own semantics.
- A compile stopped by the output ceiling could leave no artifact, and the log
  then blamed the engine's capability, sending the reader to check their adapter
  rather than the ceiling. Truncation now names itself in the missing and
  unreadable branches too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: read cancellation from the DB after the decode, not from a poller's field

`ctx.canceled_by` is only ever written by a poller, and no poller runs during
the blocking decode — which is the exact window the check was added for. So the
guard caught only a cancellation already observed before it, and the comment
beside it claimed more than it did. It now queries `v2_job_queue` directly, the
same probe `worker_lockfiles` uses before it overwrites a flow.

A failed probe answers "still running": this decides whether to discard work
already done, so an unreachable database must not be the reason a healthy deploy
loses its graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: reuse job_is_canceled rather than a second copy of it

The probe added last round was `job_is_canceled` from the same file, retyped —
same query, same `Connection::Http` behaviour. Reused instead.

Its doc said a non-database connection was "a failed probe", which reads as an
error path. It is not: it is the agent worker, and on one there is no database
to ask, so only the deadline answers and a cancel issued during the decode is
not observable. The retry path avoids that by refusing to run on an agent worker
at all — which an optional annotation has no business doing — so the gap is
recorded at both ends instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: close the agent-worker cancellation gap instead of documenting it

The previous commit said a cancel issued during the decode is not observable on
an agent worker. It is: `ping_job_status` returns `canceled_by` over both
connection kinds, and is how the poller itself notices one there. So the check
asks through the ping rather than querying `v2_job_queue` directly, and holds on
an agent worker, where a direct query reaches no database at all.

`job_is_canceled` goes back to private and its doc to what it said before — the
retry that calls it still refuses to run on an agent worker for its own reasons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: decode the index under the job poller instead of checking after it

Two findings with one cause: the decode was the only phase of this pass with no
subprocess behind it, so nothing heartbeated while it ran. A large index left
the worker silent for as long as it took, which the zombie sweep reads as a dead
job and restarts — and the cancellation check bolted on afterwards could only
ever report what had already happened, while dropping the ping's
`already_completed`, so a force-cancelled deploy still published its graph.

Running it under `run_future_with_polling_update_job_poller` answers all of it:
the poller pings throughout, and ends the phase with an `Err` on cancellation,
`AlreadyCompleted` or the phase timeout. The bespoke probe is gone with it.

Verified on a live deploy: 32 edges and 4 typed schemas ingested through the
polled decode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop a cancelled decode, and say what the read phase can now do

Putting the decode under the poller heartbeats it and ends the phase when the
job does, but dropping a `JoinHandle` detaches a blocking task rather than
cancelling it — so a cancelled job left a thread decoding up to four million
rows for a job that was over. The row loop reads an abandonment flag that a drop
guard on the awaiting future sets, so the decode stops at its next row.

That same change made the read phase able to `Err`, and three places still said
it could not — decision 14 in as many words. The distinction that holds is
narrower: nothing the ARTIFACT does or fails to do can fail a job, so absent,
unreadable and partial are all values; the JOB can still end the phase the read
runs in. Stated that way in the module doc, the `Artifact` doc, `MAX_INDEX_ROWS`
and the decision.

Verified on a live deploy: 32 edges and 4 typed schemas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: share AbortOnDrop, and stop citing a hazard that is now handled

`Abandon` was `ansible_executor`'s `AbortOnDrop` retyped — same struct, same
reason, same `spawn_blocking` shape. Moved to `common` and used from both.

The paragraph explaining why the phase budget wraps the compile alone gave as
its reason "a decode still running on a blocking thread", which is exactly what
the abandonment flag now prevents. The reason that survives is the one that was
always the point: the budget exists to leave the build its share of the clock,
and only the compile can spend that share unboundedly. The decode's end is the
job's, through the poller it runs under.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: put both doc comments back on the items they describe

Moving AbortOnDrop orphaned a doc at each end: it landed between
`raw_to_string`'s doc and `raw_to_string`, and the doc of the struct it replaced
stayed behind to prefix `fetch_repo_archive`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: name the binding the row loop actually reads

`Abandoned` was neither the type nor the binding; the flag is `abandoned`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 09:58:38 +02:00
..
2024-04-08 01:42:43 +02:00

Windmill API

The API server, exposing functionality to other components and the frontend

This crate exposes both a library as well as a binary target.