mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
main
1 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
032300e28e |
feat: run dbt projects as a first-class Windmill runtime (#10326)
* fix: mount only the engine in the dbt jail, reject shadowed and malformed args
Review round 42.
The jail mounted the whole dbt cache directory, whose siblings of the
engine are `repos/` and `packages/` — other workspaces' private checkouts
and package trees, kept apart by cache key rather than by permissions. A
jailed project could read them. It now mounts the engine's own directory,
which the provisioner names; verified from inside the jail that `repos/`,
`packages/` and `state/` are invisible while the engine stays usable.
A `{{ placeholder }}` may no longer take the name of a run argument this
runtime defines. It was silently dropped from the signature, so a
descriptor like `value: "{{ select }}"` deployed and then could not be
run at all: the built-in `select` is an array and the interpolation needs
a scalar. Refused at parse, so the deploy says so.
A `vars` override that is not an object is refused rather than ignored.
Argument-schema validation is opt-in, so a string or an array silently
ran the descriptor's own vars — against a different schema or alias than
the caller asked for. `select` and `exclude` already refused theirs.
* feat(dbt): the project is the script's module bundle, not a git checkout
A dbt script now carries its whole dbt project as its module bundle. The
descriptor is the script content; `<script>__dbt/` holds the project verbatim,
so importing an existing project is `cp -r` plus `wmill sync push`, and the
worker materialises the bundle into the job directory instead of cloning.
Backend
- `prepare_project` writes the script's modules and requires `dbt_project.yml`
at the bundle root. `checkout`, the git-ssh command, the clone cache and the
repository resource are gone, along with `repo`, `project`, `ref` and
`git_ssh_identity` on the descriptor.
- Run identity and the package cache key take a `project_digest` (sorted SHA256
over the bundle) where the commit used to sit, so an edited project cannot
resume a previous run's `run_results.json` or reuse its `dbt_packages`.
- The per-run graph re-ingest is now gated on `vars` placeholders and `$var:`
env alone.
- `capture_dependency_job` takes the script's modules so a dependency job, which
has no generic module-writing step, materialises them itself.
- `dbt deps` caching strips the git remote from every package it cached, not
just the tree root: `packages.yml` can render a token into a `git:` URL.
- `git_clone.rs` is dropped and `ansible_executor.rs` returns to its own copy of
the clone helpers.
CLI
- `wmill sync pull` keeps a dbt script's lock beside its folder rather than
inside it, so the folder holds nothing but the project.
- Directories dbt generates (`target-path`, `packages-install-path`,
`clean-targets` and the usual defaults, read from `dbt_project.yml`) are
excluded from the bundle, from the sync diff and from staleness hashing.
- A module-only edit now pushes its parent dbt script and is reported as a
changed module rather than passing unnoticed.
* fix: keep a script's modules in the worker's file-system cache
The first fetch of a script version reads the database and carries its
modules; every later fetch imports from the worker's cache directory, whose
`RawScript::import` hard-coded `modules: None` and whose `export` never wrote
them. A worker restart therefore started running the script without its own
files, silently — for a dbt script, without its project, which fails with
"carries no project"; for any other script with a module bundle, with the
imports missing.
`modules.json` is now written on every export and required on import, so an
entry written by an older version fails to import and is refetched rather than
serving a stripped script for as long as the directory lives.
Also derives a dbt run's `project_digest` from the bundle the run actually
carries: `handle_dbt_job` was passing `None`, which collapsed every project in
a workspace onto one digest and let `dbt retry` resume a different project's
`run_results.json`.
* fix(dbt): give every phase the script's environment, bound the cache copies
`dbt deps` ran without the script's environment variables on an unsandboxed
worker, so a `packages.yml` resolving a private package URL through
`env_var()` could not see them while the package cache key was still built on
their digest. `with_invocation_env`, applied at three of the four call sites,
is folded into `dbt_command` so no phase can be added without it, and
`DBT_TARGET_PATH` is set after both environments rather than before.
The package cache copies ran through a bare `Command::output()`: the tree is
the project's, so a cancelled or timed-out job held its worker slot until `cp`
finished. Both the restore and the publish now run under the job poller like
every other phase.
* fix(dbt): only offer commands whose writes match the graph, honour packages-install-path
`dbt_command: run` is dropped from the allowed overrides. Asset dispatch fires a
script's deploy-time writes on any successful job, and `dbt run` covers models
only, so a project with seeds or snapshots notified consumers of relations the
invocation left stale. That is the same reason `test` was already excluded.
Narrowing what a run touches is `select`/`exclude`, which scope the graph too.
`dbt deps` writes to the project's `packages-install-path`, so a project that
moved it got no package cache at all: the publish found nothing at
`dbt_packages` and every job resolved its dependencies over the network again.
The path is read from `dbt_project.yml` and validated as project-relative,
since both cache copies are rooted at it.
Also states the sidecar's mutator contract at the module level: the dbt manifest
tables carry no RLS and grant `windmill_user` full access, so a user-scoped
transaction is not enforcement and every caller must have verified write access
to the script itself.
CLI: a module file is now grouped with its parent script for the push. Left in
a group of its own it got its own `alreadySynced`, so a push touching several
files of one bundle deployed the script once per file; the resulting versions
raced, and the asset graph could end up describing none of them.
* fix(dbt): seed a project for browser-created scripts, refuse a no-op retry
A dbt script created in the browser only got a descriptor, and the runtime
refuses a script whose bundle has no `dbt_project.yml`, so the advertised
Create → Deploy → Run path always failed its dependency job. New dbt scripts
now start with a project that builds: pointing `profile.resource` at a
warehouse is the one edit, and growing it is `wmill sync pull` plus a local
editor, which is where dbt development happens.
`dbt retry` builds its graph from the previous run's error, fail and skipped
nodes alone, so retrying an all-green run selected nothing and wrote nothing —
and a job that succeeds having written nothing still dispatches every
deploy-time write, waking every downstream consumer for relations no one
touched. Refused, with the reason.
CLI: a configured `target-path` or `packages-install-path` may be nested
(`build/target`), and `clean-targets` has a block form as well as an inline
one. Both are now parsed, and the exclusion compares the project-relative path
rather than the top-level segment, so a nested generated tree no longer lands
in the bundle and no longer makes a local `dbt run` look like a project change.
* fix(dbt): lock a project once, find the parent on either path separator
A dbt script's modules are its dbt project, not helper code with dependencies
of its own, so the generic per-module lock loop is skipped for it: the parent
lock already ran `dbt deps` and `dbt parse` over the whole project. Locking
each file separately re-materialised the bundle and re-invoked dbt once per
file, so a project of N files paid N project-sized passes and a large one timed
the deploy out. The 13-file fixture went from 14 relock passes to 1.
`pushParentScriptForModule` searched the raw path for `__dbt/`, so on Windows,
where the folder is spelled `__dbt\`, a module-only edit returned without
deploying its parent while the caller still recorded the file as synced. It now
goes through `getScriptBasePathFromModulePath`, which normalizes separators.
Also drops the last of the external-repository wording from the descriptor's
module docs and from the `codebase` rejection a user can hit.
* feat(dbt): infer the run form locally, keep test-only retries from cascading
`windmill-parser-wasm-yaml` 1.770.0 carries `parse_dbt`, so the browser and the
CLI derive a dbt script's run arguments from its descriptor instead of waiting
for the deploy to hand back a schema. Pins bumped in both.
`generate-metadata` was rewriting a dbt script's `lock` field on every run: a
dbt lock comes from the dependency job on a worker, so nothing generates it
locally and the resolved `!inline` reference was left inlined into the metadata
or blanked. It is restored instead, and a push straight after
`generate-metadata` is a no-op again.
A retry now needs a failed node that materialises something. `dbt retry` builds
its graph from error, fail and skipped nodes, and with `test_behavior:
after_all` a failing test is what `run_results.json` ends up describing — so the
retry reran tests, wrote nothing, succeeded, and still dispatched every
deploy-time write.
The dbt badge's destination is deterministic: writers outrank readers, and among
several writers of one relation (which the backend permits) the smallest id
wins, rather than whichever write edge arrived last.
* feat(dbt): browse the project and read a run's per-node result
Two views a dbt user expects and that the generic script surfaces do not give.
**The project.** A dbt script's editor gains a Project tab beside its
descriptor: the module bundle as the tree dbt itself expects, each file
read-only with syntax highlighting. The existing module tab strip is a flat row
built for a couple of helper files and does not survive a real project; a
13-file fixture already overflows it. Directories sort before files so it reads
like the checkout on disk, and an empty bundle explains the `cp -r` instead of
showing a blank pane.
**The run.** `DisplayResult` renders a dbt invocation's per-node breakdown above
the raw payload: totals, then a table of node, kind, target relation, rows and
time, with failures and warnings sorted first and carrying their message. The
data was already structured; it was being shown as JSON to scroll and PASS/WARN
counts to find in the log. On a failed run the same JSON rides in the error
message after the exit-status line, so it is parsed back out — that is the case
worth rendering, since the failing node is what the user came for.
* docs(dbt): say that profile.resource is what buys the asset graph
The starter descriptor described `profile.resource` as the thing rendered into
profiles.yml, with the project's own file as an equal alternative. It is not
equal: the resource PATH is the warehouse's identity in the asset graph, so a
project bringing its own profiles.yml runs fine and silently gets no assets, no
lineage and no cascade. The deploy already says so in its log; now the
descriptor a user starts from says it too, before they choose.
* fix(dbt): authorize a resource used only for asset identity, clean up after failed installs
A descriptor setting both `profile.profiles_yml` and `profile.resource` took
its connection from the project's file but returned the resource path as the
graph's warehouse identity without ever reading it. A script editor could
therefore publish `table://<any resource>/...` writes, and wake that
warehouse's subscribers, while connecting somewhere else. The resource is now
read on that path too — reading is what authorizes it — so the combination
keeps working for the case that wants it (keep your own profiles.yml, still get
lineage) and fails closed otherwise.
Provisioning cleaned up its staging directory only on the paths someone
remembered, so a run of failed or cancelled first-use installs accumulated
venvs, tarballs and installer scripts until the worker's disk was gone. All
three engines now hold their scratch paths in a guard that removes them on
drop, which is the one exit every path takes, cancellation included.
Frontend: `partial success` is dbt's word for a node that built but whose tests
failed, counted in `totals.error` and redone by a retry, so it ranks with the
failures instead of rendering green with its message hidden. And the run panel
now keys off the worker's engine discriminator rather than `{nodes, totals}`,
which is a shape an ordinary script can return. Both pinned by unit tests on
the extracted `parseDbtRun` helpers.
* feat(dbt): show a run's models on the run page
The run page is where you land on a running job, and until now it showed a dbt
run as streaming text: the per-node table only renders once the job has
produced a result, and the graph that moves per model lived on the pipeline
page you had to navigate to. A Models section now sits above the result,
scoped to the running script's own relations and its `ref()` lineage, polling
while the job is in flight so nodes move as dbt walks the DAG.
No `resolveGraph`: that merges drafts and live editor buffers into the
persisted graph, and a run page has neither.
* feat(dbt): retry failed nodes automatically, and from any worker
**Node-level retry, in the job.** `retry_failed_nodes: {attempts, delay_seconds}`
rebuilds only what a failed build left failed or skipped, before the job reports
failure. dbt confines a failure to its own subtree and `dbt retry` resumes
exactly that set, so a transient warehouse error costs those nodes rather than
the project. Doing it 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.
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 has to be every
node the job touched, or the nodes that succeeded before the retry settle no
materializations. Pinned by a test.
**Durable retry state.** `run_results.json` is now saved to `dbt_run_state` as
well as the worker's local cache, so an explicit `dbt_command: retry` works from
any worker of the group rather than only the one that failed. Only the results
are stored: `dbt retry` also needs `manifest.json`, roughly sixty times larger
and growing with the project (732 KB against 12 KB on the 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.
* fix(dbt): restore the sqlx cache, make retries cancellable and path-aware
**SQLx cache.** A `cargo sqlx prepare` deleted 750 entries, including the
enterprise queries CI needs under `SQLX_OFFLINE=true`, and the check that was
supposed to catch it reported zero losses because it was run from `backend/`
with a `backend/`-prefixed path, so its baseline was empty and it failed open.
All 750 are restored; the branch now adds 19 and deletes none, and
`SQLX_OFFLINE=true cargo check` passes.
**Retry backoff observes cancellation.** `canceled_by` is only written by the
job poller, which does not run between attempts, so re-reading it reported the
state as of the failed attempt and missed every cancel issued during the wait
— the whole window the check exists to cover. The wait now reads
`v2_job_queue.canceled_by` each second, and the job's deadline is honoured
before starting another dbt process.
**Retry state follows its script.** `dbt_run_state` is path-keyed like the
manifest sidecar but, unlike it, nothing regenerates it: a rename moves the row
so a resumable failure survives, while archive and delete clear it, so a script
later created at that path cannot inherit a stranger's failure and its
arguments.
**CLI.** `table` joins ducklake and s3object in the local graph's auto-trigger
kinds, matching `is_auto_trigger_kind` and the frontend's set; without it a
local graph and the generated docs omitted a cascade edge the deploy has.
* fix(dbt): carry only the project files a bundle can hold, and say what it drops
Exploring real and edge-case projects surfaced three frictions, all in the
import path a user hits first.
**A binary file broke the push, opaquely.** dbt projects carry images under
`docs/`, stray `.DS_Store` files and occasionally a parquet seed. Read as text
they become mojibake, and a NUL among them is rejected by Postgres with
`unsupported Unicode escape sequence` — which `wmill sync push` then reported as
success, exiting 0 with the script never created. Binary files are now detected
the way `git` detects them, by a NUL in the first 8000 bytes rather than by
extension, and skipped with the reason.
**The size guard the docs promised did not exist.** Now it does: 5 MB per file,
which only ever catches a committed dataset. Real dbt code is about 500 bytes
median and 1.9 KB at p90.
**Skipped files became a permanent phantom diff.** The push dropped them while
the sync diff still offered them, so every push reported changes no push could
resolve. One predicate now answers for the push, the staleness hash and the
diff.
Verified on a project with unicode filenames and content, CRLF endings, an
empty model, an ephemeral model, a disabled model, a `.md` docs block, an
extensionless README, six levels of nesting, a 7.6 MB seed and a PNG: it
pushes, round-trips byte-for-byte through pull, deploys to 7 dbt nodes and 6
`table://` assets (ephemeral and disabled correctly absent), and runs green.
* fix(dbt): resolve dbt-core against the adapter, settle partial success, unify status
**Adapters could not be provisioned.** The 1.x engine pinned `dbt-core` to a
fixed version independent of the adapter, but several adapters cap below it:
`dbt-mysql` at `~=1.7`, `dbt-oracle` and `dbt-databricks` below 1.12, and
`dbt-salesforce` has no package at all (it exists only inside Fusion). Those
projects failed at provisioning with a uv resolver dump. The install now asks
for a range and lets the adapter choose, and records what the resolver picked so
the lock pins a version that adapter can take.
The floor is the CLI this runtime invokes: resolving down to dbt-core 1.7
produced a working venv that then failed with `No such option '--target'`, which
is worse than not resolving. An adapter with no release in range now fails
naming itself and pointing at `dbt-core-2x` or `fusion`, instead of a resolver
dump. Salesforce is refused up front with the reason.
**`partial success` left a model stuck on `Running`.** It is dbt's word for a
node that built and then failed its tests, and it was already treated as a
failure when counting totals and deciding a retry — but the two sites that
settle the RELATION fell through to "says nothing", so the tailer's `Running`
was never replaced and a finished job showed a model still building. Six status
comparisons had drifted apart, two folding case and four not, while dbt-core 1.x
echoes the author's casing and 2.x uppercases; they are now one classifier.
**Agent workers.** The durable retry state and the cancellation poll both need a
database, which an agent worker reaches only through the API. The automatic node
retry is refused there rather than running a wait it could not interrupt, and
the docs say "any worker with a database connection" instead of overclaiming.
Also clears `dbt_run_state` when a path stops being a dbt script, and moves
`run_identity`'s contract onto `run_identity` from the digest helper below it.
* feat(dbt): show the transform behind a model on the run graph
The run page's graph carried a node for the script itself and drew every
relation as a bare table. Both were wrong for that page: the graph there is
already scoped to one script, so a node standing for it distinguishes nothing
(on the pipeline page it separates one project from another, which is why it
exists), and dbt's own DAG node is the model — the SQL and the relation it
writes are one thing, so a graph of relations alone leaves out what a reader
came to see.
The script node is dropped, and selecting a model now shows its SQL underneath
the canvas with its file path and materialization, read-only, the same view the
pipeline details pane gives.
* feat(dbt): move the graph with the run
The worker has always recorded a state per relation as dbt walks the DAG —
`running` when a model starts, `materialized` or `failed` when it ends — but
nothing rendered it: the graph response carries what a relation IS, not what a
particular run is doing to it, so the canvas had nothing to show and a running
job looked identical to a finished one.
`assets/run_progress/{job_id}` returns that state for one job, the run page
polls it beside the graph, and the asset node carries a spinner or its outcome.
Errors and retries need nothing extra: a failed node writes `failed`, and an
in-job retry rewrites the same row, so the node returns to `running` and on to
its new outcome by itself.
`materialized_partition` holds a relation's CURRENT state keyed by relation, so
filtering on `job_id` returns exactly what this run last touched — which is the
question a run page asks, and why a superseded older run shows nothing.
* feat(dbt): a dbt project is not a data pipeline
Deploying a dbt script marked it `auto_kind = 'pipeline'`, which enrolled it
in pipeline membership: the folder became a Pipeline entry on the home page,
the script folded into it, and `/pipeline/<folder>` opened a canvas holding
the project's whole model DAG next to the pipeline's own scripts. A folder
holding both then read as two projects in one editor, and the pipeline editor
offered to author transforms that are in fact authored in a local `dbt run`
loop and pushed as the script's bundle.
A dbt script is now never a pipeline member, and the pipeline canvas drops the
dbt script node. Its models stay, with their `ref()` lineage: the relations are
what a downstream pipeline script reads, and dropping them would break the
cascade from a dbt run — the point of giving dbt models `table://` identity.
Also drops a screenshot committed to this branch by accident.
* fix(dbt): authorize run_progress through the job, drop dbt from the local graph
`run_progress` read `materialized_partition` through `user_db` on the
assumption that RLS would scope the rows. That table has RLS disabled and no
policies, so any workspace member could pass a job id and read that run's
relation paths, row counts and error text. It now joins `v2_job`, which does
carry per-user policies, so a caller who cannot see the job sees nothing —
the same pattern `v2_job_completed` reads need. Verified as a plain member:
the old query returned 6 rows for another user's run, the new one returns 0,
while the job's owner still sees all 6.
The CLI's local graph still forced `in_pipeline` on every dbt script, so
`pipeline docs --local` and `pipeline dev` kept presenting a dbt project as a
pipeline the deploy no longer enrolls. It now skips them, matching the server.
A dbt descriptor has no asset parser locally, so nothing is lost: its models
come from the manifest the deploy derives.
Declares `run_progress` in openapi.yaml so the frontend uses the generated
client instead of a handwritten fetch; the generated `status` union also
replaces a hand-rolled string mapping.
* fix(dbt): drop the dbt node from the CLI's deployed pipeline views too
`pipeline dev` and `pipeline docs` (without `--local`) read `/assets/graph`
directly. That endpoint is asset-usage driven rather than membership driven, so
it returns a dbt script like any producer — and both commands render every
runnable, so a dbt project still showed up as a pipeline script there after the
local builder stopped emitting one.
`hideDbtRunnables` mirrors the frontend's projection of the same payload. It is
generic over the graph shape so the bounded-cascade view (`BCGraph`, a narrower
type over identical JSON) passes through without a cast.
The relations stay: they are what a downstream pipeline script reads, and the
node is what attributes them to a producer for every other consumer of the
endpoint, so the filter belongs in the views rather than the query.
* fix(dbt): narrow a selective run's cascade, settle the finished run graph
Review-round fixes.
A `select`/`exclude` run builds part of the project, but asset dispatch reads
the deploy-time write set for the whole script, so a run selecting one model
woke the subscribers of every other. Dispatch now intersects that set with the
relations the run actually recorded as materialized, scoped to dbt because it is
the only producer whose write set is decided per run. A run that recorded
nothing still dispatches everything, so an agent worker whose reconciliation
failed cascades as before. Verified both ways: `select: [extra_model]` no longer
wakes the `fct_orders` subscriber, and a full run still does.
`hideDbtRunnables` keyed its removal set on path alone while the graph keys
runnables by `(usage_kind, path)`, so a flow sharing a path with a dbt script
lost its node, edges and triggers too. Both copies now key on the pair.
The run graph never took a final reading when a job finished, so the last state
shown was whatever the tick before completion saw. Only `dbt-core-1x` streams
node events; the other engines record every relation during end-of-run
reconciliation, so their finished graph showed nothing until a reload.
`DbtNodeOutcome::Inconclusive` collapsed statuses the tally has to tell apart,
so two sites re-lowercased the status beside the classifier and `no-op` landed
in `totals.error` — a clean run reporting an error in its own result. Split into
Warn / Skipped / NoOp / Unknown so every site falls out of one match; `no-op` is
kept out of the retry set, which dbt spells as error / fail / skipped.
Also: reattach two doc comments to the items they describe, and correct the
engine-distribution table — only dbt-core-2x is baked into the images, 1.x is a
per-adapter venv provisioned on first use, and the default is compiled in rather
than an instance setting.
* fix(dbt): make the model chip inert where its project node is not on the graph
The canvas passed `onDbtSelect` unconditionally, so the chip always rendered
`cursor-pointer` and hover-highlighted — but the owner map is empty on both
graphs this feature added, since the run page carries no runnables and the
pipeline page hides the dbt node. The chip advertised a click that resolved to
nothing. It now takes its handlers only when the relation has an owner on this
graph, so it stays live on the surfaces that do show the project node.
`classify_status` and `DbtNodeOutcome` were `pub` in a private module with no
caller outside the file, unlike every neighbour.
* fix(dbt): take the cascade's write set from the run's own result
The previous narrowing read `materialized_partition`, which was wrong twice.
That table keeps one row per relation and the newest writer takes `job_id`, so
two overlapping runs over the same model erase each other's claim to it: the
earlier job would dispatch a subset of what it built, or none of it.
And an empty row set was read as "recording failed, dispatch everything" when it
is also a real answer. A `select` matching no model, or one resolving to tests
only, exits 0 having built nothing — and then woke every consumer of every model
in the project, which is the opposite of what the narrowing exists to do and is
reachable by a typo in a run argument.
The run now reports the relations it materialized in its own result, which is
immutable and per job. Absent means the producer said nothing (a job from before
the field, a non-dbt producer) and the whole deploy-time set dispatches as
before; present-and-empty means it built nothing and dispatches nothing.
Verified on all three: an unmatched selector builds nothing and wakes nobody, a
selector naming one unsubscribed model wakes nobody, and a full run wakes the
subscriber.
Also indexes `materialized_partition (workspace_id, job_id)` -- the run page
polls that shape every 2s and no existing index leads with `job_id` -- corrects
the selective-cascade section of the design doc, which still described the old
deploy-time behavior, and reattaches `buildLocalPipelineGraph`'s doc comment.
* docs(dbt): attach the CLI JSDoc to its function, correct the index rationale
The `hideDbtRunnables` JSDoc ended up documenting the type declared beneath it —
made while fixing the same mistake one function down.
The migration's comment credited the cascade with a `job_id` lookup that the
same commit replaced with a read of the job's own result. The run page's poll is
the only reader keyed on that column.
* fix(dbt): refuse graph publication for a removed script, allow test-only retries
An archived or hard-deleted script could still republish its graph: the
publication guard filtered `deleted` but not `archived`, and treated a missing
row as "nothing newer exists" rather than "nothing left to publish for". A
dependency job or dynamic run finishing after the removal put the asset,
provenance and subscription rows back with nothing left to clear them.
`dbt_command: retry` refused a run whose only failures were tests, which is
precisely what `test_behavior: after_all` produces. That restriction existed
because a successful job dispatched its whole deploy-time write set, so a
test-only retry would have woken every consumer for relations no one touched —
the cascade now dispatches what the run reports materializing, so it wakes
nobody and the restriction only blocked a legitimate retry.
* fix(dbt): gate run progress behind the job-read check, not RLS alone
The endpoint joined `v2_job` so RLS would decide visibility, which it does — but
`require_job_read_access` adds two things RLS does not: a scoped token's
`if_jobs:filter_tags` restriction, and the app-embed cutoff that stops untrusted
app JS from inheriting the viewer's broader job access. A scoped or embed token
could therefore read relation names, statuses, row counts and errors for jobs
the ordinary job endpoints deny it.
That helper is private to `windmill-api`, which depends on `windmill-api-assets`
rather than the reverse, so the endpoint moves to the job routes instead of the
check being duplicated. It is job-scoped anyway:
`/w/{ws}/assets/run_progress/{job_id}` becomes
`/w/{ws}/jobs/run_progress/{id}`, and the frontend follows the generated client.
* feat(dbt): a dbt run does not trigger downstream runs
dbt orders its own DAG, so a cascade only ever adds one thing: waking a Windmill
script that reads a mart. That edge is narrow, and only half of it can even be
expressed — nothing outside dbt can declare a `table://` write, since
`// materialize` accepts DuckLake targets only, so an ingestion script cannot
wake a dbt project.
Against that, dispatching correctly 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. The per-relation state table cannot supply one
(it keeps a single row per relation stamped with the last writer), and the
result field added for it made a run's own output carry the cascade's bookkeeping.
So `asset_dispatch` returns early for `ScriptLang::Dbt`, before the producer
gate. dbt still materializes, records per-model state and publishes its graph:
models, `ref()` lineage and live run progress are unchanged, and a
`# on table://<mart>` reader still renders beside the model it reads. It simply
does not fire. Wiring it up later means deciding what a selective run should
notify, which is the actual work.
Verified: a full run of a 6-model project succeeds and starts nothing, where it
previously triggered its subscriber; the run page still reports all 6 relations
and the folder graph still carries 14 tables and 8 ref() edges.
* fix(dbt): remove the cascade surface, settle stranded models, fix nested __mod
Stopping dispatch left its surface behind. `table://` was still an auto-trigger
kind, `persist_ingest` still derived subscriptions from a manifest's reads, and
the deploy still accepted `# on table://` — so the canvas drew cascade arrows
into scripts nothing could wake. All three are gone: the kind no longer derives,
the ingest only deletes rows earlier versions wrote, and the deploy refuses the
annotation with a message saying why rather than persisting a silent no-op.
`DescriptorTriggers` went with them; every field it parsed was cascade config.
A model marked `running` by the live tailer was never settled when the run did
not finish: reconciliation only revisits nodes `run_results.json` names, and a
cancelled or timed-out run has none for the model in flight, so the finished job
showed a relation building forever. It is now settled on every exit path.
Verified by cancelling a run mid-flight: 3 models `running` before, 3 `failed`
after, none stranded.
`getScriptBasePathFromModulePath` took the first matching suffix rather than the
outermost boundary, so `proj__dbt/models/legacy__mod/a.sql` resolved to
`proj__dbt/models/legacy`. dbt owns its directory names verbatim, so a folder
ending `__mod` is legal inside a project, and a module-only sync would have
looked for a descriptor that is not there and skipped the deploy.
* fix(dbt): colour a finished run's models from its own result
`materialized_partition` keeps one row per relation stamped with whichever job
wrote it last, so reopening a run showed only the models no later run had
touched since — down to none for an old run, which reads as a broken page rather
than as stale data. Reproduced: a 6-model run reported 6 relations, then a second
run rebuilt one shared model and the first reported 5.
A finished run already carries the answer. Its result lists every node with a
status, and the graph carries each asset's dbt `unique_id`, so the two join
directly — no path derivation, nothing stored twice, and nothing a later run can
overwrite. The endpoint stays for the live window, where the result does not
exist yet, and as the fallback for a run that never produced one (cancelled or
killed, whose relations the worker settles in the table instead).
`relationOutcome` mirrors the worker's `classify_status` so the colour drawn over
a record agrees with the record: `warn`, `skipped` and `no-op` leave the relation
untouched and stay uncoloured, as do tests and analyses, which match no asset.
Verified in the browser on the run whose model had been stolen: all six
relations green again, both sources correctly uncoloured.
* docs(dbt): record why only dbt-core 1.x has live per-model progress
`emits_node_events()` reads as "the Rust engines produce no node events", which
is false and would close off the option. They produce exactly the same events;
they put them on the console and ignore `--log-format-file json`, which both
accept. Measured on 2.0.0-alpha.5 and fusion 2.0.0-preview.202: 15 node events
each on stdout, 0 in the file log, for a three-model project.
Taking them means owning the job log's presentation to work around a flag that
is documented and simply unimplemented, so the note records the measurement, the
sample event, and that flipping the predicate is the whole change once either
engine honours it.
* fix(dbt): give HighlightCode a dialect-agnostic sql language
`npm run check` had three errors the fast check does not reach: `"sql"` is not a
value `HighlightCode` accepts. Every SQL dialect it knows maps to one grammar,
but a dbt model is compiled by whichever adapter the project targets, so naming
a dialect would be a guess — `sql` is now a value in its own right.
`langOf` was typed `string` and returned `markdown`, `python` and `text`, none
of which the component accepts either, so a dbt project's YAML and Python files
rendered unhighlighted. It now returns the component's own prop type, which is
what caught them, and `undefined` for what has no grammar rather than a name
that silently means the same thing.
Verified in the project panel: SQL 22 tokens, YAML 27, where YAML was plain.
* fix(dbt): stop failing no-op models, drop table triggers client-side, keep cross-selection edges
The sweep that settles a run's stranded relations was marking `no-op`, `warn`
and `skipped` models FAILED on successful runs: reconciliation reports those
nodes without settling their record, so they were indistinguishable from a model
the run never reached. It now excludes every relation the run accounted for, so
only the genuinely abandoned ones are settled.
`table` was removed from the backend's auto-trigger kinds but left in both
client mirrors, so the editor and `pipeline dev`/`docs` kept drawing cascade
arrows the deploy will not create.
`isModuleEntryPoint` scanned for the first `__mod/`, the same bug its sibling
just had: a `legacy__mod/script.ts` nested in a dbt project — dbt owns those
names verbatim — read as that script's entry point. Both now anchor on the
outermost boundary.
A script selecting a model whose parent another script builds dropped the parent
entirely, so no `dbt_edge` could reach it and the two relations sat on the graph
unconnected. The parent is now kept as an endpoint and recorded as a READ, since
this script does not build it — splitting a project across selections only
composes if the seam still draws.
* fix(dbt): don't double-run after-all tests, count only models a script builds
An `after_all` run whose test phase failed saves a `run_results.json` holding
tests alone. Retrying it reran exactly those tests — and then the test phase ran
the whole suite again, appending a second copy of every result: duplicate ids in
the run table, doubled totals. A retry whose saved results are tests alone IS
the test phase, so the suite is not run after it, and the two phases now merge
by node id rather than concatenating.
Keeping a selection's unselected parents as nodes made them count toward the
`×N` badge, whose tooltip says "materializes N models" — a script selecting one
mart claimed the staging models upstream of it, and the number grew with the
seam. The count now comes from the relations the script writes.
That change also made the cross-selection read block dead, with a comment
asserting the inverse of what now happens; it is removed, and the test that
covered it still passes on the new arm. The test I added landed between a
neighbouring test's comment and its `#[test]`, orphaning the attribute so that
test stopped running.
Two display fixes: the run page no longer shows a relation's SQL when the
provenance belongs to another project that materializes the same relation, and
the editor no longer draws an explicit `# on table://` arrow the deploy refuses.
The starter descriptor no longer promises the removed cascade.
* fix(dbt): clear untouched models, reject unknown descriptor fields
A `no-op` model was left `running` forever on a successful run. The previous
attempt at this stopped the sweep marking such models FAILED but gave them no
terminal state instead, so they simply never settled. Reconciliation now returns
what it settled and what the run reported but did not build, and the two get
opposite treatment: a relation the run left untouched has its row DELETED, which
is what the finished run's own result says about it (`relationOutcome` colours a
`no-op` nothing), so the live and settled views agree; only a relation the run
never reached at all is failed.
The descriptor accepted unknown fields, so `selcet:` was ignored and left an
empty selection — building the whole project — and a misspelled `target` fell
back to the profile's default. It rejects them now. That immediately caught two
of our own test fixtures still passing `repo:`, a field removed with the git
path, which is exactly the class of mistake it exists to stop.
`isDbtModulePath` matched `__dbt/` anywhere in a path, the third site with that
bug: `foo__mod/vendor/x__dbt/a.ts` read as a dbt project file, and the push then
looked for `foo.script.yaml` and could skip the edit.
A verbatim dbt bundle dropped any file named `*.lock` before it reached the
module map, so an authored `uv.lock` never deployed and the unmodified-project
round trip quietly lost it. The exclusion now applies only to `__mod` bundles,
where `.lock` really is the script's own lockfile — in the walker that hashes
modules too, or a change to such a file would not register as one.
Also: `langOf` fell back to `undefined`, which HighlightCode resolves to
TypeScript rather than to no highlighting, so seeds and Markdown were coloured
as code; and four comments still gave the removed cascade as the reason for
sharing an asset node, which is now lineage.
* fix(dbt): retry failed tests too, anchor the last __dbt path check
`retry_failed_nodes` only ran after the model phase, which fails before the
`after_all` test phase exists — so a project whose models built and whose tests
failed got no retry at all, exempting exactly the failure mode that separate
phase produces. The loop is now a function, called after both phases.
`isDbtGeneratedPath` matched `__dbt/` anywhere, the fourth site with that bug:
`foo__mod/vendor/x__dbt/target/a.ts` counted as generated dbt output, so
`ignoreF` excluded an ordinary module file and a module-only edit never deployed
its parent script.
`wmill sync push` still dropped an ADDED or DELETED `.lock` three branches
before the module arm, so the earlier fix only covered a first push: adding a
`uv.lock` to a deployed project was reported as a change forever and never
applied, and deleting one left it deployed. Editing worked, which is what made
the round trip look whole.
Also removes a duplicate `#[test]` that was double-registering a test and
detaching its neighbour's comment, and rewrites seven comments that still gave
the cascade as the reason for behaviour that now serves lineage only.
* fix(dbt): bound the excluded-file read, keep the retry budget job-wide
`isBundledModuleFile` read a file in full before deciding it was too big or
binary, so a project sitting next to a multi-gigabyte parquet seed loaded the
whole thing only to reject it. It now takes the size from `stat` and reads at
most the 8 KB the NUL check needs: a 191 MB file is rejected in 0.0ms at 82 MB
RSS.
Calling the retry helper after both phases gave each its own `attempts` budget,
so a job could spend double what the descriptor asked for — the bound exists
because every attempt is a real dbt invocation holding a worker slot. The budget
is now the job's, spent across whichever phases fail, and the field says so.
Extracting that helper had also placed it between `#[allow(clippy::
too_many_arguments)]` and `run_dbt`, taking the attribute off the 12-argument
function it was written for.
* fix(dbt): actually spend the retry budget
`retry_failed_nodes` looped on `while *remaining > 0` and never decremented it,
so a failing job reissued `dbt retry` — logging "attempt 1 of 3" each time —
until the job's deadline instead of `attempts` times. The decrement existed
briefly and was lost when the function was re-extracted by hand.
Claiming and counting are now one operation, `claim_attempt`, because keeping
them apart is exactly how the bound goes missing: the loop cannot iterate
without spending the budget.
Its test is bounded by its own `for` rather than by the function under test. An
earlier version collected `std::iter::from_fn(|| claim_attempt(..))`, which
against a non-spending `claim_attempt` is an infinite iterator — it allocated
until the machine died. A test for a loop bound must fail an assertion when the
bound regresses, not consume the host: it now reports `[1, 1, 1, …]` against
`[1, 2, 3]` in 0.00s.
* fix(dbt): ask before reading, not after
Bounding `isBundledModuleFile` did nothing for the bundle builder, which read
the whole file into memory and only then asked whether to keep it — so a
multi-gigabyte seed beside a project was still loaded in full just to be
skipped. The predicate is now consulted first, and the read happens only for
files the bundle actually carries.
* feat(dbt): animate the ref() edges feeding the model being built
The nodes moved during a run but the edges did not, so the graph showed where
dbt had got to without showing it flowing there.
Reuses the canvas's existing rule rather than adding a second one: an edge
animates when it touches what is happening. For a pipeline that is the running
script; for dbt the unit of work is the model, so a `ref()` edge animates while
its target builds. Same `animated` field, same visual language, no new styling.
Verified mid-run on a 7-model project: of six `ref()` edges only the two feeding
the model then building were animated, and none once the job finished.
* feat(dbt): show what each model wrote, and say when its SQL is another project's
Three things a reader wanted from the run graph and could not get.
Row counts: the worker already records one per relation and `run_progress`
already returned it, but the graph used only `status` and dropped the number. A
model that built green having emitted zero rows is the failure that looks like a
success, so the count is on the node.
The relation's fully-qualified name, copyable: there is no table browser to open,
so the next best affordance is the exact identifier to paste into a SQL client.
It is parsed with `splitRelation`, which honours quoting the way the worker's
`split_relation` does — splitting on every period renders
`"wh"."analytics.v2"."orders"` as a relation `orders` in a schema `v2`, which
does not exist.
And when two projects materialize one relation, the graph keeps a single
provenance winner, so the losing project's node carries the other's model. The
SQL was already suppressed there — correctly, it is not this run's code — but
silently, which reads as a dead click. It now says so.
* fix(dbt): a finished run's graph is the models it built, not today's project
`/assets/graph` is the current deploy, so an old run's graph drifted with the
project: a model added after it appeared as though the run had built it, and the
older the run the wronger the picture. A finished run's node set now comes from
its own result, which named exactly what it touched.
Sources survive the filter regardless — dbt never lists them in
`run_results.json` because it does not build them, but they are the upstream the
run read, and dropping them would leave the models hanging.
The graph is still the current deploy's, so a model renamed or deleted since
cannot be drawn at all. Rather than a silently shorter graph, the count is
stated above it.
Verified by adding a model after a run: the old run renders 7 models without it,
a fresh run renders 8 with it.
* feat(dbt): preview a model's rows with `dbt show`
There was no way to see the data behind a node — only its SQL and its row count.
`dbt show` selects from a model and returns rows, and every engine ships it, so
the preview needs no adapter code of ours: no connection path, no dialect-correct
quoting, no type coercion for ten warehouses. It runs against the profile the
run already renders.
It is a `dbt_command` rather than a new endpoint, so it inherits the whole job
path — authorization, isolation, cancellation, logs, engine provisioning — and
`limit` joins the run form beside it. That the allowlist can admit it at all is a
consequence of dropping the cascade: while a successful job dispatched its
deploy-time write set, a command that wrote nothing woke every consumer for
relations nothing had touched.
Read-only, and treated as such: no graph republish, no materialization records,
no retry state, no test phase. Captured rather than streamed, like `dbt ls` —
these rows are the result, not commentary, and the job-log writer is what
`NO_LOGS_AT_ALL` discards.
Verified: `{"dbt_command":"show","select":["stg_customers"],"limit":3}` returns
three rows; a preview leaves `materialized_partition` untouched (62 → 62, 0 rows
for the job); `clean` is still refused by the allowlist.
* feat(dbt): preview a model's rows from the graph, and keep our locks out of dbt projects
The run page could show a model's SQL and how many rows it wrote, but not the
data. Selecting a model now offers "Preview rows", which runs the script with
`dbt_command: show` and renders the result as a table.
Explicit rather than on-select: a preview is a job, so it costs a worker slot
and the engine's start-up, and previewing on every click would spend both on
mere navigation. Sources are excluded — dbt shows what a model SELECTs, and a
source is not one.
Also: `updateModuleLocks` was the one module helper that never learned about
verbatim bundles, so it walked a dbt project writing `foo.lock` beside `foo.sql`.
None of those files is a Windmill script needing a lockfile, and the bundle
promises to round-trip the project byte-for-byte — our artifacts have no business
in it.
Verified in the browser: selecting `stg_customers` and previewing returns the
columns `id`/`src` and five rows from the warehouse.
* fix(dbt): keep a run's models when another project owns their provenance
Scoping a finished run's graph to the ids it named dropped relations whose
provenance winner belongs to a different project — so a run of a project sharing
a schema showed 3 of the 6 models it had built. An id that was never this run's
package cannot be judged against its result, so it is kept: the relation IS one
the run wrote, and hiding it understates the run. The same rule applies to the
"no longer in the project" count, which otherwise reported deletions that were
only provenance collisions.
Previews are now cached per model and survive the selection moving. One was
thrown away whenever the reader clicked elsewhere, which for a job costing a
worker slot and an engine start-up meant re-running it to see it again — and the
run continues in the background, so leaving and returning finds the rows there.
The spinner also never span: `startIcon` takes the icon and its classes
separately, so the animation has to be passed alongside.
How long it took is shown with the rows. A preview is a job, and its cost should
not be something the reader has to guess at.
* fix(dbt): resolve argument references, clamp the show limit, flag renamed relations
`handle_dbt_job` cloned `job.args` where every other executor calls
`build_args_map`, so a `$var:` / `$res:` / `$encrypted:` argument reached dbt as
the literal string. A placeholder holding a schema or an `enabled` flag would
then build a different slice of the project than the caller asked for.
`--limit` took any positive i64, and the worker buffers the whole of dbt's
stdout to read the rows out of it — so a caller with only run permission could
make it hold an unbounded allocation. It is clamped to a ceiling now, extracted
as `show_limit` so the bound is pinned by a test rather than inline in an async
function nothing can reach.
And a model keeps its id when its alias or schema changes, so an old run's node
showed today's relation while the run wrote another — the page asserting it had
materialized a table that did not exist yet. The run's result carries the
relation each node actually wrote, so the drift is detectable without a graph
snapshot, and the count is stated above the graph. Rendering the run's own
lineage still needs a per-job snapshot; this stops the page claiming otherwise.
* fix(dbt): stop persisting resolved secrets, bound the preview by bytes
Resolving `$var:` / `$res:` / `$encrypted:` for dbt — added in the previous
commit — meant `save_run_state` wrote the resolved PLAINTEXT into
`dbt_run_state.args` and the worker's `state.json`. The row outlives the job, so
a secret stayed in the database and a later `dbt_command: retry` replayed it
after the grant was revoked or the value rotated. The invocation now carries the
args as submitted alongside the resolved ones, run state persists those, and the
restore path resolves them again under whoever is retrying.
Clamping `--limit` bounded the row COUNT, not the size: one column can hold a
megabyte, so a thousand rows is a thousand megabytes, and `run_capturing`
buffers all of it. The captured output has a byte ceiling now.
`limit` became a built-in argument without joining `RESERVED_ARG_NAMES`, so a
descriptor writing `{{ limit }}` was silently handed the preview control's
default instead of being told the name is taken.
Two display fixes: the relation-drift banner compared a canonicalized (lower
case) asset path against the warehouse's own spelling, so it fired on every
model of every finished Snowflake run; and caching a preview's failure left
`Preview rows` dead for that model until reload.
* feat(dbt): key the graph by script version so a run renders its own project
The dbt graph was keyed by path alone, so a deploy overwrote the only copy and a
run page could only ever show today's project — an older run rendered today's
models, SQL and `ref()` lineage no matter what it had run. My previous attempt
filtered that view to the ids the run named, which stopped it lying but could not
show what was gone: the data no longer existed.
`dbt_node` / `dbt_edge` now carry `script_hash` in their primary key, so each
deployed version keeps its own graph, and the run page passes the version its job
recorded. Per DEPLOY, not per run — ten thousand runs of one version share one
graph — and a composite FK to `script (workspace_id, hash)` with ON DELETE
CASCADE means a version's graph dies with it. Nothing pruned these before,
because there was one copy per path; they would otherwise have accumulated with
no sweep.
Two deploys of one path now write disjoint rows, so the graph can no longer be
lost to a race. `claim_graph_publication` remains only for what is still
path-keyed — the `asset` usage rows — and an older deploy finishing late records
its own graph before declining to touch those, where before it published nothing
at all.
A pinned request is scoped by the version's own nodes rather than by `asset`:
that table describes the current deploy, so scoping through it would filter a
model out of the very run that built it.
Verified end to end: deployed v1 (8 models), ran it, deployed v2 with four models
removed and one rewritten. The old run renders 8 models, 6 ref() edges and v1's
SQL; a new run renders 4 and the v2 rewrite.
* fix(dbt): scope graph cleanup to one version, bound the preview capture
Archive and delete both act on a single `hash`, but the graph cleanup they
called deleted every row for the path. Now that the graph is keyed per
version, archiving an old version erased the live one's models, SQL and
lineage, and nothing repaired it. Both callers have the path in hand, so the
by-hash wrapper is gone and they use the version-scoped clear directly.
`dbt show` checked its 8 MB ceiling after `wait_with_output` had already
buffered everything, so the ceiling could not bound what the worker held.
`run_capturing` now reads both pipes incrementally against a caller-supplied
limit and kills the child on overflow. The read buffers are heap-allocated:
as arrays they were baked into the future, which the job poller boxes several
layers deep, and that overflowed the worker thread's stack — a `dbt show` run
aborted the whole worker process.
A retry's `dbt parse` ran on the arguments as submitted while the build ran on
resolved ones, so a `$var:` shaping the graph parsed verbatim. The parse moves
to the caller, after resolution.
A run that names its own `select`/`exclude` now drops the descriptor's
`selector`: dbt resolves `--selector` instead of `--select`, so passing both
made a preview of one model return another's rows.
Also: log instead of silently swallowing a `modules` column that fails to
deserialize (pre-existing, but for dbt it means running with no project at
all); keep the model SQL reachable once a preview has landed; render which
node the rows came from; stringify object-valued cells; document
`dbt_script_hash` in the OpenAPI spec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: record the per-worktree dev environment and the backend-run check
Three mistakes this guidance would have prevented, each of which cost a cycle:
A worktree has its own database and ports, but AGENTS.md stated the
single-checkout defaults as facts. Pointing `DATABASE_URL` at another
worktree's database makes `cargo sqlx prepare` fail on every query touching a
table your migrations added — and it deletes `.sqlx/` before it fails, so the
cache is gutted rather than merely stale. Starting a backend on the wrong port
leaves the UI up with every call 502ing, which reads as an application bug.
Both values are now discoverable with commands that work as written.
`prepare` is also documented as the wrong tool for a removal-only change: the
cache is already complete for CI, and the only residue is orphaned entries that
can be found by text-matching against the sources without a database.
Nothing told a reader that `cargo check` does not exercise a worker path. A
read buffer declared as an array inside an async block is baked into the
future, and once boxed by the job poller it overflows the worker thread's
stack — compiling and unit-testing clean while aborting the whole worker
process at runtime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): scope the remaining path-wide reads and clears to one version
Four places still spoke for a whole path after the graph became per-version:
The relation-root drift check read `dbt_node` by path with an unordered
`LIMIT 1`, so with v1 at root A and v2 at root B it could answer with v1's
row, suppress the refresh v2 needed, and leave v2's graph naming relations the
run does not build. It now reads this job's version.
`dbt_dep`'s no-resource branch cleared the path, so a descriptor edited to
bring its own `profiles.yml` emptied every earlier version's graph and with it
every finished run's page. The ownership being given up is the path-keyed
`asset` usages cleared beside it; the graph clear is now this version's.
The graph was inserted before the publication claim checked the version was
still live. Archive and delete only soft-update `script`, so the foreign key
still accepted an in-flight dependency job's rows and the failed claim
committed them — and because pinned queries deliberately serve archived
versions, deleted model SQL became readable again. The write is now gated on a
`FOR UPDATE` liveness check.
`clear_dbt_run_state_by_script_hash` resolved a hash to a path and deleted the
path's saved run. `dbt_run_state` is keyed by path by design — one saved run
per script — so archiving one version discarded the live version's resumable
failure. It clears only once no live version of the path is left; `identity`
already refuses a resume whose project, warehouse or engine moved.
"Preview rows" ran `runScriptByPath` while the SQL beside it was pinned to a
hash, so an old run showed its own SQL over today's rows. Verified end to end:
with v3 deployed, the v2 run's preview runs v2's hash and returns v2's rows.
Also: keep the TAIL of a captured stderr, since dbt prints its summary last;
one `$derived` for the parsed result rather than five; collapse three
near-identical argument accessors onto one generic; fold the single-use
`copy_dir_command` into its caller; and give `parseDbtRun.ts` one status
classifier instead of spelling dbt's failure vocabulary twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(dbt): one JobCtx down the executor, one table per adapter
Two changes aimed at the operations this code will keep having: adding a
phase, and adding a warehouse.
`JobCtx` already bundled the five values every phase needs, and nine functions
took it — but the top of the executor threaded the fields apart and rebuilt the
struct at each call, so the same literal appeared eight times and each new
phase meant five more parameters. It is now built once per entry point and
reborrowed. `prepare_project` goes from 21 parameters to 17, `retry_failed_nodes`
from 15 to 11, and `run_dbt` drops below the lint threshold. The two remaining
constructions are the worker boundary, where the pieces genuinely arrive apart.
`DbtAdapter` answered five questions with five parallel matches over the same
eleven variants, plus a sixth list of adapters kept by hand in a test. The
facts now live in one `AdapterSpec` per adapter, reached through one exhaustive
match, so adding a warehouse states its name, driver, package, port, database
key and licensing together and the compiler demands the arm. Each arm spreads
from a Postgres base, which makes the inheritance visible per adapter instead
of hidden in the `_ =>` defaults `default_port` and `database_key` used to
carry. `DbtAdapter::ALL` replaces the list the test kept separately.
Verified by dumping all seven facts for all eleven adapters before and after:
byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): job-keyed run progress, and stop path-wide reads and clears
Six findings from the last round, in the order they bite.
The publication liveness gate refused on `archived`, but `create_script`
archives the parent on every redeploy — so deploying v2 while v1's dependency
job was still parsing left v1 without a graph, permanently, which is the exact
case the unconditional write existed to serve. It gates on `deleted` alone now;
an explicit archive is still covered by the `FOR UPDATE` ordering.
A project-owned `profiles.yml` trusted `profile.type` instead of reading the
file. The Rust engines carry every adapter, so a CE script could declare
`postgres` over a target that is `sqlserver` and have dbt connect with the
enterprise adapter. The file is read whichever way, and a descriptor that
disagrees with it is refused.
Renaming a dbt script, or editing one so its newest version is no longer dbt,
cleared the graph for the whole path — every older version's models, SQL and
lineage, which their own finished runs still render. Neither needs it: graph
queries join on `(path, hash)` through a `language = 'dbt'` CTE, so an old
version's rows cannot attach to whatever lives at that path next.
Live progress read `materialized_partition`, whose key is the relation and
whose `job_id` is only the last writer. Two runs of one project took rows from
each other. Progress now has its own job-keyed table; the relation table is
untouched, because one row per relation is right for the pipeline canvas and
fork defer. Verified with two overlapping builds: both keep 6 rows in the new
table, while the old one attributes 6 to one run and 0 to the other.
`Scratch::drop` removed a half-installed virtualenv synchronously from inside
the job future, blocking a runtime thread; it goes to `spawn_blocking`, with a
direct call when there is no runtime to hand it to.
The E2E list asked for a `# on table://` subscription the deploy now refuses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(dbt): snapshot a dynamic descriptor's graph per run
A `{{ }}` placeholder in `vars` can enable a different set of models per run, so
those runs re-ingest the graph. Keyed by version alone, each re-ingest
overwrote the last: reopening an older run showed the newer run's project, and
a model only the older run built was gone entirely — no SQL, no lineage, and
nothing the saved result could colour, since it can only tint nodes that are
there.
`dbt_node` / `dbt_edge` gain `job_id`. A run of a dynamic descriptor writes its
own snapshot under its job id and its page reads it back; a static descriptor
writes the version's graph once, under a zero-UUID sentinel, and every run of it
reads that. The sentinel is a value rather than NULL because `job_id` is part of
the primary key and Postgres does not treat two NULLs as the same key, so each
re-ingest would add a row set instead of replacing one.
`/assets/graph` takes `dbt_job_id` and prefers a snapshot when one exists,
falling back to the version's graph otherwise — so a run page passes it
unconditionally and static descriptors are unaffected. Snapshots age out after
30 days, pruned by the runs that write them, so no background sweep has to learn
about these tables.
Verified end to end: one deploy, two runs of it with `extra=yes` and `extra=no`
gating a model's `enabled`. The version's graph holds 6 models, run 1's snapshot
7 including `opt_extra`, run 2's 6 without it; the endpoint returns each run's
own and falls back to the version's when the parameter is omitted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* perf(dbt): only snapshot a run whose graph differs, and prune from every run
Two costs the per-run snapshot carried, both found by measuring it rather than
by reading it.
A snapshot was written for every run of a dynamic descriptor, but marking one
dynamic is conservative: `graph_is_per_run` is true whenever `vars` holds a
`{{ }}` placeholder or `env` holds a `$var:`, which says the arguments reach dbt
and not that they change which models exist. The usual case is a date var, whose
graph is identical run after run, so the table filled with copies of an
unchanging picture — around 1 KB per model per run, which is a gigabyte or so a
month for a 200-model project on an hourly schedule. A row set now carries a
digest of its nodes, edges and relation root, and a run whose digest matches the
version's writes nothing; the read already falls back to the version's graph, so
those pages are unchanged. Only a run whose model set really differs pays.
The prune was hung off the progress reporter, which exists only for engines that
emit node events — so a Fusion or dbt-core-2x instance accumulated snapshots and
never deleted any. Retention that stops working because of an engine choice is
not retention; it runs detached from every dbt run instead.
Verified against a descriptor with a var-gated model: the version's graph holds
8 rows, a run that resolves to that same graph stores none at all, and a run
that enables the extra model stores its own 9. Both pages still render their own
project — 6 assets without the extra model, 7 with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): scope every dbt_node join to the chosen snapshot
`job_id` joined the key, but only the scoping CTE and `dbt_edge` were taught to
filter on it. The outer node SELECT and the parent/child joins in the edge query
were not, so each model came back once per retained snapshot plus once for the
version's graph, and each edge matched every combination of the two — the model
count multiplied and the edge join fanned out quadratically. Measured against
one stored snapshot: 17 node rows where 8 are wanted, and 28 edge pairs where 7
are. The response dedup hid the edge blow-up from the payload, not from the
plan, and the run page refetches the graph every two seconds.
The progress table gained writers it was missing. `terminalize_running_relations`
settled only the relation-keyed table, so a cancelled or killed run — the case
that function exists for, since it leaves no `run_results.json` — showed every
in-flight model still spinning on the run page for as long as the row lived. An
agent worker cannot write the new table at all, having no database of its own,
so the read falls back to the relation-keyed one when a job has no rows there.
Also: a wrapped string literal missing its backslash put eighteen spaces in the
middle of the profile-disagreement error; a comment still described concurrent
runs of one dynamic version overwriting each other's graph, which is what
keying by job removed; and the `materialized_partition` index justified itself
by a run-page poll that has since moved to another table, though the closing
sweep still earns it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): give a graph snapshot a marker row, and scope what reads it
Five findings, four of which are the same mistake in different places: a
snapshot's identity was inferred from its contents.
Existence was inferred from a `dbt_node` row, so a dynamic run that disabled
every model — a legitimately empty graph — read as "no snapshot" and its page
showed the deployed models instead. The digest was a column repeated on every
node and read back with a `LIMIT 1` carrying no `job_id`, so a run could compare
itself against another run's digest and suppress a snapshot it needed. The
relation-root drift check read the same rows unscoped, so after a drift it could
find a previous run's root and conclude nothing had moved.
`dbt_graph_snapshot` holds one row per stored graph — path, version, job,
digest, timestamp. Existence is that row, the digest lives there once, the drift
check reads the deployed row explicitly, and the retention sweep deletes markers
first and then the rows no marker stands for. The digest is SHA-256 rather than
`DefaultHasher`, whose output is documented as unstable across Rust releases:
this value outlives the process that computed it, so a toolchain bump would have
silently stopped every comparison matching and quietly reinstated the duplicate
snapshots the digest exists to prevent.
`/run_progress` ignored the view token, so a share-link viewer got the graph and
was refused the progress that colours it.
A preview sent only its own three arguments, so a descriptor with a required
`{{ }}` var could not be previewed at all and an overridden one previewed a
different relation than the page was showing. The run's arguments go first now,
with the preview's three overriding.
Verified on a project whose only model is var-gated: the deploy stores a marker
with zero nodes, a run with the var set stores a marker with one, and the
endpoint answers 0 and 1 respectively rather than showing the deployed models
for both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(dbt): squash the runtime's migrations into one
Ten migrations reshaping the same three tables is a history no installation
ever had. `dbt_node` gained `script_hash`, then `job_id`, then `ingested_at`,
with its primary key rebuilt twice; `graph_digest` was added by one migration
and dropped by the next after the digest moved to its own table. On a fresh
database all of that replays to arrive at a shape the schema can simply state,
and this feature has never shipped, so there is no upgrade path to preserve.
One migration now creates `dbt_node`, `dbt_edge`, `dbt_graph_snapshot`,
`dbt_run_state` and `dbt_run_progress` in their final shape, carrying forward
the rationale each of the replaced migrations recorded. The enum additions stay
in `add_dbt_lang`, since a value cannot be added and used in one transaction,
and the `materialized_partition` index stays separate because it belongs to a
table this feature did not introduce.
Verified by rebuilding: dropped the five tables, replayed from the single
migration, and confirmed the result is identical — same primary keys, the same
two composite `script` foreign keys, the same seven indexes. Every `sqlx::query!`
in the workspace then compiled against it, which checks each column's name, type
and nullability, and a deploy plus run on the rebuilt schema produced 8 nodes,
7 edges, a snapshot marker and 6 progress rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): authorize snapshot reads, bound the prune, give the marker a lifecycle
`dbt_job_id` is caller-supplied and selected straight from `dbt_graph_snapshot`,
which carries no RLS — so a caller who could see the script could read any run's
model set and relation paths, which a dynamic alias or schema can encode. Both
graph queries now require the job itself to be visible, in the authed
transaction, the same gate `raw_code` already applies to the script that
produced it.
The drift check compared against the deployed graph alone, which misses the way
back: a run at root B republishes the path-keyed `asset` usages at B, and
returning the profile to A then matches the deploy and skips the refresh,
leaving those usages at B while dbt builds A. It reads the most recent ingest
for the version instead — the one that last wrote them — ordered rather than an
arbitrary `LIMIT 1`.
The prune anti-joined every non-deployed node and edge with no age predicate, so
each run scanned the whole retained sidecar and concurrent runs duplicated it.
All three deletes share one age bound again, with the sentinel spelled as a
literal so the partial indexes apply — a bound parameter cannot be proven to
match the index predicate.
`dbt_graph_snapshot` was the one dbt table nothing in the script lifecycle
deleted: no `script` foreign key and absent from both `clear_dbt_manifest*`
sites. A marker outliving its rows is read as a snapshot with no nodes, and its
digest still answers the suppression check, so an identical run would write
nothing and then render an empty graph. It cascades like the rows now and both
clears take it.
Also: the preview cleared `exclude` rather than inheriting it, since previewing
a model the run excluded reached dbt as `--select m --exclude m`; and
`terminalize_running_relations` no longer claims to cover a killed worker, which
never reaches it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): quote profile names, record where usages were published, stop polling the graph
A profile name comes from the project's own `dbt_project.yml` and a target from
the descriptor, and both were interpolated into `profiles.yml` as bare YAML —
including as mapping keys. A name like `prod # hidden` truncates the mapping and
a newline opens a sibling key of the author's choosing. Both are rendered as
quoted scalars now, as are the BigQuery keyfile's keys, with a test that asserts
the document still parses to exactly the keys we wrote.
The drift check read the most recent ingest, which latches: a run that returns
to the deployed root re-ingests but stores no snapshot (its digest matches the
version's), so the moved run's rows stay newest and every later run pays an
extra parse and ingest. The publisher now records the root it published the
path-keyed usages at, which is the only thing that answers "where do the current
usages point" — the deploy's own root goes stale as soon as a run republishes.
The run page polled `/assets/graph` every two seconds alongside progress, so it
re-sent every node's SQL for the length of a run — hundreds of KB a tick on a
real project, for a graph that a dynamic descriptor re-ingests exactly once
before the build. It fetches once more shortly after mount and then polls
progress alone.
Node results carry `outcome` beside `status`. `status` stays dbt's own word, but
dbt owns that vocabulary — 1.x and 2.x differ on casing and `no-op` arrived in a
minor release — so publishing only it would force a break or a lie the first
time it moves. `outcome` is the stable half a downstream script branches on.
Also: the worker's dbt entry points are `pub(crate)`, since nothing outside the
crate calls them and they resolve secrets and launch processes; and the snapshot
gate records that it is RLS-only where `/jobs/run_progress` also honours a
share-link token, which is a gap in what a shared page shows rather than in what
it protects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(dbt): pin the graph storage invariants against a real database
Every defect review found in this area was DB-shaped — which row set a read
resolves to, which rows a clear takes, whether a snapshot exists at all — and
none of it is reachable from a unit test on a pure function. Four rounds
established these answers and nothing guarded them, which is why each round kept
finding another.
Six cases, on the harness the repo already uses for schema-shaped behaviour:
an identical run stores no snapshot and leaves no marker; a differing run keeps
its own while the version's is untouched; an empty run graph is still a snapshot
rather than an absent one; clearing one version leaves the others whole; the
path-wide clear takes the markers with it; and the sweep ages out run snapshots
while never touching a version's own graph.
`IngestedNode` gains `Default` so a test can state the two fields a case is
about rather than the eighteen it is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* perf(dbt): bound a script's stored graphs by deploy count
Run snapshots expire on a clock, but a VERSION's graph could not: its reader is
every finished run of that version, and a run page is as old as its job. So
nothing reclaimed them — a deploy graph went only when its `script` row was hard
deleted, which Windmill does not routinely do. A CI deploying on every commit
added a full model set with SQL bodies per commit, forever: roughly 200 KB a
deploy for a 200-model project, which is gigabytes a year across an instance.
Bounded by COUNT instead of age, since age is the thing that cannot be right
here. The newest 50 deploys per path keep their graph and older ones are
reclaimed, making growth `versions x models` rather than unbounded in time.
Generous on purpose: reaching the bound empties that version's run pages, so it
exists to stop unbounded growth rather than to be hit in normal use. Ordered by
the script's own `created_at`, so a late-finishing job re-ingesting an old
version cannot promote it.
Pinned by a test that deploys past the bound and asserts both halves: the count
holds, and the newest version is always among the survivors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): let the run page know when its snapshot has landed
The one-shot graph refetch was wrong: a dynamic descriptor's ingest happens
before the build but after cloning, dependency install and parse, so a fixed
delay either fires too early — and the run page then shows the deployed models
for the whole run, never that run's own — or keeps re-sending the whole graph
for the length of it. Neither is a timing problem to tune; the page had no way
to tell "the snapshot is not written yet" from "this run has none".
`/assets/graph` answers that directly: `dbt_snapshot_job` is the job the dbt half
resolved from, when one was asked for and found. The page polls the graph until
that is its own job, and stops. A static descriptor never snapshots, so an
attempt cap ends it there rather than polling for the run's duration.
`dbt_node.relation_root` is gone. The drift check moved to the marker's
`published_relation_root`, which left the column written on every node and read
by nothing.
`outcome` was published as the stable half of the result contract, but the
in-tree consumer still ranked and coloured from dbt's own word — so the field
existed and nothing used it. `statusRank` takes it, `DbtRunResult` passes it, and
`classifyStatus` is documented as the fallback for results that predate it and
for the live event stream, which carries dbt's word alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(dbt): record what a share-link viewer actually sees
The comment at the snapshot gate said the graph "falls back to the deployed
set", which is only the rarer half of it. A share link is an extra grant for a
logged-in user who lacks access to the job, so the usual case is no read on the
script either — and then the `live` CTE matches nothing and the whole dbt half
comes back empty. A blank Models panel over working progress rows, not a
fallback.
`docs/dbt-runtime.md` now carries the analysis a follow-up needs: that relaxing
this leaks nothing, because `v2_job_completed.result` already gives that viewer
every node's `unique_id` and `relation_name` — the graph's only incremental
exposure is `raw_code`, which is gated separately on seeing the script. And the
shape of the fix: `OptViewToken` and `validate_view_token` are self-contained
enough to move into `windmill-api-auth`, which `windmill-api-assets` already
depends on, after which the gate can honour a token for that job's snapshot
alone while `raw_code` stays where it is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): keep model SQL behind the scripts:read scope, and unbreak CI
`/assets/graph` is authorized as `assets:read`, and RLS decides whether the
caller can see the script that produced a node — but RLS is not a scoped
token's grants. A token deliberately narrowed to `assets:read` could therefore
read model source and repository paths for scripts outside its `scripts:read`
paths. The same `build_scope_path_predicate` the macro endpoint already applies
now gates `raw_code` and `original_file_path`; the relation's shape is
unaffected, only its body is withheld.
`DbtAdapter::ALL` exists for the tests that must cover every adapter, so it is
dead in a release build and `-D warnings` failed all four backend checks on it.
It is `#[cfg(test)]` now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): stop the graph poll at the ingest, and type `limit` in the schema
The poll's stop condition was a snapshot appearing, with a 40-attempt cap
behind it — so a STATIC descriptor, which never snapshots, took the cap every
time and re-fetched the whole graph forty times. That is most of what removing
the poll was meant to save, and static is the common case.
The ingest runs BEFORE the build, so the first model to report progress proves
it has already happened: a snapshot absent by then is one this run never
writes. Progress arriving is now the second exit, and the cap is only a
backstop for a run that reports none at all.
`limit` is declared `Typ::Int` but `dbt_arg_schema` had no integer arm, so the
run form and the generated clients saw an untyped default and offered no
numeric control for a value the worker clamps. Covered by the schema test.
`relationOutcome` still re-derived from dbt's word while `statusRank` had moved
to `outcome`; both read it now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): make a retry prove its arguments still resolve the same
The saved arguments are the ones SUBMITTED, so a `$var:` in them is re-resolved
on retry. The identity did not cover the resolved values, so a variable that
changed between the failed run and the retry was accepted — and which graph the
retry then used depended on WHERE it landed: a worker holding the local
snapshot replays the saved manifest, while a database restore reparses with the
new value. Placement decided whether the resumed failures described the
relations being built.
The identity gains a digest of the resolved arguments, and is compared in two
halves because resolution happens between them. Project, warehouse, engine and
env are checkable up front; the arguments are not, because a retry request
carries only `dbt_command` and the ones to compare are the SAVED arguments after
this caller has re-resolved them. Comparing the whole string up front would have
refused every retry — which is what the obvious version of this fix does.
A row written before the digest existed has no last segment, and still restores
rather than being refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): keep pre-upgrade retries working, and stop losing a late snapshot
Splitting the identity on its last `|` read a pre-upgrade row's env digest as an
arguments digest and left only `<run_identity>` as the prefix, so every saved
failure on an upgraded instance became unretryable — a regression the previous
commit's own test missed by using an identity with no `|` in it at all, which is
not what an old one looks like. The digest is tagged (`|args=`) rather than
positional, and the test now uses a real pre-upgrade identity.
The graph poll gave up after a bounded number of tries, but provisioning and
`dbt deps` precede the ingest and can outlast that on a cold worker — and the
engines that emit no node events never produce the progress that ends it early.
A finished run now reloads the graph unconditionally, and the poll's own exit
issues one last load: progress proves the ingest happened, not that the previous
tick saw it, and dbt's compile window is wider than one tick.
A `dbt retry` restores the failed run's arguments inside the worker and they are
never written back to the retry job, whose own args are just
`{"dbt_command": "retry"}` — so previewing a row on a retry's page ran without
the vars the run used. The result now carries the invocation's arguments as
SUBMITTED, so a `$var:` stays a reference and no resolved value is published.
The deploy-count sweep ran instance-wide on every dbt run: `FROM script WHERE
language = 'dbt'` has no index to stand on, and both orphan deletes are the
complement of every partial index here. It is scoped to the running script's
`(workspace_id, path)` — which `index_script_on_path_created_at` serves — and
the orphan deletes only run when a marker actually went.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): hide dbt from module-less pickers, and stabilise the retry digests
`processLangs` feeds every language picker, including flow steps and app inline
scripts. Those are raw bodies with nowhere to carry a module bundle, and a dbt
script IS its bundle — so choosing dbt there produced a job that could only fail
once the worker looked for `dbt_project.yml`. Those two surfaces use
`processInlineLangs`, which drops the languages that need modules; a flow still
reaches dbt the way it reaches any script, by path to a deployed one.
`graph_digest` moved to SHA-256 because it is persisted and compared by a later
worker, and `DefaultHasher` is documented as unstable across Rust releases — but
the retry identity's own digests were left on it, and they are persisted in
`dbt_run_state.identity` for exactly the same comparison. A toolchain bump would
have refused every saved failure as a different project. All three go through
one `stable_digest`, length-prefixed so no split of the same bytes collides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): enforce the tag scope on snapshot reads, reset state between runs
A tag scope is an orthogonal hard restriction: a token limited to some tags must
not read a job outside them however else it is authorized. The snapshot lookup
went through `v2_job` RLS alone, which knows nothing about tags, so such a token
could still retrieve a run's model set and its dynamic relation paths. The same
predicate `require_job_read_access` applies for the progress half of the page is
applied here — `get_scope_tags` is already public in `windmill-api-auth`, and it
is `None` for an unscoped caller, so a normal session pays nothing.
SvelteKit reuses the run graph between run ids, and `graphTries`, `polled` and
`raw` all describe the previous job: a spent retry count stopped the next run's
snapshot poll before it began, and stale progress coloured its models with
another run's statuses. All three reset when the graph key changes.
Also a wrapped string literal missing its backslashes, which put two ~22-space
runs in the middle of the retry-refusal message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(dbt): put each digest helper's rationale on its own function
Inserting `stable_digest` above `split_identity` split that function's doc, so
five lines describing where the identity divides ended up introducing the
hasher. Each is back on the function it describes, stated once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dbt): read the run-pinned graph through the job, not the asset graph
Pinning the asset graph to one run is job-scoped data, but `dbt_job_id` sat on
`/assets/graph`, authorized as `assets:read`. The job-read contract —
`require_job_read_access` — is five parts that pull in opposite directions (tag
scope restrictive, `created_by` permissive, app-embed restrictive-overriding,
view token permissive, RLS underneath), so plain RLS is neither a stricter nor a
looser approximation of it. Restating the parts near the graph query kept leaving
one out: first the job check entirely, then the share-link asymmetry, then the
tag scope, and the app-embed restriction was still missing and failing open.
The helper cannot be called from `windmill-api-assets`, because `windmill-api`
depends on that crate. So move the read instead of the check: the run-pinned
graph is now `GET /w/{w_id}/jobs/dbt_graph/{id}` in `windmill-api`, on the same
gate as the `run_progress` it colours, and `/assets/graph` has no `dbt_job_id`
parameter at all.
- `asset_graph_for` takes the job as an argument from an already-authorized
caller; the route handler passes `None`.
- Extract the graph response into a `AssetGraph` component schema, now that two
paths return it.
- The run page fetches the job route when it has a job id.
* fix(dbt): charge assets:read on the run-graph route, trust the job gate in SQL
Round 13 findings on the route moved last commit.
The scope domain comes from the URL segment, so putting the read under `/jobs`
asked a scoped token for `jobs:read` alone while returning asset-graph data that
`/assets/graph` charges `assets:read` for. A token narrowed to polling run status
could read workspace topology, and the missing-job fallback made it cheaper still
— any random UUID skipped the job gate. Both scopes are now required: the job
gate reaches this run, `assets:read` reaches asset data at all.
The `chosen` CTE re-decided job visibility under plain RLS after the caller had
already passed `require_job_read_access`. It could only disagree, and did so
silently by falling back to the deployed graph — a share-link viewer entitled to
the run was shown a different run's model set. Dropped; the contract is that a
job reaching `asset_graph_for` is already authorized.
Also: the flow editor's `+` insert menu still offered dbt (the third
`processLangs` caller, missed when the other two moved to `processInlineLangs`),
the docs still described the deleted `dbt_job_id` parameter, and the new handler
had again been inserted between `get_run_progress`'s doc comment and its
function.
* fix(dbt): resolve a pinned run's version from the job row, not script RLS
Local codex review of the branch.
A share-link viewer is entitled to the run and usually has no grant on the
project — that is what the link works around. The graph's `live` CTE resolved the
version by selecting `script` inside the viewer's RLS transaction, so it answered
for their access to the project rather than for the run they were given: the
Models panel came back blank beneath working progress rows.
A pinned run now takes its path and hash from the job row the handler already
read after authorizing the job, so `live` does not consult `script` at all.
`raw_code` keeps its own `EXISTS` against `script`, so the model bodies stay
behind access to the project. Verified under RLS as an unprivileged role: the
shape query goes 0 rows -> 1, the `raw_code` gate stays 0.
Taking the version from the job also means a caller can no longer pin one
project's version while naming another's run, since `dbt_script_hash` is ignored
when a job is given.
The run page's graph fetch is a raw `fetch`, which bypasses the interceptor that
adds `X-View-Token` to generated-client calls, so a shared page was refused
before any of this mattered; it goes through `appendViewToken` now.
Also trims three comments to the AGENTS.md limit, dropping drafting-history
rationale that belongs in docs/dbt-runtime.md.
* fix(dbt): snapshot vars-overridden runs, carry the pinned version everywhere
Second local codex pass.
A `vars` run argument overrides the descriptor's, and vars drive `enabled`,
alias, schema, database and materialization — so such a run builds relations the
deployed graph does not describe. It now snapshots under its own job id, which
per-job keying makes safe: the version's graph stays for runs that did not
override. The old comment claimed gating on it would strand the override's graph
for the next default run, which was true only when the write went to the
deployed slot.
Two sites still read the caller's `dbt_script_hash` instead of the version
resolved from the job, so `/jobs/dbt_graph/{id}` without that redundant
parameter dropped models the run's version had and a later deploy removed.
The `dbt_snapshot_job` marker re-checked `v2_job` under RLS — the recheck the
graph query itself drops. A share-link viewer got the right graph and a null
marker, so the run page refetched it 40 times before giving up.
`wmill script preview` read the bundle with the generic `__mod` suffix and
script-module parsing, so previewing a `.dbt.yaml` omitted the project and failed
on the missing `dbt_project.yml`. It uses the same suffix and verbatim read as
deploy.
* fix(dbt): key retry state by principal, not by script path alone
`dbt_run_state` held one row per (workspace, script path), and a retry replaces
the caller's arguments with the saved ones. Anyone able to run the script could
therefore retry whoever ran it last, replaying that run's literal `select` and
`vars` against the warehouse and publishing them as their own job's
`invocation_args`. Running the script was already theirs to do; seeing another
principal's arguments was not.
`permissioned_as` joins the key, so a retry resumes only state written under the
same authority. Two runs sharing an authority can already act for each other, so
this is the boundary that matches the rest of the job model.
* test(dbt): pin what a caller without access to the project sees of its run
The share-link case had no regression guard, and every fix in this area touched
one of its two halves: the graph's SHAPE has to survive a caller who cannot read
the script, and the model SQL must not.
Two cases against a real database, calling `asset_graph_for` as a member with no
grant on the project's folder: pinned to a run, the models render and `raw_code`
is withheld; unpinned, the same caller sees nothing of it, so making the first
work did not relax the second.
Both assertions were checked by mutation — reverting the `live` bypass empties
the graph, and dropping the `raw_code` script gate leaks `select 1` — so neither
passes on the code it is meant to catch.
* fix(dbt): key the worker-local retry cache by principal too
Keying `dbt_run_state` by `permissioned_as` left its worker-local twin keyed by
workspace and script path alone, so the boundary held only where the database row
was consulted. An agent worker never reads that table — `Connection::Http` leaves
`latest_job` as `None` — so there the local cache was the whole boundary and it
had none: the next principal to retry the script on that worker restored the
previous one's `select` and `vars`.
Also records the sqlx `--all-targets` trap in the update-sqlx skill: it is needed
for queries inside tests, and in a CE checkout it aborts on `tests/otel.rs`
(EE-only `otel_ee`) after having already emptied the cache.
* fix(dbt): log a dropped retry-state save, correct the run-progress contract
Saving retry state is best-effort — losing it costs a retry, not the run that
just finished — but `.ok()` dropped the reason too. The only symptom was `dbt
retry` reporting nothing to resume, which reads as a bug in retry rather than a
failed write. Found by running a real failing build against a worker whose
binary predated the `permissioned_as` column: the insert violated NOT NULL and
said nothing.
The run-progress endpoint's OpenAPI description promised an empty list for a
caller who cannot see the job. It is refused instead; an empty list means the job
recorded nothing yet or is unknown here.
* fix(dbt): return the retry-state write failure the warning was added to report
`save_run_state` discarded the insert result, so the caller's warning could never
fire and a lost retry row stayed silent — the symptom being `dbt retry` finding
nothing on another worker.
The error is held rather than returned at once: the worker-local copy is what an
agent worker resumes from, so a failed insert must not cost that too. Every exit
after it surfaces it, including the ones that give up on the local save.
* fix(dbt): decide a retry's graph from its restored args, keep local state in step
Three from the seventh local review.
A retry submits only `dbt_command`, so the vars-override check ran against an
empty argument set and left `graph_is_per_run` false. The failed run's arguments
are restored afterwards, and those are what the retry builds with — an overridden
one wrote no snapshot for its own job and its page fell back to the deployed
graph, showing the wrong enabled models, aliases and schemas. The decision is
re-asked once the restore has happened.
A failed durable write no longer publishes the worker-local generation either.
`restore` accepts a local generation only when the database row names it, so
publishing one the database never recorded made this worker reject its own newest
state and resume the previous run's — its selection and vars, or "nothing to
retry" if that one had succeeded. An agent worker attempts no durable write, so
it keeps its local copy as before.
A rename that also converts away from dbt moved the old path's retry state onto
the new one, reinstating what the conversion had just cleared and leaving one
user's arguments and results under a path no dbt script occupies. It moves only
while the destination stays dbt, and clears the source otherwise.
* fix(dbt): drop retry state when a run produced none, let module pushes fail loudly
Three from the eighth local review.
A run that never wrote `run_results.json` — cancelled, timed out, or dead before
dbt got there — left the PREVIOUS run's state authoritative in both the database
and the local pointer, so a later `dbt retry` resumed that older invocation's
failed nodes. Producing nothing resumable now clears both copies, so neither can
answer for the other.
`wmill sync push` wrapped the descriptor lookup and its deployment in one
try/catch meant for a missing parent. Any API failure or invalid descriptor was
reported as "no parent found" and swallowed, so a module-only push exited zero
with the remote project unchanged. Only the lookup is tolerated now.
Also condenses a comment that narrated how earlier status comparisons behaved.
* fix(dbt): forget retry state on pre-build exits too, drop cascade claims
A dynamic run whose pre-build `dbt parse` or graph ingest fails returns before
the save that clears stale state, so the previous run stayed authoritative in
both the database and the local pointer and `dbt retry` resumed ITS failed nodes
— writing relations the run that just failed never touched. Both exits now
invalidate, through one helper shared with the no-artifact case.
Two frontend comments described dbt producer rows as driving cascade dispatch.
The executor returns before dispatch for every dbt job and deployment rejects
`table://` subscriptions, so they promised behaviour that cannot occur; they
describe the lineage and ownership that is actually retained.
* fix(dbt): let the database decide retry state where it is reachable
A SQL worker treated "no `dbt_run_state` row" as no opinion and accepted any
worker-local generation. But no row is the authoritative answer that the last
invocation left nothing resumable, so a local pointer that outlived it — an
unlink that failed, a process killed between the delete and the removal, a stale
cache — resurrected a replaced run and let `dbt retry` write relations it never
touched. An agent worker keeps accepting its local copy: it has no authority to
consult.
Invalidation failures are logged rather than dropped, since a silent one is
exactly what leaves the pointer behind.
* docs(dbt): record how to run an agent worker locally, keep archived graphs
Every step of standing one up fails as something else: a normal build cannot
start one at all, the server's routes need a separate feature, and all three
token mistakes surface as a bare 401 on the agent with the reason only in the
server log. Written down with the error each produces.
Also keeps a dbt script's graph when it is ARCHIVED rather than deleted. The
pinned read resolves versions through a CTE that already skips archived rows, so
clearing bought nothing and emptied the Models panel of every completed run of
the project. Deletion still clears it.
* feat(dbt): let an agent worker publish its graph, through one endpoint
An agent worker was refused any dbt script whose profile comes from a Windmill
resource — the common case — because it could neither read the stored relation
root to check for drift nor re-ingest a corrected one.
Those look like two needs but collapse into one: verification exists only to
decide whether the stored graph still describes reality, so a worker that can
PUBLISH never has to ask. It stores what it just parsed.
`POST /api/agent_workers/dbt_graph/{workspace_id}` is the whole addition. It
wraps the same `replace_dbt_manifest` the SQL path calls, so digest suppression,
the marker write and retention cannot drift between the two transports, and it
refuses a job the token's tags do not cover. `IngestedManifest`/`IngestedNode`
gain Deserialize to cross the wire.
Two guards go, both now false: the pre-build refusal, and the `Connection::Sql`
gate added earlier to stop a `vars` override grounding an agent run.
Live progress stays SQL-only — that is a per-model event stream, and routing it
through the API would mean a round trip per node.
* docs(dbt): warn that a differing cargo feature set swaps the shared binary
* docs(dbt): record the verified agent-worker behaviour and the tmpfs quota trap
An agent worker now runs a dbt job end to end, retries, and publishes its graph
— confirmed with a dynamic descriptor whose per-run snapshot came back through
the new endpoint. The doc said it was refused; that was true before the endpoint
existed.
Also `WINDMILL_DIR`: on a dev box the job dies with `Disk quota exceeded (os
error 122)` writing the project's files while `df` shows free space AND free
inodes, because /tmp is a tmpfs carrying a per-USER quota. Point the worker at a
real disk rather than trying to clean up beneath it.
* chore(dbt): pin the EE revision carrying the agent graph endpoint
* fix(dbt): bind the published graph to the job, break the completed-page poll loop
Five from the thirteenth local review.
The EE endpoint took `script_path` and `script_hash` from the payload and checked
only that the supplied job carried one of the agent's tags, so an agent holding
any matching-tag job could name another script and replace its graph. Both are
read from the verified queue row now and the request carries only the job id. A
raw preview has no version, so it no-ops rather than 422ing before dbt runs.
`IngestedManifest`/`IngestedNode` take `#[serde(default)]`: they were
serialize-only, and a field the serializer skips made the whole manifest
unparseable on the receiving side.
A completed run page fetched the graph forever — `load()` assigns `raw`, which
recomputes `settled`, which re-entered the same effect. The final fetch is keyed
to the job by a plain (non-reactive) variable, and `settled` is read untracked.
The pin now names a revision that compiles: the previous one still called
`authed.tags()`, a method that does not exist, because both that fix and the JSON
response landed after it was committed.
* fix(dbt): keep a run snapshot out of the script's deployed ownership
Everything `persist_ingest` writes after the manifest is keyed by PATH — one row
set per script, describing what is deployed there. A run snapshot was still
reaching it, so a one-off `vars` override republished that invocation's relations
as the script's ownership and the workspace graph stayed on the override's
schemas and aliases: an ordinary run of a static descriptor never ingests again
to correct it, so only a redeploy would. A snapshot now stops after recording its
own rows.
The row preview also selected a bare model name, which dbt resolves across every
installed package while `show` takes a single node — a project model sharing its
name with a package's was previewed wrongly or refused. It selects the
package-qualified FQN.
* fix(dbt): forget stale retry state when preparation itself fails
`prepare_project` runs before every path that could clear it, and it fails for
reasons unrelated to the saved run — a profile that stopped resolving, a
provision cancelled, packages that will not install. The invocation still left
nothing resumable, so the previous one must not stay authoritative: a repaired
project would otherwise let `dbt retry` rebuild an older run's selection and
write relations the latest invocation never reached. A retry is exempt, since it
is trying to use that state and failing to prepare says nothing about it.
Also corrects the runtime doc, which still described agent workers as unable to
run dynamic descriptors or Windmill-resolved profiles. They publish their graph
through the API now; what they do not get is live progress and a durable retry
row, and the doc says so.
* fix(dbt): spell the whole FQN for preview, bound retained retry generations
The FQN selector added last commit was `<package>.<name>`, but a dbt FQN is the
resource's path within its package and the matcher must consume the selector and
end on equal lengths — so it matched nothing for a model under `models/marts/`,
which is the layout most projects use and the one this repo's own complex fixture
has. The middle segments come from `original_file_path`, whose first element is
the resource root the FQN excludes. Without a path it falls back to the bare
name: ambiguous across packages, but a selector dbt resolves rather than rejects.
Tested on a nested model, which is the input that separates the three spellings.
Superseded retry generations were removed only when a later run published one,
and never inside the hour-long grace period — so a burst left a manifest and a
results copy per run with nothing afterwards to collect them. At most four now
sit in the grace window, oldest evicted first.
* fix(dbt): scope preview state to the run, seed the project on a language switch
Previews are keyed by `unique_id`, which is the same string for the same model in
every run, and the run-change effect reset only the graph and progress. Opening a
second run of one project therefore showed the previous run's rows immediately,
and `runPreview` treated them as cached and refused to fetch. A generation
counter also drops a preview that resolves after navigation, which the reset
alone cannot catch.
The dbt project was seeded only by the empty-script bootstrap, but dbt is in the
ordinary language picker: reaching it by switching a draft produced a script with
no `dbt_project.yml`, which the runtime refuses to deploy or run. Both entry
points seed now, and neither touches modules that already exist.
* chore(dbt): cache the agent graph endpoint's query for the EE offline build
* fix(dbt): publish the graph a moved profile built
A run snapshot stopped before everything `persist_ingest` keys by PATH, which is
right for a one-off `vars` override and wrong for the other two reasons a run
re-ingests. `graph_is_per_run` was one bool for all of them, and the profile
drift check both sets it and reads back what the publisher recorded: a profile
moved A->B was detected by every run forever, each paying a `dbt parse` for a
snapshot nobody reads while the asset rows went on naming schema A.
The reason is carried now (`GraphRefresh`), and it decides both writes. Drift is
the version's own move, so it rewrites the VERSION's graph and republishes the
ownership that ends the drift; a dynamic descriptor snapshots under its job id
and still publishes; anything the CALLER scoped — an overridden `vars`, a
narrowed `select` — snapshots and publishes nothing, so one invocation's subset
can neither stand as what the script owns nor drop the models it left out from
the version's graph. Where they meet the caller wins, and the next ordinary run
settles the drift.
A restore also rebuilt `run_results.json` by copying the generation directory a
second time, so a burst of saves pruning it mid-restore left `dbt retry` with
nothing to resume and a job that reported success. It is written from the bytes
the restore already read; a manifest that went the same way falls back to the
parse a database restore pays anyway, and a generation that vanished before
either read falls back to the database's row for that same run instead of
reporting there is nothing to retry.
* fix(dbt): select a row preview by package, not by file path
The preview built dbt's FQN by dropping one segment of `original_file_path`,
which assumes the model root is `models/`. A project setting
`model-paths: ["src/models"]` turned `src/models/marts/orders.sql` into
`pkg.models.marts.orders`, and dbt's matcher — equal lengths, compared from the
front — resolves that to nothing: the preview came back empty for every model in
the project.
It selects `<name>,package:<pkg>` instead. The comma is dbt's intersection
operator, so this names the node by its own name and the package it belongs to,
which is what the FQN was reaching for and needs no knowledge of the resource
root. Verified on dbt-core 1.12, dbt-core 2.0.0-alpha.5 and fusion
2.0.0-preview.202, including a package shipping a model whose name the root
project also uses.
* fix(dbt): refuse a lockfile version that is not one, keep a named selector
Two things a preview reaches that a deploy does not vouch for.
A raw preview submits its own `lock`, so `engine_version` arrives from the
caller and was interpolated straight into the engine cache path — `../..` in it
made the download, extraction and rename land anywhere the worker can write,
and provisioning runs on the host rather than inside the dbt jail. Both it and
`adapter_version` (a pip requirement) are now accepted only as a plain version
token.
`effective_selector` also read any submitted `select`/`exclude` as an override
of the descriptor's named selector. The generated run form posts a default back
for every field the caller left untouched, and a selector descriptor's `select`
default is `[]` — so pressing Test, saving a schedule or firing a webhook built
the WHOLE project instead of `--selector nightly`. An override is now one that
DIFFERS from the descriptor's own value; a run that wants the whole project
despite the selector asks with `["*"]`.
* fix(dbt): let a moved profile settle, from the runs that actually happen
Two ways the drift check could never come to rest, both verified against a real
run of a real project on a normal worker.
`add_caller_args` read any submitted `select`/`exclude` as a caller's narrowing.
The generated run form posts a default back for every field left untouched, so
every run from the UI, a schedule, a webhook or a flow step carried them and was
marked caller-scoped: with the profile moved A->B, each one stored its models
under its own job id and left the workspace graph — and the root the check reads
back — at A. Since no UI run omits the field, the "an ordinary run settles it"
escape hatch was unreachable. Both this and `effective_selector` now ask one
question, `selection_is_overridden`: DIFFERENT from the descriptor's, not merely
submitted.
The root was also recorded beside the path-keyed publication rather than beside
the graph it describes, so a version that cannot claim the path — an older one
run by hash, a deploy overtaken by a newer one — rewrote its graph at the moved
root and recorded nothing. Its next run then compared against a root that was
absent or two moves stale and skipped the refresh its own run page needed. It is
written wherever the deployed row's graph is.
Verified end to end: same UI-shaped arguments before and after, the moved
profile now republishes (asset rows and version graph both move to the new
schema), a second run detects nothing and re-parses nothing, and moving the
profile back settles it again.
`prune_dbt_run_graphs` also ran from runs alone, while a deploy writes a whole
node set of its own, `raw_code` per model included: a project redeployed on
every push by CI and run nightly kept one full graph per push until the next
run, and one deployed but never run kept them for good.
* fix(dbt): drop a self-dependent effect in the run graph
`previewGen` was `$state` written by the effect that also reads it, three lines
under a `finalLoadFor` that is a plain `let` for exactly that reason. Nothing
reactive reads it — the only reads are inside `runPreview`, a plain async
function — so it becomes a plain `let` too.
* fix(dbt): pin a retry to the engine versions it resolved
`run_identity` carried the engine KIND but not the version it resolved, nor the
dbt-core 1.x adapter's. Redeploy an unchanged project after a release and it
locks a newer dbt or adapter while the saved `run_results.json` still passes the
check, so `dbt retry` feeds one version's artifacts to another — the exact
reproducibility the lockfile exists to hold. Both resolved versions are in the
identity now; a real failure and retry still resumes.
Also drops three comments that outlived what they describe: two said `[]`
clears a descriptor's selector, which `selection_is_overridden` reversed, and
one pointed at an agent-worker guard that no longer exists — the agent path
reaches the ingest deliberately and publishes through the API.
* fix(dbt): discard a run graph the page has already navigated away from
The component is reused across runs, so a slow `/jobs/dbt_graph` or progress
response could land after the reset and put the previous run's models, statuses
and failure state on the current run's page, where nothing would fetch again to
correct it. Every response is now checked against the generation it was
requested under — the counter the preview path already used, renamed for what
it means.
The graph poll also backs off. Neither of its stops is reachable for a whole
class of runs — `dbt_snapshot_job` never matches a static descriptor, and
`polled` stays empty for the engines that emit no node events — so an ordinary
run walked to the cap, re-sending every model's SQL 40 times in two minutes.
* fix(dbt): forget the previous run when the durable save fails, keep quoting
`save_run_state` returns the database error when its upsert fails, which leaves
run N-1's row and local generation in place: same project, same arguments, so a
`dbt retry` matches them and resumes an older attempt's failed nodes against
this checkout — the outcome the no-results branch twelve lines above calls
`invalidate_run_state` to prevent, reached by another door. It now goes through
the same call. Best effort, since the delete goes to the database that just
refused a write, but the local pointer is what a retry landing back here reads.
The run page also rejoined a relation's parts after `splitRelation` stripped
their quotes, so the one name the button exists to paste —
`"wh"."analytics.v2"."Order Items"` — was copied as something no client
resolves. It copies `relation_name` verbatim.
And a source on a finished run was called another project's: the check that
guards against two projects claiming one relation asks whether this run executed
the node, and a run executes no sources — they appear in no `run_results.json`.
Nothing materializes a source, so that warning could never be true of one.
Docs: the `vars`-override paragraph still said such a run does not refresh the
graph, which the table above it contradicts — it refreshes under its job id and
publishes nothing.
* fix(dbt): keep the asset rows and the version's models describing one graph
The workspace graph takes an asset's relations from the path-keyed `asset` rows
and its models, SQL, tests and lineage from the version's `dbt_node`/`dbt_edge`.
A dynamic descriptor published the former while storing the latter under its own
job id, so a placeholder that moved an alias or a schema left the current graph
with assets no model stands behind — nothing dbt contributes to them survives.
Ownership is published exactly when the VERSION's graph was written now, which
is the only state in which the two agree. Two cases are settled elsewhere by
design: an override's relations are a one-off, and a dynamic descriptor at a
moved profile keeps the deploy's ownership until a redeploy — its runs each show
their own models and it re-parses regardless, so the undetected drift costs it
nothing it was not already paying.
An agent worker has no durable row, so its local `current` pointer is the whole
of what a retry reads — and every local publication failure returned success
with the PREVIOUS run's pointer still in place. Where a row exists that is
harmless (`restore` takes a local generation only when the row names it), so the
abandonment is scoped to the agent case.
A failed `dbt show` also cleared the retry state: the preparation-failure exempts
`retry` but not a read-only command, and the run page's row preview is exactly
that, run as the principal the state is keyed by — so a preview that could not
provision took the retry away from the run being looked at.
Frontend: the run-change reset left `loading` and `failed` behind, so the gap
before the next run's answer rendered "no models in the asset graph" — a claim
about the descriptor — over a project that is fine. And three derivations argued
from "the graph is the current deploy", which the pinned endpoint made untrue;
each is still needed, for the version-graph rewrite and retention reasons now
written down.
* fix(cli): let --skip-scripts cover a script's module files
The module shortcut in `elementsToMap` maps the file and `continue`s before
every skip filter, and a module is deployed as part of its parent script — so
`wmill sync push --skip-scripts` still pushed the script whenever one of its
modules changed, and pull still overwrote them locally. Harmless while a module
was a rare helper file; every file of a dbt project is one of these now.
* docs(dbt): a dynamic descriptor's ownership stays the deploy's
* fix(dbt): read the run out of a failure whose message has braces of its own
`parseDbtRun` anchored on the FIRST `{` in the error message and parsed
everything after it. The worker appends the structured result after the error
text, and dbt's errors carry braces — a Jinja template, the compiled SQL, an
adapter's own JSON — so the failures most worth reading were the ones whose
summary and per-node outcomes the run page dropped. Every brace is tried now,
bounded, and the first that parses as a run wins.
Pins the EE revision that gives the agent publish endpoint the deleted-version
guard the SQL path takes: deletion is soft, the foreign key still accepts graph
rows, and the pinned graph query serves non-live versions, so an agent finishing
during a delete put a deleted project's model SQL back on screen. The query is
byte-identical to `persist_ingest`'s, so the offline cache already covers it —
verified with a full-EE `SQLX_OFFLINE=true` check.
* fix(dbt): seed a project when a modular draft switches to dbt
`seedDbtProject` returned whenever the draft carried any module at all, so a
modular script holding a `helper.ts` reached dbt with none of what dbt needs:
the project view is read-only, and the worker refuses a bundle without
`dbt_project.yml`, so that draft could neither run nor deploy. Keyed on the
project file now, and the seed goes in under whatever is already there — the
previous language's helpers are inert to dbt and the user's to remove.
Also records this runtime's schema in `backend/summarized_schema.txt`: the
`table` asset kind, the `dbt` script language, the five dbt tables and the
`materialization_status` enum the progress table uses.
* docs(dbt): move the pipeline-membership rationale out of the deploy path
* fix(dbt): gate a pinned run's model SQL on the version it belongs to
The `EXISTS` against `script` is the only thing standing between a share-link
viewer and the project's source, and it matched the workspace and path alone.
`extra_perms` is a grant on a ROW: archive a version that granted someone
access, recreate the path with narrower permissions, and that stale grant
satisfied the probe while the query returned the NEW version's `raw_code`. Both
probes name the hash now. The regression test drives exactly that shape and
fails without it, returning `select 2` to a caller granted only on the archived
version.
* fix(dbt): keep a delimiter an identifier escaped by doubling
Every dialect these relations come from escapes its own delimiter by doubling
it, and both split functions closed the quoted section on the first half and
reopened on the second: `"schema"."a""b"` came out as `a.b`. The manifest keeps
the real spelling, so the run wrote its per-model status and row counts under an
asset path no graph node has — the node simply never moves, which is the failure
mode this splitter exists to prevent.
Fixed in the worker and in its frontend mirror, which have to agree, with a case
per delimiter on both sides.
* fix(dbt): key retry state by the caller, not only by the principal it runs as
An `on_behalf_of` script executes every caller's job as its owner, so
`permissioned_as` names one principal for all of them and the retry state — the
durable row and the worker-local generation both — collapsed onto a single
entry. After one caller's run failed, the next could submit `dbt_command: retry`
and resume it: their arguments replayed against the warehouse, and handed back
through `invocation_args`. Nothing else separated them, and on an agent worker
the local directory is the whole boundary.
`created_by` joins the key in both places. For an ordinary script it changes
nothing — `permissioned_as` is already that caller — and a run that was itself
superseded was never resumable anyway.
Includes the offline cache for the four changed queries and the three the
pinned-graph regression test added last commit, which had none: `prepare`
without `--all-targets` does not compile test targets, so CI's
`SQLX_OFFLINE=true ... --all-targets` would have failed on them.
* fix(dbt): compare the schema too when reporting a relation that moved
`relationDrift` compared the leaf name alone, and the move it exists to report —
a profile repointed at another schema, which a later run then writes into the
version's graph — leaves every model's name exactly where it was. So the one
case that reliably produces a graph naming relations this run did not write was
the one case the notice stayed silent for.
The schema segment joins the comparison, qualified against qualified: an
unqualified one means the target's own database, which the relation names
anyway, so comparing that would report a move on every node.
* fix(dbt): bound the retry state now that it is keyed per caller
Keying by `created_by` fixed one caller resuming another's run and created a
growth problem doing it: a shared `on_behalf_of` script kept one row and one
worker directory for everyone who had ever run it, and the generation prune only
bounds files INSIDE a directory.
Three bounds, none of them new machinery. A run with nothing failed or skipped
saves nothing — `dbt retry` builds from those nodes alone, so that state could
only ever be refused — while still clearing what the previous run left, since
its failures are no longer what last happened here. The rows expire on the same
30-day clock as a run snapshot, swept per path by the prune every dbt job
already spawns. And the worker-local directories are swept there too, by the age
of the pointer a save rewrites, because their digest names neither the script
nor the caller.
* docs(dbt): the retry state is worker-affine only on an agent worker
* fix(auth): only the server may set a token label that names a user
`create_token_internal` wrote `NewToken.label` verbatim, and the auth layer reads
some labels as an IDENTITY: `username_override_from_label` maps
`ephemeral-script-end-user-<name>` to exactly `<name>`, which then becomes
`created_by` on every job that token pushes. The label is free-form request
input, so any member could mint a token that speaks as somebody else — the shape
`require_job_read_access` already works around when it refuses to trust
`username_override` and falls back to an RLS probe, and the one that made dbt's
retry-state key (`created_by`) forgeable for an `on_behalf_of` script.
The labels are refused where request input enters: the member-facing
`tokens/create`, and `impersonate`, which names its subject in
`impersonate_email` and has no business renaming the caller too. The legitimate
producers are unaffected — a job's own token comes from `create_token_for_owner`
in the worker, and native triggers and app-embed tokens build their labels
themselves rather than accepting one.
`Ephemeral lsp token` stays allowed: its override is the fixed sentinel `lsp`,
not a name the caller chose, and the editor mints exactly that label through this
endpoint for its language server. The test pins the two lists together, so an arm
added to `username_override_from_label` that lets a label choose a name fails
until it is reserved too.
* Revert "fix(auth): only the server may set a token label that names a user"
This reverts commit
|