Files
windmill/backend/windmill-queue/src/asset_dispatch.rs
T
Ruben FiszelandClaude Opus 4.8 12f92e3ab7 [ee] feat(backend): native script retry without one-step-flow wrapping (#9688)
* feat(backend): native script retry without one-step-flow wrapping

Schedules and data pipelines that retry a single script previously wrapped
it in a one-step flow (JobKind::SingleStepFlow), creating extra job rows, a
v2_job_status row, and UI projection complexity. This adds native retry on a
plain JobKind::Script job.

- RetrySettings: flatten Retry into a deduped retry_settings table, carried
  via the existing runnable_settings_handle (lazy, off the hot path).
- push() materializes a bare-script-with-retry SingleStepFlow into a native
  Script job (gated on min-version + no handlers/retry_if).
- add_completed_job re-pushes the next attempt on failure with backoff,
  tracking the attempt counter in v2_job_queue.extras and the chain via
  parent_job; schedule completion handlers fire only on the terminal attempt.
- frontend: ScriptRetryChain shows the attempt chain on the run page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(backend): native retry_if eval + per-occurrence schedule handlers

Extends native script retry to the two cases that previously stayed on the
one-step-flow path:

- retry_if: evaluated natively on the failure path via a feature-gated
  windmill-jseval dep (quickjs) over the failure result + flow_input; push
  materializes such policies natively only when quickjs is available.
- on_failure_times / on_recovery: apply_schedule_handlers now resolves each
  past scheduled occurrence's terminal status across its native-retry chain
  (root OR any parent_job=root child succeeded) and excludes the current
  occurrence, so the counting is per-occurrence rather than per-attempt.

All scheduled-script retries now go native (schedule.rs gate removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(backend): always materialize retry_if natively; unsupported without quickjs

retry_if is evaluated by the worker (which always has quickjs), not the
pusher, so gating materialization on the pusher's feature was wrong. The
flow path was never a real fallback either — the flow runtime needs quickjs
to evaluate retry_if too. retry_if now always goes native; on a worker
without quickjs it is unsupported and fails closed (no retry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(backend): un-park asset-cascade (pipeline) retry

Native retry resolves the blocker that parked pipeline retry: a retried
subscriber is now a Script job (not a one-step flow / flow step), so it
stays eligible for asset dispatch and can trigger its own downstream on
recovery.

- scripts.rs: persist // retry <count> [<delay>] to script_trigger on asset
  edges (was dropped with a TODO warning).
- asset_dispatch.rs: is_eligible_kind keys off flow_step_id, not parent_job,
  so native-retry attempts dispatch on success while flow steps stay excluded.
- tests: retry-bearing subscriber now dispatches as a native Script carrying
  the policy in runnable_settings_handle; native-retry attempt is eligible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): cap native retry interval, lazy result serialization, idempotent retry push

Hardening from a self-review of the native retry path:
- Cap the backoff at MAX_RETRY_INTERVAL to match the flow-runtime path
  (evaluate_retry); the exponential formula could otherwise schedule up to
  ~18h vs the flow path's 6h.
- Serialize the failure result lazily (only when a retry_if policy needs it),
  so the common failure no longer pays the serialization on the failure path.
- Push each retry with a deterministic id per (root, attempt). If a worker
  dies between enqueueing the retry and finalizing the current attempt, the
  reaper re-handles the attempt and lands here again — push rejects the
  duplicate id, so the retry is enqueued exactly once (no double-retry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): defer schedule handlers idempotently on retry-push replay (review P1)

Address local-review findings:
- P1: retry_pending was derived from the retry push *result*, so on a worker
  crash + reaper replay the duplicate-id push returned Err → retry_pending
  flipped to false → apply_schedule_handlers fired for the non-terminal
  attempt (and the terminal attempt later fired them again). Pre-check whether
  the deterministic retry id already exists and report it as pending without
  re-pushing, so the handler-deferral invariant is crash-idempotent too.
- P2: refresh the stale 'wrap the script in a one-step flow' comment in the
  asset-cascade retry push — it now materializes a native Script.
- Add RetrySettings <-> Retry round-trip unit tests (clamping edges).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backend): native retry chain + per-occurrence status sqlx tests

Close the two integration-test gaps flagged in local review:
- chains_attempts_and_is_idempotent: drives maybe_enqueue_native_script_retry
  through attempt0 -> retry1 -> retry2 -> exhausted (counter, backoff, max-attempts)
  and asserts crash-replay idempotency (the P1 fix: a replayed completion reports
  pending without double-enqueueing).
- per_occurrence_status_counts_recovered_as_success: pins the exact per-occurrence
  terminal-status query from jobs_ee::apply_schedule_handlers — a retried-but-
  recovered occurrence counts as success, retries (parent_job set) are excluded
  from occurrence counting, and the current occurrence is excluded.
- canceled_job_does_not_retry: cancellation wins over a pending retry.

Runtime sqlx API (no .sqlx cache entry needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): exclude schedule handlers from the retry-attempt chain

The retry chain listed all script children of the root by parent_job, but
schedule completion handlers (on_failure/on_recovery/on_success) are also
script children — when the occurrence has no retries, the handler's parent is
the root itself, so a successful, never-retried job rendered a bogus
'Retries (1)' badge pointing at the handler. Filter children to re-runs of the
same script (matching script_hash); real retries keep the root's hash, handlers
run a different script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): surface schedule handlers on the run page

