mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
af36498432
* feat(pipelines): record upstream snapshot ids on cascade-dispatched jobs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: batch upstream-snapshot lookup and memoize per subscriber Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
892 lines
35 KiB
Rust
892 lines
35 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 script 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. Any
|
||
//! asset-writing top-level script cascades — there is no `// pipeline`
|
||
//! gate on the producer side; subscriptions alone define the graph.
|
||
//!
|
||
//! 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`.
|
||
//!
|
||
//! 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
|
||
//! (self-loop) or already appears in the cascade lineage
|
||
//! (`trigger.chain`) — cycle detection, which bounds the cascade
|
||
//! without capping legitimate depth.
|
||
//!
|
||
//! Args sent to subscribers:
|
||
//! ```json
|
||
//! {
|
||
//! "trigger": {
|
||
//! "kind": "asset",
|
||
//! "asset_kind": "s3object",
|
||
//! "asset_path": "...",
|
||
//! "producer_path": "...",
|
||
//! "producer_job_id": "...",
|
||
//! "chain": ["f/a/producer0", "f/a/producer1"],
|
||
//! "upstream_snapshots": [
|
||
//! { "asset": "ducklake://analytics/orders", "snapshot_id": 42 }
|
||
//! ]
|
||
//! }
|
||
//! }
|
||
//! ```
|
||
//!
|
||
//! `upstream_snapshots` is a forensic record only (present when at least one
|
||
//! direct upstream has a captured materialization snapshot): it says which
|
||
//! substrate version each of the subscriber's `// on` assets was at when the
|
||
//! job was dispatched, so a failing run can be replayed against DuckLake
|
||
//! time-travel (`AT (VERSION => n)`). Nothing pins the consumer's reads to it.
|
||
//!
|
||
//! Errors are logged but never bubble up to fail the producer's job.
|
||
|
||
use crate::{push, MiniCompletedJob, PushArgs, PushIsolationLevel};
|
||
use serde::Serialize;
|
||
use serde_json::value::RawValue;
|
||
use sqlx::types::Json;
|
||
use sqlx::{Pool, Postgres};
|
||
use std::collections::HashMap;
|
||
use std::sync::Arc;
|
||
use uuid::Uuid;
|
||
use windmill_common::assets::{parse_asset_trigger_ref, AssetKind};
|
||
use windmill_common::error::{self, Result};
|
||
use windmill_common::get_latest_deployed_hash_for_path;
|
||
use windmill_common::jobs::{JobKind, JobPayload, JobTriggerKind};
|
||
use windmill_common::partition::PARTITION_ARG;
|
||
use windmill_common::scripts::ScriptHash;
|
||
use windmill_common::triggers::TriggerMetadata;
|
||
use windmill_common::users::{get_email_from_permissioned_as, 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";
|
||
|
||
/// Arg key holding the cascade trigger object (carries `chain`, `partition`,
|
||
/// producer metadata) injected into every dispatched subscriber.
|
||
const TRIGGER_ARG: &str = "trigger";
|
||
|
||
/// Arg key (under `trigger.chain`) carrying the cascade lineage: the ordered
|
||
/// list of producer paths already run in this chain. Used to detect cycles
|
||
/// (a producer re-appearing) and stop only the cyclic edge — so deep but
|
||
/// *acyclic* pipelines are never truncated.
|
||
const CHAIN_KEY: &str = "chain";
|
||
|
||
/// Safety backstop on lineage length. Cycle detection already bounds an
|
||
/// acyclic cascade (a path can't repeat), so this only guards against a
|
||
/// runaway from a bug. Set far above any real pipeline depth.
|
||
const MAX_CHAIN_LEN: usize = 1000;
|
||
|
||
/// 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>,
|
||
}
|
||
|
||
/// Per-decision outcome persisted to `dispatch_event` so the producer's
|
||
/// job detail page can show what happened to each subscriber. Mirrors
|
||
/// the `DISPATCH_OUTCOME` Postgres enum exactly.
|
||
#[derive(Debug, Clone, Copy, sqlx::Type)]
|
||
#[sqlx(type_name = "DISPATCH_OUTCOME", rename_all = "snake_case")]
|
||
enum DispatchOutcome {
|
||
Dispatched,
|
||
JoinPending,
|
||
Skipped,
|
||
}
|
||
|
||
/// Outcome-specific fields. Event constructors take the four "always-present"
|
||
/// columns positionally and bundle the rest here so each call site only
|
||
/// names what it actually carries.
|
||
#[derive(Debug, Default)]
|
||
struct EventOptions<'a> {
|
||
child_job_id: Option<Uuid>,
|
||
partition: Option<&'a str>,
|
||
received_inputs: Option<i32>,
|
||
required_inputs: Option<i32>,
|
||
debounce_s: Option<i32>,
|
||
reason: Option<&'a str>,
|
||
}
|
||
|
||
/// One accumulated `dispatch_event` row. Owned (not borrowed) so the whole
|
||
/// dispatch pass can collect rows and flush them in a single batched INSERT
|
||
/// at the end, avoiding an N+1 (one INSERT per subscriber × asset write).
|
||
#[derive(Debug)]
|
||
struct EventRow {
|
||
subscriber_path: String,
|
||
asset_kind: AssetKind,
|
||
asset_path: String,
|
||
outcome: DispatchOutcome,
|
||
child_job_id: Option<Uuid>,
|
||
partition: Option<String>,
|
||
received_inputs: Option<i32>,
|
||
required_inputs: Option<i32>,
|
||
debounce_s: Option<i32>,
|
||
reason: Option<String>,
|
||
}
|
||
|
||
impl EventRow {
|
||
fn new(
|
||
subscriber_path: &str,
|
||
asset_kind: AssetKind,
|
||
asset_path: &str,
|
||
outcome: DispatchOutcome,
|
||
opts: EventOptions<'_>,
|
||
) -> Self {
|
||
EventRow {
|
||
subscriber_path: subscriber_path.to_string(),
|
||
asset_kind,
|
||
asset_path: asset_path.to_string(),
|
||
outcome,
|
||
child_job_id: opts.child_job_id,
|
||
partition: opts.partition.map(str::to_string),
|
||
received_inputs: opts.received_inputs,
|
||
required_inputs: opts.required_inputs,
|
||
debounce_s: opts.debounce_s,
|
||
reason: opts.reason.map(str::to_string),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Best-effort batched insert into `dispatch_event`. Never propagates — the
|
||
/// dispatch contract is "logging failures must not retroactively fail the
|
||
/// producer's job." All rows accumulated over a dispatch pass go in one
|
||
/// INSERT (UNNEST) to avoid an N+1 across (subscriber × asset write).
|
||
async fn flush_events(db: &DB, workspace_id: &str, producer_job_id: Uuid, events: &[EventRow]) {
|
||
if events.is_empty() {
|
||
return;
|
||
}
|
||
// Column-oriented arrays for UNNEST. Each Vec is one column across all rows.
|
||
let subscriber_paths: Vec<String> = events.iter().map(|e| e.subscriber_path.clone()).collect();
|
||
let asset_kinds: Vec<AssetKind> = events.iter().map(|e| e.asset_kind).collect();
|
||
let asset_paths: Vec<String> = events.iter().map(|e| e.asset_path.clone()).collect();
|
||
let outcomes: Vec<DispatchOutcome> = events.iter().map(|e| e.outcome).collect();
|
||
let child_job_ids: Vec<Option<Uuid>> = events.iter().map(|e| e.child_job_id).collect();
|
||
let partitions: Vec<Option<String>> = events.iter().map(|e| e.partition.clone()).collect();
|
||
let received_inputs: Vec<Option<i32>> = events.iter().map(|e| e.received_inputs).collect();
|
||
let required_inputs: Vec<Option<i32>> = events.iter().map(|e| e.required_inputs).collect();
|
||
let debounce_s: Vec<Option<i32>> = events.iter().map(|e| e.debounce_s).collect();
|
||
let reasons: Vec<Option<String>> = events.iter().map(|e| e.reason.clone()).collect();
|
||
|
||
let res = sqlx::query!(
|
||
r#"INSERT INTO dispatch_event (
|
||
workspace_id, producer_job_id, subscriber_path,
|
||
asset_kind, asset_path, outcome,
|
||
child_job_id, partition,
|
||
received_inputs, required_inputs,
|
||
debounce_s, reason
|
||
)
|
||
SELECT $1, $2, sp, ak, ap, oc, cj, pt, ri, rq, db, rs
|
||
FROM unnest(
|
||
$3::text[], $4::ASSET_KIND[], $5::text[], $6::DISPATCH_OUTCOME[],
|
||
$7::uuid[], $8::text[], $9::int[], $10::int[], $11::int[], $12::text[]
|
||
) AS t(sp, ak, ap, oc, cj, pt, ri, rq, db, rs)"#,
|
||
workspace_id,
|
||
producer_job_id,
|
||
&subscriber_paths,
|
||
asset_kinds as Vec<AssetKind>,
|
||
&asset_paths,
|
||
outcomes as Vec<DispatchOutcome>,
|
||
&child_job_ids as &[Option<Uuid>],
|
||
&partitions as &[Option<String>],
|
||
&received_inputs as &[Option<i32>],
|
||
&required_inputs as &[Option<i32>],
|
||
&debounce_s as &[Option<i32>],
|
||
&reasons as &[Option<String>],
|
||
)
|
||
.execute(db)
|
||
.await;
|
||
if let Err(e) = res {
|
||
tracing::error!(
|
||
"failed to record {} dispatch_event row(s) for producer {}: {e:#}",
|
||
events.len(),
|
||
producer_job_id
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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());
|
||
}
|
||
// A parented script is dispatch-eligible only as a native retry attempt — a
|
||
// re-run of the SAME runnable as its chain parent. Schedule/error/recovery
|
||
// handlers are also parented `Script` children but run a DIFFERENT script;
|
||
// excluding them stops a handler that happens to declare assets from
|
||
// triggering a cascade (the pre-native-retry `parent_job IS NULL` guard
|
||
// excluded every parented child).
|
||
if job.parent_job.is_some() && !is_native_retry_attempt(db, job).await? {
|
||
return Ok(DispatchResult::default());
|
||
}
|
||
let runnable_path = match job.runnable_path.as_deref() {
|
||
Some(p) if !p.is_empty() => p,
|
||
_ => return Ok(DispatchResult::default()),
|
||
};
|
||
|
||
// Producer gate (cached): this hook fires on every top-level
|
||
// script/preview completion, and the overwhelmingly common case is a
|
||
// script that writes no asset. The per-workspace producer→writes map is
|
||
// cached and invalidated by a trigger on `asset`, so a non-producer
|
||
// completion costs one in-memory lookup and zero queries. The map is
|
||
// keyed on the deploy-time `asset` table by path, so an undeployed/new
|
||
// preview (no asset rows for its path) is a non-producer and never
|
||
// cascades — same as the previous per-completion lookup.
|
||
let producers = workspace_producer_writes(db, &job.workspace_id).await?;
|
||
let Some(writes) = producers.get(runnable_path).cloned() else {
|
||
return Ok(DispatchResult::default());
|
||
};
|
||
|
||
let args = fetch_args(db, &job.workspace_id, job.id).await?;
|
||
if read_skip_arg(args.as_ref()) {
|
||
return Ok(DispatchResult::default());
|
||
}
|
||
// Parse the cascade `trigger` object once; both the lineage chain and
|
||
// the propagated partition are read from it.
|
||
let trigger_map = args
|
||
.as_ref()
|
||
.and_then(|a| a.get(TRIGGER_ARG))
|
||
.and_then(|t| serde_json::from_str::<HashMap<String, Box<RawValue>>>(t.get()).ok());
|
||
let chain = read_chain(trigger_map.as_ref());
|
||
let partition = read_partition(args.as_ref(), trigger_map.as_ref());
|
||
if chain.len() >= MAX_CHAIN_LEN {
|
||
tracing::warn!(
|
||
"asset-trigger dispatch skipped: cascade lineage length {} >= backstop {} (job {}, path {})",
|
||
chain.len(),
|
||
MAX_CHAIN_LEN,
|
||
job.id,
|
||
runnable_path
|
||
);
|
||
return Ok(DispatchResult::default());
|
||
}
|
||
// Lineage propagated to any subscriber pushed from this producer: the
|
||
// ancestors that already ran, plus this producer.
|
||
let mut next_chain = chain.clone();
|
||
next_chain.push(runnable_path.to_string());
|
||
|
||
let mut dispatched = Vec::new();
|
||
// Best-effort dispatch_event rows accumulated over the whole pass and
|
||
// flushed in one batched INSERT at the end (avoids an N+1 over
|
||
// subscriber × asset write). The mid-pass join-slot writes
|
||
// (record_and_check_join_slot) are a separate table and unaffected.
|
||
let mut events: Vec<EventRow> = Vec::new();
|
||
// A subscriber listening to several of this producer's writes is pushed
|
||
// once per edge; its upstream-snapshot record is identical across those
|
||
// pushes (same instant, same trigger set), so resolve it once per pass.
|
||
let mut snapshot_memo: HashMap<String, Arc<Vec<UpstreamSnapshot>>> = HashMap::new();
|
||
for (asset_kind, asset_path) in writes {
|
||
let Some(prefix) = asset_kind.canonical_prefix() else {
|
||
continue;
|
||
};
|
||
let trigger_ref = format!("{}{}", prefix, asset_path);
|
||
let subs = fetch_subscribers(db, &job.workspace_id, &trigger_ref).await?;
|
||
for sub in subs {
|
||
let Subscriber { path: sub_path, join_all, debounce_s, retry_count, retry_delay_s } =
|
||
sub;
|
||
if sub_path == runnable_path {
|
||
events.push(EventRow::new(
|
||
&sub_path,
|
||
asset_kind,
|
||
&asset_path,
|
||
DispatchOutcome::Skipped,
|
||
EventOptions { reason: Some("self_loop"), ..Default::default() },
|
||
));
|
||
continue;
|
||
}
|
||
// Cycle guard: a subscriber already in this producer's lineage
|
||
// would re-enter the chain (A→…→A), looping forever. Stop only
|
||
// this edge — sibling branches still dispatch, and acyclic chains
|
||
// of any depth are unaffected.
|
||
if chain.iter().any(|p| p == &sub_path) {
|
||
events.push(EventRow::new(
|
||
&sub_path,
|
||
asset_kind,
|
||
&asset_path,
|
||
DispatchOutcome::Skipped,
|
||
EventOptions { reason: Some("cycle_detected"), ..Default::default() },
|
||
));
|
||
continue;
|
||
}
|
||
if join_all {
|
||
match crate::cascade::handle_join(
|
||
db,
|
||
&job.workspace_id,
|
||
&sub_path,
|
||
&trigger_ref,
|
||
partition.as_deref(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(crate::cascade::JoinDecision::Skip(reason)) => {
|
||
events.push(EventRow::new(
|
||
&sub_path,
|
||
asset_kind,
|
||
&asset_path,
|
||
DispatchOutcome::Skipped,
|
||
EventOptions { reason: Some(reason), ..Default::default() },
|
||
));
|
||
continue;
|
||
}
|
||
Ok(crate::cascade::JoinDecision::Pending { received, required }) => {
|
||
events.push(EventRow::new(
|
||
&sub_path,
|
||
asset_kind,
|
||
&asset_path,
|
||
DispatchOutcome::JoinPending,
|
||
EventOptions {
|
||
partition: partition.as_deref(),
|
||
received_inputs: Some(received),
|
||
required_inputs: Some(required),
|
||
..Default::default()
|
||
},
|
||
));
|
||
continue; // slot incomplete — wait for the rest
|
||
}
|
||
Ok(crate::cascade::JoinDecision::Fire) => {} // fall through to push
|
||
Err(e) => {
|
||
tracing::error!("join-slot check failed for {}: {e:#}", sub_path);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
// Forensic upstream-state capture, resolved at dispatch time (a
|
||
// debounced job that gets superseded is re-pushed by the later
|
||
// arrival, which re-resolves — the surviving job records what its
|
||
// own dispatch saw). Best-effort: a lookup failure must not stop
|
||
// the cascade.
|
||
let snapshots = match snapshot_memo.get(&sub_path) {
|
||
Some(s) => s.clone(),
|
||
None => {
|
||
let s = Arc::new(
|
||
upstream_snapshots(db, &job.workspace_id, &sub_path)
|
||
.await
|
||
.unwrap_or_else(|e| {
|
||
tracing::error!(
|
||
"upstream-snapshot lookup failed for {}: {e:#}",
|
||
sub_path
|
||
);
|
||
Vec::new()
|
||
}),
|
||
);
|
||
snapshot_memo.insert(sub_path.clone(), s.clone());
|
||
s
|
||
}
|
||
};
|
||
match push_subscriber(
|
||
db,
|
||
job,
|
||
&sub_path,
|
||
asset_kind,
|
||
&asset_path,
|
||
runnable_path,
|
||
&next_chain,
|
||
partition.as_deref(),
|
||
debounce_s,
|
||
retry_count,
|
||
retry_delay_s,
|
||
&snapshots,
|
||
)
|
||
.await
|
||
{
|
||
Ok(id) => {
|
||
events.push(EventRow::new(
|
||
&sub_path,
|
||
asset_kind,
|
||
&asset_path,
|
||
DispatchOutcome::Dispatched,
|
||
EventOptions {
|
||
child_job_id: Some(id),
|
||
partition: partition.as_deref(),
|
||
debounce_s,
|
||
..Default::default()
|
||
},
|
||
));
|
||
dispatched.push(id);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!("failed to push asset-triggered job for {}: {e:#}", sub_path)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
flush_events(db, &job.workspace_id, job.id, &events).await;
|
||
|
||
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;
|
||
}
|
||
// Flow steps (and sub-flow jobs) carry `flow_step_id` and are ineligible.
|
||
// Native script-retry attempts carry `parent_job` (the chain root) but no
|
||
// `flow_step_id`; whether a parented job is actually a retry attempt (vs a
|
||
// schedule/error handler child) is decided in `try_dispatch`.
|
||
if job.flow_step_id.is_some() {
|
||
return false;
|
||
}
|
||
true
|
||
}
|
||
|
||
// Native retry attempts carry an explicit `native_retry_attempt` marker; no
|
||
// other parented `Script` child (schedule handlers, WAC inline children, flow
|
||
// steps) does. One indexed point lookup, only for parented jobs.
|
||
async fn is_native_retry_attempt(db: &DB, job: &MiniCompletedJob) -> Result<bool> {
|
||
Ok(sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM native_retry_attempt WHERE job_id = $1) AS \"exists!\"",
|
||
job.id,
|
||
)
|
||
.fetch_one(db)
|
||
.await?)
|
||
}
|
||
|
||
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(trigger_map: Option<&HashMap<String, Box<RawValue>>>) -> Vec<String> {
|
||
trigger_map
|
||
.and_then(|m| m.get(CHAIN_KEY))
|
||
.and_then(|v| serde_json::from_str::<Vec<String>>(v.get()).ok())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// 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>>>,
|
||
trigger_map: Option<&HashMap<String, Box<RawValue>>>,
|
||
) -> Option<String> {
|
||
if let Some(v) = args.and_then(|a| a.get(PARTITION_ARG)) {
|
||
if let Ok(s) = serde_json::from_str::<String>(v.get()) {
|
||
return Some(s);
|
||
}
|
||
}
|
||
serde_json::from_str::<String>(trigger_map?.get(PARTITION_ARG)?.get()).ok()
|
||
}
|
||
|
||
lazy_static::lazy_static! {
|
||
/// Per-workspace map of producer script path → the assets it writes
|
||
/// (`usage_access_type IN ('w','rw')`). Serves both the producer gate
|
||
/// (is this path a producer?) and the writes themselves, so a completion
|
||
/// that isn't a producer costs a single in-memory lookup and zero
|
||
/// queries — the dispatch hook fires on every top-level script/preview
|
||
/// completion instance-wide, the overwhelming majority of which write no
|
||
/// asset. An empty map means the workspace has no asset producers (no
|
||
/// pipelines). Invalidated per workspace by `notify_asset_producer_change`
|
||
/// (a trigger on `asset`) through the polling notify system; until the
|
||
/// next poll a freshly-deployed producer may not cascade (sub-poll lag,
|
||
/// acceptable for a data pipeline).
|
||
pub static ref ASSET_PRODUCER_WRITES_CACHE:
|
||
quick_cache::sync::Cache<String, Arc<HashMap<String, Vec<(AssetKind, String)>>>> =
|
||
quick_cache::sync::Cache::new(1000);
|
||
}
|
||
|
||
/// Test hook: disables the producer-writes cache so every dispatch reads the
|
||
/// current DB. Integration tests use `#[sqlx::test]` isolated DBs that all
|
||
/// share one workspace id, so a process-global cache keyed by workspace would
|
||
/// clobber across DBs under concurrent test threads. Always `false` in
|
||
/// production (the cache is invalidated via the notify_event poller instead).
|
||
pub static ASSET_PRODUCER_CACHE_DISABLED: std::sync::atomic::AtomicBool =
|
||
std::sync::atomic::AtomicBool::new(false);
|
||
|
||
/// Load (cached) the producer→writes map for a workspace. The single load
|
||
/// query replaces the per-completion producer lookup; once cached, every
|
||
/// completion in the workspace is served from memory until invalidation.
|
||
async fn workspace_producer_writes(
|
||
db: &Pool<Postgres>,
|
||
workspace_id: &str,
|
||
) -> Result<Arc<HashMap<String, Vec<(AssetKind, String)>>>> {
|
||
let use_cache = !ASSET_PRODUCER_CACHE_DISABLED.load(std::sync::atomic::Ordering::Relaxed);
|
||
if use_cache {
|
||
if let Some(map) = ASSET_PRODUCER_WRITES_CACHE.get(workspace_id) {
|
||
return Ok(map);
|
||
}
|
||
}
|
||
let rows = sqlx::query!(
|
||
r#"
|
||
SELECT
|
||
usage_path AS "usage_path!",
|
||
kind AS "kind!: AssetKind",
|
||
path AS "path!"
|
||
FROM asset
|
||
WHERE workspace_id = $1
|
||
AND usage_kind = 'script'
|
||
AND usage_access_type IN ('w', 'rw')
|
||
"#,
|
||
workspace_id,
|
||
)
|
||
.fetch_all(db)
|
||
.await?;
|
||
let mut map: HashMap<String, Vec<(AssetKind, String)>> = HashMap::new();
|
||
for r in rows {
|
||
map.entry(r.usage_path).or_default().push((r.kind, r.path));
|
||
}
|
||
let map = Arc::new(map);
|
||
if use_cache {
|
||
ASSET_PRODUCER_WRITES_CACHE.insert(workspace_id.to_string(), map.clone());
|
||
}
|
||
Ok(map)
|
||
}
|
||
|
||
/// Forensic record of one direct upstream's state at dispatch time: the
|
||
/// latest captured materialization snapshot of an asset in the subscriber's
|
||
/// `// on` trigger set. Serialized into the dispatched job's `trigger` arg
|
||
/// (`upstream_snapshots`) so a failing consumer run stays debuggable against
|
||
/// DuckLake time-travel. Record-only — the consumer's reads are not pinned.
|
||
#[derive(Debug, Serialize)]
|
||
struct UpstreamSnapshot {
|
||
/// Canonical asset uri, e.g. `ducklake://analytics/orders_daily`.
|
||
asset: String,
|
||
snapshot_id: i64,
|
||
/// Partition whose write produced this snapshot — i.e. the latest slice
|
||
/// written, not necessarily the slice this consumer processes. The
|
||
/// snapshot itself is table-global. Omitted for whole-table
|
||
/// materializations.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
partition: Option<String>,
|
||
}
|
||
|
||
/// Latest captured snapshot per direct upstream of `subscriber_path`: its
|
||
/// asset trigger set joined against `materialized_partition`, keeping the
|
||
/// highest `snapshot_id` per asset (the newest substrate version the consumer
|
||
/// could read). Assets with no captured snapshot (non-materialized upstreams)
|
||
/// simply produce no entry. Two queries total regardless of upstream count.
|
||
async fn upstream_snapshots(
|
||
db: &Pool<Postgres>,
|
||
workspace_id: &str,
|
||
subscriber_path: &str,
|
||
) -> Result<Vec<UpstreamSnapshot>> {
|
||
let refs = sqlx::query_scalar!(
|
||
r#"SELECT DISTINCT trigger_ref AS "trigger_ref!"
|
||
FROM script_trigger
|
||
WHERE workspace_id = $1
|
||
AND runnable_path = $2
|
||
AND trigger_kind = 'asset'
|
||
AND runnable_kind = 'script'
|
||
ORDER BY trigger_ref"#,
|
||
workspace_id,
|
||
subscriber_path,
|
||
)
|
||
.fetch_all(db)
|
||
.await?;
|
||
// Keep only refs with a recognized asset prefix, preserving ref order so
|
||
// the recorded list is deterministic.
|
||
let parsed: Vec<(String, AssetKind, String)> = refs
|
||
.into_iter()
|
||
.filter_map(|r| parse_asset_trigger_ref(&r).map(|(k, p)| (r, k, p)))
|
||
.collect();
|
||
if parsed.is_empty() {
|
||
return Ok(Vec::new());
|
||
}
|
||
let kinds: Vec<AssetKind> = parsed.iter().map(|(_, k, _)| *k).collect();
|
||
let paths: Vec<String> = parsed.iter().map(|(_, _, p)| p.clone()).collect();
|
||
let rows = sqlx::query!(
|
||
r#"SELECT DISTINCT ON (mp.asset_kind, mp.asset_path)
|
||
mp.asset_kind AS "asset_kind: AssetKind", mp.asset_path,
|
||
mp.snapshot_id AS "snapshot_id!", mp.partition
|
||
FROM materialized_partition mp
|
||
JOIN unnest($2::ASSET_KIND[], $3::text[]) AS u(kind, path)
|
||
ON mp.asset_kind = u.kind AND mp.asset_path = u.path
|
||
WHERE mp.workspace_id = $1
|
||
AND mp.status = 'materialized' AND mp.snapshot_id IS NOT NULL
|
||
ORDER BY mp.asset_kind, mp.asset_path, mp.snapshot_id DESC"#,
|
||
workspace_id,
|
||
kinds as Vec<AssetKind>,
|
||
&paths,
|
||
)
|
||
.fetch_all(db)
|
||
.await?;
|
||
let mut latest: HashMap<(AssetKind, String), (i64, String)> = rows
|
||
.into_iter()
|
||
.map(|r| ((r.asset_kind, r.asset_path), (r.snapshot_id, r.partition)))
|
||
.collect();
|
||
Ok(parsed
|
||
.into_iter()
|
||
.filter_map(|(trigger_ref, kind, path)| {
|
||
let (snapshot_id, partition) = latest.remove(&(kind, path))?;
|
||
Some(UpstreamSnapshot {
|
||
asset: trigger_ref,
|
||
snapshot_id,
|
||
partition: (!partition.is_empty()).then_some(partition),
|
||
})
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// A subscriber row resolved from `script_trigger`. Bundles the per-edge
|
||
/// options (debounce) and the script-level policy fields (`join_all`,
|
||
/// retry) that travel together to dispatch.
|
||
struct Subscriber {
|
||
path: String,
|
||
join_all: bool,
|
||
debounce_s: Option<i32>,
|
||
retry_count: Option<i16>,
|
||
retry_delay_s: Option<i32>,
|
||
}
|
||
|
||
async fn fetch_subscribers(
|
||
db: &Pool<Postgres>,
|
||
workspace_id: &str,
|
||
trigger_ref: &str,
|
||
) -> Result<Vec<Subscriber>> {
|
||
// 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.
|
||
// `join_all` = `// trigger all` (AND join); `debounce_s` = the opt-in
|
||
// debounce window resolved at deploy (NULL = fan-out, the default).
|
||
// `retry_count` / `retry_delay_s` = the `// retry <n> [<delay>]` policy
|
||
// (NULL = no retry).
|
||
let rows = sqlx::query!(
|
||
r#"
|
||
SELECT runnable_path AS "runnable_path!", join_all AS "join_all!", debounce_s,
|
||
retry_count, retry_delay_s
|
||
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?;
|
||
Ok(rows
|
||
.into_iter()
|
||
.map(|r| Subscriber {
|
||
path: r.runnable_path,
|
||
join_all: r.join_all,
|
||
debounce_s: r.debounce_s,
|
||
retry_count: r.retry_count,
|
||
retry_delay_s: r.retry_delay_s,
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
async fn push_subscriber(
|
||
db: &DB,
|
||
producer: &MiniCompletedJob,
|
||
subscriber_path: &str,
|
||
asset_kind: AssetKind,
|
||
asset_path: &str,
|
||
producer_path: &str,
|
||
chain: &[String],
|
||
partition: Option<&str>,
|
||
debounce_s: Option<i32>,
|
||
retry_count: Option<i16>,
|
||
retry_delay_s: Option<i32>,
|
||
upstream_snapshots: &[UpstreamSnapshot],
|
||
) -> Result<Uuid> {
|
||
// Same resolution as every other trigger path (`script_path_to_payload`):
|
||
// latest deployed hash plus the script's own runnable settings
|
||
// (concurrency, debounce, timeout), resolved through the
|
||
// runnable-settings handle. The cascade must not bypass a subscriber's
|
||
// concurrency limit just because it was triggered by an asset write.
|
||
let script = get_latest_deployed_hash_for_path(
|
||
None,
|
||
db.clone(),
|
||
&producer.workspace_id,
|
||
subscriber_path,
|
||
)
|
||
.await?
|
||
.prefetch_cached(db)
|
||
.await?;
|
||
let hash = ScriptHash(script.hash);
|
||
let tag = script.tag;
|
||
let concurrency_settings = script.runnable_settings.concurrency_settings;
|
||
|
||
// Debounce / retry semantics are a `private` feature (see `cascade`).
|
||
// OSS degrades both: debounce falls back to the subscriber's own
|
||
// script-level settings, retry is never applied.
|
||
let debouncing_settings = crate::cascade::cascade_debouncing_settings(
|
||
subscriber_path,
|
||
partition,
|
||
debounce_s,
|
||
script.runnable_settings.debouncing_settings,
|
||
);
|
||
|
||
// When the cascade declares a retry, hand `push` a one-step-flow request
|
||
// carrying the policy + `language`; `push` materializes it into a native
|
||
// retryable `Script` (not a flow), so a failed/recovered subscriber stays
|
||
// eligible to trigger its own downstream. No retry = plain `ScriptHash`.
|
||
let payload = if let Some(retry) = crate::cascade::cascade_retry(retry_count, retry_delay_s) {
|
||
JobPayload::SingleStepFlow {
|
||
path: subscriber_path.to_string(),
|
||
hash: Some(hash),
|
||
flow_version: None,
|
||
language: Some(script.language),
|
||
args: HashMap::new(),
|
||
retry: Some(retry),
|
||
error_handler_path: None,
|
||
error_handler_args: None,
|
||
skip_handler: None,
|
||
cache_ttl: script.cache_ttl,
|
||
cache_ignore_s3_path: script.cache_ignore_s3_path,
|
||
priority: script.priority,
|
||
tag_override: tag.clone(),
|
||
trigger_path: None,
|
||
apply_preprocessor: false,
|
||
concurrency_settings,
|
||
debouncing_settings,
|
||
}
|
||
} else {
|
||
JobPayload::ScriptHash {
|
||
hash,
|
||
path: subscriber_path.to_string(),
|
||
cache_ttl: script.cache_ttl,
|
||
cache_ignore_s3_path: script.cache_ignore_s3_path,
|
||
dedicated_worker: script.dedicated_worker,
|
||
language: script.language,
|
||
priority: script.priority,
|
||
apply_preprocessor: false,
|
||
debouncing_settings,
|
||
concurrency_settings,
|
||
labels: script.labels,
|
||
}
|
||
};
|
||
|
||
// Run the subscriber under its deployer's identity — never the
|
||
// producer's. Subscriptions are workspace-wide, so attributing the run
|
||
// to the producer would let anyone who can deploy a `// on` script
|
||
// execute code with the permissions of whoever happens to write the
|
||
// asset (e.g. an admin's scheduled job). `on_behalf_of_email` (an
|
||
// explicit service-account opt-in at deploy) takes precedence for the
|
||
// email; otherwise the deployer's email is resolved from their
|
||
// username.
|
||
let permissioned_as = username_to_permissioned_as(&script.created_by);
|
||
let email = match script.on_behalf_of_email {
|
||
Some(obo) => obo,
|
||
None => {
|
||
get_email_from_permissioned_as(&permissioned_as, &producer.workspace_id, db).await?
|
||
}
|
||
};
|
||
|
||
let mut args: HashMap<String, Box<RawValue>> = HashMap::new();
|
||
let mut trigger_payload = serde_json::json!({
|
||
"kind": "asset",
|
||
"asset_kind": serde_json::to_value(&asset_kind).expect("AssetKind serializes"),
|
||
"asset_path": asset_path,
|
||
"producer_path": producer_path,
|
||
"producer_job_id": producer.id.to_string(),
|
||
CHAIN_KEY: chain,
|
||
PARTITION_ARG: partition,
|
||
});
|
||
if !upstream_snapshots.is_empty() {
|
||
trigger_payload["upstream_snapshots"] =
|
||
serde_json::to_value(upstream_snapshots).expect("UpstreamSnapshot serializes");
|
||
}
|
||
args.insert(TRIGGER_ARG.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,
|
||
script.timeout,
|
||
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)
|
||
}
|