/* * 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. */ use std::{ collections::{HashMap, HashSet}, sync::Arc, vec, }; use anyhow::Context; use async_recursion::async_recursion; use axum::{ body::Bytes, extract::{FromRequest, Query}, http::Request, response::{IntoResponse, Response}, Form, RequestExt, }; use bigdecimal::ToPrimitive; use chrono::{DateTime, Duration, Utc}; use prometheus::IntCounter; use reqwest::{ header::{HeaderMap, CONTENT_TYPE}, Client, StatusCode, }; use rsmq_async::RsmqConnection; use serde::{Deserialize, Serialize}; use serde_json::{json, value::RawValue}; use sqlx::{types::Json, FromRow, Pool, Postgres, Transaction}; #[cfg(feature = "benchmark")] use std::time::Instant; use tokio::sync::RwLock; use tracing::{instrument, Instrument}; use ulid::Ulid; use uuid::Uuid; use windmill_audit::{audit_log, ActionKind}; #[cfg(not(feature = "enterprise"))] use windmill_common::worker::PriorityTags; use windmill_common::{ db::{Authed, UserDB}, error::{self, Error}, flow_status::{ BranchAllStatus, FlowStatus, FlowStatusModule, FlowStatusModuleWParent, Iterator, JobResult, RestartedFrom, RetryStatus, MAX_RETRY_ATTEMPTS, MAX_RETRY_INTERVAL, }, flows::{FlowModule, FlowModuleValue, FlowValue}, jobs::{ get_payload_tag_from_prefixed_path, script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode, }, oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH, schedule::Schedule, scripts::{ScriptHash, ScriptLang}, users::SUPERADMIN_SECRET_EMAIL, worker::{to_raw_value, WORKER_CONFIG}, DB, METRICS_ENABLED, }; #[cfg(feature = "enterprise")] use windmill_common::worker::CLOUD_HOSTED; use crate::{ schedule::{get_schedule_opt, push_scheduled_job}, QueueTransaction, }; lazy_static::lazy_static! { pub static ref HTTP_CLIENT: Client = reqwest::ClientBuilder::new() .user_agent("windmill/beta") .build().unwrap(); // TODO: these aren't synced, they should be moved into the queue abstraction once/if that happens. static ref QUEUE_PUSH_COUNT: prometheus::IntCounter = prometheus::register_int_counter!( "queue_push_count", "Total number of jobs pushed to the queue." ) .unwrap(); static ref QUEUE_DELETE_COUNT: prometheus::IntCounter = prometheus::register_int_counter!( "queue_delete_count", "Total number of jobs deleted from the queue." ) .unwrap(); static ref QUEUE_PULL_COUNT: prometheus::IntCounter = prometheus::register_int_counter!( "queue_pull_count", "Total number of jobs pulled from the queue." ) .unwrap(); pub static ref WORKER_EXECUTION_FAILED: Arc>> = Arc::new(RwLock::new(HashMap::new())); } #[cfg(feature = "enterprise")] const MAX_FREE_EXECS: i32 = 1000; #[cfg(feature = "enterprise")] const MAX_FREE_CONCURRENT_RUNS: i32 = 15; const ERROR_HANDLER_USERNAME: &str = "error_handler"; const ERROR_HANDLER_USER_GROUP: &str = "g/error_handler"; const ERROR_HANDLER_USER_EMAIL: &str = "error_handler@windmill.dev"; #[derive(Clone, Debug)] pub struct CanceledBy { pub username: Option, pub reason: Option, } #[async_recursion] pub async fn cancel_job<'c: 'async_recursion>( username: &str, reason: Option, id: Uuid, w_id: &str, mut tx: Transaction<'c, Postgres>, db: &Pool, rsmq: Option, force_cancel: bool, ) -> error::Result<(Transaction<'c, Postgres>, Option)> { let job_running = get_queued_job(id, &w_id, &mut tx).await?; if job_running.is_none() { return Ok((tx, None)); } let job_running = job_running.unwrap(); if ((job_running.running || job_running.root_job.is_some()) || (job_running.job_kind == JobKind::Flow || job_running.job_kind == JobKind::FlowPreview)) && !force_cancel { let id = sqlx::query_scalar!( "UPDATE queue SET canceled = true, canceled_by = $1, canceled_reason = $2, scheduled_for = now(), suspend = 0 WHERE id = $3 AND workspace_id = $4 RETURNING id", username, reason, id, w_id ) .fetch_optional(&mut *tx) .await?; if let Some(id) = id { tracing::info!("Soft cancelling job {}", id); } } else { let reason = reason .clone() .unwrap_or_else(|| "unexplicited reasons".to_string()); let e = serde_json::json!({"message": format!("Job canceled: {reason} by {username}"), "name": "Canceled", "reason": reason, "canceler": username}); let add_job = add_completed_job_error( &db, &job_running, format!("canceled by {username}: (force cancel: {force_cancel})"), job_running.mem_peak.unwrap_or(0), Some(CanceledBy { username: Some(username.to_string()), reason: Some(reason) }), e, rsmq.clone(), "server", ) .await; if let Err(e) = add_job { tracing::error!("Failed to add canceled job: {}", e); } } if let Some(mut rsmq) = rsmq.clone() { rsmq.change_message_visibility(&job_running.tag, &id.to_string(), 0) .await .map_err(|e| anyhow::anyhow!(e))?; } let mut jobs = vec![id]; let mut jobs_to_cancel = vec![]; while !jobs.is_empty() { let p_job = jobs.pop(); let new_jobs = sqlx::query_scalar!( "SELECT id FROM queue WHERE parent_job = $1 AND workspace_id = $2", p_job, w_id ) .fetch_all(&mut *tx) .await?; jobs.extend(new_jobs.clone()); jobs_to_cancel.extend(new_jobs); } for job in jobs_to_cancel { let (ntx, _) = cancel_job( username, reason.clone(), job, w_id, tx, db, rsmq.clone(), force_cancel, ) .await?; tx = ntx; } Ok((tx, Some(id))) } #[derive(Serialize, Debug)] pub struct WrappedError { pub error: serde_json::Value, } pub async fn register_metric( l: &Arc>>, s: &str, fnew: F2, f: F, ) -> Option where F: FnOnce(&T) -> R, F2: FnOnce(&str) -> (T, R), { if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { let lock = l.read().await; let counter = lock.get(s); if let Some(counter) = counter { let r = f(counter); drop(lock); Some(r) } else { drop(lock); let (metric, r) = fnew(s); let mut m = l.write().await; (*m).insert(s.to_string(), metric); Some(r) } } else { None } } #[instrument(level = "trace", skip_all)] pub async fn add_completed_job_error( db: &Pool, queued_job: &QueuedJob, logs: String, mem_peak: i32, canceled_by: Option, e: serde_json::Value, rsmq: Option, worker_name: &str, ) -> Result { register_metric( &WORKER_EXECUTION_FAILED, &queued_job.tag, |s| { let counter = prometheus::register_int_counter!(prometheus::Opts::new( "worker_execution_failed", "Number of jobs having failed" ) .const_label("name", worker_name) .const_label("tag", s)) .expect("register prometheus metric"); counter.inc(); (counter, ()) }, |c| c.inc(), ) .await; let result = WrappedError { error: e }; tracing::error!( "job {} did not succeed: {}", queued_job.id, serde_json::to_string(&result).unwrap_or_else(|_| "".to_string()) ); let _ = add_completed_job( db, &queued_job, false, false, Json(&result), logs, mem_peak, canceled_by, rsmq, ) .await?; Ok(result) } fn flatten_jobs(modules: Vec) -> Vec { modules .into_iter() .filter_map(|m| match m { FlowStatusModule::Success { job, flow_jobs, .. } | FlowStatusModule::Failure { job, flow_jobs, .. } => { if let Some(flow_jobs) = flow_jobs { Some(flow_jobs) } else { Some(vec![job]) } } _ => None, }) .flatten() .collect::>() } lazy_static::lazy_static! { pub static ref GLOBAL_ERROR_HANDLER_PATH_IN_ADMINS_WORKSPACE: Option = std::env::var("GLOBAL_ERROR_HANDLER_PATH_IN_ADMINS_WORKSPACE").ok(); } #[instrument(level = "trace", skip_all, name = "add_completed_job")] pub async fn add_completed_job< T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection + Clone + Send, >( db: &Pool, queued_job: &QueuedJob, success: bool, skipped: bool, result: Json<&T>, logs: String, mem_peak: i32, canceled_by: Option, rsmq: Option, ) -> Result { // tracing::error!("Start"); // let start = tokio::time::Instant::now(); let is_flow = queued_job.job_kind == JobKind::Flow || queued_job.job_kind == JobKind::FlowPreview; let duration = if is_flow { let jobs = queued_job.parse_flow_status().map(|s| { let mut modules = s.modules; modules.extend([s.failure_module.module_status]); flatten_jobs(modules) }); if let Some(jobs) = jobs { sqlx::query_scalar!( "SELECT SUM(duration_ms) as duration FROM completed_job WHERE id = ANY($1)", jobs.as_slice() ) .fetch_one(db) .await .ok() .flatten() .map(|x| x.to_i64()) .flatten() } else { tracing::warn!("Could not parse flow status"); None } } else { None }; let mut tx: QueueTransaction<'_, R> = (rsmq.clone(), db.begin().await?).into(); let job_id = queued_job.id; // tracing::error!("1 {:?}", start.elapsed()); let mem_peak = mem_peak.max(queued_job.mem_peak.unwrap_or(0)); let _duration: i64 = sqlx::query_scalar!( "INSERT INTO completed_job AS cj ( workspace_id , id , parent_job , created_by , created_at , started_at , duration_ms , success , script_hash , script_path , args , result , logs , raw_code , raw_lock , canceled , canceled_by , canceled_reason , job_kind , schedule_path , permissioned_as , flow_status , raw_flow , is_flow_step , is_skipped , language , email , visible_to_owner , mem_peak , tag , priority ) VALUES ($1, $2, $3, $4, $5, COALESCE($6, now()), COALESCE($26, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE($6, now()))))*1000), $7, $8, $9,\ $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $27, $28, $29, $30, $31) ON CONFLICT (id) DO UPDATE SET success = $7, result = $11, logs = concat(cj.logs, $12) RETURNING duration_ms", queued_job.workspace_id, queued_job.id, queued_job.parent_job, queued_job.created_by, queued_job.created_at, queued_job.started_at, success, queued_job.script_hash.map(|x| x.0), queued_job.script_path, &queued_job.args as &Option>>>, result as Json<&T>, logs, queued_job.raw_code, queued_job.raw_lock, canceled_by.is_some(), canceled_by.clone().map(|cb| cb.username).flatten(), canceled_by.clone().map(|cb| cb.reason).flatten(), queued_job.job_kind.clone() as JobKind, queued_job.schedule_path, queued_job.permissioned_as, &queued_job.flow_status as &Option>>, &queued_job.raw_flow as &Option>>, queued_job.is_flow_step, skipped, queued_job.language.clone() as Option, duration as Option, queued_job.email, queued_job.visible_to_owner, if mem_peak > 0 { Some(mem_peak) } else { None }, queued_job.tag, queued_job.priority, ) .fetch_one(&mut tx) .await .map_err(|e| Error::InternalErr(format!("Could not add completed job {job_id}: {e}")))?; // tracing::error!("2 {:?}", start.elapsed()); // tracing::error!("Added completed job {:#?}", queued_job); let mut skip_downstream_error_handlers = false; tx = delete_job(tx, &queued_job.workspace_id, job_id).await?; // tracing::error!("3 {:?}", start.elapsed()); if !queued_job.is_flow_step && queued_job.schedule_path.is_some() && queued_job.script_path.is_some() { (skip_downstream_error_handlers, tx) = apply_schedule_handlers( tx, db, queued_job.schedule_path.as_ref().unwrap(), queued_job.script_path.as_ref().unwrap(), &queued_job.workspace_id, success, result, job_id, queued_job.started_at.unwrap_or(chrono::Utc::now()), queued_job.priority, ) .await?; } if !queued_job.is_flow_step && queued_job.job_kind != JobKind::Flow && queued_job.job_kind != JobKind::FlowPreview && queued_job.schedule_path.is_some() && queued_job.script_path.is_some() { // script only tx = handle_maybe_scheduled_job( tx, db, queued_job.schedule_path.as_ref().unwrap(), queued_job.script_path.as_ref().unwrap(), &queued_job.workspace_id, ) .await?; } if queued_job.concurrent_limit.is_some() { if let Err(e) = sqlx::query_scalar!( "UPDATE concurrency_counter SET job_uuids = job_uuids - $2 WHERE concurrency_id = $1", queued_job.full_path(), queued_job.id.hyphenated().to_string(), ) .execute(&mut tx) .await { tracing::error!("Could not decrement concurrency counter: {}", e); } tracing::debug!("decremented concurrency counter"); } tx.commit().await?; #[cfg(feature = "enterprise")] if *CLOUD_HOSTED && !is_flow && _duration > 1000 { let additional_usage = _duration / 1000; let w_id = &queued_job.workspace_id; let premium_workspace = *windmill_common::worker::CLOUD_HOSTED && sqlx::query_scalar!("SELECT premium FROM workspace WHERE id = $1", w_id) .fetch_one(db) .await .map_err(|e| Error::InternalErr(format!("fetching if {w_id} is premium: {e}")))?; let _ = sqlx::query!( "INSERT INTO usage (id, is_workspace, month_, usage) VALUES ($1, $2, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + $3", if premium_workspace { w_id } else { &queued_job.email }, premium_workspace, additional_usage as i32) .execute(db) .await .map_err(|e| Error::InternalErr(format!("updating usage: {e}"))); } if !skip_downstream_error_handlers && matches!(queued_job.job_kind, JobKind::Flow | JobKind::Script) && queued_job.parent_job.is_none() && !success { if let Err(e) = send_error_to_global_handler(rsmq.clone(), &queued_job, db, result).await { tracing::error!( "Could not run global error handler for job {}: {}", &queued_job.id, e ); } if let Err(e) = send_error_to_workspace_handler( rsmq.clone(), &queued_job, canceled_by.is_some(), db, result, ) .await { tracing::error!( "Could not run workspace error handler for job {}: {}", &queued_job.id, e ); } } tracing::debug!("Added completed job {}", queued_job.id); // tracing::error!("4 {:?}", start.elapsed()); Ok(queued_job.id) } pub async fn run_error_handler< 'a, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection + Clone + Send, >( rsmq: Option, queued_job: &QueuedJob, db: &Pool, result: Json<&'a T>, error_handler_path: &str, error_handler_extra_args: Option, is_global: bool, ) -> Result<(), Error> { let w_id = &queued_job.workspace_id; let script_w_id = if is_global { "admins" } else { w_id }; // script workspace id let job_id = queued_job.id; let (job_payload, tag) = script_path_to_payload(&error_handler_path, db, script_w_id).await?; let mut extra = HashMap::new(); extra.insert("workspace_id".to_string(), to_raw_value(&w_id)); extra.insert("job_id".to_string(), to_raw_value(&job_id)); extra.insert("path".to_string(), to_raw_value(&queued_job.script_path)); extra.insert( "is_flow".to_string(), to_raw_value(&queued_job.raw_flow.is_some()), ); extra.insert( "started_at".to_string(), to_raw_value(&queued_job.started_at), ); extra.insert("email".to_string(), to_raw_value(&queued_job.email)); if let Some(schedule_path) = &queued_job.schedule_path { extra.insert("schedule_path".to_string(), to_raw_value(schedule_path)); } if let Some(extra_args) = error_handler_extra_args { if let serde_json::Value::Object(args_m) = extra_args { for (k, v) in args_m { extra.insert(k, to_raw_value(&v)); } } else { return Err(error::Error::ExecutionErr( "args of scripts needs to be dict".to_string(), )); } } // TODO: this should be injected when the EH is defined in the BE. if error_handler_path.to_string().starts_with("hub/") && error_handler_path .to_string() .ends_with("/workspace-or-schedule-error-handler-slack") { // default slack error handler being used -> we need to inject the slack token let slack_resource = format!("$res:{WORKSPACE_SLACK_BOT_TOKEN_PATH}"); extra.insert("slack".to_string(), to_raw_value(&slack_resource)); } let tx = PushIsolationLevel::IsolatedRoot(db.clone(), rsmq); let (uuid, tx) = push( &db, tx, script_w_id, job_payload, PushArgs { extra, args: result.to_owned() }, if is_global { "global" } else { ERROR_HANDLER_USERNAME }, if is_global { SUPERADMIN_SECRET_EMAIL } else { ERROR_HANDLER_USER_EMAIL }, if is_global { SUPERADMIN_SECRET_EMAIL.to_string() } else { ERROR_HANDLER_USER_GROUP.to_string() }, None, None, Some(job_id), Some(job_id), None, false, false, None, true, tag, None, None, None, ) .await?; tx.commit().await?; let error_handler_type = if is_global { "global" } else { "workspace" }; tracing::info!( "Sent error of job {job_id} to {error_handler_type} error handler under uuid {uuid}" ); Ok(()) } pub async fn send_error_to_global_handler< 'a, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection + Clone + Send, >( rsmq: Option, queued_job: &QueuedJob, db: &Pool, result: Json<&'a T>, ) -> Result<(), Error> { if let Some(ref global_error_handler) = *GLOBAL_ERROR_HANDLER_PATH_IN_ADMINS_WORKSPACE { run_error_handler( rsmq, queued_job, db, result, global_error_handler, None, true, ) .await? } Ok(()) } pub async fn send_error_to_workspace_handler< 'a, 'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection + Clone + Send, >( rsmq: Option, queued_job: &QueuedJob, is_canceled: bool, db: &Pool, result: Json<&'a T>, ) -> Result<(), Error> { let w_id = &queued_job.workspace_id; let mut tx = db.begin().await?; let (error_handler, error_handler_extra_args, error_handler_muted_on_cancel) = sqlx::query_as::<_, (Option, Option, bool)>( "SELECT error_handler, error_handler_extra_args, error_handler_muted_on_cancel FROM workspace_settings WHERE workspace_id = $1", ).bind(&w_id) .fetch_optional(&mut *tx) .await .context("sending error to global handler")? .ok_or_else(|| Error::InternalErr(format!("no workspace settings for id {w_id}")))?; if is_canceled && error_handler_muted_on_cancel { return Ok(()); } if let Some(error_handler) = error_handler { let ws_error_handler_muted: Option = match queued_job.job_kind { JobKind::Script => { sqlx::query_scalar!( "SELECT ws_error_handler_muted FROM script WHERE workspace_id = $1 AND hash = $2", queued_job.workspace_id, queued_job.script_hash.unwrap().0, ) .fetch_optional(db) .await? } JobKind::Flow => { sqlx::query_scalar!( "SELECT ws_error_handler_muted FROM flow WHERE workspace_id = $1 AND path = $2", queued_job.workspace_id, queued_job.script_path.as_ref().unwrap(), ) .fetch_optional(db) .await? } _ => None, }; if !ws_error_handler_muted.unwrap_or(false) { run_error_handler( rsmq, queued_job, db, result, &error_handler.strip_prefix("script/").unwrap(), error_handler_extra_args, false, ) .await? } } Ok(()) } #[instrument(level = "trace", skip_all)] pub async fn handle_maybe_scheduled_job<'c, R: rsmq_async::RsmqConnection + Clone + Send + 'c>( mut tx: QueueTransaction<'c, R>, db: &Pool, schedule_path: &str, script_path: &str, w_id: &str, ) -> windmill_common::error::Result> { let schedule = get_schedule_opt(tx.transaction_mut(), w_id, schedule_path).await?; if schedule.is_none() { tracing::error!( "Schedule {schedule_path} in {w_id} not found. Impossible to schedule again" ); return Ok(tx); } let schedule = schedule.unwrap(); if schedule.enabled && script_path == schedule.script_path { let res = push_scheduled_job( db, tx, Schedule { workspace_id: w_id.to_owned(), path: schedule.path.clone(), edited_by: schedule.edited_by, edited_at: schedule.edited_at, schedule: schedule.schedule, timezone: schedule.timezone, enabled: schedule.enabled, script_path: schedule.script_path, is_flow: schedule.is_flow, args: schedule .args .and_then(|e| serde_json::to_value(e).map_or(None, |v| Some(v))), extra_perms: serde_json::to_value(schedule.extra_perms).expect("hashmap -> json"), email: schedule.email, error: None, on_failure: schedule.on_failure, on_failure_times: schedule.on_failure_times, on_failure_exact: schedule.on_failure_exact, on_failure_extra_args: schedule.on_failure_extra_args, on_recovery: schedule.on_recovery, on_recovery_times: schedule.on_recovery_times, on_recovery_extra_args: schedule.on_recovery_extra_args, ws_error_handler_muted: schedule.ws_error_handler_muted, }, ) .await; match res { Ok(tx) => Ok(tx), Err(err) => { sqlx::query!( "UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3", err.to_string(), &schedule.workspace_id, &schedule.path ) .execute(db) .await?; tracing::warn!("Could not schedule job for {}: {}", schedule_path, err); Err(err) } } } else { Ok(tx) } } #[derive(Clone, Serialize)] struct CompletedJobSubset { success: bool, result: Option, started_at: chrono::DateTime, } async fn apply_schedule_handlers< 'a, 'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection + Clone + Send + 'c, >( mut tx: QueueTransaction<'c, R>, db: &Pool, schedule_path: &str, script_path: &str, w_id: &str, success: bool, result: Json<&'a T>, job_id: Uuid, started_at: DateTime, job_priority: Option, ) -> windmill_common::error::Result<(bool, QueueTransaction<'c, R>)> { let schedule = get_schedule_opt(tx.transaction_mut(), w_id, schedule_path).await?; if schedule.is_none() { tracing::error!( "Schedule {schedule_path} in {w_id} not found. Impossible to apply schedule handlers" ); return Ok((false, tx)); } let schedule = schedule.unwrap(); let skip_downstream_error_handlers = schedule.ws_error_handler_muted; if !success { if let Some(on_failure_path) = schedule.on_failure.clone() { let times = schedule.on_failure_times.unwrap_or(1).max(1); let exact = schedule.on_failure_exact.unwrap_or(false); if times > 1 || exact { let past_jobs = sqlx::query_as!( CompletedJobSubset, "SELECT success, result, started_at FROM completed_job WHERE workspace_id = $1 AND schedule_path = $2 AND script_path = $3 AND id != $4 ORDER BY created_at DESC LIMIT $5", &schedule.workspace_id, &schedule.path, &schedule.script_path, job_id, if exact { times } else { times - 1 } as i64, ).fetch_all(&mut tx).await?; let match_times = if exact { past_jobs.len() == times as usize && past_jobs[..(times - 1) as usize].iter().all(|j| !j.success) && past_jobs[(times - 1) as usize].success } else { past_jobs.len() == ((times - 1) as usize) && past_jobs.iter().all(|j| !j.success) }; if !match_times { return Ok((skip_downstream_error_handlers, tx)); } } let on_failure_result = handle_on_failure( db, tx, job_id, schedule_path, script_path, schedule.is_flow, w_id, &on_failure_path, result, times, started_at, schedule.on_failure_extra_args, &schedule.email, job_priority, ) .await; match on_failure_result { Ok((_, ntx)) => { tx = ntx; } Err(err) => { sqlx::query!( "UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3", format!("Could not trigger error handler: {err}"), &schedule.workspace_id, &schedule.path ) .execute(db) .await?; tracing::warn!( "Could not trigger error handler for {}: {}", schedule_path, err ); return Err(err); } } } } else { if let Some(on_recovery_path) = schedule.on_recovery.clone() { let times = schedule.on_recovery_times.unwrap_or(1).max(1); let past_jobs = sqlx::query_as!( CompletedJobSubset, "SELECT success, result, started_at FROM completed_job WHERE workspace_id = $1 AND schedule_path = $2 AND script_path = $3 AND id != $4 ORDER BY created_at DESC LIMIT $5", &schedule.workspace_id, &schedule.path, &schedule.script_path, job_id, times as i64, ).fetch_all(&mut tx).await?; if past_jobs.len() < times as usize { return Ok((skip_downstream_error_handlers, tx)); } let n_times_successful = past_jobs[..(times - 1) as usize].iter().all(|j| j.success); if !n_times_successful { return Ok((skip_downstream_error_handlers, tx)); } let failed_job = past_jobs[past_jobs.len() - 1].clone(); if !failed_job.success { let on_recovery_result = handle_on_recovery( db, tx, job_id, schedule_path, script_path, schedule.is_flow, w_id, &on_recovery_path, failed_job, result, times, started_at, schedule.on_recovery_extra_args, ) .await; match on_recovery_result { Ok(ntx) => { tx = ntx; } Err(err) => { sqlx::query!( "UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3", format!("Could not trigger recovery handler: {err}"), &schedule.workspace_id, &schedule.path ) .execute(db) .await?; tracing::warn!( "Could not trigger recovery handler for {}: {}", schedule_path, err ); return Err(err); } } } } } Ok((skip_downstream_error_handlers, tx)) } pub async fn handle_on_failure< 'a, 'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection + Clone + Send + 'c, >( db: &Pool, tx: QueueTransaction<'c, R>, job_id: Uuid, schedule_path: &str, script_path: &str, is_flow: bool, w_id: &str, on_failure_path: &str, result: Json<&'a T>, failed_times: i32, started_at: DateTime, extra_args: Option, email: &str, priority: Option, ) -> windmill_common::error::Result<(Uuid, QueueTransaction<'c, R>)> { let (payload, tag) = get_payload_tag_from_prefixed_path(on_failure_path, db, w_id).await?; let mut extra = HashMap::new(); extra.insert("schedule_path".to_string(), to_raw_value(&schedule_path)); extra.insert("workspace_id".to_string(), to_raw_value(&w_id)); extra.insert("job_id".to_string(), to_raw_value(&job_id)); extra.insert("path".to_string(), to_raw_value(&script_path)); extra.insert("is_flow".to_string(), to_raw_value(&is_flow)); extra.insert("started_at".to_string(), to_raw_value(&started_at)); extra.insert("email".to_string(), to_raw_value(&email)); extra.insert("failed_times".to_string(), to_raw_value(&failed_times)); if let Some(args_v) = extra_args { if let serde_json::Value::Object(args_m) = args_v { for (k, v) in args_m { extra.insert(k, to_raw_value(&v)); } } else { return Err(error::Error::ExecutionErr( "args of scripts needs to be dict".to_string(), )); } } // TODO: This should be inject when the EH is defined in the FE. if on_failure_path.to_string().starts_with("script/hub/") && on_failure_path .to_string() .ends_with("/workspace-or-schedule-error-handler-slack") { // default slack error handler being used -> we need to inject the slack token let slack_resource = format!("$res:{WORKSPACE_SLACK_BOT_TOKEN_PATH}"); extra.insert("slack".to_string(), to_raw_value(&slack_resource)); } let tx = PushIsolationLevel::Transaction(tx); let (uuid, tx) = push( &db, tx, w_id, payload, PushArgs { extra, args: result.to_owned() }, ERROR_HANDLER_USERNAME, ERROR_HANDLER_USER_EMAIL, ERROR_HANDLER_USER_GROUP.to_string(), None, None, Some(job_id), Some(job_id), None, false, false, None, true, tag, None, None, priority, ) .await?; tracing::info!( "Pushed on_failure job {} for {} to queue", uuid, schedule_path ); return Ok((uuid, tx)); } // #[derive(Serialize)] // pub struct RecoveryValue { // error_started_at: chrono::DateTime, // schedule_path: String, // path: String, // is_flow: boolean, // extra_args: serde_json::Value // } async fn handle_on_recovery< 'a, 'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection + Clone + Send + 'c, >( db: &Pool, tx: QueueTransaction<'c, R>, job_id: Uuid, schedule_path: &str, script_path: &str, is_flow: bool, w_id: &str, on_recovery_path: &str, error_job: CompletedJobSubset, successful_job_result: Json<&'a T>, successful_times: i32, successful_job_started_at: DateTime, extra_args: Option, ) -> windmill_common::error::Result> { let (payload, tag) = get_payload_tag_from_prefixed_path(on_recovery_path, db, w_id).await?; let mut args = error_job .result .unwrap_or(json!({})) .as_object() .unwrap() .clone(); args.insert("error_started_at".to_string(), json!(error_job.started_at)); args.insert("schedule_path".to_string(), json!(schedule_path)); args.insert("path".to_string(), json!(script_path)); args.insert("is_flow".to_string(), json!(is_flow)); args.insert( "success_result".to_string(), serde_json::from_str(&serde_json::to_string(&successful_job_result).unwrap()) .unwrap_or_else(|_| json!("{}")), ); args.insert("success_times".to_string(), json!(successful_times)); args.insert( "success_started_at".to_string(), json!(successful_job_started_at), ); if let Some(args_v) = extra_args { if let serde_json::Value::Object(args_m) = args_v { args.extend(args_m); } else { return Err(error::Error::ExecutionErr( "args of scripts needs to be dict".to_string(), )); } } // TODO: This should be inject when the EH is defined in the FE. if on_recovery_path .to_string() .eq("script/hub/2430/slack/schedule-recovery-handler-slack") { // default slack error handler being used -> we need to inject the slack token let slack_resource = format!("$res:{WORKSPACE_SLACK_BOT_TOKEN_PATH}"); args.insert("slack".to_string(), json!(slack_resource)); } let tx = PushIsolationLevel::Transaction(tx); let (uuid, tx) = push( &db, tx, w_id, payload, args, ERROR_HANDLER_USERNAME, ERROR_HANDLER_USER_EMAIL, ERROR_HANDLER_USER_GROUP.to_string(), None, None, Some(job_id), Some(job_id), None, false, false, None, true, tag, None, None, None, ) .await?; tracing::info!( "Pushed on_recovery job {} for {} to queue", uuid, schedule_path ); return Ok(tx); } pub async fn pull( db: &Pool, rsmq: Option, suspend_first: bool, ) -> windmill_common::error::Result> { loop { let job = pull_single_job_and_mark_as_running_no_concurrency_limit( db, rsmq.clone(), suspend_first, ) .await?; if job.is_none() { return Ok(None); } // concurrency check. If more than X jobs for this path are already running, we re-queue and pull another job from the queue let pulled_job = job.unwrap(); if pulled_job.script_path.is_none() || pulled_job.concurrent_limit.is_none() || pulled_job.canceled { if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { QUEUE_PULL_COUNT.inc(); } return Ok(Option::Some(pulled_job)); } let itx = db.begin().await?; let mut tx: QueueTransaction<'_, _> = (rsmq.clone(), itx).into(); // Else the job is subject to concurrency limits let job_script_path = pulled_job.script_path.clone().unwrap(); let job_custom_concurrent_limit = pulled_job.concurrent_limit.unwrap(); // setting concurrency_time_window to 0 will count only the currently running jobs let job_custom_concurrency_time_window_s = pulled_job.concurrency_time_window_s.unwrap_or(0); tracing::debug!( "Job concurrency limit is {} per {}s", job_custom_concurrent_limit, job_custom_concurrency_time_window_s ); sqlx::query_scalar!( "SELECT null FROM queue WHERE id = $1 FOR UPDATE", pulled_job.id ) .fetch_one(&mut tx) .await .context("lock job in queue")?; let jobs_uuids_init_json_value = serde_json::from_str::( format!("{{\"{}\": {{}}}}", pulled_job.id.hyphenated().to_string()).as_str(), ) .expect("Unable to serialize job_uuids column to proper JSON"); let running_job = sqlx::query_scalar!( "INSERT INTO concurrency_counter(concurrency_id, job_uuids) VALUES ($1, $2) ON CONFLICT (concurrency_id) DO UPDATE SET job_uuids = jsonb_set(concurrency_counter.job_uuids, array[$3], '{}') RETURNING (SELECT COUNT(*) FROM jsonb_object_keys(job_uuids))", pulled_job.full_path(), jobs_uuids_init_json_value, pulled_job.id.hyphenated().to_string(), ) .fetch_one(&mut tx) .await .map_err(|e| { Error::InternalErr(format!( "Error getting concurrency count for script path {job_script_path}: {e}" )) })?; tracing::debug!("running_job: {}", running_job.unwrap_or(0)); let script_path_live_stats = sqlx::query!( "SELECT COALESCE(j.min_started_at, q.min_started_at) AS min_started_at, COALESCE(completed_count, 0) AS completed_count FROM (SELECT script_path, MIN(started_at) as min_started_at, COUNT(*) as completed_count FROM completed_job WHERE script_path = $1 AND job_kind != 'dependencies' AND started_at + INTERVAL '1 MILLISECOND' * duration_ms > (now() - INTERVAL '1 second' * $2) AND workspace_id = $3 AND canceled = false GROUP BY script_path) as j FULL OUTER JOIN (SELECT script_path, MIN(started_at) as min_started_at FROM queue WHERE script_path = $1 AND job_kind != 'dependencies' AND running = true AND workspace_id = $3 AND canceled = false GROUP BY script_path) as q ON q.script_path = j.script_path", job_script_path, f64::from(job_custom_concurrency_time_window_s), &pulled_job.workspace_id ) .fetch_one(&mut tx) .await .map_err(|e| { Error::InternalErr(format!( "Error getting concurrency count for script path {job_script_path}: {e}" )) })?; let concurrent_jobs_for_this_script = script_path_live_stats.completed_count.unwrap_or_default() as i32 + running_job.unwrap_or(0) as i32; tracing::debug!( "Current concurrent jobs for this script: {}", concurrent_jobs_for_this_script ); if concurrent_jobs_for_this_script <= job_custom_concurrent_limit { if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { QUEUE_PULL_COUNT.inc(); } tx.commit().await?; return Ok(Option::Some(pulled_job)); } let x = sqlx::query_scalar!( "UPDATE concurrency_counter SET job_uuids = job_uuids - $2 WHERE concurrency_id = $1 RETURNING (SELECT COUNT(*) FROM jsonb_object_keys(job_uuids))", pulled_job.full_path(), pulled_job.id.hyphenated().to_string(), ) .fetch_one(&mut tx) .await .map_err(|e| { Error::InternalErr(format!( "Error decreasing concurrency count for script path {job_script_path}: {e}" )) })?; tracing::debug!("running_job after decrease: {}", x.unwrap_or(0)); let job_uuid: Uuid = pulled_job.id; let min_started_at: Option> = script_path_live_stats.min_started_at; let avg_script_duration: Option = sqlx::query_scalar!( "SELECT CAST(ROUND(AVG(duration_ms) / 1000, 0) AS BIGINT) AS avg_duration_s FROM (SELECT duration_ms FROM completed_job WHERE script_path = $1 ORDER BY started_at DESC LIMIT 10) AS t", job_script_path ) .fetch_one(&mut tx) .await?; // optimal scheduling is: 'older_job_in_concurrency_time_window_started_timestamp + script_avg_duration + concurrency_time_window_s' let estimated_next_schedule_timestamp = min_started_at.unwrap_or(pulled_job.scheduled_for) + Duration::seconds(avg_script_duration.map(i64::from).unwrap_or(0)) + Duration::seconds(i64::from(job_custom_concurrency_time_window_s)); tracing::info!("Job '{}' from path '{}' has reached its concurrency limit of {} jobs run in the last {} seconds. This job will be re-queued for next execution at {}", job_uuid, job_script_path, job_custom_concurrent_limit, job_custom_concurrency_time_window_s, estimated_next_schedule_timestamp); let job_log_line_break = '\n'; let job_log_event = format!( "Re-scheduled job to {estimated_next_schedule_timestamp} due to concurrency limits" ); if rsmq.is_some() { // if let Some(ref mut rsmq) = tx.rsmq { // if using redis, only one message at a time can be poped from the queue. Process only this message and move to the next elligible job // In this case, the job might be a job from the same script path, but we can't optimise this further // if using posgtres, then we're able to re-queue the entire batch of scheduled job for this script_path, so we do it let requeued_job_tag = sqlx::query_scalar::<_, String>(&format!( "UPDATE queue SET running = false , started_at = null , scheduled_for = '{estimated_next_schedule_timestamp}' , logs = CASE WHEN logs IS NULL OR logs = '' THEN '{job_log_event}'::text WHEN logs LIKE '%{job_log_event}' THEN logs ELSE concat(logs, '{job_log_line_break}{job_log_event}'::text) END WHERE id = '{job_uuid}' RETURNING tag" )) .fetch_one(&mut tx) .await .map_err(|e| Error::InternalErr(format!("Could not update and re-queue job {job_uuid}. The job will be marked as running but it is not running: {e}")))?; if let Some(ref mut rsmq) = tx.rsmq { rsmq.send_message( job_uuid.to_bytes_le().to_vec(), Option::Some(estimated_next_schedule_timestamp), requeued_job_tag, ); } tx.commit().await?; } else { // if using posgtres, then we're able to re-queue the entire batch of scheduled job for this script_path, so we do it sqlx::query(&format!( "UPDATE queue SET running = false , started_at = null , scheduled_for = '{estimated_next_schedule_timestamp}' , logs = CASE WHEN logs IS NULL OR logs = '' THEN '{job_log_event}'::text WHEN logs LIKE '%{job_log_event}' THEN logs ELSE concat(logs, '{job_log_line_break}{job_log_event}'::text) END WHERE (id = '{job_uuid}') OR (script_path = '{job_script_path}' AND running = false AND scheduled_for <= now())" )) .fetch_all(&mut tx) .await .map_err(|e| Error::InternalErr(format!("Could not update and re-queue job {job_uuid}. The job will be marked as running but it is not running: {e}")))?; tx.commit().await? } } } async fn pull_single_job_and_mark_as_running_no_concurrency_limit< 'c, R: rsmq_async::RsmqConnection + Send + Clone, >( db: &Pool, rsmq: Option, suspend_first: bool, ) -> windmill_common::error::Result> { let job: Option = if let Some(mut rsmq) = rsmq { #[cfg(feature = "benchmark")] let instant = Instant::now(); // TODO: REDIS: Race conditions / replace last_ping // TODO: shuffle this list to have fairness let mut all_tags = WORKER_CONFIG.read().await.worker_tags.clone(); let mut msg: Option<_> = None; let mut tag = None; while msg.is_none() && !all_tags.is_empty() { let ntag = all_tags.pop().unwrap(); tag = Some(ntag.clone()); msg = rsmq .receive_message::>(&ntag, Some(10)) .await .map_err(|e| anyhow::anyhow!(e))?; } // #[cfg(feature = "benchmark")] // println!("rsmq 1: {:?}", instant.elapsed()); // println!("3.1: {:?} {rs}", instant.elapsed()); if let Some(msg) = msg { let uuid = Uuid::from_bytes_le( msg.message .try_into() .map_err(|_| anyhow::anyhow!("Failed to parsed Redis message"))?, ); let m2r = sqlx::query( "UPDATE queue SET running = true , started_at = coalesce(started_at, now()) , last_ping = now() , suspend_until = null WHERE id = $1 RETURNING *", ) .bind(uuid) .fetch_optional(db) .await?; let m2 = if let Some(row) = m2r { Some(QueuedJob::from_row(&row)?) } else { None }; rsmq.delete_message(&tag.unwrap(), &msg.id) .await .map_err(|e| anyhow::anyhow!(e))?; #[cfg(feature = "benchmark")] println!("rsmq 2: {:?}", instant.elapsed()); m2 } else { None } } else { /* Jobs can be started if they: * - haven't been started before, * running = false * - are flows with a step that needed resume, * suspend_until is non-null * and suspend = 0 when the resume messages are received * or suspend_until <= now() if it has timed out */ let config = WORKER_CONFIG.read().await.clone(); let tags = config.worker_tags.clone(); #[cfg(not(feature = "enterprise"))] let priority_tags_sorted = vec![PriorityTags { priority: 0, tags: tags.clone() }]; #[cfg(feature = "enterprise")] let priority_tags_sorted = config.priority_tags_sorted.clone(); drop(config); let r = if suspend_first { sqlx::query("UPDATE queue SET running = true , started_at = coalesce(started_at, now()) , last_ping = now() , suspend_until = null WHERE id = ( SELECT id FROM queue WHERE suspend_until IS NOT NULL AND (suspend <= 0 OR suspend_until <= now()) AND tag = ANY($1) ORDER BY priority DESC NULLS LAST, created_at FOR UPDATE SKIP LOCKED LIMIT 1 ) RETURNING *") .bind(tags) .fetch_optional(db) .await? } else { None }; let r = if let Some(row) = r { Some(QueuedJob::from_row(&row)?) } else { None }; if r.is_none() { // #[cfg(feature = "benchmark")] // let instant = Instant::now(); let mut highest_priority_job: Option = None; for priority_tags in priority_tags_sorted { let r = sqlx::query( "UPDATE queue SET running = true , started_at = coalesce(started_at, now()) , last_ping = now() , suspend_until = null WHERE id = ( SELECT id FROM queue WHERE running = false AND scheduled_for <= now() AND tag = ANY($1) ORDER BY priority DESC NULLS LAST, scheduled_for, created_at FOR UPDATE SKIP LOCKED LIMIT 1 ) RETURNING *", ) .bind(priority_tags.tags.clone()) .fetch_optional(db) .await?; if let Some(pulled_row) = r { let pulled_job = QueuedJob::from_row(&pulled_row)?; highest_priority_job = Some(pulled_job.clone()); tracing::debug!( "Pulling for job {} with tags {:?} with priority {}", pulled_job.id, priority_tags.tags, priority_tags.priority ); break; } // else continue pulling for lower priority tags } // #[cfg(feature = "benchmark")] // println!("pull query: {:?}", instant.elapsed()); highest_priority_job } else { r } }; Ok(job) } #[derive(FromRow)] struct ResultR { result: Option>>, } pub async fn get_result_by_id( db: Pool, w_id: String, flow_id: Uuid, node_id: String, json_path: Option, ) -> error::Result> { match get_result_by_id_from_running_flow( &db, w_id.as_str(), &flow_id, node_id.as_str(), json_path.clone(), ) .await { Ok(res) => Ok(res), Err(_) => { let running_flow_job = sqlx::query_as::<_, QueuedJob>( "SELECT * FROM queue WHERE COALESCE((SELECT root_job FROM queue WHERE id = $1), $1) = id AND workspace_id = $2" ).bind(flow_id) .bind(&w_id) .fetch_optional(&db) .await?; let restarted_from = windmill_common::utils::not_found_if_none( running_flow_job .map(|fj| fj.parse_flow_status()) .flatten() .map(|status| status.restarted_from) .flatten(), "Flow result by id in leaf jobs", format!("{}, {}", flow_id, node_id), )?; get_result_by_id_from_original_flow( &db, w_id.as_str(), &restarted_from.flow_job_id, node_id.as_str(), json_path.clone(), ) .await } } } #[async_recursion] async fn get_result_by_id_from_running_flow( db: &Pool, w_id: &str, flow_id: &Uuid, node_id: &str, json_path: Option, ) -> error::Result> { let flow_job_result = sqlx::query!( "SELECT leaf_jobs->$1::text as leaf_jobs, parent_job FROM queue WHERE COALESCE((SELECT root_job FROM queue WHERE id = $2), $2) = id AND workspace_id = $3", node_id, flow_id, w_id, ) .fetch_optional(db) .await?; let flow_job_result = windmill_common::utils::not_found_if_none( flow_job_result, "Flow result by id in leaf jobs", format!("{}, {}", flow_id, node_id), )?; let job_result = flow_job_result .leaf_jobs .map(|x| serde_json::from_value(x).ok()) .flatten(); if job_result.is_none() && flow_job_result.parent_job.is_some() { let parent_job = flow_job_result.parent_job.unwrap(); let root_job = sqlx::query_scalar!("SELECT root_job FROM queue WHERE id = $1", parent_job) .fetch_optional(db) .await? .flatten() .unwrap_or(parent_job); return get_result_by_id_from_running_flow(db, w_id, &root_job, node_id, json_path).await; } let result_id = windmill_common::utils::not_found_if_none( job_result, "Flow result by id", format!("{}, {}", flow_id, node_id), )?; extract_result_from_job_result(db, w_id, result_id, json_path).await } #[async_recursion] async fn get_result_by_id_from_original_flow( db: &Pool, w_id: &str, completed_flow_id: &Uuid, node_id: &str, json_path: Option, ) -> error::Result> { let flow_job = sqlx::query_as::<_, CompletedJob>( "SELECT * FROM completed_job WHERE id = $1 AND workspace_id = $2", ) .bind(completed_flow_id) .bind(w_id) .fetch_optional(db) .await?; let flow_job = windmill_common::utils::not_found_if_none( flow_job, "Flow result by id in leaf jobs", format!("{}", completed_flow_id), )?; let mut leaf_jobs_for_flow = HashMap::::new(); compute_leaf_jobs_for_completed_flow( db, w_id, flow_job.id, Some(flow_job.clone()), &mut leaf_jobs_for_flow, ) .await?; tracing::debug!( "Fetching leaf jobs for flow {} : {:?}", flow_job.id, leaf_jobs_for_flow ); if !leaf_jobs_for_flow.contains_key(&node_id.to_string()) { // if the flow is itself a restart flow, the step job might be from the upstream flow let restarted_from = windmill_common::utils::not_found_if_none( flow_job .parse_flow_status() .map(|status| status.restarted_from) .flatten(), "Flow result by id in leaf jobs", format!("{}", completed_flow_id), )?; return get_result_by_id_from_original_flow( db, w_id, &restarted_from.flow_job_id, node_id, json_path, ) .await; } // if the job is in the leaf_jobs map, then fetch its result and return let leaf_job_uuid = leaf_jobs_for_flow.get(&node_id.to_string()).unwrap(); extract_result_from_job_result(db, w_id, leaf_job_uuid.to_owned(), json_path).await } #[async_recursion] async fn compute_leaf_jobs_for_completed_flow( db: &Pool, w_id: &str, completed_flow_id: Uuid, completed_flow_row: Option, // if provided, will be used, otherwise completed_flow_id must be set and the job definition will be pulled from DB recursive_result: &mut HashMap, ) -> error::Result<()> { let flow_status = match completed_flow_row { Some(job) => job.parse_flow_status(), None => { let job_status_raw = sqlx::query_scalar!( "SELECT flow_status FROM completed_job WHERE id = $1 AND workspace_id = $2", completed_flow_id, w_id, ) .fetch_one(db) .await?; job_status_raw .map(|raw| serde_json::from_value::(raw).ok()) .flatten() } }; let flow_job_status_modules = windmill_common::utils::not_found_if_none( flow_status.map(|fs| fs.modules), "Flow result by id in leaf jobs", format!("{}", completed_flow_id), )?; let children_jobs = sqlx::query_as::<_, (Uuid, JobKind)>( "SELECT id, job_kind FROM completed_job WHERE parent_job = $1 AND workspace_id = $2", ) .bind(completed_flow_id) .bind(w_id) .fetch_all(db) .await?; for child_job in children_jobs { let child_job_id = child_job.0; let child_job_kind = child_job.1; match child_job_kind { JobKind::Script | JobKind::Preview | JobKind::Script_Hub => { // if is potentially a leaf job. Get its step_id from the initial flow definition and add it to the result map for module in &flow_job_status_modules { if module.job().map(|id| id == child_job_id).unwrap_or(false) { recursive_result.insert(module.id(), JobResult::SingleJob(child_job_id)); } } } JobKind::Flow | JobKind::FlowPreview => { // Extract the leaf job for this flow and add them to the result map for module in &flow_job_status_modules { // we add the module as an element of ListJob for this step ID and recursiively extract leaf job of the sub-flow match module { FlowStatusModule::Success { flow_jobs: Some(jobs), .. } => { let jobs_set: HashSet = HashSet::from_iter(jobs.iter().cloned()); if jobs_set.contains(&child_job_id) { let new_list_job = match recursive_result.get(&module.id()) { Some(JobResult::ListJob(jobs_list)) => { let mut jobs_list_c = jobs_list.clone(); jobs_list_c.push(child_job_id); jobs_list_c } _ => vec![child_job_id], }; recursive_result .insert(module.id(), JobResult::ListJob(new_list_job)); } } _ => {} } } compute_leaf_jobs_for_completed_flow( db, w_id, child_job_id, None, recursive_result, ) .await?; } _ => {} // do nothing } } Ok(()) } async fn extract_result_from_job_result( db: &Pool, w_id: &str, job_result: JobResult, json_path: Option, ) -> error::Result> { match job_result { JobResult::ListJob(job_ids) => { let rows = sqlx::query( "SELECT result FROM completed_job WHERE id = ANY($1) AND workspace_id = $2", ) .bind(job_ids.as_slice()) .bind(w_id) .fetch_all(db) .await? .into_iter() .filter_map(|x| ResultR::from_row(&x).ok().and_then(|x| x.result)) .collect::>>>(); Ok(to_raw_value(&rows)) } JobResult::SingleJob(x) => Ok(sqlx::query( "SELECT result #> $3 as result FROM completed_job WHERE id = $1 AND workspace_id = $2", ) .bind(x) .bind(w_id) .bind( json_path .map(|x| x.split(".").map(|x| x.to_string()).collect::>()) .unwrap_or_default(), ) .fetch_optional(db) .await? .map(|r| { ResultR::from_row(&r) .ok() .and_then(|x| x.result.map(|x| x.0)) }) .flatten() .unwrap_or_else(|| to_raw_value(&serde_json::Value::Null))), } } #[instrument(level = "trace", skip_all)] pub async fn delete_job<'c, R: rsmq_async::RsmqConnection + Clone + Send>( mut tx: QueueTransaction<'c, R>, w_id: &str, job_id: Uuid, ) -> windmill_common::error::Result> { if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { QUEUE_DELETE_COUNT.inc(); } let job_removed = sqlx::query_scalar!( "DELETE FROM queue WHERE workspace_id = $1 AND id = $2 RETURNING 1", w_id, job_id ) .fetch_optional(&mut tx) .await; if let Err(job_removed) = job_removed { tracing::error!( "Job {job_id} could not be deleted: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling" ); } else { let job_removed = job_removed.unwrap().flatten().unwrap_or(0); if job_removed != 1 { tracing::error!("Job {job_id} could not be deleted, returned not 1: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling"); } } tracing::debug!("Job {job_id} deleted"); Ok(tx) } pub async fn job_is_complete(db: &DB, id: Uuid, w_id: &str) -> error::Result { Ok(sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM completed_job WHERE id = $1 AND workspace_id = $2)", id, w_id ) .fetch_one(db) .await? .unwrap_or(false)) } pub async fn get_queued_job<'c>( id: Uuid, w_id: &str, tx: &mut Transaction<'c, Postgres>, ) -> error::Result> { let r = sqlx::query( "SELECT * FROM queue WHERE id = $1 AND workspace_id = $2", ) .bind(id) .bind(w_id) .fetch_optional(&mut **tx) .await?; if let Some(row) = r { Ok(Some(QueuedJob::from_row(&row)?.to_owned())) } else { Ok(None) } } pub enum PushIsolationLevel<'c, R: rsmq_async::RsmqConnection + Send + 'c> { IsolatedRoot(DB, Option), Isolated(UserDB, Authed, Option), Transaction(QueueTransaction<'c, R>), } #[macro_export] macro_rules! fetch_scalar_isolated { ( $query:expr, $tx:expr) => { match $tx { PushIsolationLevel::IsolatedRoot(db, rmsq) => { let r = $query.fetch_optional(&db).await; $tx = PushIsolationLevel::IsolatedRoot(db, rmsq); r } PushIsolationLevel::Isolated(db, user, rsmq) => { let mut ntx = db.clone().begin(&user).await?; let r = $query.fetch_optional(&mut *ntx).await; $tx = PushIsolationLevel::Isolated(db, user, rsmq); r } PushIsolationLevel::Transaction(mut tx) => { let r = $query.fetch_optional(&mut tx).await; $tx = PushIsolationLevel::Transaction(tx); r } } }; } use sqlx::types::JsonRawValue; #[derive(Serialize)] pub struct PushArgs { #[serde(flatten)] pub extra: HashMap>, #[serde(flatten)] pub args: Json, } #[derive(Deserialize)] pub struct RequestQuery { pub raw: Option, pub include_header: Option, } #[axum::async_trait] impl FromRequest for PushArgs>> where S: Send + Sync, { type Rejection = Response; async fn from_request( req: Request, _state: &S, ) -> Result { let (content_type, mut extra, use_raw) = { let headers_map = req.headers(); let content_type_header = headers_map.get(CONTENT_TYPE); let content_type = content_type_header.and_then(|value| value.to_str().ok()); let query = Query::::try_from_uri(req.uri()).unwrap().0; let raw = query.raw.as_ref().is_some_and(|x| *x); ( content_type, build_extra(&headers_map, query.include_header), raw, ) }; if content_type.is_none() || content_type.unwrap().starts_with("application/json") { let bytes = Bytes::from_request(req, _state) .await .map_err(IntoResponse::into_response)?; let str = String::from_utf8(bytes.to_vec()) .map_err(|e| Error::BadRequest(format!("invalid utf8: {}", e)).into_response())?; if use_raw { extra.insert("raw_string".to_string(), to_raw_value(&str)); } let wrap_body = str.len() > 0 && str.chars().next().unwrap() != '{'; if wrap_body { let args = serde_json::from_str::>>(&str) .map_err(|e| Error::BadRequest(format!("invalid json: {}", e)).into_response())? .unwrap_or_else(|| to_raw_value(&serde_json::Value::Null)); let mut hm = HashMap::new(); hm.insert("body".to_string(), args); Ok(PushArgs { extra, args: Json(hm) }) } else { let hm = serde_json::from_str::>>>(&str) .map_err(|e| Error::BadRequest(format!("invalid json: {}", e)).into_response())? .unwrap_or_else(HashMap::new); Ok(PushArgs { extra, args: Json(hm) }) } } else if content_type .unwrap() .starts_with("application/x-www-form-urlencoded") { let Form(payload): Form>>> = req.extract().await.map_err(IntoResponse::into_response)?; return Ok(PushArgs { extra: HashMap::new(), args: Json(payload.unwrap_or_else(HashMap::new)), }); } else { Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response()) } } } lazy_static::lazy_static! { static ref INCLUDE_HEADERS: Vec = std::env::var("INCLUDE_HEADERS") .ok().map(|x| x .split(',') .map(|s| s.to_string()) .collect()).unwrap_or_default(); } pub fn build_extra( headers: &HeaderMap, include_header: Option, ) -> HashMap> { let mut args = HashMap::new(); let whitelist = include_header .map(|s| s.split(",").map(|s| s.to_string()).collect::>()) .unwrap_or_default(); whitelist .iter() .chain(INCLUDE_HEADERS.iter()) .for_each(|h| { if let Some(v) = headers.get(h) { args.insert( h.to_string().to_lowercase().replace('-', "_"), to_raw_value(&v.to_str().unwrap().to_string()), ); } }); args } impl PushArgs>> { pub fn empty() -> Self { PushArgs { extra: HashMap::new(), args: Json(HashMap::new()) } } } pub fn empty_args() -> Box { return JsonRawValue::from_string("{}".to_string()).unwrap(); } impl From>> for PushArgs>> { fn from(value: HashMap>) -> Self { PushArgs { extra: HashMap::new(), args: Json(value) } } } // impl From> for PushArgs { // fn from(value: PushArgsInner) -> Self { // PushArgs::Unwrapped(value) // } // } // #[instrument(level = "trace", skip_all)] pub async fn push<'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection + Send + 'c>( _db: &Pool, mut tx: PushIsolationLevel<'c, R>, workspace_id: &str, job_payload: JobPayload, args: T, user: &str, email: &str, permissioned_as: String, scheduled_for_o: Option>, schedule_path: Option, parent_job: Option, root_job: Option, job_id: Option, is_flow_step: bool, mut same_worker: bool, pre_run_error: Option<&windmill_common::error::Error>, visible_to_owner: bool, mut tag: Option, custom_timeout: Option, flow_step_id: Option, priority_override: Option, ) -> Result<(Uuid, QueueTransaction<'c, R>), Error> { #[cfg(feature = "enterprise")] if *CLOUD_HOSTED { let premium_workspace = *CLOUD_HOSTED && sqlx::query_scalar!("SELECT premium FROM workspace WHERE id = $1", workspace_id) .fetch_one(_db) .await .map_err(|e| { Error::InternalErr(format!("fetching if {workspace_id} is premium: {e}")) })?; // we track only non flow steps let usage = if !matches!( job_payload, JobPayload::Flow(_) | JobPayload::RawFlow { .. } ) { sqlx::query_scalar!( "INSERT INTO usage (id, is_workspace, month_, usage) VALUES ($1, $2, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1 RETURNING usage.usage", if premium_workspace { workspace_id } else { email }, premium_workspace ) .fetch_one(_db) .await .map_err(|e| Error::InternalErr(format!("updating usage: {e}")))? } else if *CLOUD_HOSTED && !premium_workspace { sqlx::query_scalar!( " SELECT usage.usage + 1 FROM usage WHERE is_workspace = false AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND id = $1", email ) .fetch_optional(_db) .await? .flatten() .unwrap_or(0) } else { 0 }; if *CLOUD_HOSTED && !premium_workspace { let is_super_admin = sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", email) .fetch_optional(_db) .await? .unwrap_or(false); if !is_super_admin { if usage > MAX_FREE_EXECS && !matches!(job_payload, JobPayload::Dependencies { .. }) && !matches!(job_payload, JobPayload::FlowDependencies { .. }) && !matches!(job_payload, JobPayload::AppDependencies { .. }) { return Err(error::Error::BadRequest(format!( "User {email} has exceeded the free usage limit of {MAX_FREE_EXECS} that applies outside of premium workspaces." ))); } let in_queue = sqlx::query_scalar!("SELECT COUNT(id) FROM queue WHERE email = $1", email) .fetch_one(_db) .await? .unwrap_or(0); if in_queue > MAX_FREE_EXECS.into() { return Err(error::Error::BadRequest(format!( "User {email} has exceeded the jobs in queue limit of {MAX_FREE_EXECS} that applies outside of premium workspaces." ))); } let concurrent_runs = sqlx::query_scalar!( "SELECT COUNT(id) FROM queue WHERE running = true AND email = $1", email ) .fetch_one(_db) .await? .unwrap_or(0); if concurrent_runs > MAX_FREE_CONCURRENT_RUNS.into() { return Err(error::Error::BadRequest(format!( "User {email} has exceeded the concurrent runs limit of {MAX_FREE_CONCURRENT_RUNS} that applies outside of premium workspaces." ))); } } } } let ( script_hash, script_path, raw_code_tuple, job_kind, mut raw_flow, flow_status, language, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, low_level_priority, ) = match job_payload { JobPayload::ScriptHash { hash, path, concurrent_limit, concurrency_time_window_s, cache_ttl, language, dedicated_worker, priority, } => ( Some(hash.0), Some(path), None, JobKind::Script, None, None, Some(language), concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, priority, ), JobPayload::ScriptHub { path } => { ( None, Some(path), None, // Some((script.content, script.lockfile)), JobKind::Script_Hub, None, None, None, None, None, None, None, None, ) } JobPayload::Code(RawCode { content, path, language, lock, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, }) => ( None, path, Some((content, lock)), JobKind::Preview, None, None, Some(language), concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, None, ), JobPayload::Dependencies { hash, dependencies, language, path } => ( Some(hash.0), Some(path), Some((dependencies, None)), JobKind::Dependencies, None, None, Some(language), None, None, None, None, None, ), JobPayload::FlowDependencies { path } => { let value_json = fetch_scalar_isolated!( sqlx::query_scalar!( "SELECT value FROM flow WHERE path = $1 AND workspace_id = $2", path, workspace_id ), tx )? .ok_or_else(|| Error::InternalErr(format!("not found flow at path {:?}", path)))?; let value = serde_json::from_value::(value_json).map_err(|err| { Error::InternalErr(format!( "could not convert json to flow for {path}: {err:?}" )) })?; ( None, Some(path), None, JobKind::FlowDependencies, Some(value.clone()), Some(FlowStatus::new(&value)), // this is a new flow being pushed, flow_status is set to flow_value None, None, None, None, None, None, ) } JobPayload::AppDependencies { path, version } => ( Some(version), Some(path), None, JobKind::AppDependencies, None, None, None, None, None, None, None, None, ), JobPayload::RawFlow { value, path, restarted_from } => { let flow_status: FlowStatus = match restarted_from { Some(restarted_from_val) => { let (_, _, step_n, truncated_modules, _) = restarted_flows_resolution( _db, workspace_id, Some(value.clone()), restarted_from_val.flow_job_id, restarted_from_val.step_id.as_str(), restarted_from_val.branch_or_iteration_n, ) .await?; FlowStatus { step: step_n, modules: truncated_modules, // failure_module is reset failure_module: FlowStatusModuleWParent { parent_module: None, module_status: FlowStatusModule::WaitingForPriorSteps { id: "failure".to_string(), }, }, // retry status is reset retry: RetryStatus { fail_count: 0, failed_jobs: vec![] }, // TODO: for now, flows with approval conditions aren't supported for restart approval_conditions: None, restarted_from: Some(RestartedFrom { flow_job_id: restarted_from_val.flow_job_id, step_id: restarted_from_val.step_id, branch_or_iteration_n: restarted_from_val.branch_or_iteration_n, }), } } _ => FlowStatus::new(&value), // this is a new flow being pushed, flow_status is set to flow_value }; ( None, path, None, JobKind::FlowPreview, Some(value.clone()), Some(flow_status), None, value.concurrent_limit.clone(), value.concurrency_time_window_s, value.cache_ttl.map(|x| x as i32), None, value.priority, ) } JobPayload::Flow(flow) => { let value_json = fetch_scalar_isolated!( sqlx::query_scalar!( "SELECT value FROM flow WHERE path = $1 AND workspace_id = $2", flow, workspace_id ), tx )? .ok_or_else(|| Error::InternalErr(format!("not found flow at path {:?}", flow)))?; let value = serde_json::from_value::(value_json).map_err(|err| { Error::InternalErr(format!( "could not convert json to flow for {flow}: {err:?}" )) })?; ( None, Some(flow), None, JobKind::Flow, Some(value.clone()), Some(FlowStatus::new(&value)), // this is a new flow being pushed, flow_status is set to flow_value None, value.concurrent_limit.clone(), value.concurrency_time_window_s, value.cache_ttl.map(|x| x as i32), None, value.priority, ) } JobPayload::RestartedFlow { completed_job_id, step_id, branch_or_iteration_n } => { let (flow_path, raw_flow, step_n, truncated_modules, priority) = restarted_flows_resolution( _db, workspace_id, None, completed_job_id, step_id.as_str(), branch_or_iteration_n, ) .await?; let restarted_flow_status = FlowStatus { step: step_n, modules: truncated_modules, // failure_module is reset failure_module: FlowStatusModuleWParent { parent_module: None, module_status: FlowStatusModule::WaitingForPriorSteps { id: "failure".to_string(), }, }, // retry status is reset retry: RetryStatus { fail_count: 0, failed_jobs: vec![] }, // TODO: for now, flows with approval conditions aren't supported for restart approval_conditions: None, restarted_from: Some(RestartedFrom { flow_job_id: completed_job_id, step_id, branch_or_iteration_n, }), }; ( None, flow_path, None, JobKind::Flow, Some(raw_flow.clone()), Some(restarted_flow_status), None, raw_flow.concurrent_limit, raw_flow.concurrency_time_window_s, raw_flow.cache_ttl.map(|x| x as i32), None, priority, ) } JobPayload::Identity => ( None, None, None, JobKind::Identity, None, None, None, None, None, None, None, None, ), JobPayload::Noop => ( None, None, None, JobKind::Noop, None, None, None, None, None, None, None, None, ), }; let final_priority: Option; #[cfg(not(feature = "enterprise"))] { // priority is only available on EE. Do not compute it on CE final_priority = None; } #[cfg(feature = "enterprise")] { final_priority = if *CLOUD_HOSTED { // for cloud hosted instance, priority queues is disabled None } else if priority_override.is_some() { priority_override } else { // else it takes the priority defined at the script/flow level, if it's a script or flow low_level_priority }; // else it remains empty, i.e. no priority } let is_running = same_worker; if let Some(flow) = raw_flow.as_ref() { same_worker = same_worker || flow.same_worker; for module in flow.modules.iter() { if let Some(retry) = &module.retry { if retry.max_attempts() > MAX_RETRY_ATTEMPTS { Err(Error::BadRequest(format!( "retry attempts exceeds the maximum of {MAX_RETRY_ATTEMPTS}" )))? } if matches!(retry.max_interval(), Some(interval) if interval > MAX_RETRY_INTERVAL) { let max = MAX_RETRY_INTERVAL.as_secs(); Err(Error::BadRequest(format!( "retry interval exceeds the maximum of {max} seconds" )))? } } } // If last module has a sleep or suspend, we insert a virtual identity module if flow.modules.len() > 0 && (flow.modules[flow.modules.len() - 1].sleep.is_some() || flow.modules[flow.modules.len() - 1].suspend.is_some()) { let mut modules = flow.modules.clone(); modules.push(FlowModule { id: format!("{}-v", flow.modules[flow.modules.len() - 1].id), value: FlowModuleValue::Identity, stop_after_if: None, summary: Some( "Virtual module needed for suspend/sleep when last module".to_string(), ), mock: None, retry: None, sleep: None, suspend: None, cache_ttl: None, timeout: None, priority: None, }); raw_flow = Some(FlowValue { modules, ..flow.clone() }); } } let (raw_code, raw_lock) = raw_code_tuple .map(|e| (Some(e.0), e.1)) .unwrap_or_else(|| (None, None)); let tag = if dedicated_worker.is_some_and(|x| x) { format!( "{}:{}", workspace_id, script_path.clone().expect("dedicated script has a path") ) } else if job_kind == JobKind::Script_Hub { "hub".to_string() } else { if tag == Some("".to_string()) { tag = None; } let default = || { if job_kind == JobKind::Flow || job_kind == JobKind::FlowPreview || job_kind == JobKind::Identity { "flow".to_string() } else if job_kind == JobKind::Dependencies || job_kind == JobKind::FlowDependencies { "dependency".to_string() } else { "deno".to_string() } }; tag.map(|x| x.as_str().replace("$workspace", workspace_id).to_string()) .unwrap_or_else(|| { language .as_ref() .map(|x| x.as_str().to_string()) .unwrap_or_else(default) }) }; let mut tx = match tx { PushIsolationLevel::Isolated(user_db, authed, rsmq) => { (rsmq, user_db.begin(&authed).await?).into() } PushIsolationLevel::IsolatedRoot(db, rsmq) => (rsmq, db.begin().await?).into(), PushIsolationLevel::Transaction(tx) => tx, }; let job_id: Uuid = if let Some(job_id) = job_id { let conflicting_id = sqlx::query_scalar!( "SELECT 1 FROM queue WHERE id = $1 UNION ALL select 1 FROM completed_job WHERE id = $1", job_id ) .fetch_optional(&mut tx) .await?; if conflicting_id.is_some() { return Err(Error::BadRequest(format!( "Job with id {job_id} already exists" ))); } job_id } else { Ulid::new().into() }; let uuid = sqlx::query_scalar!( "INSERT INTO queue (workspace_id, id, running, parent_job, created_by, permissioned_as, scheduled_for, script_hash, script_path, raw_code, raw_lock, args, job_kind, schedule_path, raw_flow, \ flow_status, is_flow_step, language, started_at, same_worker, pre_run_error, email, \ visible_to_owner, root_job, tag, concurrent_limit, concurrency_time_window_s, timeout, \ flow_step_id, cache_ttl, priority) VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, now()), $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, CASE WHEN $3 THEN now() END, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30) \ RETURNING id", workspace_id, job_id, is_running, parent_job, user, permissioned_as, scheduled_for_o, script_hash, script_path.clone(), raw_code, raw_lock, Json(args) as Json, job_kind.clone() as JobKind, schedule_path, raw_flow.map(|f| serde_json::json!(f)), flow_status.map(|f| serde_json::json!(f)), is_flow_step, language as Option, same_worker, pre_run_error.map(|e| e.to_string()), email, visible_to_owner, root_job, tag, concurrent_limit, concurrency_time_window_s, custom_timeout, flow_step_id, cache_ttl, final_priority, ) .fetch_one(&mut tx) .await .map_err(|e| Error::InternalErr(format!("Could not insert into queue {job_id} with tag {tag}, schedule_path {schedule_path:?}, script_path: {script_path:?}, email {email}, workspace_id {workspace_id}: {e}")))?; // TODO: technically the job isn't queued yet, as the transaction can be rolled back. Should be solved when moving these metrics to the queue abstraction. if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { QUEUE_PUSH_COUNT.inc(); } { let uuid_string = job_id.to_string(); let uuid_str = uuid_string.as_str(); let mut hm = HashMap::from([("uuid", uuid_str), ("permissioned_as", &permissioned_as)]); let s: String; let operation_name = match job_kind { JobKind::Preview => "jobs.run.preview", JobKind::Script => { s = ScriptHash(script_hash.unwrap()).to_string(); hm.insert("hash", s.as_str()); "jobs.run.script" } JobKind::Flow => "jobs.run.flow", JobKind::FlowPreview => "jobs.run.flow_preview", JobKind::Script_Hub => "jobs.run.script_hub", JobKind::Dependencies => "jobs.run.dependencies", JobKind::Identity => "jobs.run.identity", JobKind::Noop => "jobs.run.noop", JobKind::FlowDependencies => "jobs.run.flow_dependencies", JobKind::AppDependencies => "jobs.run.app_dependencies", }; audit_log( &mut tx, &user, operation_name, ActionKind::Execute, workspace_id, script_path.as_ref().map(|x| x.as_str()), Some(hm), ) .instrument(tracing::info_span!("job_run", email = &email)) .await?; } if let Some(ref mut rsmq) = tx.rsmq { rsmq.send_message(job_id.to_bytes_le().to_vec(), scheduled_for_o, tag); } Ok((uuid, tx)) } pub fn canceled_job_to_result(job: &QueuedJob) -> serde_json::Value { let reason = job .canceled_reason .as_deref() .unwrap_or_else(|| "no reason given"); let canceler = job.canceled_by.as_deref().unwrap_or_else(|| "unknown"); serde_json::json!({"message": format!("Job canceled: {reason} by {canceler}"), "name": "Canceled", "reason": reason, "canceler": canceler}) } async fn restarted_flows_resolution( db: &Pool, workspace_id: &str, flow_value_if_any: Option, completed_flow_id: Uuid, restart_step_id: &str, branch_or_iteration_n: Option, ) -> Result< ( Option, FlowValue, i32, Vec, Option, ), Error, > { let completed_job = sqlx::query_as::<_, CompletedJob>( "SELECT * FROM completed_job WHERE id = $1 and workspace_id = $2", ) .bind(completed_flow_id) .bind(workspace_id) .fetch_one(db) // TODO: should we try to use the passed-in `tx` here? .await .map_err(|err| { Error::InternalErr(format!( "completed job not found for UUID {} in workspace {}: {}", completed_flow_id, workspace_id, err )) })?; let raw_flow = completed_job .parse_raw_flow() .ok_or(Error::InternalErr(format!( "Unable to parse raw definition for job {} in workspace {}", completed_flow_id, workspace_id, )))?; let flow_status = completed_job .parse_flow_status() .ok_or(Error::InternalErr(format!( "Unable to parse flow status for job {} in workspace {}", completed_flow_id, workspace_id, )))?; let mut step_n = 0; let mut dependent_module = false; let mut truncated_modules: Vec = vec![]; for module in flow_status.modules { if flow_value_if_any .clone() .map(|fv| { fv.modules .iter() .find(|flow_value_module| flow_value_module.id == module.id()) .is_none() }) .unwrap_or(false) { // skip module as it doesn't appear in the flow_value anymore continue; } if module.id() == restart_step_id { // if the module ID is the one we want to restart the flow at, or if it's past it in the flow, // set the module as WaitingForPriorSteps as it needs to be re-run if branch_or_iteration_n.is_none() || branch_or_iteration_n.unwrap() == 0 { // The module as WaitingForPriorSteps as the entire module (i.e. all the branches) need to be re-run truncated_modules.push(FlowStatusModule::WaitingForPriorSteps { id: module.id() }); } else { // expect a module to be either a branchall (resp. loop), and resume the flow from this branch (resp. iteration) let branch_or_iteration_n = branch_or_iteration_n.unwrap(); let module_definition = raw_flow .modules .iter() .find(|flow_value_module| flow_value_module.id == restart_step_id) .ok_or(Error::InternalErr(format!( "Module {} not found in flow definition", module.id() )))?; match module_definition.value.clone() { FlowModuleValue::BranchAll { branches, parallel, .. } => { if parallel { return Err(Error::InternalErr(format!( "Module {} is a parallel branchall. It can only be restarted at a given branch if it's sequential", restart_step_id, ))); } let total_branch_number = module.flow_jobs().map(|v| v.len()).unwrap_or(0); if total_branch_number <= branch_or_iteration_n { return Err(Error::InternalErr(format!( "Branch-all module {} has only {} branches. It can't be restarted on branch {}", restart_step_id, total_branch_number, branch_or_iteration_n, ))); } let mut new_flow_jobs = module.flow_jobs().unwrap_or_default(); new_flow_jobs.truncate(branch_or_iteration_n); truncated_modules.push(FlowStatusModule::InProgress { id: module.id(), job: new_flow_jobs[new_flow_jobs.len() - 1], // set to last finished job from completed flow iterator: None, flow_jobs: Some(new_flow_jobs), branch_chosen: None, branchall: Some(BranchAllStatus { branch: branch_or_iteration_n - 1, // Doing minus one here as this variable reflects the latest finished job in the iteration len: branches.len(), }), parallel: parallel, }); } FlowModuleValue::ForloopFlow { parallel, .. } => { if parallel { return Err(Error::InternalErr(format!( "Module {} is not parallel loop. It can only be restarted at a given iteration if it's sequential", restart_step_id, ))); } let total_iterations = module.flow_jobs().map(|v| v.len()).unwrap_or(0); if total_iterations <= branch_or_iteration_n { return Err(Error::InternalErr(format!( "For-loop module {} doesn't cannot be restarted on iteration number {} as it has only {} iterations", restart_step_id, branch_or_iteration_n, total_iterations, ))); } let mut new_flow_jobs = module.flow_jobs().unwrap_or_default(); new_flow_jobs.truncate(branch_or_iteration_n); truncated_modules.push(FlowStatusModule::InProgress { id: module.id(), job: new_flow_jobs[new_flow_jobs.len() - 1], // set to last finished job from completed flow iterator: Some(Iterator { index: branch_or_iteration_n - 1, // same deal as above, this refers to the last finished job itered: vec![], // Setting itered to empty array here, such that input transforms will be re-computed by worker_flows }), flow_jobs: Some(new_flow_jobs), branch_chosen: None, branchall: None, parallel: parallel, }); } _ => { return Err(Error::InternalErr(format!( "Module {} is not a branchall or forloop, unable to restart it at step {:?}", restart_step_id, branch_or_iteration_n ))); } } } dependent_module = true; } else if dependent_module { truncated_modules.push(FlowStatusModule::WaitingForPriorSteps { id: module.id() }); } else { // else we simply "transfer" the module from the completed flow to the new one if it's a success step_n = step_n + 1; match module.clone() { FlowStatusModule::Success { id: _, job: _, flow_jobs: _, branch_chosen: _, approvers: _, } => Ok(truncated_modules.push(module)), _ => Err(Error::InternalErr(format!( "Flow cannot be restarted from a non successful module", ))), }?; } } if !dependent_module { // step not found in flow. return Err(Error::InternalErr(format!( "Flow cannot be restarted from step {} as it could not be found.", restart_step_id ))); } return Ok(( completed_job.script_path, raw_flow, step_n, truncated_modules, completed_job.priority, )); }