Extend the run-page chain component with schedule completion handlers:
- A 'Handlers' row on a scheduled job links to the on_failure/on_recovery/
  on_success runs that fired for that occurrence (found as children of the
  terminal attempt, identified by their synthetic created_by).
- A handler's own run page now shows a 'Failure/Recovery/Success handler'
  label with a link back to the run it handled and its schedule. on_recovery
  and on_success share created_by, disambiguated by the recovery-only
  error_started_at arg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): restore folder_default_permissioned_as sqlx caches dropped by prepare

An earlier `cargo sqlx prepare` on this branch ran before #8801's
folder_default_permissioned_as test merged in, so it pruned the 3 query caches
that test needs; cargo_test then failed under SQLX_OFFLINE. Restore them from main.

* fix(backend): only cascade assets from native retry attempts, not handlers (review P1)

is_eligible_kind keyed dispatch on flow_step_id alone, so every parented Script
child became asset-eligible — including schedule/error/recovery handlers (Script
jobs with parent_job set and no flow_step_id). A handler that declares assets
would then trigger a cascade the old parent_job IS NULL guard prevented. Gate
parented jobs on being a genuine retry attempt: a re-run of the SAME runnable as
its chain parent (handlers run a different script). Runtime query, no sqlx cache.

* fix(backend): cache the private-gated retry_setting asset-dispatch test query

The same prepare-without-private that dropped the folder_default caches also
pruned the cache for the retry_setting_dispatches_subscriber_as_native_script
test query (asset_trigger_dispatch.rs:721). Regenerated with --features private.

* fix(backend): exclude handler children from per-occurrence recovery (review)

A scheduled occurrence's on_failure/on_success handler runs as a successful
child (parent_job = occurrence), and the per-occurrence success EXISTS counted
ANY successful child — so a failed occurrence whose error handler succeeded was
marked 'recovered', breaking on_recovery (test_script/flow_schedule_handlers in
the merge) and on_failure_times counting. EE query now scopes the EXISTS to
same-runnable children (only native retry attempts); regenerate sqlx cache + bump
ee-repo-ref. native_retry_test gains a handler-child regression case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backend): scheduled-script retry is a native Script, not SingleStepFlow

test_push_script_with_retry / test_try_schedule_with_retry (from main) asserted
the old SingleStepFlow wrapping for scheduled-script retry; this PR makes it a
native Script. Update both to assert kind='script' and that the retry policy is
carried via runnable_settings_handle.

* fix(backend): preserve dedicated_worker on native retry + saturate count casts (cubic)

