Files
windmill/backend/windmill-queue/src/asset_dispatch.rs
T
Ruben FiszelandClaude Opus 4.7 d56b842a61 feat: partition value resolver + asset-cascade propagation
windmill-common/partition: pure resolver — time kinds (tz/format/start
anchor) + dynamic $.a.b JSONPath; 9 unit tests. asset_dispatch:
read the producer's resolved partition and thread it into every
cascaded subscriber's args + trigger.partition, so a chain resolves
once at the top. No migration (cascade needs no spec lookup). Stage
1+3 of pipeline partition runtime; run-start resolution is Stage 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:58:06 +00:00

430 lines
14 KiB
Rust

/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Runtime fan-out for asset-triggered scripts.
//!
//! When a producer pipeline script (`// pipeline`) writes an asset and a
//! downstream script subscribes to that asset via `// on s3://...`, this
//! module pushes a job for each subscriber after the producer's job
//! completes successfully.
//!
//! Eligibility (V1, narrow on purpose):
//! - Producer kind is `Script` or `Preview`. Flows defer.
//! - Producer is top-level (no `parent_job`, no `flow_step_id`).
//! - Producer succeeded.
//! - The producer's args do not contain `_wmill_skip_asset_dispatch: true`.
//! - Cascade depth (carried in args under `trigger.depth`) is below
//! `MAX_CHAIN_DEPTH`.
//!
//! Subscribers (V1):
//! - Only `script` runnables. Flow subscribers defer.
//! - The subscriber must have at least one non-archived script row.
//! - A subscriber is skipped if its path equals the producer's path
//! (avoids trivial self-loops).
//!
//! Args sent to subscribers:
//! ```json
//! {
//! "trigger": {
//! "kind": "asset",
//! "asset_kind": "s3object",
//! "asset_path": "...",
//! "producer_path": "...",
//! "producer_job_id": "...",
//! "depth": 1
//! }
//! }
//! ```
//!
//! Errors are logged but never bubble up to fail the producer's job.
use crate::{push, MiniCompletedJob, PushArgs, PushIsolationLevel};
use serde_json::value::RawValue;
use sqlx::types::Json;
use sqlx::{Pool, Postgres};
use std::collections::HashMap;
use uuid::Uuid;
use windmill_common::assets::{parse_asset_trigger_ref, AssetKind};
use windmill_common::error::{self, Result};
use windmill_common::get_latest_hash_for_path;
use windmill_common::jobs::{JobKind, JobPayload, JobTriggerKind};
use windmill_common::partition::PARTITION_ARG;
use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings};
use windmill_common::triggers::TriggerMetadata;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::worker::to_raw_value;
use windmill_common::DB;
/// Reserved arg key that suppresses asset-trigger dispatch for a single run.
/// Set by the test panel when the user opts out of the cascade.
pub const SKIP_ASSET_DISPATCH_ARG: &str = "_wmill_skip_asset_dispatch";
/// Reserved arg key (under `trigger.depth`) that carries cascade depth.
const CHAIN_DEPTH_KEY: &str = "depth";
/// Cap cascade depth so a misconfigured ring doesn't fan out unbounded.
const MAX_CHAIN_DEPTH: i64 = 5;
/// Returned to the caller (the worker's completed-job hook) so logs can
/// reference the dispatched ids.
#[derive(Debug, Default)]
pub struct DispatchResult {
pub dispatched: Vec<Uuid>,
}
/// Top-level entry. Returns `Ok(default)` and logs on any internal failure
/// rather than propagating, because dispatch is best-effort and must not
/// retroactively fail the producer.
pub async fn dispatch_asset_triggers(db: &DB, job: &MiniCompletedJob) -> DispatchResult {
match try_dispatch(db, job).await {
Ok(r) => r,
Err(e) => {
tracing::error!("asset-trigger dispatch failed for job {}: {e:#}", job.id);
DispatchResult::default()
}
}
}
async fn try_dispatch(db: &DB, job: &MiniCompletedJob) -> Result<DispatchResult> {
if !is_eligible_kind(job) {
return Ok(DispatchResult::default());
}
let runnable_path = match job.runnable_path.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return Ok(DispatchResult::default()),
};
// Args were moved from v2_job to v2_job_completed by add_completed_job
// before dispatch runs. Fetch from v2_job_completed.
let args = fetch_args(db, &job.workspace_id, job.id).await?;
if read_skip_arg(args.as_ref()) {
return Ok(DispatchResult::default());
}
let depth = read_chain_depth(args.as_ref());
let partition = read_partition(args.as_ref());
if depth >= MAX_CHAIN_DEPTH {
tracing::warn!(
"asset-trigger dispatch skipped: chain depth {} >= cap {} (job {}, path {})",
depth,
MAX_CHAIN_DEPTH,
job.id,
runnable_path
);
return Ok(DispatchResult::default());
}
let writes = fetch_producer_writes(db, &job.workspace_id, runnable_path).await?;
if writes.is_empty() {
return Ok(DispatchResult::default());
}
let mut dispatched = Vec::new();
for (asset_kind, asset_path) in writes {
let Some(prefix) = prefix_for(asset_kind) else {
continue;
};
let trigger_ref = format!("{}{}", prefix, asset_path);
let subs = fetch_subscribers(db, &job.workspace_id, &trigger_ref).await?;
for sub_path in subs {
if sub_path == runnable_path {
continue;
}
match push_subscriber(
db,
job,
&sub_path,
asset_kind,
&asset_path,
runnable_path,
depth + 1,
partition.as_deref(),
)
.await
{
Ok(id) => dispatched.push(id),
Err(e) => {
tracing::error!("failed to push asset-triggered job for {}: {e:#}", sub_path)
}
}
}
}
if !dispatched.is_empty() {
tracing::info!(
"asset-trigger dispatch from job {} ({}): pushed {} downstream jobs",
job.id,
runnable_path,
dispatched.len()
);
}
Ok(DispatchResult { dispatched })
}
fn is_eligible_kind(job: &MiniCompletedJob) -> bool {
if !matches!(job.kind, JobKind::Script | JobKind::Preview) {
return false;
}
if job.parent_job.is_some() || job.flow_step_id.is_some() {
return false;
}
true
}
async fn fetch_args(
db: &Pool<Postgres>,
workspace_id: &str,
job_id: Uuid,
) -> Result<Option<HashMap<String, Box<RawValue>>>> {
// Read from v2_job because args live there permanently — v2_job_completed
// is the *result* row and doesn't carry args. The producer's v2_job row
// is still present at dispatch time (deletion happens later in the
// completion pipeline, after this hook).
let row = sqlx::query!(
r#"SELECT args AS "args!: Json<HashMap<String, Box<RawValue>>>"
FROM v2_job
WHERE workspace_id = $1 AND id = $2"#,
workspace_id,
job_id,
)
.fetch_optional(db)
.await?;
Ok(row.map(|r| r.args.0))
}
fn read_skip_arg(args: Option<&HashMap<String, Box<RawValue>>>) -> bool {
args.and_then(|a| a.get(SKIP_ASSET_DISPATCH_ARG))
.and_then(|v| serde_json::from_str::<bool>(v.get()).ok())
.unwrap_or(false)
}
fn read_chain_depth(args: Option<&HashMap<String, Box<RawValue>>>) -> i64 {
let Some(args) = args else {
return 0;
};
let Some(trigger) = args.get("trigger") else {
return 0;
};
let Ok(map) = serde_json::from_str::<HashMap<String, Box<RawValue>>>(trigger.get()) else {
return 0;
};
map.get(CHAIN_DEPTH_KEY)
.and_then(|v| serde_json::from_str::<i64>(v.get()).ok())
.unwrap_or(0)
}
/// The partition value the producer ran with, if any. Resolved once at the
/// top of a chain (run-start) and threaded down here so every cascaded job
/// materializes the same partition without re-resolving. Top-level
/// `partition` arg (run-start injection) takes precedence over the
/// `trigger.partition` carried from an upstream cascade hop.
fn read_partition(args: Option<&HashMap<String, Box<RawValue>>>) -> Option<String> {
let args = args?;
if let Some(v) = args.get(PARTITION_ARG) {
if let Ok(s) = serde_json::from_str::<String>(v.get()) {
return Some(s);
}
}
let trigger = args.get("trigger")?;
let map = serde_json::from_str::<HashMap<String, Box<RawValue>>>(trigger.get()).ok()?;
serde_json::from_str::<String>(map.get(PARTITION_ARG)?.get()).ok()
}
fn prefix_for(kind: AssetKind) -> Option<&'static str> {
match kind {
AssetKind::S3Object => Some("s3://"),
AssetKind::Resource => Some("$res:"),
AssetKind::Ducklake => Some("ducklake://"),
AssetKind::DataTable => Some("datatable://"),
AssetKind::Volume => Some("volume://"),
// Deprecated kind from before the parser was unified — has no
// canonical trigger ref and never produced trigger rows.
AssetKind::Variable => None,
}
}
async fn fetch_producer_writes(
db: &Pool<Postgres>,
workspace_id: &str,
runnable_path: &str,
) -> Result<Vec<(AssetKind, String)>> {
let rows = sqlx::query!(
r#"
SELECT
kind AS "kind!: AssetKind",
path AS "path!"
FROM asset
WHERE workspace_id = $1
AND usage_kind = 'script'
AND usage_path = $2
AND usage_access_type IN ('w', 'rw')
"#,
workspace_id,
runnable_path,
)
.fetch_all(db)
.await?;
Ok(rows.into_iter().map(|r| (r.kind, r.path)).collect())
}
async fn fetch_subscribers(
db: &Pool<Postgres>,
workspace_id: &str,
trigger_ref: &str,
) -> Result<Vec<String>> {
// V1: script subscribers only. Flow subscribers (`runnable_kind = 'flow'`)
// are intentionally excluded — wiring them is straightforward but the
// payload shape and permissioning need their own pass.
let rows = sqlx::query!(
r#"
SELECT runnable_path AS "runnable_path!"
FROM script_trigger
WHERE workspace_id = $1
AND trigger_kind = 'asset'
AND trigger_ref = $2
AND runnable_kind = 'script'
"#,
workspace_id,
trigger_ref,
)
.fetch_all(db)
.await?;
// Sanity check: parsing the stored trigger_ref must succeed. If it
// doesn't, the row is corrupt; skip it loudly rather than silently.
if parse_asset_trigger_ref(trigger_ref).is_none() {
tracing::warn!(
"asset-trigger dispatch: trigger_ref {} did not round-trip through parse_asset_trigger_ref",
trigger_ref
);
}
Ok(rows.into_iter().map(|r| r.runnable_path).collect())
}
async fn push_subscriber(
db: &DB,
producer: &MiniCompletedJob,
subscriber_path: &str,
asset_kind: AssetKind,
asset_path: &str,
producer_path: &str,
depth: i64,
partition: Option<&str>,
) -> Result<Uuid> {
let (
hash,
tag,
_concurrency_key,
_concurrent_limit,
_concurrency_time_window_s,
_debounce_key,
_debounce_delay_s,
cache_ttl,
cache_ignore_s3_path,
language,
dedicated_worker,
priority,
_timeout,
on_behalf_of_email,
created_by,
_runnable_settings_handle,
labels,
) = get_latest_hash_for_path(db, &producer.workspace_id, subscriber_path, false).await?;
let payload = JobPayload::ScriptHash {
hash,
path: subscriber_path.to_string(),
cache_ttl,
cache_ignore_s3_path,
dedicated_worker,
language,
priority,
apply_preprocessor: false,
// V1: skip debouncing/concurrency for asset-triggered runs. The
// trigger fan-out is the user's intent — we don't want a noisy
// upstream's writes to silently drop downstream runs because a
// debounce key collides. Revisit if we see lots of dups.
debouncing_settings: DebouncingSettings::default(),
concurrency_settings: ConcurrencySettings::default(),
labels,
};
// Subscriber's own on_behalf_of_email controls identity when set;
// otherwise we run as the producer. This keeps the asset cascade
// attributable to whoever originally wrote the asset, while still
// honoring scripts that explicitly opted into a service-account email.
let (permissioned_as, email) = if let Some(obo) = on_behalf_of_email {
(username_to_permissioned_as(&created_by), obo)
} else {
(
producer.permissioned_as.clone(),
producer.permissioned_as_email.clone(),
)
};
let mut args: HashMap<String, Box<RawValue>> = HashMap::new();
let trigger_payload = serde_json::json!({
"kind": "asset",
"asset_kind": serde_json::to_value(&asset_kind).unwrap_or(serde_json::Value::Null),
"asset_path": asset_path,
"producer_path": producer_path,
"producer_job_id": producer.id.to_string(),
CHAIN_DEPTH_KEY: depth,
PARTITION_ARG: partition,
});
args.insert("trigger".to_string(), to_raw_value(&trigger_payload));
// Carry the producer's resolved partition forward as a top-level arg so
// the subscriber's body can read it and the next cascade hop's
// `read_partition` picks it up — keeps the whole chain on one partition,
// resolved once at the top. Omitted entirely for non-partitioned chains.
if let Some(p) = partition {
args.insert(PARTITION_ARG.to_string(), to_raw_value(&p));
}
// Attribute the dispatched run to a synthetic user so audit logs reflect
// it came from the asset cascade, not the original human runner.
let pseudo_user = format!("asset-{producer_path}");
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
let (id, tx) = push(
db,
tx,
&producer.workspace_id,
payload,
PushArgs { args: &args, extra: None },
&pseudo_user,
&email,
permissioned_as,
Some(producer_path),
None,
Some(producer_path.to_string()),
None,
None,
None,
None,
false,
false,
None,
true,
tag,
None,
None,
None,
None,
false,
None,
Some(TriggerMetadata::new(
Some(producer_path.to_string()),
JobTriggerKind::Asset,
)),
None,
)
.await
.map_err(|e| error::Error::internal_err(format!("push asset-triggered job: {e:#}")))?;
tx.commit().await?;
Ok(id)
}