//! Running a dbt project as a Windmill job. //! //! One `dbt build` per job, not one job per model. That is the shape //! astronomer-cosmos arrived at with `ExecutionMode.WATCHER` after per-model //! Airflow tasks proved roughly 6x slower on a real project; dbt's own //! threading provides the parallelism and Windmill provides the observability //! (docs/dbt-runtime.md). Per-model status comes from dbt's JSON event stream //! while the run is in flight, and the structured job result comes from //! `run_results.json` at the end. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use sha2::{Digest, Sha256}; use tokio::process::Command; use uuid::Uuid; use windmill_common::client::AuthedClient; use windmill_common::error::{self, Error}; use windmill_common::materialization::{ record_materialization, MaterializationStatus, RecordMaterializationRequest, }; use windmill_common::worker::{to_raw_value, write_file, Connection}; use windmill_parser_yaml::{ parse_dbt_descriptor, DbtDescriptor, DbtTestBehavior, DBT_COMMANDS, DBT_COMMAND_ARG, DBT_COMMAND_LABEL, DBT_DEFAULT_WAREHOUSE, }; use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::common::{ render_nsjail_rlimit_as, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, }; use crate::common::{start_child_process, OccupancyMetrics}; use crate::dbt_engine::{provision_engine, ProvisionedEngine, DBT_CACHE_DIR}; use crate::dbt_profiles::{ ensure_adapter_licensed, render_dbt_profile, render_profile, DbtAdapter, KnownAdapter, }; use crate::handle_child::{ get_mem_peak, handle_child, run_future_with_polling_update_job_poller, JobCtx, JobDeadline, }; use crate::worker::write_module_files; use crate::{ is_sandboxing_enabled, GIT_PATH, NSJAIL_DBT_RLIMIT_AS_MB, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, TZ_ENV, }; /// The profile name Windmill renders into `profiles.yml`. dbt takes the profile /// to use from `dbt_project.yml`, so the rendered file must answer to whatever /// name the project declares — resolved from the project file, with this as the /// fallback for the (invalid) case where it declares none. const FALLBACK_PROFILE_NAME: &str = "windmill"; /// Written to the script's lockfile at deploy. #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct DbtDependencyLocks { /// The `/` the profile resolved to at deploy. The /// resource is re-read on every run, so a schema or catalog changed on it /// afterwards moves every relation the project builds — and the stored /// graph, which still names the old ones, has to be re-ingested. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_relation_root: Option, /// dbt-core 1.x only: its adapter is a separate package versioning /// independently of core, so pinning core alone still lets a rebuilt cache /// resolve different runtime behavior. #[serde(default, skip_serializing_if = "Option::is_none")] pub adapter_version: Option, /// Digest of the `package-lock.yml` produced at deploy. Package trees are /// worker-local, so a cache miss on another worker must prove it resolved /// the same dependencies before it may run this script version. #[serde(default, skip_serializing_if = "Option::is_none")] pub package_lock_digest: Option, pub manifest_digest: String, pub engine: String, pub engine_version: String, } /// Per-node outcome, from `run_results.json`. #[derive(Serialize, Debug, Clone)] pub struct DbtNodeResult { pub unique_id: String, /// dbt's own status word, verbatim (`success`, `error`, `partial success`, /// `no-op`, …). Kept because it is what the log and dbt's docs say, but it /// is dbt's vocabulary to change — read `outcome` to make a decision. pub status: String, /// The same result in Windmill's terms, which is the stable half of this /// contract: `passed` | `failed` | `warned` | `skipped` | `no_op` | /// `unknown`. A dbt release that renames a status, or adds one, moves /// `status` and leaves this alone. pub outcome: &'static str, pub execution_time: Option, #[serde(skip_serializing_if = "Option::is_none")] pub rows_affected: Option, #[serde(skip_serializing_if = "Option::is_none")] pub relation_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, /// Test nodes: how many rows violated the assertion. #[serde(skip_serializing_if = "Option::is_none")] pub failures: Option, } /// The job result. Partial failure is dbt's normal case, so the result has to /// be legible without reading the log: which models succeeded, which failed, /// which tests failed and at what severity. #[derive(Serialize, Debug)] pub struct DbtRunResult { pub engine: String, pub engine_version: String, pub command: String, pub totals: DbtTotals, pub nodes: Vec, /// The arguments this invocation ran with, as SUBMITTED — a `$var:` stays a /// reference, so no resolved value (and no secret) is published. /// /// Present because a `dbt retry` restores the failed run's arguments inside /// the worker and they are never written back to the retry job: its own /// args are just `{"dbt_command": "retry"}`. Anything that needs to act on /// what the run actually used — the row preview, which is a `dbt show` of /// the same project — cannot get them from the job. #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")] pub invocation_args: std::collections::HashMap>, } #[derive(Serialize, Debug, Default)] pub struct DbtTotals { pub total: usize, pub success: usize, pub error: usize, pub warn: usize, pub skipped: usize, } #[derive(Deserialize, Debug)] struct RunResults { #[serde(default)] results: Vec, } #[derive(Deserialize, Debug)] struct RunResultNode { unique_id: String, status: String, #[serde(default)] execution_time: Option, #[serde(default)] adapter_response: serde_json::Value, #[serde(default)] relation_name: Option, #[serde(default)] message: Option, #[serde(default)] failures: Option, } pub(crate) async fn handle_dbt_job( requirements_o: Option<&String>, job_dir: &str, worker_name: &str, job: &MiniPulledJob, mem_peak: &mut i32, canceled_by: &mut Option, conn: &Connection, client: &AuthedClient, inner_content: &str, envs: HashMap, occupancy_metrics: &mut OccupancyMetrics, modules: Option<&HashMap>, ) -> error::Result> { let descriptor = parse_dbt_descriptor(inner_content)?; let locks: Option = requirements_o .map(|s| { serde_json::from_str(s) .map_err(|e| Error::internal_err(format!("reading the dbt lockfile: {e}"))) }) .transpose()?; // Through `build_args_map`, like every other executor: dbt cannot resolve a // `$var:` / `$res:` / `$encrypted:` reference, so passing one raw sends the // literal string to `--vars` — a placeholder holding a schema or an `enabled` // flag would then build a different slice of the project than was asked for. let args = flatten_command( crate::common::build_args_map(job, client, conn) .await? .unwrap_or_else(|| job.args.as_ref().map(|a| a.0.clone()).unwrap_or_default()), )?; // As submitted, command block and all: this is what the state saves and the // result publishes, and both describe an invocation of this script, not one // executor's view of it. let raw_args = job.args.as_ref().map(|a| a.0.clone()).unwrap_or_default(); let inv = Invocation { args: args.clone(), raw_args, envs: envs.clone(), strict: true }; // One wall clock for the whole job. A dbt job is a sequence of // subprocesses — provision, deps, parse, ls, build, then the // `after_all` tests — and each would otherwise resolve the job's full // timeout for itself. let deadline = JobDeadline::start(conn, &job.workspace_id, job.id, job.timeout).await; // Built once and reborrowed into every phase. The five fields travel // together through the whole executor, so passing them apart means each new // phase grows another five parameters and another copy of this literal. let mut ctx = JobCtx { mem_peak, canceled_by, occupancy_metrics, worker_name, deadline }; // From EVERY dbt run, not from the progress reporter: that reporter runs only // for engines emitting node events, so hanging the prune off it leaves a // Fusion-only or dbt-core-2x instance accumulating rows nothing deletes. if let Connection::Sql(pool) = conn { let (pool, prune_w_id) = (pool.clone(), job.workspace_id.clone()); let prune_path = job.runnable_path.clone().unwrap_or_default(); tokio::spawn(async move { windmill_common::dbt_manifest::prune_run_progress(&pool, &prune_w_id).await; if let Err(e) = windmill_common::dbt_manifest::prune_dbt_run_graphs(&pool, &prune_path, &prune_w_id) .await { tracing::warn!("pruning dbt run graph snapshots: {e:#}"); } }); } let prepared = prepare_project( &descriptor, inner_content, locks.as_ref(), job_dir, &job.id, &job.workspace_id, job.runnable_path.as_deref().unwrap_or_default(), job.runnable_id.map(|h| h.0), conn, client, &mut ctx, &envs, modules, ) .await; // A preparation failure leaves the saved run alone: nothing up to here — the // bundle, `dbt deps`, the engine, the profile — touches a relation, so the // previous run's failures still describe the warehouse. Only an interrupted // BUILD invalidates that, which the save at the end of the job decides. let mut prepared = prepared?; // A `vars` override drives `enabled`, alias, schema, database and // materialization, so the run's models are not the deployed graph's: it // snapshots under its own job id. Re-asked below for a retry, which submits // only `dbt_command` until the failed run's arguments are restored. prepared.graph_refresh.add_caller_args(&descriptor, &args)?; let command = match arg_str(&args, "dbt_command")? { // An allowlist, not a passthrough: the value becomes the dbt subcommand, // and running a script needs weaker permission than editing it — so an // unchecked arg would let a runner invoke `clean` or `seed` on the // descriptor's warehouse. Some(c) if DBT_COMMANDS.contains(&c.as_str()) => c, Some(c) => { return Err(Error::BadRequest(format!( "`dbt_command` must be one of {}, got `{c}`", DBT_COMMANDS.join(", ") ))) } None => windmill_parser_yaml::default_dbt_command(&descriptor).to_string(), }; // A parse is the whole job: it resolves the project into a manifest, stores // the graph and stops. Handled before everything below because none of it // applies — nothing is built, so there is no test phase, no materialization, // no retry state and no ownership to publish. if command == "parse" { return run_parse_only( &prepared, &descriptor, // Tolerant of the `{{ }}` placeholders only a run can fill, exactly // as the deploy's own parse is: refreshing an editor buffer must not // require filling the run form in first, and the graph it produces is // the one the deploy would store. &Invocation { strict: false, ..inv }, &mut ctx, job, conn, ) .await; } // `dbt retry` resumes from the previous run's `run_results.json`, which is what // makes one-job-per-invocation defensible. Each attempt gets a fresh job dir, so // that state is restored along with the ARGUMENTS it ran with: dbt reuses the // failed invocation's selection and vars, so every phase must agree with them. let mut restored_results_digest: Option = None; let inv = if command == "retry" { // Read BEFORE the restore replaces the arguments. A retry must name the run // it resumes: only the latest failure of this script is kept, so an unnamed // one would mean "whatever failed last" and quietly resume a different run // than the caller was looking at. let expected = arg_str(&inv.args, "dbt_retry_job")?.filter(|s| !s.trim().is_empty()); let Some(expected) = expected else { return Err(Error::BadRequest( "a `retry` needs `dbt_retry_job`, the id of the run to resume. Open that run and \ use `Resume this run`, or pass its id: only the latest failure of this script is \ kept, so a retry names which one it means" .to_string(), )); }; // Parsed, not compared as text: the saved run is a `uuid`, and an id // that differs only in case or in braces names the same run. let expected = Uuid::parse_str(expected.trim()).map_err(|_| { Error::BadRequest(format!( "`dbt_retry_job` must be the id of the run to resume, got `{expected}`" )) })?; let restored = restore_run_state( &prepared, &job.workspace_id, &job.permissioned_as, &inv, expected, conn, ) .await?; restored_results_digest = Some(restored.results_digest.clone()); // Restored args are the ones SUBMITTED, so the references they carry are // resolved again now — against this caller's access, not the original's. let inv = Invocation { args: flatten_command( crate::common::transform_json(client, &job.workspace_id, &restored.args, job, conn) .await? .unwrap_or_else(|| restored.args.clone()), )?, raw_args: restored.args, ..inv }; // Compared only now that they are resolved: a `$var:` whose value moved // selects a different node set, so the saved failures no longer describe // what a retry would build. Refused rather than resumed, since which // graph it would use depends on which worker it lands on. if let Some(saved) = restored.args_digest.as_deref() { if saved != inv.resolved_args_digest() { return Err(Error::BadRequest( "the values this run's arguments resolve to have changed since the run \ being retried, so its failures no longer describe what a retry would \ build; run the script normally instead" .to_string(), )); } } // The restored arguments decide this retry's graph, and RESOLVED ones: a // saved `select` spelled `$res:` is a string until resolved, so reading the // raw form refuses the retry as "must be a list of strings" for a reference // that resolves to the very list the failed run built with. prepared .graph_refresh .add_caller_args(&descriptor, &inv.args)?; if restored.needs_parse { // After the resolution above, so the manifest describes the project // the build is about to retry. run_dbt_parse( &prepared, &descriptor, &inv, &mut ctx, &job.id, &job.workspace_id, conn, ) .await?; } inv } else { inv }; // Ingested BEFORE the build, from a `dbt parse` with this run's vars, so the // models shown are the ones about to be built. Rows are keyed by path, version // AND job so no two runs collide; the path-keyed `asset` usage belongs to one // version, which `claim_graph_publication` arbitrates (docs/dbt-runtime.md). if prepared.graph_refresh.needed() && !windmill_parser_yaml::dbt::is_read_only_command(&command) { // A parse and an ingest write no relation either, so a failure in either // leaves the saved run as accurate as the preparation exits above do. if command != "retry" { run_dbt_parse( &prepared, &descriptor, &inv, &mut ctx, &job.id, &job.workspace_id, conn, ) .await?; } // For a retry the restored manifest already describes the invocation // being resumed, so only the ingest runs — with that invocation's // arguments, which the selection resolver needs to interpolate. ingest_from_run(&prepared, &descriptor, &inv, &mut ctx, job, conn).await?; } // A read-only command prints rows to stdout, so it is captured rather than // streamed, and nothing below applies: nothing was built, so there is no graph // to publish, no materialization, no test phase and nothing to retry. if windmill_parser_yaml::dbt::is_read_only_command(&command) { return run_show( &prepared, &descriptor, &inv, &mut ctx, &job.id, &job.workspace_id, conn, ) .await; } let mut run = run_dbt( &prepared, &command, &descriptor, &inv, job, conn, &mut ctx, true, ) .await; // `after_all` is two invocations, models then tests, and each REWRITES // `run_results.json` — so the model results are read before the test phase // overwrites them, or the job reports tests alone and nothing settles the // models' materializations. let mut results = read_run_results(&prepared.project_dir).await; // In-job node retry: rebuilding only the failed and skipped nodes, while the // previous attempt's `run_results.json` is still in the job directory. Never on // an agent worker, which cannot read `v2_job_queue` — the wait below would be // uninterruptible, so a cancelled job would hold its slot and then start dbt. let node_retry = descriptor .retry_failed_nodes .filter(|_| matches!(conn, Connection::Sql(_))); let mut retries_left = node_retry.map(|p| p.attempts()).unwrap_or(0); if let Some(policy) = node_retry.filter(|_| run.is_err()) { retry_failed_nodes( policy, &prepared, &descriptor, &inv, job, conn, &mut ctx, &mut run, &mut results, &mut retries_left, ) .await; } // A `retry` whose saved results were tests alone IS the test phase: dbt reran // exactly those tests, so running the suite after it would execute every test // a second time and report each one twice. `test_behavior: after_all` is how // `run_results.json` comes to hold tests alone. let retry_was_the_test_phase = command == "retry" && !results.is_empty() && results .iter() .all(|n| n.unique_id.starts_with("test.") || n.unique_id.starts_with("unit_test.")); // `retry` counts as the model phase too: a run that failed midway and was // retried to success would otherwise return green having never tested. if run.is_ok() && matches!(descriptor.test_behavior, DbtTestBehavior::AfterAll) && matches!(command.as_str(), "build" | "retry") && !retry_was_the_test_phase { run = run_dbt( &prepared, "test", &descriptor, &inv, job, conn, &mut ctx, // The tests must be scoped exactly like the models were: testing // the whole project would assert against models this script never // builds, the same failure the ingest-side scoping fixes. true, ) .await; // Merged, not appended: the model phase and the test phase can name the // same node, and a duplicate would double its totals and collide as a key // in the result table. merge_results(&mut results, read_run_results(&prepared.project_dir).await); // The same policy applies to a failing test. `dbt retry` redoes test // nodes, and the descriptor promises to retry the ones that failed — // retrying the model phase alone would exempt the failure mode // `test_behavior: after_all` exists to produce. if let Some(policy) = node_retry.filter(|_| run.is_err()) { retry_failed_nodes( policy, &prepared, &descriptor, &inv, job, conn, &mut ctx, &mut run, &mut results, &mut retries_left, ) .await; } } // Best-effort: losing the state costs a retry, not the run that just // finished. Logged rather than dropped — without it the only symptom is // `dbt retry` reporting nothing to resume, which reads as a bug in retry. if let Err(e) = save_run_state( &prepared, &job.workspace_id, &job.permissioned_as, job.visible_to_owner, &job.id, &inv, restored_results_digest.as_deref(), conn, ) .await { tracing::warn!("dbt: could not save retry state for job {}: {e:#}", job.id); } let reconciled = reconcile_materializations(&prepared, &results, job, conn, client).await; terminalize_running_relations(job, &reconciled, conn).await; let result = build_result(&prepared, &command, results, &inv); match run { Ok(()) => Ok(to_raw_value(&result)), Err(e) => { // dbt's exit code already honors each test's own `severity`: a // failing `warn` test leaves the run successful. Overriding that // would make the same project behave differently on Windmill than // it does locally, which is the promise this feature is built on. append_logs( &job.id, &job.workspace_id, format!("\n{}\n", render_failures(&result)), conn, ) .await; Err(Error::ExecutionErr(format!( "{e}\n\n{}", serde_json::to_string_pretty(&result).unwrap_or_default() ))) } } } /// Deploy-time lock: materialise the script's project and parse it, so its /// models land in the asset graph before it has ever run. /// /// This is the one place where dbt does not fit the shape every other language /// uses. `parse_assets_for_lang` is a pure function of the script content, and /// dbt's assets are not derivable from the descriptor — they need the project /// on disk and a dbt invocation. So the dependency job, which already runs on a /// worker with the engine available, does the parse and writes the `asset` rows /// itself; `parse_assets_for_lang` returns `None` for dbt and leaves them /// alone. That also makes redeploy the graph-refresh mechanism, with no /// separate concept (docs/dbt-runtime.md, decision 12). #[allow(clippy::too_many_arguments)] pub(crate) async fn dbt_dep( content: &str, // The project this dependency job is deploying: a dependency job has no // generic module-writing step, so the executor materialises them. modules: Option<&HashMap>, job_id: &Uuid, mem_peak: &mut i32, canceled_by: &mut Option, job_dir: &str, db: &sqlx::Pool, worker_name: &str, w_id: &str, script_path: &str, occupancy_metrics: &mut OccupancyMetrics, token: &str, base_internal_url: &str, ) -> error::Result { let descriptor = parse_dbt_descriptor(content)?; // A DEPLOY writes a whole node set of its own, `raw_code` included, so it // has to reclaim as well: hung off runs alone, 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. { let (pool, prune_w_id) = (db.clone(), w_id.to_string()); let prune_path = script_path.to_string(); tokio::spawn(async move { if let Err(e) = windmill_common::dbt_manifest::prune_dbt_run_graphs(&pool, &prune_path, &prune_w_id) .await { tracing::warn!("pruning dbt graphs at deploy: {e:#}"); } }); } // The script's own `envs`, exactly as a run gets them. A project can drive a // model's schema, alias or `enabled` from `env_var()`, so parsing with an // empty environment would record one relation at deploy and build another // at run time — with no per-run refresh to correct it. let envs = script_envs(db, job_id, w_id).await; let conn = Connection::Sql(db.clone()); let client = AuthedClient::new( base_internal_url.to_string(), w_id.to_string(), token.to_string(), None, ); // A dependency job carries no per-job timeout of its own, but its phases // still share one wall clock rather than each getting the instance-wide // one. let deadline = JobDeadline::start(&conn, w_id, *job_id, None).await; let mut ctx = JobCtx { mem_peak, canceled_by, occupancy_metrics, worker_name, deadline }; let prepared = prepare_project( &descriptor, content, None, job_dir, job_id, w_id, script_path, deploying_script_hash(db, job_id).await, &conn, &client, &mut ctx, &envs, modules, ) .await?; // A deploy has no job arguments, so it tolerates the `{{ }}` placeholders // only a run can fill (see `Invocation::strict`). Its environment is the // script's, matching what the run will parse with. let inv = Invocation { envs: envs.clone(), strict: false, ..Default::default() }; run_dbt_parse(&prepared, &descriptor, &inv, &mut ctx, job_id, w_id, &conn).await?; let selected = resolve_selection(&prepared, &descriptor, &inv, &mut ctx, job_id, w_id, &conn).await?; let manifest = read_manifest(&prepared.project_dir).await?; let manifest_digest = digest( &tokio::fs::read_to_string( prepared .project_dir .join(ARTIFACTS_DIR) .join("manifest.json"), ) .await .unwrap_or_default(), ); // Two deploys of one path can run concurrently — nothing serializes // dependency jobs. The GRAPH is keyed by version so both may write theirs; // the path-keyed asset usages are claimed by the newest. let publisher = match deploying_script_hash(db, job_id).await { Some(hash) => GraphPublisher::Version(hash), None => GraphPublisher::Unversioned, }; let superseded = if let Some(warehouse) = prepared.warehouse.as_deref() { let ingested = windmill_common::dbt_manifest::ingest_manifest( &manifest, warehouse, prepared.default_database.as_deref(), selected.as_ref(), ); let published = persist_ingest( db, w_id, script_path, &ingested, &prepared.relation_root(), publisher, None, true, ) .await?; if published { append_logs( job_id, w_id, format!( "\nIngested {} dbt nodes and {} edges into the asset graph\n", ingested.nodes.len(), ingested.edges.len() ), &conn, ) .await; } !published } else { // No warehouse identity, so nothing can be ingested — but this version's // rows must still go, or a descriptor moved to its own profiles.yml keeps // claiming relations it no longer describes. let mut tx = db.begin().await?; // Clearing is a publication too: an older job that no longer describes the // script must not wipe a newer deploy's graph. let published = claim_graph_publication(&mut tx, w_id, script_path, publisher).await?; if published { // This VERSION's rows, never the path's: what is given up is the // path-keyed usage cleared below, while an older version's graph is // what its own finished runs still render. if let GraphPublisher::Version(hash) = publisher { windmill_common::dbt_manifest::clear_dbt_manifest_version( &mut tx, w_id, script_path, hash, ) .await?; } windmill_common::assets::replace_static_asset_usage(&mut tx, w_id, script_path, &[]) .await?; tx.commit().await?; append_logs( job_id, w_id, "\nNo asset-graph ingest: the descriptor names no `profile.warehouse` beside \ its own `profile.profiles_yml`, so there is no warehouse identity to key \ `dbt://` assets on. Any previously ingested nodes for this script have been \ cleared.\n" .to_string(), &conn, ) .await; } !published }; if superseded { append_logs( job_id, w_id, "\nA newer version of this script was deployed while this job ran, so the asset \ graph was left describing that one.\n" .to_string(), &conn, ) .await; } serde_json::to_string_pretty(&DbtDependencyLocks { manifest_digest, profile_relation_root: Some(prepared.relation_root()), engine: prepared.engine.engine.as_str().to_string(), engine_version: prepared.engine.version.clone(), adapter_version: prepared.engine.adapter_version.clone(), package_lock_digest: prepared.package_lock_digest.clone(), }) .map_err(|e| Error::internal_err(format!("serializing the dbt lockfile: {e}"))) } /// The `envs` of the script this dependency job is deploying, in the same shape /// a run receives them. Empty when the version cannot be resolved — a raw /// dependency job has no script row. async fn script_envs( db: &sqlx::Pool, job_id: &Uuid, w_id: &str, ) -> HashMap { let Some(hash) = deploying_script_hash(db, job_id).await else { return HashMap::new(); }; let envs = sqlx::query_scalar!( "SELECT envs FROM script WHERE workspace_id = $1 AND hash = $2", w_id, hash ) .fetch_optional(db) .await .ok() .flatten() .flatten(); crate::worker::build_envs(envs.as_ref()).unwrap_or_default() } /// The script version this dependency job is deploying. `None` for a raw /// dependency job (the CLI's lock generation), which has no script row. async fn deploying_script_hash(db: &sqlx::Pool, job_id: &Uuid) -> Option { sqlx::query_scalar!("SELECT runnable_id FROM v2_job WHERE id = $1", job_id) .fetch_optional(db) .await .ok() .flatten() .flatten() } /// Why a run re-ingests its graph instead of trusting the deployed version's — /// and, since the reasons differ in WHOSE graph the result is, what becomes of /// it. /// /// A model set this RUN decides — a caller's override, or a descriptor dynamic by /// construction — is stored under the job id so the run page shows what it built, /// leaving the script's ownership alone: those schemas and aliases would otherwise /// stand as the script's until the next deploy. /// /// A moved PROFILE is the one that republishes, because every later run of this /// version resolves there too. Publishing is also what ENDS a drift: the check /// reads back the published root, so a run that saw a move and did not republish /// leaves the next one seeing the same move. #[derive(Clone, Copy, Default)] pub struct GraphRefresh { /// This run's models are not the deployed descriptor's: a `{{ }}` /// placeholder in `vars` or a `$var:` in `env` (re-resolved every run), or /// an invocation that overrode `vars`. Vars steer `enabled`, alias, schema, /// database and materialization, so the deployed graph names another run's /// relations. per_run_models: bool, /// The profile resolves somewhere other than where the published usages /// point. The relations moved for the VERSION, not for one invocation. profile_drift: bool, } impl GraphRefresh { /// Whether this run parses and ingests a graph of its own at all. fn needed(&self) -> bool { self.per_run_models || self.profile_drift } /// The job to key this graph under, or `None` to write the version's own. /// /// Only a DRIFT alone writes the version's: the move is permanent, and /// storing it per run would leave every later run — which no longer detects /// a drift, because this one published the new root — reading the pre-move /// rows. A run whose models are its own goes under the job id, in both /// directions: written as the version's, a narrowing selection would drop /// every model this invocation left out, and a widening one would add models /// that version never had. fn snapshot_job(&self, job_id: uuid::Uuid) -> Option { self.per_run_models.then_some(job_id) } /// Whether this ingest also becomes what the script owns. /// /// Exactly when it wrote the VERSION's graph and the caller scoped nothing. /// The workspace graph takes an asset's relations from the `asset` rows and /// its models, SQL, tests and `ref()` lineage from that version's /// `dbt_node`/`dbt_edge`, so publishing relations the version's graph does /// not name leaves those assets with no model behind them — a placeholder /// that moves an alias would empty the current graph of everything dbt /// contributes to it. /// /// So a run that stored a snapshot of its own publishes nothing, which /// leaves two cases settled elsewhere and deliberately: an override's /// relations are a one-off and are meant not to stand as the script's, and a /// dynamic descriptor at a moved profile keeps its ownership at the deploy's /// relations until a redeploy — every run of it still shows its own models, /// and it re-parses regardless, so the undetected-forever drift costs it /// nothing it was not already paying. /// /// The exact complement of `snapshot_job`, which is what lets the agent /// worker's payload carry one `per_run` bit and no second flag: decouple the /// two and that wire format stops describing this decision. fn publishes_ownership(&self) -> bool { !self.per_run_models } /// Fold in what this invocation's own arguments say about its model set. fn add_caller_args( &mut self, descriptor: &DbtDescriptor, args: &HashMap>, ) -> error::Result<()> { if has_vars_override(args) { self.per_run_models = true; } // A caller's selection is not necessarily a SUBSET of the deployed one: // deployed `select: ["tag:nightly"]`, overridden with `["*"]`, builds models // the deployed graph never had — and those are the ones whose progress, SQL // and lineage the run page would otherwise have nothing to draw. if selection_is_overridden(descriptor, args)? { self.per_run_models = true; } Ok(()) } } pub struct PreparedProject { pub project_dir: PathBuf, pub profiles_dir: PathBuf, pub engine: ProvisionedEngine, /// Why this run's graph is its own rather than the deployed version's, if /// it is. pub graph_refresh: GraphRefresh, /// Digest of the project's own files: the identity of the code that runs. /// It keys the package cache (a `local:` dependency's content appears in no /// manifest) and gates retry state, so a project edited between attempts /// cannot resume the old one. pub project_digest: String, /// Resolution produced by `dbt deps` at deploy. The package cache is local /// to one worker; this makes a cache miss on another worker fail closed if /// an unlocked range or mutable Git revision has moved meanwhile. pub package_lock_digest: Option, /// The workspace warehouse's NAME, the `` component of every /// `dbt://` asset this project produces. `None` when the project brings its /// own `profiles.yml` and names no warehouse, in which case there is no /// stable warehouse identity to key assets on. pub warehouse: Option, /// The descriptor's `profile.target`, passed as `--target` so it applies to /// a project-owned `profiles.yml` as well as a rendered one. pub target: Option, /// The profile target's database. Nodes that override it qualify their /// `dbt://` schema segment so two databases cannot collapse onto one node. pub default_database: Option, /// The profile target's schema, for the drift check against the lockfile. pub default_schema: Option, pub script_path: String, pub env: Vec<(String, String)>, /// The descriptor body, kept so an ingest can re-read its `# on` / `# mute` /// annotations without threading the content through every caller. pub descriptor_content: String, /// The descriptor's `env`, resolved, in a stable order. Feeds run identity; /// `env` itself is not usable there because it carries per-job values. pub descriptor_env: std::collections::BTreeMap, /// The invocation's own environment (the script's `envs`), in a stable /// order. Every phase gets it, `dbt deps` included: `packages.yml` can /// resolve a private package URL through `env_var()`, and a phase that saw /// a different environment from the one the cache key was built on would /// populate that key with the wrong tree. pub invocation_env: Vec<(String, String)>, /// Digest of the above. Keys the package cache alongside the descriptor's /// environment; digested because the values are resolved secrets. pub invocation_env_digest: u64, /// Written nsjail profile for this job, when the worker sandboxes jobs. /// `None` means the phases run unsandboxed, exactly as before. pub sandbox_config: Option, /// One-way digest of the rendered profile — the resolved connection, not /// just the names it exposes. A resource repointed from one warehouse to /// another that happens to use the same database and schema names is /// invisible to `relation_root`, and a retry would then execute the saved /// failures against a warehouse where the successful nodes do not exist. pub profile_digest: String, } impl PreparedProject { /// Where this run's relations live: the resolved schema and database. Drift /// here since the deploy means the stored graph names relations that no /// longer exist. fn relation_root(&self) -> String { format!( "{}|{}", self.default_schema.as_deref().unwrap_or(""), self.default_database.as_deref().unwrap_or(""), ) } /// The environment `dbt_project.yml`'s own `env_var()` calls render /// against: the two the run gives dbt, in the order the child receives /// them. `HOME` is left out on purpose — it is Windmill's, not the /// project's, and it differs on every attempt. fn template_env(&self) -> HashMap { self.descriptor_env .iter() .map(|(k, v)| (k.clone(), v.clone())) .chain(self.invocation_env.iter().cloned()) .collect() } /// A digest of the DESCRIPTOR's resolved environment, for `run_identity`. /// Digested rather than listed: the values are resolved secrets. /// /// Only the descriptor's own entries. `env` additionally carries `HOME`, /// set to this job's directory, which differs on every attempt — hashing it /// would make a retry reject its own predecessor every time. fn env_digest(&self) -> String { stable_digest( self.descriptor_env .iter() .flat_map(|(k, v)| [k.as_str(), v.as_str()]), ) } /// Everything that decides which relations a run produces, which is what a /// retry has to match before it may resume a saved `run_results.json`: same /// project files, same warehouse and target, same engine. Identity only, /// never credentials — the profile is digested, and the digest is one-way. /// /// Anything omitted here is something a redeploy could change while a stale /// `run_results.json` stays eligible, so `dbt retry` would resume one /// project's failures inside another. The descriptor's resolved environment /// is in it because `env_var()` can drive a model's schema, database, alias /// or `enabled`. fn run_identity(&self) -> String { // The descriptor whole, not field by field, or the next field added to it // is silently left out. And the RESOLVED engine and adapter versions: an // unchanged project redeployed after a release resolves a newer dbt, whose // retry would otherwise feed one version's `run_results.json` to another. format!( "{}|{}|{}|{}|{}|{}|{}|{}|{}", self.project_digest, self.package_lock_digest.as_deref().unwrap_or(""), self.engine.engine.as_str(), self.engine.version, self.engine.adapter_version.as_deref().unwrap_or(""), digest(&self.descriptor_content), self.env_digest(), self.relation_root(), self.profile_digest, ) } } #[allow(clippy::too_many_arguments)] pub(crate) async fn prepare_project( descriptor: &DbtDescriptor, descriptor_content: &str, locks: Option<&DbtDependencyLocks>, job_dir: &str, job_id: &Uuid, w_id: &str, // Keys the per-script retry-state cache. Passed in rather than patched onto // the result afterwards: an empty value silently shares one state directory // across every dbt script in the workspace, so a retry resumes another // project's run_results.json. script_path: &str, // The version this job runs, which is the one whose stored graph the drift // check below must read. `None` for a preview, which has no stored graph. script_hash: Option, conn: &Connection, client: &AuthedClient, ctx: &mut JobCtx<'_>, // The invocation's own environment (script-level `envs`). Needed here // because under a sandbox it must travel in the jail profile rather than // on the process that execs nsjail. invocation_env: &HashMap, // The script's files: the dbt project itself. modules: Option<&HashMap>, ) -> error::Result { // The project IS this script's files. Nothing is fetched: the bundle is the // project. A dependency job has no generic module-writing step, so it does // the writing here; a run rewrites the same bytes, which costs nothing next // to the dbt invocations that follow. if let Some(modules) = modules { write_module_files(job_dir, modules, None).await?; } let project_dir = PathBuf::from(job_dir); if !project_dir.join("dbt_project.yml").exists() { return Err(Error::BadRequest( "this dbt script carries no project: `dbt_project.yml` was not found. Copy a dbt \ project into its `