Address cubic CI review:
- P1: the SingleStepFlow->native Script materialization dropped dedicated_worker,
  so a dedicated-worker scheduled script lost its dedicated pool on retry. Resolve
  it from the script row in push so the materialized Script keeps the dedicated tag.
- P2: saturate the u32->i32 retry-attempt narrowings (RetrySettings::from) and the
  u32->i16 // retry count narrowing (scripts.rs) instead of wrapping.

* fix(backend): use a retry-specific signal, not runnable equality (codex review)

Address Codex CI review:
- P1: is_native_retry_attempt treated any same-runnable parented Script child as
  a retry. WAC v2 inline children have that exact shape, so an inline child of an
  asset producer would cascade. Use a retry-specific signal instead: the job
  carries a retry_settings policy (always re-inserted by maybe_enqueue) and has no
  flow_innermost_root_job. Apply the same flow_innermost guard to the EE
  per-occurrence EXISTS (WAC inline children must not count as a recovery).
- P1: the deterministic retry-id pre-check raced with push; a concurrent duplicate
  now resolves as 'retry pending' (re-check on the duplicate-id error) instead of
  flipping retry_pending to false and firing handlers early.
- Tests: native_retry + asset_trigger_dispatch gain WAC-inline-child cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(backend): explicit native_retry_attempt marker, drop heuristics

Replace the per-site "is this a retry?" inference (parent_job + runnable match +
flow_innermost / retry_settings) with one explicit marker: a sparse
native_retry_attempt(job_id, attempt) table, written in maybe_enqueue. The marker
also carries the attempt counter (previously in v2_job_queue.extras), so it's the
single source of truth.

- asset_dispatch: is_native_retry_attempt is now one indexed EXISTS on the marker.
- EE per-occurrence query: joins the marker instead of guessing by runnable/flow_innermost.
- maybe_enqueue: reads/writes the marker (persistent) instead of queue extras.
- Lifecycle: swept with the job in retention (log_cleanup), no FK to keep bulk delete cheap.
- Eliminates handler / WAC-inline-child misclassification by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): sweep native_retry_attempt markers in the periodic retention path too (codex)

The marker has no FK and relies on retention cleanup; log_cleanup.rs swept it but
the periodic monitor.rs path deleted v2_job rows without it, orphaning markers.
Add the same WHERE job_id = ANY(...) sweep there.

* fix(backend): widen native_retry_attempt.attempt to integer (cubic)

The smallint column was cast to/from u32 and could wrap a retry chain longer than
i16::MAX into premature exhaustion. Use integer, matching the retry policy's i32
attempt count, so no narrowing occurs on the maybe_enqueue read/write path.

* feat(frontend): mark retries via is_retry on listJobs; drop SAVEPOINT

- Expose an is_retry flag on jobs (UnifiedJob/CompletedJob/QueuedJob + openapi),
  computed from the native_retry_attempt marker. The run-page chain now filters
  retry attempts by is_retry instead of the script_hash heuristic, so WAC v2
  inline children (same script, parent_job) no longer render as retries (codex).
- Revert the marker-cleanup SAVEPOINT (an unused pattern in this codebase): keep
  the plain catch-and-continue matching the other side-table deletes; the table is
  created by a startup migration so it always exists when cleanup runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): mark is_retry sqlx(default) so non-list job queries can omit it

The single-job GET query maps directly to CompletedJob/QueuedJob via FromRow but
does not select is_retry, which errored with "no column found". Only the list
endpoint populates the marker; #[sqlx(default)] lets every other query omit the
column and default to None.

* feat(backend): select is_retry in single-job GET too for consistency

The list endpoint already exposes the marker; populate it on the single-job GET
(both completed and queued variants) as well so a run loaded directly reflects
its retry status. #[sqlx(default)] stays as a safety net for any other query.

* feat(backend): reap orphaned native_retry_attempt markers via periodic sweep

