mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 16:01:42 +00:00
baefa1345b
* feat: give dbt its own editor with an explicitly refreshed model graph Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: bump ee ref for the agent-worker dbt editor graph Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: scope editor graph retention by principal, carry parse context, honor nlang Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: bump ee ref Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the dbt editor's model graph and log panel mounted across tabs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the dbt_edge to dbt_node joins on an index-usable equality Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: poll a parse until the job ends, resolve the project key, correct the docs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: surface a slow parse's job, bound poll failures, drop banned bindable defaults Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hide the dbt Generated UI content, not only its tab Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: honor disabled Triggers in the dbt tab fallback, record permissioned_as Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: one dbt pane with the run drawn on the models, and a full-height script graph Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: move the dbt build arguments behind the Build button Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: trim the dbt editor toolbar and stop the graph asserting a cause it lacks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: mark dbt as alpha in the language picker and announce it once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: trim the dbt alpha notice Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: give a selected dbt model the whole detail section, with a close that deselects Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: close the dbt detail panel by clicking away, and make its close obvious Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: cache the agent-worker dbt query, which needs the private feature to compile Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: never fall back to a settings tab the embedder disabled Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: preview dbt rows from the same project the graph was parsed from * feat: hide the script-kind selector for dbt projects * fix: pin a dbt row preview to the project its graph was parsed from * fix: pin a dbt row preview to the arguments its graph was parsed under * fix: keep dbt preview placeholders live while its vars stay pinned * fix: report a warehouse-less dbt parse's counts and flag stale preview args * fix: tell the pinned-vars case apart from a stale placeholder * chore: update ee-repo-ref to 59044635769f18f8ff5073236cfc7b5f41e917cc This commit updates the EE repository reference after PR #707 was merged in windmill-ee-private. Previous ee-repo-ref: 7e424384cdd4cef8653b55b04f17ad3f801bc50c New ee-repo-ref: 59044635769f18f8ff5073236cfc7b5f41e917cc Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1166 lines
68 KiB
Markdown
1166 lines
68 KiB
Markdown
# Windmill as a dbt runtime
|
||
|
||
Implementation spec for running an existing dbt project on Windmill with no
|
||
changes to the project itself. Companion to [`pipelines-vs-dbt.md`](./pipelines-vs-dbt.md),
|
||
which covers the opposite direction (native pipeline features that replace dbt).
|
||
The two are complementary: this is the adoption ramp, that is the long game.
|
||
|
||
Benchmark to beat is Airflow + [astronomer-cosmos](https://astronomer.github.io/astronomer-cosmos/),
|
||
the dominant way dbt is orchestrated today.
|
||
|
||
## Scope
|
||
|
||
- **In**: run an unmodified dbt project synced into Windmill, one Windmill job per
|
||
invocation, live per-model observability, dbt models as first-class assets in
|
||
the existing asset graph.
|
||
- **Out**: one Windmill job per dbt model, `state:modified` / slim CI,
|
||
`dbt docs` hosting, semantic layer, dbt platform integration.
|
||
- **CE**: the runtime, the manifest ingest, the asset graph and every piece of
|
||
UI ship in CE, as do all adapters except two. Only the `mssql` and `oracle`
|
||
adapters are EE, mirroring the native `ScriptLang` boundary (decision 21).
|
||
|
||
## Decision log
|
||
|
||
| # | Decision | Resolution |
|
||
|---|---|---|
|
||
| 1 | dbt engine | Three-way toggle (`dbt-core-1x` \| `dbt-core-2x` \| `fusion`); shipped default `dbt-core-1x`, instance-configurable. See below |
|
||
| 2 | Artifact shape | `ScriptLang::Dbt` |
|
||
| 3 | Graph in v0 | Yes, both runtime and graph |
|
||
| 4 | Execution granularity | One job per invocation |
|
||
| 5 | Project storage | The project is the script's module bundle; nothing is cloned. See "Where the dbt project lives" |
|
||
| 6 | Multiple run configs | Per-run `select` on one script; N scripts means N projects |
|
||
| 7 | Run-time `select` | Descriptor default plus run-arg override |
|
||
| 8 | Credentials | Workspace warehouses, plus `profiles.yml` passthrough. A descriptor never names a resource. See below |
|
||
| 9 | Adapter mappings | postgres, redshift, mysql, snowflake, bigquery, databricks; others via the project's own `profiles.yml` |
|
||
| 10 | Private repo auth | Not applicable: the project is synced, not fetched |
|
||
| 11 | Asset kind | `dbt://<warehouse>/<schema>/<name>` — keyed on the relation, not on dbt's node id. See below |
|
||
| 12 | Graph refresh | Deploy-time, re-ingested per run only when the descriptor is dynamic, plus an explicit `parse` of the editor's buffer. See below |
|
||
| 13 | Manifest storage | Sidecar table for nodes/edges. Full manifest **not** stored — see below |
|
||
| 14 | Metadata depth | Tests, strategy, tags, freshness, column descriptions. Column **lineage** is not in the manifest — see below |
|
||
| 15 | Node rendering | Asset nodes per model plus one runnable node for the script |
|
||
| 16 | Progress | Live, from the JSON event stream |
|
||
| 17 | Test failures | Honor dbt's own `severity` |
|
||
| 18 | Retry | Automatic node-level retry in-job, plus `dbt retry` as a run argument. See below |
|
||
| 19 | Caching | Worker-local global cache, keyed by the project digest and the resolution the deploy pinned |
|
||
| 20 | Images | Full images only |
|
||
| 21 | Licensing | CE except the `mssql` / `oracle` adapters. See below |
|
||
| 22 | Naming | Match Cosmos field names; importer deferred |
|
||
| 23 | Descriptor | `wm_dbt.yaml` inside the project, OPTIONAL. See below |
|
||
| 24 | Warehouse | Configured on the workspace by name, `main` by default. See below |
|
||
|
||
## Decision 1: engine toggle, and why the shipped default is not Fusion yet
|
||
|
||
`engine: dbt-core-1x | dbt-core-2x | fusion` in the descriptor. Omitted, it is
|
||
`dbt-core-1x`, which runs today's projects untouched.
|
||
|
||
No engine is baked into any image. Each is fetched or built on first use and
|
||
cached, for a different reason in each case.
|
||
|
||
| Engine | Distribution | Cold start | License |
|
||
|---|---|---|---|
|
||
| `dbt-core-1x` (default) | A uv venv resolved per adapter on first use, then cached. **Cannot** be baked: the adapter is a Python package chosen per project | One venv build per (core range, adapter) | Apache 2.0 |
|
||
| `dbt-core-2x` | One adapter-agnostic Rust binary, fetched from GitHub releases on first use, cached | One download | Apache 2.0 |
|
||
| `fusion` | **Never bundled.** Fetched from dbt Labs on first use, cached | One download (~290MB) | dbt Fusion engine license agreement |
|
||
|
||
2.x is the one that *could* be baked, and deliberately is not: it is a
|
||
pre-release (`2.0.0-alpha.5`) that nothing is defaulted onto, so baking it costs
|
||
a layer in every image and a version pinned in two places with nothing keeping
|
||
them in step. An operator who wants an engine pre-staged — an air-gapped
|
||
instance, or a fleet that should not fetch per worker — populates
|
||
`DBT_BUNDLED_DIR` (default `/usr/local/dbt`) with `core2x-<version>/dbt-sa-cli`
|
||
in a derived image; the worker prefers it over its own cache.
|
||
|
||
Two things to know before choosing 2.x: it is a pre-release, and it does not
|
||
emit the per-node events the run page animates (see "Live per-model progress"),
|
||
so a run on it reports its models only at the end.
|
||
|
||
The 1.x venv resolves `dbt-core>=1.8,<2.0.0` *together with* the adapter rather
|
||
than pinning a core version, because several adapters cap below the newest core
|
||
(dbt-oracle and dbt-databricks below 1.12) and an independent pin makes those
|
||
projects unprovisionable. The lockfile records whichever version the resolver
|
||
actually chose. Both bounds and each engine version are env-overridable
|
||
(`DBT_CORE_1X_FLOOR`, `DBT_CORE_1X_CEILING`, `DBT_CORE_2X_VERSION`).
|
||
|
||
Fusion is the fastest option and the toggle exists so users can choose it. Two
|
||
things block making it the *shipped* default, both verifiable rather than matters
|
||
of taste:
|
||
|
||
1. **Redistribution terms.** The Fusion license grants only a "limited,
|
||
non-exclusive, non-transferable, non-sublicensable" redistribution right, and
|
||
4.1 forbids introducing "obstacles or delays that have the effect of hampering
|
||
or interfering with (a) communication between Provider and End User, (b)
|
||
User's ability to view, access, or use the Product and/or any Account
|
||
Features." A sandboxed non-interactive job runner sits squarely in that
|
||
clause's path, and "may not share, pool, or relay its own login credentials to
|
||
any End User" reads directly onto putting one dbt platform token in a
|
||
workspace secret. That needs counsel, not an engineering judgment.
|
||
**Fetch-at-runtime is the mitigation**: the user's own instance pulls the
|
||
binary from dbt Labs directly, so Windmill never redistributes and never
|
||
interposes. Do not bake Fusion into any image.
|
||
2. **Fusion is v2 semantics, and v2 drops all deprecated functionality.** Every
|
||
deprecation warning, including historic ones and those added in 1.10, must be
|
||
resolved before a project runs on it. An arbitrary existing dbt 1.x project
|
||
therefore may not run unchanged, which is this feature's entire premise. dbt
|
||
ships an autofix tool and Fusion/Core interoperate side by side, so it is a
|
||
migration users can do, but not one Windmill should silently require of them.
|
||
|
||
Consequence: ship with `dbt-core-1x`, which runs today's projects untouched, and
|
||
flip the instance default to `fusion` once counsel clears the runtime-fetch model
|
||
and a real project is verified end to end on it. Both dbt-core engines are
|
||
exercised by the e2e suite, so the flip is a config change, not a port.
|
||
|
||
## Decision 21: mirror the native warehouse boundary, do not invent one
|
||
|
||
Everything structural is CE: the executor, all three engines, the manifest
|
||
ingest, the `dbt://` asset graph, live progress, the editor. The only gate is
|
||
on two adapters, and it is not a dbt-specific policy — it is the same boundary
|
||
the native script languages already draw. Since `bigquery` and `snowflake`
|
||
became CE, the only warehouse `ScriptLang`s still behind a license are `mssql`
|
||
and `oracledb`, so those two dbt adapters are EE and every other one (postgres,
|
||
mysql, duckdb, snowflake, bigquery, databricks, redshift, clickhouse,
|
||
salesforce) is CE. Gating any of the others would make reaching a warehouse
|
||
through dbt stricter than reaching it natively, which is backwards.
|
||
|
||
Those two are *recognized* (for the gate and for the pip package the 1.x
|
||
engine's venv needs), not rendered from a resource: an `oracledb` resource is
|
||
`{user, password, database}` with no host/protocol/service, and dbt-sqlserver
|
||
needs an ODBC `driver` the images do not install. Both reach their warehouse
|
||
through the project's own `profiles.yml`, which is also how duckdb, clickhouse
|
||
and salesforce work.
|
||
|
||
The gate almost never fires in practice: `dbt-core-2x` supports neither adapter,
|
||
so it can only apply to `dbt-core-1x` with one of those two.
|
||
|
||
**The mechanism differs from the native languages.** They gate at compile time,
|
||
so a CE binary simply lacks the executor. That is not available here: there is
|
||
one dbt executor and the adapter is only known once the profile resolves. So it
|
||
is a runtime check on the resolved adapter, at both deploy and run, and it must
|
||
say what is wrong — a silent degradation that surfaces later as a connection
|
||
error is worse than no gate at all.
|
||
|
||
One trap: `ee_oss::LICENSE_KEY_VALID` is initialized to `true` in the OSS
|
||
variant, so reading it alone passes on a CE build. The check is
|
||
`cfg!(feature = "enterprise") && LICENSE_KEY_VALID`, which rejects both a CE
|
||
build and an enterprise build whose key did not verify.
|
||
|
||
## Decision 11: `dbt://`, keyed on the relation and not on the dbt node
|
||
|
||
`dbt://<warehouse>/<schema>/<name>`, one `AssetKind`, where `<warehouse>` is the
|
||
workspace warehouse's NAME, so two scripts running against the same warehouse
|
||
agree on identity.
|
||
|
||
The SCHEME names the producer, because dbt is the only thing that creates one of
|
||
these: no other language derives warehouse relations, `// materialize` takes
|
||
DuckLake targets only, and a dbt run does not dispatch. Calling the kind
|
||
something generic promised a parity with native Snowflake and BigQuery scripts
|
||
that does not exist.
|
||
|
||
The PATH is the physical relation, and that is the load-bearing half. dbt-core
|
||
has no cross-project `ref()`: two projects meet when one materializes a mart and
|
||
the next declares it a `source`. Their dbt identities differ there —
|
||
`model.a_pkg.orders` against `source.b_pkg.analytics.orders` — while the relation
|
||
does not, so keying on `unique_id` would make every project an island and turn
|
||
the handoff into two unconnected nodes. `unique_id` also embeds the package name
|
||
from `dbt_project.yml`, which two unrelated projects may both call `analytics`,
|
||
collapsing two different tables onto one node. The relation cannot collide that
|
||
way. It is also what a DuckDB, Python, TS or Ansible script can name in a
|
||
`// on dbt://…` annotation to join the lineage — those four are the languages
|
||
with a body-asset parser; the native SQL ones cannot declare assets at all.
|
||
|
||
A dbt run does **not** trigger those readers. See "no cascade from dbt" below.
|
||
|
||
An ephemeral model (an inlined CTE, never written), an exposure, or a source that
|
||
is not separately modelled has no physical relation and therefore no place in
|
||
this namespace. If those ever prove worth rendering they need a key of their own
|
||
— `unique_id` suits them, precisely because nothing else can refer to them.
|
||
|
||
Two traps, both of which quietly defeat the point if handled wrong.
|
||
|
||
**Identifier canonicalization.** `manifest.json` gives `relation_name`
|
||
pre-quoted (`"windmill"."Analytics"."Orders"`), an annotation is written by hand,
|
||
and the warehouses disagree on case: Snowflake folds unquoted identifiers up,
|
||
Postgres folds them down, DuckDB compares case-insensitively. Two spellings of
|
||
one table produce two nodes, no edge, and nothing looks broken in isolation. So
|
||
one rule is applied in exactly one place — `parse_asset_syntax`, the single
|
||
point where an asset URI becomes a graph key: strip the quote characters
|
||
(`"`, backtick, `[`/`]`) from the schema and name, then ASCII-lowercase them,
|
||
matching the case-insensitive identifier comparison the DuckDB paths already
|
||
use. The warehouse-name prefix is spelled as the workspace configures it and
|
||
stays case-sensitive.
|
||
|
||
**Warehouse identity is the workspace warehouse's name**, exactly as
|
||
`ducklake://main.orders` keys on the workspace lake's name — never the host,
|
||
account or database. A descriptor cannot name a resource at all (Decision 24), so
|
||
there is exactly one spelling per warehouse and the ambiguity a per-project
|
||
resource would create does not arise. The warehouse names the default database
|
||
too, so it stays out of the key; a model that *overrides*
|
||
its database (Snowflake `database`, BigQuery `project`) is genuinely elsewhere and
|
||
qualifies its schema segment as `<database>.<schema>`, so two same-named relations
|
||
in different databases cannot collapse onto one node. A project that brings its own
|
||
`profiles.yml` reports its target's database from that file, read with the same
|
||
keys the renderer writes, so it spells a relation exactly as a workspace-warehouse
|
||
project does and the two meet on one node. Only where the target leaves its
|
||
database implicit does every relation qualify, because assuming they share one
|
||
database is exactly what would collapse them.
|
||
|
||
Three call sites derive this key: the manifest ingest that creates the node, and
|
||
the live-progress and end-of-run paths that record status against it. They share
|
||
one function, because a site that derives it differently records progress against
|
||
a path no node has — the run still succeeds and the graph simply never moves. The same
|
||
warehouse is reachable under several hostnames, and credential material has no
|
||
business in an asset key. Accepted limitation, worth knowing before it is
|
||
filed as a bug: **two workspace warehouses pointing at the same physical
|
||
warehouse do not unify**, so assets under one will not share edges with assets
|
||
under the other. Point both projects at one warehouse to link them.
|
||
|
||
## Decision 24: the warehouse is a workspace setting, named, and the only one
|
||
|
||
A descriptor names a warehouse by NAME (`profile.warehouse`, `main` when it names
|
||
none) and cannot name a resource. Admins configure the warehouses under Settings
|
||
→ dbt, where each entry points at a resource, exactly as `large_file_storage`
|
||
points at the object-storage resource and a DuckLake names its catalog.
|
||
|
||
Three things follow, and they are the reason for the rule rather than
|
||
consequences to work around.
|
||
|
||
**A dbt project carries no connection.** The same project runs locally against a
|
||
developer's own `~/.dbt/profiles.yml` and on Windmill against the workspace
|
||
warehouse, with no Windmill-specific file in between and nothing to strip before
|
||
committing it to a repository. This is what makes Decision 23 possible at all: if
|
||
a project had to name its own resource, the descriptor could never be optional.
|
||
|
||
**Asset identity has exactly one spelling.** Keying on a name is only sound
|
||
because a name is all there is. Had both `profile.resource` and
|
||
`profile.warehouse` existed, one physical warehouse would be reachable under two
|
||
spellings and two projects on it would silently fail to share nodes — the exact
|
||
failure Decision 11 exists to prevent.
|
||
|
||
**dbt is unpermissioned, and the blast radius is bounded by construction
|
||
instead.** The warehouse resource is read with NO permission check on the runner,
|
||
exactly as `s3://` reaches the workspace bucket without the caller being granted
|
||
the storage resource: configuring a warehouse is what makes it available, and
|
||
anyone who may run a dbt script may build with it and read its models. What is
|
||
reachable stays bounded because only an admin writes the setting and a descriptor
|
||
cannot name a resource, only one of the names an admin configured.
|
||
|
||
Per-relation rules were considered and rejected. `s3://` can enforce a path glob
|
||
because Windmill mediates every object operation through its proxy; dbt has no
|
||
such chokepoint — Windmill renders `profiles.yml` and dbt opens its own
|
||
connection. A rule could only be a pre-run check against the manifest, and a
|
||
`pre-hook`, a macro or `dbt run-operation` issues arbitrary SQL on the same
|
||
connection, so it would stop the ordinary case while implying a guarantee it
|
||
cannot keep.
|
||
|
||
A project that brings its own `profiles.yml` still connects with it, and then
|
||
names a warehouse only to say where its assets belong. The name must still match
|
||
a configured warehouse — a typo is not identity, it strands the project's models
|
||
on a node nothing else reaches — but it grants nothing, since nothing here is
|
||
granted. It gets no identity by default, because defaulting to `main` would key a
|
||
self-hosted profile's tables onto a workspace warehouse it never connected to.
|
||
|
||
That label is worth having only because such a project spells its relations the
|
||
same way: Windmill reads the target's database out of the project's own file
|
||
(Decision 11), so a mart it builds and a workspace-warehouse project's `source`
|
||
on the same relation land on ONE node. Without that the label would name a
|
||
namespace and still share nothing, which is the failure it exists to prevent.
|
||
|
||
An agent worker cannot read the database, so it resolves the name through a
|
||
job-scoped API route. That route returns the resolved connection, which is why it
|
||
requires a job token: a running job already holds those credentials in its
|
||
rendered `profiles.yml`, and a browsable route would hand them to anyone. The
|
||
same worker posts its per-model outcomes to a second job-scoped route, since the
|
||
live reporter tails a log straight into the database and cannot run there. An
|
||
agent's run page therefore fills in when the run ends rather than during it.
|
||
Both routes are posted with the JOB's token: an agent's own credential
|
||
authenticates only against the agent surface.
|
||
|
||
## Decision 23: the descriptor is optional, and lives inside the project
|
||
|
||
`<script>__dbt/wm_dbt.yaml`. An unmodified dbt project — one `cp -r` away from a
|
||
developer's working copy, or a repository cloned as-is — is already a complete
|
||
Windmill script: it runs the whole project against the workspace's default
|
||
warehouse. The descriptor appears only when the project wants something
|
||
Windmill-specific: run arguments, a named warehouse, an engine pin, a test
|
||
policy.
|
||
|
||
It lives INSIDE the project rather than beside it so that an author writes
|
||
nothing outside the directory dbt itself reads. A dbt developer's working copy
|
||
and a Windmill bundle are then the same directory, which is the whole bargain of
|
||
Decision 5.
|
||
|
||
Absent means an empty descriptor, never a missing script. `dbt_project.yml` is
|
||
what identifies a project — the descriptor cannot, being optional — and three
|
||
rules keep "absent" from reading as a change: the export omits an empty
|
||
descriptor, the sync map gives BOTH sides the empty descriptor an absence means
|
||
(so neither reads as an addition), and a pull deletes rather than writes one.
|
||
Without all three a descriptor-less project either diffs forever or grows the
|
||
very file this decision exists to avoid.
|
||
|
||
## Decision 12: the graph refreshes with the deploy
|
||
|
||
The project's files are the script's, so a deploy already sees exactly what will
|
||
run: it parses the bundle and stores the graph. "Refresh" is just "redeploy". No
|
||
manual button, no webhook, no separate mechanism.
|
||
|
||
The one case that cannot be settled at deploy is a descriptor that is dynamic by
|
||
construction: a `vars` value spelled with a `{{ placeholder }}`, or an `env`
|
||
value spelled `$var:` (re-resolved every run). dbt vars can steer `enabled`,
|
||
aliases, schemas, databases and materializations, so for those the deploy cannot
|
||
know what will run and the graph is re-ingested from every run's own manifest,
|
||
under that run's job id. A run that cannot refresh those rows fails rather than
|
||
showing a stale graph. What the SCRIPT owns stays the deploy's — see "Which
|
||
run's graph becomes what the script owns" for why the two cannot diverge.
|
||
|
||
An agent worker reaches the database only through the API, so it POSTs the graph
|
||
it parsed to `/api/agent_workers/dbt_graph/{workspace}` instead of writing it —
|
||
which is why it needs no way to READ the stored relation root: it re-ingests
|
||
every run, so its own run page shows the profile it actually used. What it
|
||
publishes is that per-run snapshot alone — the path-keyed ownership rows are
|
||
written by the deploy and by database-connected workers. Dynamic descriptors and
|
||
Windmill-resolved profiles therefore both run there. What an agent does not get is LIVE progress —
|
||
that is a per-model event stream, and a round trip per node is the wrong trade —
|
||
so its per-model state is settled from `run_results.json` when the run ends, and
|
||
its retry state lives only in the worker-local generation. See
|
||
[agent-worker-e2e.md](./agent-worker-e2e.md).
|
||
|
||
The refresh happens **before** the build, from a `dbt parse` with this run's own
|
||
vars and env, so a run in flight is already showing the models it is building.
|
||
|
||
A dynamic descriptor's graph is a property of the RUN, not of the deployed
|
||
version, so it is stored per job — see "The graph belongs to a script version"
|
||
below. Two concurrent runs of one such script therefore keep their own, and each
|
||
run page shows the models that run built.
|
||
|
||
Re-ingesting is nearly free: the run parses the project (about a second) before
|
||
building it and ingests that manifest.
|
||
|
||
The parse is what makes a newly added model appear in the same run that builds
|
||
it, rather than one run late: the graph is written before the build, so the run
|
||
page shows the model while it is being built.
|
||
|
||
## What a share-link viewer sees
|
||
|
||
A share link is not anonymous access: the token is HMAC'd with the workspace key
|
||
and scoped to one job and its descendants. It is an extra grant for a **logged-in
|
||
user who lacks access to that job** — which is normally why someone was sent a
|
||
link.
|
||
|
||
Both halves of a dbt run page go through one gate: `/jobs/run_progress/{id}` and
|
||
`/jobs/dbt_graph/{id}`, each behind `require_job_read_access`, which validates
|
||
the token. The graph then has a second, independent filter — RLS on the `script`
|
||
row — and a viewer sent a link usually has no grant there. Deciding the graph's
|
||
SHAPE under that filter is wrong: it would answer for the caller's access to the
|
||
project rather than for the run they were given, and the Models panel would come
|
||
back blank beneath working progress rows.
|
||
|
||
So a pinned run resolves its version from the JOB ROW, not from `script`: the
|
||
`live` CTE takes the path and hash the handler read after authorizing the job.
|
||
Two things make that safe rather than a widening:
|
||
|
||
- **It leaks nothing new.** `v2_job_completed.result` already carries every
|
||
node's `unique_id` and `relation_name`, and this viewer can read it — the
|
||
model set and its relations are already visible to them.
|
||
- **`raw_code` is gated separately**, on an `EXISTS` against `script` in the
|
||
authed transaction. The body of a model is the project's source code and stays
|
||
behind access to the project, whatever the shape query resolved.
|
||
|
||
The path and hash coming from the job row rather than the query also means a
|
||
caller cannot pin one project's version while naming another's run.
|
||
|
||
## What a dbt job returns, and which half of it is a contract
|
||
|
||
The result is `{engine, engine_version, command, totals, nodes, invocation_args}`,
|
||
and each node carries both `status` and `outcome`.
|
||
|
||
`invocation_args` is the arguments the run used, as SUBMITTED — a `$var:` stays a
|
||
reference, so no resolved value is published — and it is omitted when empty. It
|
||
exists because a `dbt retry` restores the failed run's arguments inside the
|
||
worker and never writes them back to the retry job, whose own args are just
|
||
`{"command": {"label": "retry", "dbt_retry_job": "<id>"}}`: the row preview, which
|
||
is a `dbt show` of the same project, has nowhere else to get them. On a retry it is therefore ANOTHER
|
||
invocation's arguments, which is why a hidden run saves no state at all (see the
|
||
retry section).
|
||
|
||
`status` is dbt's own word, verbatim — `success`, `error`, `partial success`,
|
||
`no-op`. It is what the log says and what dbt's docs describe, so it belongs in
|
||
the result, but it is dbt's vocabulary and dbt may change it: 1.x and 2.x
|
||
already differ on casing, and `no-op` arrived in a minor release.
|
||
|
||
`outcome` is the same result in Windmill's terms — `passed`, `failed`, `warned`,
|
||
`skipped`, `no_op`, `unknown` — and it is the half a downstream script should
|
||
branch on. A dbt release that renames a status moves `status` and leaves
|
||
`outcome` where it is. Publishing only dbt's word would have made every such
|
||
release either a break for users or a lie in our mapping.
|
||
|
||
## The graph belongs to a script version
|
||
|
||
`dbt_node` / `dbt_edge` are keyed `(workspace_id, script_path, script_hash,
|
||
job_id, unique_id)`. Each deployed version keeps its own graph, and a job records
|
||
the version it ran (`v2_job.runnable_id`), so a run page asks for that one:
|
||
`/assets/graph?dbt_script_hash=<hex>` renders the project as it was — its models,
|
||
its SQL, its `ref()` lineage — instead of whatever is deployed today.
|
||
|
||
`job_id` is the second half, and it exists for dynamic descriptors only. A
|
||
`{{ }}` placeholder in `vars` can enable a different set of models per run, so
|
||
those runs re-ingest; keyed by version alone, each re-ingest overwrote the last
|
||
and reopening an older run showed the newer run's project, with any model only
|
||
the older run built simply gone. A run of a dynamic descriptor therefore writes
|
||
its own snapshot under its job id, and its page reads the graph through
|
||
`GET /w/{w_id}/jobs/dbt_graph/{id}`, passing the version hash.
|
||
|
||
A static descriptor writes nothing per run: its graph is the version's, under
|
||
the zero-UUID `DEPLOYED_GRAPH` sentinel, and every run of it reads that. The
|
||
sentinel is a value rather than NULL because `job_id` is in the primary key and
|
||
Postgres does not treat two NULLs as one key, so a re-ingest would accumulate row
|
||
sets instead of replacing one. The route falls back to it whenever the job has no
|
||
snapshot, which is why a run page can use it unconditionally rather than having
|
||
to know whether its descriptor was dynamic.
|
||
|
||
Pinning to a run is job-scoped, so it is a job route and not a parameter on
|
||
`/assets/graph`: it needs the whole job-read contract, which is
|
||
`require_job_read_access`. That helper lives in `windmill-api`, which depends on
|
||
`windmill-api-assets`, so the read moved to the check rather than the check to
|
||
the read. The route charges `assets:read` on top of the `jobs:read` its URL
|
||
implies, since the body it returns is asset data.
|
||
|
||
A snapshot is only written when it DIFFERS from the version's graph, compared by
|
||
a digest of the nodes, edges and relation root. Marking a descriptor dynamic is
|
||
conservative — a `{{ }}` in `vars` says the arguments reach dbt, not that they
|
||
change which models exist — so the usual dynamic run (a date var) resolves to
|
||
exactly the graph the deploy stored, and storing that per run would duplicate an
|
||
unchanging picture. Those runs write nothing and read the version's graph
|
||
through the fallback; only a run whose model set really differs pays.
|
||
|
||
### Which run's graph becomes what the script owns
|
||
|
||
Re-ingesting has several causes and they do not want the same thing, so the
|
||
reason is carried rather than a bool (`GraphRefresh`):
|
||
|
||
| Cause | Graph written | Path-keyed `asset` ownership |
|
||
|---|---|---|
|
||
| Descriptor is dynamic (`{{ }}` in `vars`, `$var:` in `env`) | under the job id | untouched |
|
||
| The run overrode `vars` | under the job id | untouched |
|
||
| The run narrowed `select`/`exclude` | nothing, unless another cause already made it ingest — then under the job id | untouched |
|
||
| The profile moved since the last publish | the **version's** graph | republished |
|
||
|
||
Ownership follows the version's graph exactly, which is what the first three
|
||
rows have in common: the workspace graph takes an asset's relations from the
|
||
`asset` rows and its models, SQL, tests and `ref()` lineage from that version's
|
||
`dbt_node`/`dbt_edge`, so publishing relations the version's graph does not name
|
||
leaves those assets with no model behind them — a placeholder that moves an
|
||
alias would empty the current graph of everything dbt contributes to it. A run
|
||
storing a snapshot of its own therefore publishes nothing, and an override's
|
||
schemas and aliases do not stand as the script's until the next deploy, which is
|
||
what a snapshot is for.
|
||
|
||
The consequence for a dynamic descriptor is that its ownership stays the
|
||
deploy's, and a profile that moves under one is settled by a redeploy rather than
|
||
by a run: every run of it already shows its own models and re-parses regardless,
|
||
so the drift it keeps re-detecting costs it nothing it was not already paying.
|
||
|
||
The last row is the one that has to publish. The drift check compares the
|
||
resolved root against `relation_root_at_last_ingest`, so a run that saw a move and
|
||
did not republish leaves the next run seeing the same move — forever, with the
|
||
asset rows still naming the old schema and every run paying a `dbt parse` for a
|
||
snapshot nobody reads. It rewrites the VERSION's graph rather than a per-run
|
||
snapshot for the same reason: once the root is republished no later run detects
|
||
the move, so a snapshot would leave those runs reading the pre-move rows.
|
||
|
||
A snapshot wins where they meet: a drifted run that also overrode its arguments,
|
||
or whose descriptor is dynamic, snapshots under its job id and publishes
|
||
nothing, and the drift is settled by an ordinary run of a static descriptor or
|
||
by a redeploy — a wasted parse per overriding run, where the alternative is one
|
||
caller's subset standing as the script's own, or replacing the version's graph
|
||
with a picture missing every model that run did not select.
|
||
|
||
Both halves have a retention story, and they differ because their readers do. A
|
||
run's snapshot expires on a clock — 30 days — because the run page that reads it
|
||
is transient. A VERSION's graph cannot: its reader is every finished run of that
|
||
version, and a run page is as old as its job. So version graphs are bounded by
|
||
deploy COUNT instead — the newest 50 per path keep theirs — which makes growth
|
||
`versions x models` rather than unbounded in time. Without it a CI deploying on
|
||
every commit adds a full model set per commit and nothing ever reclaims it. The
|
||
bound is generous on purpose: reaching it empties that version's run pages, so
|
||
it exists to stop unbounded growth rather than to be hit in normal use.
|
||
|
||
Both are pruned by every dbt run, so no background sweep has to know about the
|
||
tables. The prune is
|
||
deliberately not hung off the progress reporter, which exists only for engines
|
||
that emit node events: retention that stops working because an instance chose
|
||
Fusion is not retention. A version's own graph lives as long as the version.
|
||
|
||
### The third provenance: a parse of the editor's buffer
|
||
|
||
The dbt editor draws a graph of the project **as it is in the editor**, refreshed
|
||
on demand by a `dbt_command: "parse"` job over the buffer — the deploy's own
|
||
deps → parse → ingest path, with no build. That graph is neither of the two
|
||
above: the buffer differs from what is deployed, which is the point of
|
||
refreshing it, and a project being written may have no deployed version at all.
|
||
|
||
So it is keyed to its own PREVIEW JOB with **no version** — `script_hash IS
|
||
NULL` — and readable only back through that job id
|
||
(`GET /jobs/dbt_graph/{id}`), never through the path. That is what keeps the
|
||
property `GraphPublisher::Unversioned` exists for: a parse publishes no
|
||
path-keyed `asset` usages and no relation root, so a principal who needs only
|
||
`jobs:run` still cannot restate what a deployed project's graph says.
|
||
|
||
Three consequences of the version being absent:
|
||
|
||
* **`script_hash` is nullable**, so the primary keys of `dbt_node`, `dbt_edge`
|
||
and `dbt_graph_snapshot` became two partial unique indexes each — versioned
|
||
rows keyed by their version, editor rows by their job alone. The composite
|
||
foreign key to `script` is unchanged: `MATCH SIMPLE` is satisfied by a NULL,
|
||
so a versioned row still cascades with its version and a version-less one is
|
||
outside its reach. A partial arbiter also has to be named, so the marker's
|
||
`ON CONFLICT` repeats `WHERE script_hash IS NOT NULL`.
|
||
* **Being outside that cascade, they need clearing by hand.** A route that
|
||
deletes the `script` rows outright reclaims the versioned graph through
|
||
`ON DELETE CASCADE` and deliberately locks nothing ahead of the script row;
|
||
a version-less row references nothing, so it would survive its own script.
|
||
The delete-by-path and bulk-delete routes therefore call
|
||
`clear_dbt_editor_graphs` — AFTER the delete, beside the retry state, since
|
||
every dbt writer takes the script row first and a sidecar taken ahead of it
|
||
deadlocks one of the pair. Archiving clears neither: it leaves the `script`
|
||
row, and both graphs still answer for finished runs.
|
||
* **No digest suppression.** A run's snapshot that matches the version's stores
|
||
nothing and reads the version's back; an editor parse always stores, because
|
||
the editor pins to its own job and a suppressed write leaves it nothing to pin
|
||
to — and its provenance label would then claim a parse that is not on screen.
|
||
* **Bounded per (path, PRINCIPAL), not by age**: the newest
|
||
`DBT_EDITOR_GRAPHS_KEPT` parses of one script by one identity keep their
|
||
graph, dropped as each refresh lands, since the ones before it are dead the
|
||
moment a newer parse arrives. The principal is load-bearing rather than
|
||
incidental — a preview's PATH is chosen by a caller who needs only `jobs:run`,
|
||
so a count bounded per path alone is a way to retire the graphs of whoever is
|
||
actually editing that script. `permissioned_as` is the execution principal the
|
||
queue derived, which is why `dbt_run_state` keys on it too. The instance-wide
|
||
age sweep every dbt run performs still catches one refreshed once and left.
|
||
|
||
A `parse` of a job that DOES name a deployed version — the scriptable form, from
|
||
a flow or the CLI — writes an ordinary per-run snapshot of that version instead,
|
||
suppressed when it agrees with the deploy. Either way it publishes no ownership:
|
||
a parse answers for the arguments it was given, so it can no more stand as what
|
||
the script owns than an overriding run can.
|
||
|
||
Which graph is on screen is stated rather than left to be inferred — "parsed
|
||
from the editor at 14:32" against "as of last deploy" — from
|
||
`dbt_graph_ingested_at` on the graph response. The two are drawn identically, so
|
||
without the label the ambiguity the explicit refresh removes would just move
|
||
into the editor.
|
||
|
||
A parse renders `profiles.yml` before dbt runs, so it needs a resolvable
|
||
warehouse and a misconfigured project fails a refresh the way it would fail a
|
||
run. That is useful early feedback, and the empty state says so.
|
||
|
||
Per DEPLOY, not per run: ten thousand runs of one version share one graph. The
|
||
rows carry a composite foreign key to `script (workspace_id, hash)` with
|
||
`ON DELETE CASCADE`, so a version's graph dies with the version and nothing has
|
||
to sweep it.
|
||
|
||
The routes that hard-delete a script rely on exactly that for the VERSIONED
|
||
rows and clear none of them. Clearing them first would lock the sidecars ahead
|
||
of the `script` rows, the reverse of the order a publication takes — `script`
|
||
row `FOR UPDATE`, then the sidecars — and Postgres would abort one of the two
|
||
for deadlock. So anything the cascade cannot reach is cleared explicitly and
|
||
AFTER the delete, which keeps that order: `dbt_run_state` (keyed by path, no
|
||
script key), and the version-less editor graphs, whose NULL `script_hash`
|
||
satisfies the composite key without referencing anything (see "The third
|
||
provenance" above). `dbt_run_progress` (keyed by job, no key to either) is
|
||
reclaimed only by its age sweep.
|
||
|
||
Two consequences worth knowing:
|
||
|
||
* **Concurrent deploys no longer race for the graph.** Two versions write
|
||
disjoint rows, so neither can lose. `claim_graph_publication` survives only for
|
||
what is still keyed by PATH — the `asset` usage rows, of which there is one set
|
||
per script — and an older deploy finishing late now records its own graph
|
||
before declining to touch those.
|
||
* **A pinned request is scoped differently.** Unpinned, the endpoint scopes by
|
||
the relations in view, using `asset`. Pinned, `asset` is the wrong scope: it
|
||
describes the current deploy, so a model that version had and a later one
|
||
dropped would be filtered out of its own run's graph. The pinned version's
|
||
nodes are the scope instead.
|
||
|
||
## No cascade from dbt, and no pipeline membership
|
||
|
||
A finished dbt run does not trigger anything. Its models are recorded, drawn and
|
||
tracked; they do not fan out.
|
||
|
||
A dbt script is also not a pipeline member (`in_pipeline` is forced false for
|
||
`ScriptLang::Dbt` at deploy). It materializes warehouse tables, so it looks like
|
||
one, but that membership carries an editor whose premise is that you author the
|
||
transforms in it — and a dbt project is authored in a local `dbt run` / `dbt
|
||
test` loop, with Windmill as the runner and the viewer. Enrolling it put a dbt
|
||
project inside the pipeline editor and blurred which of the two a folder holds.
|
||
Its models are `dbt://` assets in the shared graph regardless: that is what
|
||
puts a native script reading one of them on the same node, and it is independent
|
||
of pipeline membership.
|
||
|
||
dbt already orders its own DAG, so a cascade would only ever add one thing:
|
||
waking a Windmill script that reads a mart. That edge is real but narrow, and
|
||
only half of it exists — nothing outside dbt can declare a `dbt://` write
|
||
(`// materialize` accepts DuckLake targets only), so the reverse direction, an
|
||
ingestion script waking a dbt project, cannot be expressed at all.
|
||
|
||
Against that, dispatching correctly from dbt is not cheap. A run's `select` can
|
||
build any subset of the project, so the deploy-time write set is not what ran;
|
||
using it wakes consumers of relations the run never touched, and narrowing it
|
||
needs a per-job record of what was built, which the per-relation state table
|
||
cannot supply (it keeps one row per relation, stamped with the last writer).
|
||
|
||
So dbt materializes and reports, and `asset_dispatch` returns early for
|
||
`ScriptLang::Dbt`. A `# on dbt://<mart>` subscription is refused outright at
|
||
deploy rather than accepted and left dormant — an edge drawn on the canvas that
|
||
can never fire is worse than an error saying so.
|
||
|
||
A plain READ still renders the consumer beside the model, which is what makes
|
||
the lineage one graph — but it is written in the script's own code, not in a
|
||
comment: the body parsers resolve an asset URI from a string literal
|
||
(`parse_asset_syntax`), so `"dbt://<resource>/<schema>/<name>"` appearing in a
|
||
Python, TS/Bun/Deno, DuckDB or Ansible script is the read. Those four are the
|
||
languages with a body-asset parser; the native warehouse ones (snowflake,
|
||
bigquery, postgresql, mysql, mssql) declare no assets at all today, so a mart
|
||
they consume joins the graph only once that inference exists. Wiring the trigger up later means deciding what a
|
||
selective run should notify — that decision is the work, not the plumbing.
|
||
|
||
## Live per-model progress, and why only dbt-core 1.x has it
|
||
|
||
`DbtEngine::emits_node_events()` is true for `dbt-core-1x` alone, so only 1.x
|
||
moves nodes on the run-page graph while it builds. The other two engines settle
|
||
every relation at the end instead.
|
||
|
||
That is a statement about **where** the engines put their events, not about
|
||
whether they produce them. Both Rust engines emit exactly the structured node
|
||
events the tailer parses:
|
||
|
||
```
|
||
$ dbt-sa-cli build --log-format json # and likewise the fusion binary
|
||
{"info":{"name":"NodeStart"},"data":{"node_info":{
|
||
"node_status":"started","unique_id":"model.probe.m3",
|
||
"node_relation":{"relation_name":"windmill_dbt_runtime.probe_sch.m3", ...}}}}
|
||
```
|
||
|
||
Measured on 2.0.0-alpha.5 and fusion 2.0.0-preview.202, a three-model project:
|
||
15 node events each on the console, 0 in the file log. `--log-format-file json`
|
||
is accepted by both — `json` is a listed value — and ignored: the file is text
|
||
either way.
|
||
|
||
The events are therefore only on stdout, which is the human-readable job log.
|
||
Taking them would mean setting `--log-format json` and rendering the log
|
||
ourselves from each event's `info.msg`, so the run's log stays readable. That
|
||
buys live progress on two pre-release engines at the price of permanently owning
|
||
log presentation, to work around something upstream has already declared it
|
||
intends to support. Not worth it: when either engine honours
|
||
`--log-format-file json`, flipping `emits_node_events()` is the whole change,
|
||
and the existing tailer starts working untouched.
|
||
|
||
A finished run is unaffected on every engine — it is coloured from the run's own
|
||
result, not from these events (decision 11's note on `run_progress`).
|
||
|
||
## Where the dbt project lives
|
||
|
||
**In Windmill.** One dbt project is one Windmill script: the script's content is
|
||
the descriptor, and the project's files ride with it as its module bundle, a
|
||
path-keyed map the worker materialises into the job directory before invoking
|
||
dbt. There is one way to do this. Nothing is cloned, so there is no repository
|
||
resource, no ref, no commit and no clone cache.
|
||
|
||
A team whose repository must stay canonical keeps it: git-sync points at that
|
||
repository and pushes it into the workspace, so the repository still holds the
|
||
truth and Windmill receives the project. A team with no repository at all pushes
|
||
straight from a working copy.
|
||
|
||
### On disk, the project is a canonical dbt project
|
||
|
||
`wmill sync pull` writes the bundle verbatim, so the tree under the module folder
|
||
is exactly what dbt expects, with the extensions dbt expects:
|
||
|
||
```
|
||
f/analytics/
|
||
└── analytics__dbt/ the module bundle: the project, unmodified
|
||
├── wm_dbt.yaml the descriptor (the script's content) — OPTIONAL
|
||
├── dbt_project.yml
|
||
├── packages.yml
|
||
├── models/staging/stg_orders.sql
|
||
├── models/marts/_marts__models.yml
|
||
├── macros/cents_to_dollars.sql
|
||
├── seeds/country_codes.csv
|
||
└── snapshots/orders_snapshot.sql
|
||
```
|
||
|
||
Import is therefore a copy, never a transformation:
|
||
|
||
```
|
||
cp -r my-dbt-project/. f/analytics/analytics__dbt/
|
||
wmill sync push
|
||
```
|
||
|
||
The descriptor is optional, and nothing above it is authored: an unmodified dbt
|
||
project is already a complete Windmill script, running the whole project against
|
||
the workspace's default warehouse. `wm_dbt.yaml` appears only when the project
|
||
needs something Windmill-specific — run arguments, a named warehouse, an engine
|
||
pin — and it lives inside the project so that a dbt developer's working copy and
|
||
a Windmill bundle are the same directory.
|
||
|
||
Locally, dbt runs against the bundle with `--project-dir analytics__dbt` (or a
|
||
`cd`), which is what a monorepo holding several dbt projects already does, and
|
||
what dbt Cloud exposes as its "project subdirectory" setting.
|
||
|
||
**Why a module bundle rather than one script per model.** Models as scripts was
|
||
considered and rejected on three counts. A Windmill path admits no dots, and the
|
||
CLI rejects bare `.sql` as ambiguous (`.pg.sql`, `.duckdb.sql`, … are the
|
||
convention), so a model could only be typed by its location inside the project,
|
||
which breaks the rule that extension determines language. dbt resolves `ref()`
|
||
project-wide and cannot run a model alone, so each model job would reassemble
|
||
and reparse the whole project anyway. And `schema.yml` describes many models at
|
||
once, so splitting models into objects while their tests and docs stay in shared
|
||
YAML puts a model's contract in a different object. The bundle keeps the project
|
||
whole, and per-model execution is offered as an action on the graph node
|
||
(`--select <model>+`) rather than as a separate object.
|
||
|
||
What that costs, stated plainly: a model has no permissions or version history of
|
||
its own. The unit of both is the project.
|
||
|
||
### Consequences
|
||
|
||
**The version is the script version.** Deploying the script deploys the project
|
||
atomically; rollback is redeploying a previous version. The lockfile keeps the
|
||
resolved engine and adapter versions and the manifest digest.
|
||
|
||
**Windmill holds the files, so the graph can show them.** A model's compiled SQL
|
||
is readable from its node in the asset graph. A dbt project has its own editor —
|
||
the file tree, the descriptor, the run arguments and the model graph, which is
|
||
the artifact's actual shape — but it is not where a dbt project is developed:
|
||
that is a CLI loop against a local warehouse (`dbt run --select`, `dbt test`).
|
||
Windmill is the runner, the viewer and the place a project is corrected.
|
||
|
||
**Two scripts against one project means two copies.** Splitting a project across
|
||
scripts, so an upstream selection and a downstream one compose, assumed a shared
|
||
repository. With bundles they would duplicate the project and drift. Prefer one
|
||
script per project with per-run `select`, and treat two scripts as two projects
|
||
(decision 6).
|
||
|
||
**Seeds are the only thing that can bloat a version.** Measured on real dbt code,
|
||
`.sql` files run about 500 bytes median and 1.9 KB at p90, so even a 5000-model
|
||
project is a few MB before compression. A single committed CSV can exceed all of
|
||
it, so the CLI drops any file over 5 MB from the bundle and says which, rather
|
||
than counting models.
|
||
|
||
**Only text is carried.** A dbt project's authored files are text; a binary one
|
||
(an image under `docs/`, a stray `.DS_Store`, a parquet seed) is skipped with
|
||
the reason. Left in, it would be read as mojibake and, if it carried a NUL,
|
||
rejected by Postgres with an opaque `unsupported Unicode escape sequence`.
|
||
Binary is detected the way `git` does it, by a NUL in the first 8000 bytes,
|
||
because `docs/` and dotfiles do not follow extensions. The push, the staleness
|
||
hash and the sync diff share one predicate: a file one drops and another keeps
|
||
is a change no push can resolve.
|
||
|
||
**Secrets are not carried.** `.env`, `.env.*` and `.envrc` are skipped with the
|
||
reason. The import above copies whatever the checkout holds, and what a
|
||
`.gitignore` was keeping out of the repo is exactly the file that must not
|
||
become a script version, readable by anyone who can read the script and handed
|
||
back on every pull. dbt does not read them either — `env_var()` takes the
|
||
process environment, which Windmill fills from the descriptor's `env` and the
|
||
script's environment variables.
|
||
|
||
**`dbt_project.yml` is rendered before it is read.** dbt allows `env_var()` in
|
||
that file, so a project may name its profile or its packages directory through
|
||
one. Windmill renders those two settings against the environment the run gives
|
||
dbt before acting on them: reading the template instead leaves a rendered
|
||
`profiles.yml` keyed under a name dbt never looks up, and a package cache
|
||
watching a directory `dbt deps` never fills.
|
||
|
||
## The script artifact
|
||
|
||
New `ScriptLang::Dbt`. Content is a YAML descriptor whose field names track dbt's
|
||
and Cosmos's vocabulary so the mental model ports without translation:
|
||
|
||
```yaml
|
||
engine: dbt-core-1x # or dbt-core-2x | fusion
|
||
profile:
|
||
warehouse: main # a warehouse configured on the workspace, by
|
||
# name; omitted takes `main`
|
||
target: prod
|
||
# schema: marts # target schema; REQUIRED for BigQuery, whose
|
||
# resource is a service-account JSON with no
|
||
# dataset in it
|
||
# profiles_yml: profiles.yml # alternative: keep your own file; it then
|
||
# names a warehouse only to say where its
|
||
# assets belong (see below)
|
||
select: ["tag:nightly+"]
|
||
exclude: []
|
||
test_behavior: build # build | after_all | none
|
||
vars: # typed: numbers/bools/lists keep their type,
|
||
run_date: "{{ run_date }}" # and string leaves take job arguments
|
||
strict: false
|
||
threads: 8
|
||
full_refresh: false
|
||
env: # for the project's own `{{ env_var() }}`
|
||
DBT_PASSWORD: $var:u/rf/wh_password
|
||
```
|
||
|
||
`env` values spelled `$var:<path>` are resolved to that Windmill variable, so a
|
||
project keeping its own `profiles.yml` never needs a credential written into the
|
||
descriptor — which is versioned script content. Both this map and the script's
|
||
own environment variables apply to the deploy-time parse as well as the run, so
|
||
an `env_var()` feeding a schema, alias or `enabled` produces the same relation
|
||
in the stored graph and in the build either way. Prefer the descriptor's `env`
|
||
when the value belongs to the project rather than to one deployment of it: it is
|
||
versioned with the descriptor, so a redeploy from git carries it.
|
||
|
||
`select`/`exclude`/`selector` are passed **verbatim** to dbt. Do not reimplement
|
||
the selector grammar; Cosmos's manifest path had to, and it is a recurring source
|
||
of divergence. One thing is decided before dbt sees them: a run that spells out
|
||
`select` or `exclude` drops the descriptor's `selector`, because dbt resolves
|
||
`--selector` *instead of* `--select` and passing both would silently build the
|
||
descriptor's nodes rather than the ones the run asked for. "Spells out" means
|
||
DIFFERS from the descriptor's own value, not merely "was submitted": the
|
||
generated run form posts a default back for every field left untouched, and a
|
||
selector descriptor's `select` default is `[]`, so reading a submitted `[]` as
|
||
an override dropped `--selector` from every run started from the UI, a schedule
|
||
or a webhook and built the whole project. A run that wants the whole project
|
||
despite the selector asks for it with a selection that differs — `["*"]`.
|
||
`select` and `vars` are overridable per run via job args. The **graph** stays the
|
||
deployed descriptor's: asset rows are written at deploy, like every other
|
||
language's, so a run-arg override changes what gets built without changing what
|
||
the graph says the script owns. Split the project into several scripts
|
||
(decision 6) when the graph itself should differ.
|
||
|
||
A `vars` override does re-ingest — vars steer `enabled`, aliases, schemas and
|
||
materializations, so the deployed graph would name another run's relations — but
|
||
under the job id alone, never as what the script owns: publishing an override's
|
||
relations would leave them recorded for the next default run, which then builds
|
||
the descriptor's while the graph shows the override's. See "Which run's graph
|
||
becomes what the script owns" for the whole table, including the profile move
|
||
that is the one cause a run publishes.
|
||
|
||
`vars` interpolates from job args with `interpolate_template` (`common.rs`,
|
||
shared with the Ansible executor). The syntax is `{{ arg_name }}`.
|
||
|
||
`select`/`exclude` also scope **what the script owns in the graph**, resolved by
|
||
asking dbt (`dbt ls --output json`) rather than by interpreting the selector
|
||
string. Without that a narrowly-selected script registers as the producer of
|
||
every model in the project, and two scripts splitting one project would each
|
||
claim all of it. Running several scripts with different selections only composes
|
||
because of this.
|
||
|
||
## Deploy path
|
||
|
||
New `ScriptLang::Dbt` arm in `worker_lockfiles.rs` (near the `ScriptLang::Ansible`
|
||
arm at :2758), producing:
|
||
|
||
```rust
|
||
struct DbtDependencyLocks {
|
||
manifest_digest: String,
|
||
engine: String,
|
||
engine_version: String,
|
||
adapter_version: Option<String>,
|
||
package_lock_digest: Option<String>,
|
||
profile_relation_root: Option<String>,
|
||
}
|
||
```
|
||
|
||
Steps: write the script's modules into the job directory, `dbt deps`, `dbt parse`
|
||
for the manifest, then ingest.
|
||
|
||
Ingestion writes the rows the native parser writes, via
|
||
`replace_static_asset_usage` (`windmill-common/src/assets.rs:254`) into
|
||
`asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, columns)`.
|
||
The language dispatch point is `parse_assets_for_lang`
|
||
(`windmill-api-scripts/src/asset_inference.rs:33`).
|
||
|
||
**The one architectural wrinkle.** Every other language's asset parsing there is a
|
||
pure function of script content. dbt's needs the bundle on disk and a dbt
|
||
invocation, so it cannot run inline: it runs as a deploy-time job, persists the manifest, and
|
||
`parse_assets_for_lang` reads the persisted result. **Prototype this first**, it
|
||
is the assumption most likely to reshape the phasing.
|
||
|
||
### Dependencies resolve at deploy, and are pinned for every run
|
||
|
||
A project declaring `packages.yml` ranges or a mutable git revision asks dbt to
|
||
*resolve* them, and dbt re-resolves on every `dbt deps`. Windmill resolves once, at
|
||
deploy, and pins the result — the same contract every other language's lockfile
|
||
gets here.
|
||
|
||
The deploy records the digest of the `package-lock.yml` dbt produced into
|
||
`DbtDependencyLocks`. That digest keys the worker-local package cache and joins the
|
||
run identity that gates `dbt retry`. A run restores the tree under that key; a worker
|
||
that resolves anything else is refused rather than run, because accepting it would let
|
||
one resolution's `run_results.json` decide what a retry rebuilds.
|
||
|
||
Only a run has a resolution to be held to. The deploy establishes one and accepts
|
||
whatever dbt returns — including a `package-lock.yml` dbt rewrites itself, which it
|
||
does whenever the `sha1_hash` it stored for `packages.yml` no longer matches. Holding
|
||
the deploy to a committed lock would refuse the first deploy after a package is added,
|
||
with no way out, since redeploying resolves the same way again.
|
||
|
||
Consequences worth knowing before choosing whether to commit a lockfile:
|
||
|
||
- **To pick up a newer version of a ranged dependency, deploy a CHANGE.** Only a
|
||
deploy re-resolves, and an unchanged push is skipped as a no-op — the lock and the
|
||
schema are both derived, so they are compared as they would be stored rather than
|
||
as they arrive. Editing `packages.yml`, or committing the `package-lock.yml` you
|
||
want, is what moves a pinned resolution.
|
||
- **A committed `package-lock.yml` lets a deploy hit the cache**, since it is the
|
||
digest the lookup is keyed on before `dbt deps` has run. A project without one, or
|
||
whose committed lock is not what dbt resolves, pays a real `dbt deps` per deploy.
|
||
This is dbt's own recommendation for the same reason.
|
||
- **The refusal is per worker, not per script.** `dbt deps` writes its lock into the
|
||
job directory, so a project with a range and no committed lock reproduces its
|
||
resolution only from a cache hit. Once upstream publishes a new version, that
|
||
script keeps running on every worker already holding its tree and fails on the
|
||
first cold one — same commit, same arguments, different outcome by worker. A
|
||
deploy that changes something re-pins and clears it; committing the lock avoids it
|
||
entirely.
|
||
|
||
Nothing evicts these worker-local caches — package trees, engine installs and retry
|
||
state alike — and `cache_clear` does not reach them either: it removes
|
||
`$WINDMILL_DIR/cache/`, while all three live under `$WINDMILL_DIR/cache_nomount/`,
|
||
as bun's cache does. An operator reclaims them by deleting that directory. Engine
|
||
installs dominate the space by two orders of magnitude (~270–290 MB each, bounded
|
||
by engine version) and package trees grow one tree per edit of a project that
|
||
declares packages. Sweeping either on an age or a size bound is follow-up work.
|
||
|
||
## Run path
|
||
|
||
One `dbt build` per job, the shape Cosmos arrived at with `ExecutionMode.WATCHER`
|
||
after per-model Airflow tasks proved roughly 6x slower (about 5.5 minutes for one
|
||
`dbt run` versus about 32 minutes for 184 per-model invocations on
|
||
google/fhir-dbt-analytics). dbt's own threading provides parallelism; Windmill
|
||
provides observability.
|
||
|
||
### The run's arguments are one command block
|
||
|
||
A run takes a single `command` argument, plus one argument per `{{ placeholder }}`
|
||
the descriptor interpolates. `command` is a `oneOf` whose variant IS the command,
|
||
so it carries exactly the overrides that command takes:
|
||
|
||
```jsonc
|
||
{"command": {"label": "build", "select": [], "exclude": [], "vars": {}, "full_refresh": false}}
|
||
{"command": {"label": "retry", "dbt_retry_job": "019fb410-8ea9-…"}}
|
||
{"command": {"label": "show", "model": "stg_orders", "vars": {}, "limit": 100}}
|
||
{"command": {"label": "parse", "vars": {}}}
|
||
```
|
||
|
||
`show` and `parse` are accepted by the worker but are not run-form variants:
|
||
each is a thing to do to the project in front of you rather than a job to fill a
|
||
form in for, and the graph, the assets list and the dbt editor are where they
|
||
live. Both stay reachable from a flow, the CLI and the API, which is what makes
|
||
the editor's refresh scriptable and testable rather than a UI-only affordance.
|
||
|
||
The union is the point: `dbt_retry_job` is required where it means something and
|
||
absent everywhere else, `show` takes the ONE model it previews rather than the
|
||
`select`/`exclude` pair that narrows a build, `full_refresh` cannot reach a
|
||
command that ignores it, and
|
||
the run form renders a toggle over the variants rather than a list of fields that
|
||
quietly do nothing. The worker spreads the block over the run's arguments to read
|
||
them — `label` becomes `dbt_command` — which is why a `{{ placeholder }}` may not
|
||
take one of those names (`RESERVED_ARG_NAMES`). What a run SUBMITTED keeps the
|
||
block, since that is what `dbt_run_state` saves and `invocation_args` publishes.
|
||
|
||
1. Materialise the script's modules into the job directory, restore
|
||
`dbt_packages/` from cache.
|
||
2. Render `profiles.yml` from the resource, or use the project's own file with
|
||
Windmill secrets injected as env vars for `{{ env_var() }}`.
|
||
3. `dbt build --log-format json` plus `select`/`exclude`/`vars`/`threads`.
|
||
4. Stream events: each `NodeFinished` updates per-model status live and emits
|
||
`RecordMaterializationRequest` (`windmill-common/src/materialization.rs:53`),
|
||
which already carries `asset_kind`, `asset_path`, `partition`, `status`,
|
||
`row_count`, `job_id`, `error`, `schema`. `run_results.json` supplies all of it.
|
||
5. Structured job result (per-model status, timing, rows, failed tests), not just
|
||
an exit code. Partial failure is dbt's normal case and must be legible without
|
||
reading logs.
|
||
6. **Node-level retry.** `retry_failed_nodes: {attempts, delay_seconds}` in the
|
||
descriptor rebuilds only what a failed build left failed or skipped, in the
|
||
same job, before reporting failure. dbt confines a failure to its own
|
||
subtree, so a transient warehouse error costs those nodes rather than the
|
||
project. In-job is what keeps the state question out of it: the previous
|
||
attempt's `run_results.json` is still in the job directory, so there is
|
||
nothing to persist and no worker to land back on. This is the granularity
|
||
astronomer-cosmos gets from one Airflow task per model, without the ~6x that
|
||
per-model tasks measured (decision 4).
|
||
|
||
A retry's `run_results.json` names only the nodes it redid, so it overlays
|
||
the accumulated results rather than replacing them: the job's result must be
|
||
every node the job touched, or the nodes that succeeded before the retry
|
||
settle no materializations.
|
||
|
||
7. `dbt retry` resumes from the failure point using `run_results.json`, which is
|
||
what makes one-job-per-invocation defensible. It is saved twice: to the
|
||
worker's local cache, and to `dbt_run_state` in the database, so a retry
|
||
works from any worker with a database connection. An agent worker reaches
|
||
the database only through the API, which does not expose this, so it keeps
|
||
only its own local copy; the automatic node retry is refused there for the
|
||
same reason, since its wait could not observe a cancellation. Only `run_results.json` is stored there.
|
||
`dbt retry` also needs `manifest.json`, roughly sixty times larger and
|
||
growing with the project (732 KB against 12 KB on a six-node fixture), but
|
||
the manifest is a pure function of the project files, vars and env — all of
|
||
which the stored identity already pins — so a worker restoring from the
|
||
database re-derives it with a `dbt parse` of about a second. It is a run
|
||
argument (the `retry` command variant) rather than the automatic behavior of
|
||
Windmill's generic retry, which has no per-language hook to change the invoked
|
||
command.
|
||
Each attempt gets a fresh job dir, so the previous run's `target/` is cached
|
||
per (workspace, script) on the worker and restored for a retry.
|
||
|
||
**Naming the run.** The `retry` variant requires `dbt_retry_job`, the id of
|
||
the run to resume, and the worker refuses one that is not the run it holds —
|
||
naming both ids, since "that is not the one" is otherwise indistinguishable
|
||
from "nothing is saved". Only the latest failure of a script is kept, so an
|
||
unnamed retry would mean "whatever failed last" and would quietly resume a
|
||
different run than the caller was looking at. The run page's `Resume this run`
|
||
and `Run again → dbt retry with same args` both fill it in; on the run form,
|
||
choosing `retry` prefills it with the run that caller's own retry would land
|
||
on. It is not a selector: naming a run other than the saved one is refused,
|
||
not resumed.
|
||
|
||
**Concurrency is the script's, not the retry's.** A retry that starts while
|
||
another run of the same script is in flight rebuilds nodes that run may also
|
||
be rebuilding — appending an incremental model twice. That is what two
|
||
concurrent `build`s of one project do as well: dbt takes no cross-process
|
||
lock, so a project that must not run twice at once sets the script's
|
||
concurrency limit, which covers its retries with it. A lock held across a
|
||
dbt execution instead would outlive worker deaths and cancellations.
|
||
|
||
**Who may resume it.** The state is keyed `(workspace, script_path,
|
||
permissioned_as)` — one saved run per script per identity it executes as — so
|
||
anyone entitled to run the script as that principal may resume its last
|
||
failure, which is the same capability as re-running that job: running the
|
||
script requires read access on it, and that access shows them the run and its
|
||
arguments already. Naming the run neither widens nor narrows that; the id is
|
||
checked against what the state holds, not against the caller.
|
||
|
||
For an `on_behalf_of` script that means every caller shares one saved run,
|
||
since they all execute as the owner — deliberately, since the state describes
|
||
the script's last run under the owner's identity rather than any one caller's.
|
||
|
||
**What a retry actually adds, and where that crosses a line.** Resuming grants
|
||
no capability a caller lacks: they may already run the script as that principal,
|
||
and a plain run builds everything the descriptor selects, of which a retry
|
||
rebuilds a subset. The one thing it adds is information — the result carries
|
||
`invocation_args`, the resumed run's arguments as SUBMITTED, because a retry
|
||
job's own args are just the command and the run it names, and the row preview
|
||
needs the real ones.
|
||
|
||
For almost every shape the caller could already read that run, so nothing
|
||
crosses: under `f/`, `see_folder_extra_perms_user` makes a job readable to
|
||
everyone with read on the folder, which is also what grants execution; an
|
||
ordinary script's runs are `permissioned_as` the caller, and another user's runs
|
||
are keyed under their own principal and unreachable. It takes all three of a
|
||
`u/<owner>` path, `extra_perms` sharing and `on_behalf_of` for "may run as this
|
||
principal" to be broader than "may read this principal's jobs" — and there a
|
||
grantee learns the literal argument values of another's run. References stay
|
||
references, so no resolved secret is among them.
|
||
|
||
That residual is accepted rather than gated. A gate needs the caller's identity,
|
||
and a worker has only `created_by` — `display_username()`, which a token LABEL
|
||
supplies. Resolving it as a username denies every labelled token (a CI token
|
||
becomes `label-<name>`, which is no workspace member) and still trusts a name;
|
||
authorizing where the caller is real means the submitting path, not the worker.
|
||
The exposure did not justify either.
|
||
|
||
That equivalence holds only while the run is READABLE, so the one run that
|
||
breaks it saves nothing: a job pushed `invisible_to_owner` is hidden from the
|
||
script's owners, and a retry publishes the arguments it restored, which would
|
||
make that retry the one way to see them. A hidden run therefore keeps no
|
||
retry state at all — it cannot be resumed by anyone, including whoever
|
||
launched it, which is the cheaper half of the trade. Keying by the initiating
|
||
caller would not have worked instead: `created_by` is `display_username()`,
|
||
which a token LABEL supplies, so two callers can share one value and one can
|
||
name a third person (GHSA-8x8x-88qc-qp4r, whose fix was to stop trusting that
|
||
name for authorization). A retry does name the run it resumes, but that name is
|
||
checked against the saved state rather than authorized as a job read — doing
|
||
the latter, which is what would let a hidden run be resumed by its own author,
|
||
needs the submitting path, where the caller is real.
|
||
8. Test failures honor dbt's `severity`: `error` fails the job, `warn` surfaces
|
||
without failing. Overriding this would make the same project behave differently
|
||
on Windmill than locally, breaking the core promise.
|
||
|
||
## Two decisions the implementation narrowed
|
||
|
||
**Decision 13 — no S3 copy of the manifest.** The sidecar holds every field the
|
||
graph renders; nothing reads a stored `manifest.json`, so writing one to S3
|
||
would be an unread copy of data that is already reproducible by redeploying (or,
|
||
for a dynamic descriptor, by the next run). Worth adding the day something needs the
|
||
parts the sidecar drops — compiled SQL, macro definitions — and not before.
|
||
|
||
**Decision 14 — column lineage is not available.** The decision assumed
|
||
`manifest.json` carries column-to-column edges; it does not, in either core
|
||
engine. What it does carry is declared column *descriptions*, which are
|
||
ingested. Real column lineage would need Fusion (which does static analysis) or
|
||
a SQL-AST pass of our own, so `columnLineageGraph.ts` is not wired up for dbt.
|
||
|
||
## Concept mapping
|
||
|
||
| dbt | Windmill | Mechanism |
|
||
|---|---|---|
|
||
| model relation | `dbt://` asset | new `AssetKind` |
|
||
| `ref()` graph | lineage edges | `replace_static_asset_usage` |
|
||
| `materialized: table` | `materialize_strategy: replace` | `AssetGraphRunnableNode` |
|
||
| `materialized: incremental` | `append` or `merge` (by `unique_key`) | same |
|
||
| `{% snapshot %}` | `scd2` | same, incl. `<dim>_current` handling |
|
||
| `unique`/`not_null`/`accepted_values`/`relationships` | `data_tests` | exact 1:1 with the four `// data_test` kinds |
|
||
| declared column metadata | `columns` on the asset node | descriptions only; see the note below |
|
||
| model `tags` | node badge | `tag` |
|
||
| source freshness | `freshness` | `last_success_at` chip |
|
||
| `run_results.json` | materialization records | `record_materialization` |
|
||
| `dbt_packages/` | worker-local cache | keyed by `packages.yml`, the project digest and the `package-lock.yml` the deploy resolved |
|
||
|
||
## Phases
|
||
|
||
**Phase 1: run it.** `ScriptLang::Dbt` across the 41 `ADD_NEW_LANG` sites (mostly
|
||
one-liners in `EditorBar.svelte`, `scripts.ts`, `script_helpers.ts`,
|
||
`LanguageIcon.svelte`, `script_common.ts`). Engine provisioning for all three
|
||
options in `Dockerfile` and `docker/DockerfileFull*` (bundle 1x and 2x, fetch
|
||
Fusion at runtime). New `backend/windmill-worker/src/dbt_executor.rs`: descriptor
|
||
parse, bundle materialisation, `profiles.yml` render, `dbt build`, log
|
||
passthrough, structured result, retry.
|
||
|
||
**Phase 2: graph.** `DbtDependencyLocks` and the deploy arm. Migration via
|
||
`cargo sqlx migrate add -r dbt_runtime`. New `dbt://` `AssetKind` with
|
||
its `canonical_prefix`. Manifest ingest. Deploy-time ingest plus the per-run
|
||
re-ingest for dynamic descriptors. Extend `AssetGraphRunnableNode`/`AssetGraphAssetNode` in
|
||
`frontend/src/lib/components/assets/AssetGraph/types.ts` with dbt provenance and
|
||
render through the existing `RunnableNode.svelte` / `AssetNode.svelte` /
|
||
`DataTestNode.svelte`.
|
||
|
||
**Phase 3: live progress and ergonomics.** JSON event stream to per-model status
|
||
on the canvas mid-run. `record_materialization` per model. Profile and select
|
||
pickers in the editor. Per-model failure triage in the run view.
|
||
|
||
**Phase 4 (not in this PR).** `--defer` and `state:modified`. Partition and
|
||
backfill integration so `BackfillRangeDialog.svelte` works on dbt models.
|
||
`wmill dbt import <dag.py>` reading `DbtDag(...)` kwargs.
|
||
|
||
## E2E test requirements
|
||
|
||
Against a real dbt project (jaffle_shop shape) and the local Postgres:
|
||
|
||
1. **Happy path**: deploy a dbt script, run it, assert models exist in the
|
||
warehouse and the job succeeds with a structured per-model result.
|
||
2. **Engine parity**: the same project passes on `dbt-core-1x` and `dbt-core-2x`.
|
||
Fusion covered by a manually-run test, not CI (runtime fetch).
|
||
3. **Test severity**: a failing `error`-severity test fails the job; a failing
|
||
`warn`-severity test does not.
|
||
4. **Retry**: a run failing midway, retried, resumes via `dbt retry` and does not
|
||
rebuild already-successful models.
|
||
5. **Graph ingest**: after deploy, model assets and `ref()` edges exist; a native
|
||
script reading one of the marts gets an edge to it.
|
||
6. **Shared node**: a native script that READS a mart renders as a reader of the
|
||
same node the dbt model writes — one node, not two islands. Declared with a
|
||
plain read (`# dbt://<mart>`), never `# on`: a `dbt://` subscription is
|
||
refused at deploy, because nothing but dbt writes a warehouse relation and a
|
||
dbt run does not dispatch (see "no cascade from dbt").
|
||
7. **Selection**: descriptor `select`/`exclude`, and a run-arg override, each
|
||
build only the expected subset.
|
||
8. **Dynamic descriptors**: a `{{ }}` placeholder in `vars` re-ingests the graph
|
||
from the run's own manifest, so a model that placeholder enables appears in
|
||
the same run that builds it.
|
||
9. **Both credential paths**: resource-rendered `profiles.yml`, and the project's
|
||
own `profiles.yml` with env-var injection.
|
||
10. **Caching**: a second run reuses the cached `dbt_packages/` with no network
|
||
fetch.
|
||
|
||
Keep only tests that pin behavior a future change could break. Per AGENTS.md,
|
||
delete development scaffolding before marking the PR ready.
|