The marker has no FK to v2_job (to keep the hot bulk retention delete cheap), so
direct job deletions (workspace/job delete, schedule clearing) would leave marker
rows orphaned. Rather than add explicit cleanup to every v2_job delete site (which
must then be remembered for every future path), reap orphans in the periodic
delete_expired_items pass: DELETE FROM native_retry_attempt WHERE NOT EXISTS (the
job). The table is sparse so the anti-join drives off it and probes v2_job by PK —
cheap. Retention still sweeps markers inline (keeps the table small so this stays
cheap); a transient orphan is harmless (nothing reads is_retry for a gone job).

* fix(frontend): include flow handlers in retry chain handler row (codex)

Schedule on_failure/on_recovery/on_success handlers can be flow paths (flow/...),
whose handler job is a flow, not a script. The chain fetched children with
jobKinds:'script', hiding flow handlers. Drop the kind filter — retry attempts
are still selected by is_retry and handlers by created_by, so both kinds surface.

* fix(backend): carry concurrency/debouncing settings into native retries

maybe_enqueue re-pushed the next attempt with ConcurrencySettings/DebouncingSettings
::default(), dropping the script/pipeline concurrency settings the failed job carried
in its runnable_settings_handle. A retry of a concurrency-limited script then inserted
no concurrency_key and ran unbounded. Resolve both from the same handle (cached) and
pass them in the payload, which push forwards to the materialized retry. Adds a
regression test asserting the retry's handle resolves to the concurrency settings.

* fix(backend): carry concurrency/debounce into scheduled-retry root + document retry-helper auth (codex)

P1a (schedule.rs): the scheduled-retry materialization fetched the script's
concurrency/debounce settings but passed ConcurrencySettings/DebouncingSettings
::default() into the SingleStepFlow payload, so the root attempt's handle held only
the retry policy and the whole chain ran unbounded. Pass the fetched settings.
Regression test asserts the root handle resolves to retry + concurrency.

P1b (jobs.rs): document maybe_enqueue_native_script_retry's authorization contract
— it is pub only for the integration test; the sole production caller is the worker
completion path passing a DB-derived, already-authorized MiniCompletedJob.

* docs(backend): attach native-retry auth contract to the function itself (codex)

The doc block was merged with eval_retry_if's doc and bound to that function,
leaving maybe_enqueue_native_script_retry undocumented. Split them: eval_retry_if
keeps its own doc; the native-retry + authorization contract now sits directly
above maybe_enqueue_native_script_retry.

* docs(backend): regenerate served openapi-deref with is_retry + fix stale comments (codex)

- Regenerate openapi-deref.{yaml,json} (served from lib.rs): they were stale since
  1.734.0 and lacked is_retry on QueuedJob/CompletedJob, so clients reading the
  served spec couldn't see the field. Now current at 1.739.0.
- schedule.rs: a retry_if gate is evaluated at failure time and fails closed without
  quickjs (no retry); it does not fall back to a flow path.
- windmill-types jobs.rs: is_retry is selected by both the list and single-job GET
  endpoints (not list-only).

* docs(backend): fix remaining stale retry_if/quickjs comments (codex)

The retry_if block and the push materialization comments claimed push keeps
retry_if on a flow path / the worker always has quickjs. The code always
materializes native retry and the no-quickjs eval_retry_if path fails closed —
correct the comments to that constraint.

* docs(backend): fix stale quickjs-fallback + schedule-handler-restriction comments (codex)

- Cargo.toml quickjs feature: without quickjs a retry_if gate cannot be evaluated
  and the job does not retry (no one-step-flow fallback).
- jobs.rs handler-defer comment: apply_schedule_handlers resolves per-occurrence
  failure/recovery status across the retry chain, so the old 'restricted to
  schedules whose handlers don't need per-occurrence counting' claim is dropped.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:22:26 +00:00

764 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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"]
//! }
//! }
//! ```
//!
//! 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 std::sync::Arc;
use uuid::Uuid;
use windmill_common::assets::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();
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;
}
}
}
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,
)
.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)
}
/// 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>,
) -> 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 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,
});
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)
}