Compare commits

...

1 Commits

Author SHA1 Message Date
Abel Lucas
d823c546f2 backend: prepare zombies monitoring testing
move zombies monitoring to worker in order to be able to test the logic in worker tests
2025-02-10 15:40:43 +01:00
4 changed files with 612 additions and 472 deletions

View File

@@ -4,10 +4,7 @@ use std::{
fmt::Display,
ops::Mul,
str::FromStr,
sync::{
atomic::{AtomicU16, Ordering},
Arc,
},
sync::{atomic::Ordering, Arc},
time::Duration,
};
@@ -15,11 +12,7 @@ use chrono::{NaiveDateTime, Utc};
use futures::{stream::FuturesUnordered, StreamExt};
use serde::{de::DeserializeOwned, Deserializer};
use sqlx::{Pool, Postgres};
use tokio::{
join,
sync::{mpsc, RwLock},
};
use uuid::Uuid;
use tokio::{join, sync::RwLock};
#[cfg(feature = "embedding")]
use windmill_api::embeddings::update_embeddings_db;
@@ -37,7 +30,6 @@ use windmill_common::{
auth::JWT_SECRET,
ee::CriticalErrorChannel,
error,
flow_status::{FlowStatus, FlowStatusModule},
global_settings::{
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
@@ -50,12 +42,11 @@ use windmill_common::{
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
},
indexer::load_indexer_config,
jobs::QueuedJob,
oauth2::REQUIRE_PREEXISTING_USER_FOR_OAUTH,
server::load_smtp_config,
tracing_init::JSON_FMT,
users::truncate_token,
utils::{now_from_db, rd_string, report_critical_error, Mode},
utils::{rd_string, Mode},
worker::{
load_worker_config, make_pull_query, make_suspended_pull_query, reload_custom_tags_setting,
update_min_version, DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG,
@@ -66,11 +57,9 @@ use windmill_common::{
MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED,
SERVICE_LOG_RETENTION_SECS,
};
use windmill_queue::cancel_job;
use windmill_worker::{
create_token_for_owner, handle_job_error, AuthedClient, SameWorkerPayload, SameWorkerSender,
SendResult, BUNFIG_INSTALL_SCOPES, INSTANCE_PYTHON_VERSION, JOB_DEFAULT_TIMEOUT, KEEP_JOB_DIR,
NPM_CONFIG_REGISTRY, NUGET_CONFIG, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, SCRIPT_TOKEN_EXPIRY,
BUNFIG_INSTALL_SCOPES, INSTANCE_PYTHON_VERSION, JOB_DEFAULT_TIMEOUT, KEEP_JOB_DIR,
NPM_CONFIG_REGISTRY, NUGET_CONFIG, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL,
};
#[cfg(feature = "parquet")]
@@ -89,18 +78,6 @@ use crate::ee::set_license_key;
#[cfg(feature = "prometheus")]
lazy_static::lazy_static! {
static ref QUEUE_ZOMBIE_RESTART_COUNT: prometheus::IntCounter = prometheus::register_int_counter!(
"queue_zombie_restart_count",
"Total number of jobs restarted due to ping timeout."
)
.unwrap();
static ref QUEUE_ZOMBIE_DELETE_COUNT: prometheus::IntCounter = prometheus::register_int_counter!(
"queue_zombie_delete_count",
"Total number of jobs deleted due to their ping timing out in an unrecoverable state."
)
.unwrap();
static ref QUEUE_COUNT: prometheus::IntGaugeVec = prometheus::register_int_gauge_vec!(
"queue_count",
"Number of jobs in the queue",
@@ -109,26 +86,7 @@ lazy_static::lazy_static! {
}
lazy_static::lazy_static! {
static ref ZOMBIE_JOB_TIMEOUT: String = std::env::var("ZOMBIE_JOB_TIMEOUT")
.ok()
.and_then(|x| x.parse::<String>().ok())
.unwrap_or_else(|| "60".to_string());
static ref FLOW_ZOMBIE_TRANSITION_TIMEOUT: String = std::env::var("FLOW_ZOMBIE_TRANSITION_TIMEOUT")
.ok()
.and_then(|x| x.parse::<String>().ok())
.unwrap_or_else(|| "60".to_string());
pub static ref RESTART_ZOMBIE_JOBS: bool = std::env::var("RESTART_ZOMBIE_JOBS")
.ok()
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or(true);
static ref QUEUE_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
}
pub async fn initial_load(
@@ -1232,13 +1190,15 @@ pub async fn monitor_db(
) {
let zombie_jobs_f = async {
if server_mode && !initial_load {
handle_zombie_jobs(db, base_internal_url, "server").await;
match handle_zombie_flows(db).await {
Err(err) => {
tracing::error!("Error handling zombie flows: {:?}", err);
}
_ => {}
}
windmill_worker::monitor::monitor_once(
&db,
base_internal_url,
"server",
None,
None,
None,
)
.await;
}
};
let expired_items_f = async {
@@ -1517,346 +1477,6 @@ pub async fn reload_base_url_setting(db: &DB) -> error::Result<()> {
Ok(())
}
async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, worker_name: &str) {
if *RESTART_ZOMBIE_JOBS {
let restarted = sqlx::query!(
"WITH zombie_jobs AS (
UPDATE v2_job_queue q SET running = false, started_at = null
FROM v2_job j, v2_job_runtime r
WHERE j.id = q.id AND j.id = r.id
AND ping < now() - ($1 || ' seconds')::interval
AND running = true
AND kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlescriptflow')
AND same_worker = false
RETURNING q.id, q.workspace_id, ping
),
update_concurrency AS (
UPDATE concurrency_counter cc
SET job_uuids = job_uuids - zj.id::text
FROM zombie_jobs zj
INNER JOIN concurrency_key ck ON ck.job_id = zj.id
WHERE cc.concurrency_id = ck.key
)
SELECT id, workspace_id, ping FROM zombie_jobs",
*ZOMBIE_JOB_TIMEOUT,
)
.fetch_all(db)
.await
.ok()
.unwrap_or_else(|| vec![]);
#[cfg(feature = "prometheus")]
if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
QUEUE_ZOMBIE_RESTART_COUNT.inc_by(restarted.len() as _);
}
let base_url = BASE_URL.read().await.clone();
for r in restarted {
let last_ping = if let Some(x) = r.ping {
format!("last ping at {x}")
} else {
"no last ping".to_string()
};
let url = format!("{}/run/{}?workspace={}", base_url, r.id, r.workspace_id,);
let error_message = format!(
"Zombie job {} on {} ({}) detected, restarting it, {}",
r.id, r.workspace_id, url, last_ping
);
let _ = sqlx::query!("
INSERT INTO job_logs (job_id, logs)
VALUES ($1, 'Restarted job after not receiving job''s ping for too long the ' || now() || '\n\n')
ON CONFLICT (job_id) DO UPDATE SET logs = job_logs.logs || '\n' || EXCLUDED.logs
WHERE job_logs.job_id = $1",
r.id
)
.execute(db)
.await;
tracing::error!(error_message);
report_critical_error(error_message, db.clone(), Some(&r.workspace_id), None).await;
}
}
let same_worker_timeout_jobs = {
let long_same_worker_jobs = sqlx::query!(
"SELECT worker, array_agg(v2_job_queue.id) as ids FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id LEFT JOIN v2_job_runtime ON v2_job_queue.id = v2_job_runtime.id WHERE v2_job_queue.created_at < now() - ('60 seconds')::interval
AND running = true AND ping IS NULL AND same_worker = true AND worker IS NOT NULL GROUP BY worker",
)
.fetch_all(db)
.await
.ok()
.unwrap_or_else(|| vec![]);
let worker_ids = long_same_worker_jobs
.iter()
.map(|x| x.worker.clone().unwrap_or_default())
.collect::<Vec<_>>();
let long_dead_workers: std::collections::HashSet<String> = sqlx::query_scalar!(
"WITH worker_ids AS (SELECT unnest($1::text[]) as worker)
SELECT worker_ids.worker FROM worker_ids
LEFT JOIN worker_ping ON worker_ids.worker = worker_ping.worker
WHERE worker_ping.worker IS NULL OR worker_ping.ping_at < now() - ('60 seconds')::interval",
&worker_ids[..]
)
.fetch_all(db)
.await
.ok()
.unwrap_or_else(|| vec![])
.into_iter()
.filter_map(|x| x)
.collect();
let mut timeouts: Vec<Uuid> = vec![];
for worker in long_same_worker_jobs {
if worker.worker.is_some() && long_dead_workers.contains(&worker.worker.unwrap()) {
if let Some(ids) = worker.ids {
timeouts.extend(ids);
}
}
}
if !timeouts.is_empty() {
tracing::error!(
"Failing same worker zombie jobs: {:?}",
timeouts
.iter()
.map(|x| x.hyphenated().to_string())
.collect::<Vec<_>>()
.join(",")
);
}
let jobs = sqlx::query_as::<_, QueuedJob>("SELECT * FROM v2_as_queue WHERE id = ANY($1)")
.bind(&timeouts[..])
.fetch_all(db)
.await
.map_err(|e| tracing::error!("Error fetching same worker jobs: {:?}", e))
.unwrap_or_default();
jobs
};
let non_restartable_jobs = if *RESTART_ZOMBIE_JOBS {
vec![]
} else {
sqlx::query_as::<_, QueuedJob>("SELECT * FROM v2_as_queue WHERE last_ping < now() - ($1 || ' seconds')::interval
AND running = true AND job_kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlescriptflow') AND same_worker = false")
.bind(ZOMBIE_JOB_TIMEOUT.as_str())
.fetch_all(db)
.await
.ok()
.unwrap_or_else(|| vec![])
};
let timeouts = non_restartable_jobs
.into_iter()
.chain(same_worker_timeout_jobs)
.collect::<Vec<_>>();
#[cfg(feature = "prometheus")]
if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
QUEUE_ZOMBIE_DELETE_COUNT.inc_by(timeouts.len() as _);
}
for job in timeouts {
// since the job is unrecoverable, the same worker queue should never be sent anything
let (same_worker_tx_never_used, _same_worker_rx_never_used) =
mpsc::channel::<SameWorkerPayload>(1);
let same_worker_tx_never_used =
SameWorkerSender(same_worker_tx_never_used, Arc::new(AtomicU16::new(0)));
let (send_result_never_used, _send_result_rx_never_used) = mpsc::channel::<SendResult>(1);
let label = if job.permissioned_as != format!("u/{}", job.created_by)
&& job.permissioned_as != job.created_by
{
format!("ephemeral-script-end-user-{}", job.created_by)
} else {
"ephemeral-script".to_string()
};
let token = create_token_for_owner(
&db,
&job.workspace_id,
&job.permissioned_as,
&label,
*SCRIPT_TOKEN_EXPIRY,
&job.email,
&job.id,
)
.await
.expect("could not create job token");
let client = AuthedClient {
base_internal_url: base_internal_url.to_string(),
token,
workspace: job.workspace_id.to_string(),
force_client: None,
};
let last_ping = job.last_ping.clone();
let _ = handle_job_error(
db,
&client,
&job,
0,
None,
error::Error::ExecutionErr(format!(
"Job timed out after no ping from job since {} (ZOMBIE_JOB_TIMEOUT: {}, same_worker: {})",
last_ping
.map(|x| x.to_string())
.unwrap_or_else(|| "no ping".to_string()),
*ZOMBIE_JOB_TIMEOUT,
job.same_worker
)),
true,
same_worker_tx_never_used,
"",
worker_name,
send_result_never_used,
#[cfg(feature = "benchmark")]
&mut windmill_worker::bench::BenchmarkIter::new(),
)
.await;
}
}
async fn handle_zombie_flows(db: &DB) -> error::Result<()> {
let flows = sqlx::query!(
r#"
SELECT
id AS "id!", workspace_id AS "workspace_id!", parent_job, is_flow_step,
flow_status AS "flow_status: Box<str>", last_ping, same_worker
FROM v2_as_queue
WHERE running = true AND suspend = 0 AND suspend_until IS null AND scheduled_for <= now()
AND (job_kind = 'flow' OR job_kind = 'flowpreview' OR job_kind = 'flownode')
AND last_ping IS NOT NULL AND last_ping < NOW() - ($1 || ' seconds')::interval
AND canceled = false
"#,
FLOW_ZOMBIE_TRANSITION_TIMEOUT.as_str()
)
.fetch_all(db)
.await?;
for flow in flows {
let status = flow
.flow_status
.as_deref()
.and_then(|x| serde_json::from_str::<FlowStatus>(x).ok());
if !flow.same_worker.unwrap_or(false)
&& status.is_some_and(|s| {
s.modules
.get(0)
.is_some_and(|x| matches!(x, FlowStatusModule::WaitingForPriorSteps { .. }))
})
{
let error_message = format!(
"Zombie flow detected: {} in workspace {}. It hasn't started yet, restarting it.",
flow.id, flow.workspace_id
);
tracing::error!(error_message);
report_critical_error(error_message, db.clone(), Some(&flow.workspace_id), None).await;
// if the flow hasn't started and is a zombie, we can simply restart it
let mut tx = db.begin().await?;
let concurrency_key =
sqlx::query_scalar!("SELECT key FROM concurrency_key WHERE job_id = $1", flow.id)
.fetch_optional(&mut *tx)
.await?;
if let Some(key) = concurrency_key {
sqlx::query!(
"UPDATE concurrency_counter SET job_uuids = job_uuids - $2 WHERE concurrency_id = $1",
key,
flow.id.hyphenated().to_string()
)
.execute(&mut *tx)
.await?;
}
sqlx::query!(
"UPDATE v2_job_queue SET running = false, started_at = null
WHERE id = $1 AND canceled_by IS NULL",
flow.id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
} else {
let id = flow.id.clone();
let last_ping = flow.last_ping.clone();
let now = now_from_db(db).await?;
let reason = format!(
"{} was hanging in between 2 steps. Last ping: {last_ping:?} (now: {now})",
if flow.is_flow_step.unwrap_or(false) && flow.parent_job.is_some() {
format!("Flow was cancelled because subflow {id}")
} else {
format!("Flow {id} was cancelled because it")
}
);
report_critical_error(reason.clone(), db.clone(), Some(&flow.workspace_id), None).await;
cancel_zombie_flow_job(db, flow.id, &flow.workspace_id, reason).await?;
}
}
let flows2 = sqlx::query!(
r#"
DELETE
FROM parallel_monitor_lock
WHERE last_ping IS NOT NULL AND last_ping < NOW() - ($1 || ' seconds')::interval
RETURNING parent_flow_id, job_id, last_ping, (SELECT workspace_id FROM v2_job_queue q
WHERE q.id = parent_flow_id AND q.running = true AND q.canceled_by IS NULL
) AS workspace_id
"#,
FLOW_ZOMBIE_TRANSITION_TIMEOUT.as_str()
)
.fetch_all(db)
.await?;
for flow in flows2 {
if let Some(parent_flow_workspace_id) = flow.workspace_id {
tracing::error!(
"parallel Zombie flow detected: {} in workspace {}. Last ping was: {:?}.",
flow.parent_flow_id,
parent_flow_workspace_id,
flow.last_ping
);
cancel_zombie_flow_job(db, flow.parent_flow_id, &parent_flow_workspace_id,
format!("Flow {} cancelled as one of the parallel branch {} was unable to make the last transition ", flow.parent_flow_id, flow.job_id))
.await?;
} else {
tracing::info!("releasing lock for parallel flow: {}", flow.parent_flow_id);
}
}
Ok(())
}
async fn cancel_zombie_flow_job(
db: &Pool<Postgres>,
id: Uuid,
workspace_id: &str,
message: String,
) -> Result<(), error::Error> {
let mut tx = db.begin().await?;
tracing::error!(
"zombie flow detected: {} in workspace {}. Cancelling it.",
id,
workspace_id
);
(tx, _) = cancel_job(
"monitor",
Some(message),
id,
workspace_id,
tx,
db,
true,
false,
)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn reload_hub_base_url_setting(db: &DB, server_mode: bool) -> error::Result<()> {
let hub_base_url = load_value_from_global_settings(db, HUB_BASE_URL_SETTING).await?;

View File

@@ -1,6 +1,6 @@
use serde::de::DeserializeOwned;
use std::future::Future;
use std::{str::FromStr, sync::Arc};
use std::{default::Default, str::FromStr, sync::Arc};
use windmill_api_client::types::{NewScript, ScriptLang as NewScriptLanguage};
#[cfg(feature = "enterprise")]
@@ -13,7 +13,6 @@ use serde_json::json;
use sqlx::{postgres::PgListener, types::Uuid, Pool, Postgres};
use tokio::sync::RwLock;
#[cfg(feature = "enterprise")]
use tokio::time::{timeout, Duration};
use windmill_api_client::types::{CreateFlowBody, RawScript};
@@ -42,7 +41,7 @@ pub struct CompletedJob {
pub parent_job: Option<Uuid>,
pub created_by: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub started_at: chrono::DateTime<chrono::Utc>,
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
pub duration_ms: i64,
pub success: bool,
pub script_path: Option<String>,
@@ -153,7 +152,7 @@ impl ApiServer {
println!("closing api server");
let Self { tx, task, .. } = self;
drop(tx);
task.await.unwrap()
task.await?
}
}
@@ -213,7 +212,7 @@ mod suspend_resume {
}
fn flow() -> FlowValue {
serde_json::from_value(serde_json::json!({
serde_json::from_value(json!({
"modules": [{
"id": "a",
"value": {
@@ -302,7 +301,10 @@ mod suspend_resume {
let queue = listen_for_queue(&db).await;
let db_ = db.clone();
in_test_worker(&db, async move {
in_test_worker(
&db,
WorkerOptions { port, ..Default::default() },
async move {
let db = db_;
wait_until_flow_suspends(flow, queue, &db).await;
@@ -336,8 +338,9 @@ mod suspend_resume {
.unwrap();
completed.find(&flow).await.unwrap();
}, port)
.await;
}
)
.await;
server.close().await.unwrap();
@@ -409,7 +412,10 @@ mod suspend_resume {
let queue = listen_for_queue(&db).await;
let db_ = db.clone();
in_test_worker(&db, async move {
in_test_worker(
&db,
WorkerOptions { port, ..Default::default() },
async move {
let db = db_;
wait_until_flow_suspends(flow, queue, &db).await;
@@ -439,8 +445,9 @@ mod suspend_resume {
.unwrap();
completed.find(&flow).await.unwrap();
}, port)
.await;
}
)
.await;
server.close().await.unwrap();
@@ -496,7 +503,7 @@ mod retry {
results
});
return Self { addr, tx, task };
Self { addr, tx, task }
}
async fn close(self) -> Vec<u8> {
@@ -528,7 +535,7 @@ def main(last, port):
}
fn flow_forloop_retry() -> FlowValue {
serde_json::from_value(serde_json::json!({
serde_json::from_value(json!({
"modules": [{
"id": "a",
"value": {
@@ -640,15 +647,15 @@ def main(last, port):
assert_eq!(server.close().await, attempts);
assert!(
assert_eq!(
result[1]["error"]
.as_object()
.unwrap()
.get("message")
.unwrap()
.as_str()
.unwrap()
== "read"
.unwrap(),
"read"
);
}
@@ -775,7 +782,7 @@ async fn test_iteration(db: Pool<Postgres>) {
let server = ApiServer::start(db.clone()).await;
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [{
"value": {
"type": "forloopflow",
@@ -806,7 +813,7 @@ async fn test_iteration(db: Pool<Postgres>) {
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([]));
assert_eq!(result, json!([]));
/* Don't actually test that this does 257 jobs or that will take forever. */
let result =
@@ -833,7 +840,7 @@ async fn test_iteration_parallel(db: Pool<Postgres>) {
let server = ApiServer::start(db.clone()).await;
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [{
"value": {
"type": "forloopflow",
@@ -865,7 +872,7 @@ async fn test_iteration_parallel(db: Pool<Postgres>) {
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([]));
assert_eq!(result, json!([]));
/* Don't actually test that this does 257 jobs or that will take forever. */
let job =
@@ -946,7 +953,12 @@ impl RunJob {
async fn run_until_complete(self, db: &Pool<Postgres>, port: u16) -> CompletedJob {
let uuid = self.push(db).await;
let listener = listen_for_completed_jobs(db).await;
in_test_worker(db, listener.find(&uuid), port).await;
in_test_worker(
db,
WorkerOptions { port, ..Default::default() },
listener.find(&uuid),
)
.await;
let r = completed_job(uuid, db).await;
r
}
@@ -961,7 +973,12 @@ impl RunJob {
let uuid = self.push(db).await;
let listener = listen_for_completed_jobs(db).await;
test(uuid).await;
in_test_worker(db, listener.find(&uuid), port).await;
in_test_worker(
db,
WorkerOptions { port, ..Default::default() },
listener.find(&uuid),
)
.await;
let r = completed_job(uuid, db).await;
r
}
@@ -975,17 +992,34 @@ async fn run_job_in_new_worker_until_complete(
RunJob::from(job).run_until_complete(db, port).await
}
#[derive(Default)]
enum MonitorZombiesOption {
#[default]
Disabled,
Enabled {
restart_zombies: bool,
timeout_seconds: &'static str,
flow_timeout_seconds: &'static str,
},
}
#[derive(Default)]
struct WorkerOptions {
port: u16,
monitor_zombies: MonitorZombiesOption,
}
/// Start a worker with a timeout and run a future, until the worker quits or we time out.
///
/// Cleans up the worker before resolving.
async fn in_test_worker<Fut: std::future::Future>(
async fn in_test_worker<Fut: Future>(
db: &Pool<Postgres>,
options: WorkerOptions,
inner: Fut,
port: u16,
) -> <Fut as std::future::Future>::Output {
) -> <Fut as Future>::Output {
set_jwt_secret().await;
let (quit, worker) = spawn_test_worker(db, port);
let worker = tokio::time::timeout(std::time::Duration::from_secs(60), worker);
let (quit, worker) = spawn_test_worker(db, options);
let worker = timeout(Duration::from_secs(60), worker);
tokio::pin!(worker);
let res = tokio::select! {
@@ -1010,7 +1044,7 @@ async fn in_test_worker<Fut: std::future::Future>(
fn spawn_test_worker(
db: &Pool<Postgres>,
port: u16,
options: WorkerOptions,
) -> (
tokio::sync::broadcast::Sender<()>,
tokio::task::JoinHandle<()>,
@@ -1032,8 +1066,9 @@ fn spawn_test_worker(
let ip: &str = Default::default();
let tx2 = tx.clone();
let future = async move {
let base_internal_url = format!("http://localhost:{}", port);
let db2 = db.clone();
let worker_fut = async move {
let base_internal_url = format!("http://localhost:{}", options.port);
{
let mut wc = WORKER_CONFIG.write().await;
(*wc).worker_tags = windmill_common::worker::DEFAULT_TAGS.clone();
@@ -1045,7 +1080,7 @@ fn spawn_test_worker(
windmill_common::worker::make_pull_query(&wc).await;
}
windmill_worker::run_worker(
&db,
&db2,
worker_instance,
worker_name,
1,
@@ -1059,6 +1094,35 @@ fn spawn_test_worker(
.await
};
let MonitorZombiesOption::Enabled { restart_zombies, timeout_seconds, flow_timeout_seconds } =
options.monitor_zombies
else {
return (tx, tokio::task::spawn(worker_fut));
};
let mut rx = tx.subscribe();
let monitor_fut = async move {
let base_internal_url = format!("http://localhost:{}", options.port);
loop {
if rx.try_recv().is_ok() {
break;
}
windmill_worker::monitor::monitor_once(
&db,
&base_internal_url,
worker_instance,
Some(timeout_seconds),
Some(restart_zombies),
Some(flow_timeout_seconds),
)
.await;
tokio::time::sleep(Duration::from_millis(10)).await;
}
};
let future = async move {
tokio::select! { biased; _ = worker_fut => (), _ = monitor_fut => () }
};
(tx, tokio::task::spawn(future))
}
@@ -1101,10 +1165,10 @@ async fn completed_job(uuid: Uuid, db: &Pool<Postgres>) -> CompletedJob {
}
#[axum::async_trait(?Send)]
trait StreamFind: futures::Stream + Unpin + Sized {
trait StreamFind: Stream + Unpin + Sized {
async fn find(self, item: &Self::Item) -> Option<Self::Item>
where
for<'l> &'l Self::Item: std::cmp::PartialEq,
for<'l> &'l Self::Item: PartialEq,
{
use futures::{future::ready, StreamExt};
@@ -1112,7 +1176,7 @@ trait StreamFind: futures::Stream + Unpin + Sized {
}
}
impl<T: futures::Stream + Unpin + Sized> StreamFind for T {}
impl<T: Stream + Unpin + Sized> StreamFind for T {}
#[sqlx::test(fixtures("base"))]
async fn test_deno_flow(db: Pool<Postgres>) {
@@ -1228,7 +1292,7 @@ async fn test_deno_flow(db: Pool<Postgres>) {
let job = run_job_in_new_worker_until_complete(&db, job.clone(), port).await;
// println!("job: {:#?}", job.flow_status);
let result = job.json_result().unwrap();
assert_eq!(result, serde_json::json!([2, 4, 6]), "iteration: {}", i);
assert_eq!(result, json!([2, 4, 6]), "iteration: {}", i);
}
}
@@ -1238,7 +1302,7 @@ async fn test_identity(db: Pool<Postgres>) {
let server = ApiServer::start(db.clone()).await;
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [{
"value": {
"type": "rawscript",
@@ -1266,7 +1330,7 @@ async fn test_identity(db: Pool<Postgres>) {
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!(42));
assert_eq!(result, json!(42));
}
#[sqlx::test(fixtures("base"))]
@@ -1488,7 +1552,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) {
.unwrap();
assert_eq!(
result,
serde_json::json!("false 1,true 1,false 1,true 2,false 1,true 3,false 1,true 3")
json!("false 1,true 1,false 1,true 2,false 1,true 3,false 1,true 3")
);
}
@@ -1541,7 +1605,7 @@ async fn test_flow_result_by_id(db: Pool<Postgres>) {
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([[42]]));
assert_eq!(result, json!([[42]]));
}
#[sqlx::test(fixtures("base"))]
@@ -1551,7 +1615,7 @@ async fn test_stop_after_if(db: Pool<Postgres>) {
// let port = server.addr.port();
let port = 123;
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{
"id": "a",
@@ -1604,7 +1668,7 @@ async fn test_stop_after_if_nested(db: Pool<Postgres>) {
// let port = server.addr.port();
let port = 123;
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{
"id": "a",
@@ -1664,7 +1728,7 @@ async fn test_python_flow(db: Pool<Postgres>) {
let numbers = "def main(): return [1, 2, 3]";
let doubles = "def main(n): return n * 2";
let flow: FlowValue = serde_json::from_value(serde_json::json!( {
let flow: FlowValue = serde_json::from_value(json!( {
"modules": [
{
"value": {
@@ -1708,7 +1772,7 @@ async fn test_python_flow(db: Pool<Postgres>) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([2, 4, 6]), "iteration: {i}");
assert_eq!(result, json!([2, 4, 6]), "iteration: {i}");
}
}
@@ -1718,7 +1782,7 @@ async fn test_python_flow_2(db: Pool<Postgres>) {
let server = ApiServer::start(db.clone()).await;
let port = server.addr.port();
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{
"value": {
@@ -1743,7 +1807,7 @@ async fn test_python_flow_2(db: Pool<Postgres>) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("Hello"), "iteration: {i}");
assert_eq!(result, json!("Hello"), "iteration: {i}");
}
}
@@ -1783,7 +1847,7 @@ func main(derp string) (string, error) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("hello world"));
assert_eq!(result, json!("hello world"));
}
#[sqlx::test(fixtures("base"))]
@@ -1818,7 +1882,7 @@ fn main(world: String) -> Result<String, String> {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("Hello Hyrule!"));
assert_eq!(result, json!("Hello Hyrule!"));
}
// #[sqlx::test(fixtures("base"))]
@@ -1923,7 +1987,7 @@ def main():
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("hello world"));
assert_eq!(result, json!("hello world"));
}
#[sqlx::test(fixtures("base"))]
@@ -1959,7 +2023,7 @@ def main():
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!(3));
assert_eq!(result, json!(3));
}
#[sqlx::test(fixtures("base"))]
@@ -1994,7 +2058,7 @@ def main():
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("test-workspace"));
assert_eq!(result, json!("test-workspace"));
}
#[sqlx::test(fixtures("base"))]
@@ -2028,7 +2092,7 @@ export async function main(a: Date) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("object"));
assert_eq!(result, json!("object"));
}
#[sqlx::test(fixtures("base"))]
@@ -2062,7 +2126,7 @@ export async function main(a: Date) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("object"));
assert_eq!(result, json!("object"));
}
#[sqlx::test(fixtures("base"))]
@@ -2097,7 +2161,7 @@ def main(a: datetime, b: bytes):
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([true, true]));
assert_eq!(result, json!([true, true]));
}
#[sqlx::test(fixtures("base"))]
@@ -2106,7 +2170,7 @@ async fn test_empty_loop_1(db: Pool<Postgres>) {
let server = ApiServer::start(db.clone()).await;
let port = server.addr.port();
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{
"id": "a",
@@ -2153,7 +2217,7 @@ async fn test_empty_loop_1(db: Pool<Postgres>) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!(0));
assert_eq!(result, json!(0));
}
#[sqlx::test(fixtures("base"))]
@@ -2162,7 +2226,7 @@ async fn test_invalid_first_step(db: Pool<Postgres>) {
let server = ApiServer::start(db.clone()).await;
let port = server.addr.port();
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{
"value": {
@@ -2200,7 +2264,7 @@ async fn test_empty_loop_2(db: Pool<Postgres>) {
let server = ApiServer::start(db.clone()).await;
let port = server.addr.port();
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{
"value": {
@@ -2233,7 +2297,7 @@ async fn test_empty_loop_2(db: Pool<Postgres>) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([]));
assert_eq!(result, json!([]));
}
#[sqlx::test(fixtures("base"))]
@@ -2241,7 +2305,7 @@ async fn test_step_after_loop(db: Pool<Postgres>) {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await;
let port = server.addr.port();
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{
"id": "a",
@@ -2288,7 +2352,7 @@ async fn test_step_after_loop(db: Pool<Postgres>) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!(9));
assert_eq!(result, json!(9));
}
fn module_add_item_to_list(i: i32, id: &str) -> serde_json::Value {
@@ -2356,7 +2420,7 @@ async fn test_branchone_simple(db: Pool<Postgres>) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([1, 2]));
assert_eq!(result, json!([1, 2]));
}
#[sqlx::test(fixtures("base"))]
@@ -2392,7 +2456,7 @@ async fn test_branchone_with_cond(db: Pool<Postgres>) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([1, 3]));
assert_eq!(result, json!([1, 3]));
}
#[sqlx::test(fixtures("base"))]
@@ -2430,7 +2494,7 @@ async fn test_branchall_sequential(db: Pool<Postgres>) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([[1, 2], [1, 3]]));
assert_eq!(result, json!([[1, 2], [1, 3]]));
}
#[sqlx::test(fixtures("base"))]
@@ -2467,7 +2531,7 @@ async fn test_branchall_simple(db: Pool<Postgres>) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([[1, 2], [1, 3]]));
assert_eq!(result, json!([[1, 2], [1, 3]]));
}
#[derive(Deserialize)]
@@ -2615,7 +2679,7 @@ async fn test_branchone_nested(db: Pool<Postgres>) {
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!([1, 2, 3]));
assert_eq!(result, json!([1, 2, 3]));
}
#[sqlx::test(fixtures("base"))]
@@ -2675,7 +2739,7 @@ async fn test_branchall_nested(db: Pool<Postgres>) {
println!("{:#?}", result);
assert_eq!(
result,
serde_json::json!([[[[1, 2], [1, 3], 4], [[1, 2], [1, 3], 5]], [1, 6]])
json!([[[[1, 2], [1, 3], 4], [[1, 2], [1, 3], 5]], [1, 6]])
);
}
@@ -2685,7 +2749,7 @@ async fn test_failure_module(db: Pool<Postgres>) {
let server = ApiServer::start(db.clone()).await;
let port = server.addr.port();
let flow: FlowValue = serde_json::from_value(serde_json::json!({
let flow: FlowValue = serde_json::from_value(json!({
"modules": [{
"id": "a",
"value": {
@@ -2798,7 +2862,7 @@ async fn test_flow_lock_all(db: Pool<Postgres>) {
let server = ApiServer::start(db.clone()).await;
let port = server.addr.port();
let flow: windmill_api_client::types::OpenFlow = serde_json::from_value(serde_json::json!({
let flow: windmill_api_client::types::OpenFlow = serde_json::from_value(json!({
"summary": "",
"description": "",
"value": {
@@ -2898,7 +2962,12 @@ async fn test_flow_lock_all(db: Pool<Postgres>) {
.unwrap();
let mut str = listen_for_completed_jobs(&db).await;
let listen_first_job = str.next();
in_test_worker(&db, listen_first_job, port).await;
in_test_worker(
&db,
WorkerOptions { port, ..Default::default() },
listen_first_job,
)
.await;
let modules = client
.get_flow_by_path("test-workspace", "g/all/flow_lock_all", None)
@@ -3193,6 +3262,7 @@ async fn test_script_schedule_handlers(db: Pool<Postgres>) {
let db2 = db.clone();
in_test_worker(
&db,
WorkerOptions { port, ..Default::default() },
async move {
str.next().await; // completed error job
@@ -3220,7 +3290,6 @@ async fn test_script_schedule_handlers(db: Pool<Postgres>) {
);
}
},
port,
)
.await;
@@ -3264,6 +3333,7 @@ async fn test_script_schedule_handlers(db: Pool<Postgres>) {
let db2 = db.clone();
in_test_worker(
&db,
WorkerOptions { port, ..Default::default() },
async move {
str.next().await; // completed working job
let uuid = timeout(Duration::from_millis(5000), str.next()).await; // recovery handler
@@ -3287,7 +3357,6 @@ async fn test_script_schedule_handlers(db: Pool<Postgres>) {
panic!("a script was run after main job execution but was not schedule recovery handler");
}
},
port,
)
.await;
}
@@ -3346,6 +3415,7 @@ async fn test_flow_schedule_handlers(db: Pool<Postgres>) {
let db2 = db.clone();
in_test_worker(
&db,
WorkerOptions { port, ..Default::default() },
async move {
str.next().await; // completed error step
str.next().await; // completed error flow
@@ -3374,7 +3444,6 @@ async fn test_flow_schedule_handlers(db: Pool<Postgres>) {
);
}
},
port,
)
.await;
@@ -3418,6 +3487,7 @@ async fn test_flow_schedule_handlers(db: Pool<Postgres>) {
let db2 = db.clone();
in_test_worker(
&db,
WorkerOptions { port, ..Default::default() },
async move {
str.next().await; // completed working step
str.next().await; // completed working flow
@@ -3442,7 +3512,6 @@ async fn test_flow_schedule_handlers(db: Pool<Postgres>) {
panic!("a script was run after main job execution but was not schedule recovery handler");
}
},
port,
)
.await;
}
@@ -3502,6 +3571,7 @@ async fn run_deployed_relative_imports(
let db2 = db.clone();
in_test_worker(
&db,
WorkerOptions { port, ..Default::default() },
async move {
completed.next().await; // deployed script
@@ -3534,7 +3604,7 @@ async fn run_deployed_relative_imports(
assert_eq!(
result,
serde_json::json!([
json!([
"f/system/same_folder_script",
"f/system/same_folder_script",
"f/system_relative/different_folder_script",
@@ -3542,7 +3612,6 @@ async fn run_deployed_relative_imports(
])
);
},
port,
)
.await;
}
@@ -3560,6 +3629,7 @@ async fn run_preview_relative_imports(
let db2 = db.clone();
in_test_worker(
&db,
WorkerOptions { port, ..Default::default() },
async move {
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
@@ -3582,7 +3652,7 @@ async fn run_preview_relative_imports(
assert_eq!(
result,
serde_json::json!([
json!([
"f/system/same_folder_script",
"f/system/same_folder_script",
"f/system_relative/different_folder_script",
@@ -3590,7 +3660,6 @@ async fn run_preview_relative_imports(
])
);
},
port,
)
.await;
}
@@ -3864,6 +3933,7 @@ async fn test_workflow_as_code(db: Pool<Postgres>) {
let db = &db;
in_test_worker(
&db,
WorkerOptions { port, ..Default::default() },
async move {
let job = RunJob::from(JobPayload::Code(RawCode {
language: ScriptLang::Python3,
@@ -3912,7 +3982,6 @@ async fn test_workflow_as_code(db: Pool<Postgres>) {
);
}
},
port,
)
.await;
}

View File

@@ -25,6 +25,7 @@ mod handle_child;
mod job_logger;
mod job_logger_ee;
mod js_eval;
pub mod monitor;
#[cfg(feature = "mysql")]
mod mysql_executor;
#[cfg(feature = "oracledb")]

View File

@@ -0,0 +1,450 @@
use std::{
env,
sync::{atomic::AtomicU16, Arc},
};
use sqlx::{PgPool, Pool, Postgres};
use tokio::sync::mpsc;
use uuid::Uuid;
#[cfg(feature = "prometheus")]
use windmill_common::METRICS_ENABLED;
use windmill_common::{
error,
flow_status::{FlowStatus, FlowStatusModule},
jobs::QueuedJob,
utils::{now_from_db, report_critical_error},
BASE_URL,
};
use windmill_queue::cancel_job;
use crate::{
create_token_for_owner, handle_job_error, AuthedClient, SameWorkerPayload, SameWorkerSender,
SendResult, SCRIPT_TOKEN_EXPIRY,
};
lazy_static::lazy_static! {
static ref ZOMBIE_JOB_TIMEOUT: String = env::var("ZOMBIE_JOB_TIMEOUT")
.ok()
.and_then(|x| x.parse::<String>().ok())
.unwrap_or_else(|| "60".to_string());
static ref FLOW_ZOMBIE_TRANSITION_TIMEOUT: String = env::var("FLOW_ZOMBIE_TRANSITION_TIMEOUT")
.ok()
.and_then(|x| x.parse::<String>().ok())
.unwrap_or_else(|| "60".to_string());
pub static ref RESTART_ZOMBIE_JOBS: bool = env::var("RESTART_ZOMBIE_JOBS")
.ok()
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or(true);
}
#[cfg(feature = "prometheus")]
lazy_static::lazy_static! {
static ref QUEUE_ZOMBIE_RESTART_COUNT: prometheus::IntCounter =
prometheus::register_int_counter!(
"queue_zombie_restart_count",
"Total number of jobs restarted due to ping timeout."
)
.unwrap();
static ref QUEUE_ZOMBIE_DELETE_COUNT: prometheus::IntCounter =
prometheus::register_int_counter!(
"queue_zombie_delete_count",
"Total number of jobs deleted due to their ping timing out in an unrecoverable state."
)
.unwrap();
static ref QUEUE_COUNT: prometheus::IntGaugeVec =
prometheus::register_int_gauge_vec!(
"queue_count",
"Number of jobs in the queue",
&["tag"]
).unwrap();
}
pub async fn monitor_once(
db: &PgPool,
base_internal_url: &str,
worker_name: &str,
timeout_seconds: Option<&str>,
restart_zombies: Option<bool>,
transition_timeout_seconds: Option<&str>,
) {
let timeout_seconds = timeout_seconds.unwrap_or_else(|| &*ZOMBIE_JOB_TIMEOUT);
let restart_zombies = restart_zombies.unwrap_or(*RESTART_ZOMBIE_JOBS);
handle_zombie_jobs(
&db,
base_internal_url,
worker_name,
timeout_seconds,
restart_zombies,
)
.await;
let transition_timeout_seconds =
transition_timeout_seconds.unwrap_or_else(|| &*FLOW_ZOMBIE_TRANSITION_TIMEOUT);
let _ = handle_zombie_flows(&db, transition_timeout_seconds)
.await
.inspect_err(|err| tracing::error!("Error handling zombie flows: {:#}", err));
}
async fn handle_zombie_jobs(
db: &Pool<Postgres>,
base_internal_url: &str,
worker_name: &str,
timeout_seconds: &str,
restart_zombies: bool,
) {
if restart_zombies {
let restarted = sqlx::query!(
"WITH zombie_jobs AS (
UPDATE v2_job_queue q SET running = false, started_at = null
FROM v2_job j, v2_job_runtime r
WHERE j.id = q.id AND j.id = r.id
AND ping < now() - ($1 || ' seconds')::interval
AND running = true
AND kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlescriptflow')
AND same_worker = false
RETURNING q.id, q.workspace_id, ping
),
update_concurrency AS (
UPDATE concurrency_counter cc
SET job_uuids = job_uuids - zj.id::text
FROM zombie_jobs zj
INNER JOIN concurrency_key ck ON ck.job_id = zj.id
WHERE cc.concurrency_id = ck.key
)
SELECT id, workspace_id, ping FROM zombie_jobs",
timeout_seconds,
)
.fetch_all(db)
.await
.ok()
.unwrap_or_else(|| vec![]);
#[cfg(feature = "prometheus")]
if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
QUEUE_ZOMBIE_RESTART_COUNT.inc_by(restarted.len() as _);
}
let base_url = BASE_URL.read().await.clone();
for r in restarted {
let last_ping = r
.ping
.map(|x| format!("last ping at {x}"))
.unwrap_or_else(|| "no last ping".into());
let url = format!("{}/run/{}?workspace={}", base_url, r.id, r.workspace_id,);
let error_message = format!(
"Zombie job {} on {} ({}) detected, restarting it, {}",
r.id, r.workspace_id, url, last_ping
);
let _ = sqlx::query!("
INSERT INTO job_logs (job_id, logs)
VALUES ($1, 'Restarted job after not receiving job''s ping for too long the ' || now() || '\n\n')
ON CONFLICT (job_id) DO UPDATE SET logs = job_logs.logs || '\n' || EXCLUDED.logs
WHERE job_logs.job_id = $1",
r.id
)
.execute(db)
.await;
tracing::error!(error_message);
report_critical_error(error_message, db.clone(), Some(&r.workspace_id), None).await;
}
}
let same_worker_timeout_jobs = {
let long_same_worker_jobs = sqlx::query!(
"SELECT worker, array_agg(v2_job_queue.id) as ids
FROM v2_job_queue
JOIN v2_job USING (id)
LEFT JOIN v2_job_runtime USING (id)
WHERE v2_job_queue.created_at < now() - ('60 seconds')::interval
AND running = true AND ping IS NULL
AND same_worker = true
AND worker IS NOT NULL
GROUP BY worker",
)
.fetch_all(db)
.await
.ok()
.unwrap_or_else(|| vec![]);
let worker_ids = long_same_worker_jobs
.iter()
.map(|x| x.worker.clone().unwrap_or_default())
.collect::<Vec<_>>();
let long_dead_workers: std::collections::HashSet<String> = sqlx::query_scalar!(
"WITH worker_ids AS (SELECT unnest($1::text[]) as worker)
SELECT worker_ids.worker FROM worker_ids
LEFT JOIN worker_ping ON worker_ids.worker = worker_ping.worker
WHERE worker_ping.worker IS NULL OR worker_ping.ping_at < now() - ('60 seconds')::interval",
&worker_ids[..]
)
.fetch_all(db)
.await
.ok()
.unwrap_or_else(|| vec![])
.into_iter()
.filter_map(|x| x)
.collect();
let mut timeouts: Vec<Uuid> = vec![];
for worker in long_same_worker_jobs {
if worker.worker.is_some() && long_dead_workers.contains(&worker.worker.unwrap()) {
if let Some(ids) = worker.ids {
timeouts.extend(ids);
}
}
}
if !timeouts.is_empty() {
tracing::error!(
"Failing same worker zombie jobs: {:?}",
timeouts
.iter()
.map(|x| x.hyphenated().to_string())
.collect::<Vec<_>>()
.join(",")
);
}
let jobs = sqlx::query_as::<_, QueuedJob>("SELECT * FROM v2_as_queue WHERE id = ANY($1)")
.bind(&timeouts[..])
.fetch_all(db)
.await
.map_err(|e| tracing::error!("Error fetching same worker jobs: {:?}", e))
.unwrap_or_default();
jobs
};
let non_restartable_jobs = if restart_zombies {
vec![]
} else {
sqlx::query_as::<_, QueuedJob>(
"SELECT * FROM v2_as_queue
WHERE last_ping < now() - ($1 || ' seconds')::interval
AND running = true
AND job_kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlescriptflow')
AND same_worker = false",
)
.bind(timeout_seconds)
.fetch_all(db)
.await
.ok()
.unwrap_or_else(|| vec![])
};
let timeouts = non_restartable_jobs
.into_iter()
.chain(same_worker_timeout_jobs)
.collect::<Vec<_>>();
#[cfg(feature = "prometheus")]
if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
QUEUE_ZOMBIE_DELETE_COUNT.inc_by(timeouts.len() as _);
}
for job in timeouts {
// since the job is unrecoverable, the same worker queue should never be sent anything
let (same_worker_tx_never_used, _same_worker_rx_never_used) =
mpsc::channel::<SameWorkerPayload>(1);
let same_worker_tx_never_used =
SameWorkerSender(same_worker_tx_never_used, Arc::new(AtomicU16::new(0)));
let (send_result_never_used, _send_result_rx_never_used) = mpsc::channel::<SendResult>(1);
let label = if job.permissioned_as != format!("u/{}", job.created_by)
&& job.permissioned_as != job.created_by
{
format!("ephemeral-script-end-user-{}", job.created_by)
} else {
"ephemeral-script".to_string()
};
let token = create_token_for_owner(
&db,
&job.workspace_id,
&job.permissioned_as,
&label,
*SCRIPT_TOKEN_EXPIRY,
&job.email,
&job.id,
)
.await
.expect("could not create job token");
let client = AuthedClient {
base_internal_url: base_internal_url.to_string(),
token,
workspace: job.workspace_id.to_string(),
force_client: None,
};
let last_ping = job.last_ping.clone();
let _ = handle_job_error(
db,
&client,
&job,
0,
None,
error::Error::ExecutionErr(format!(
"Job timed out after no ping from job since {} (timeout: {}, same_worker: {})",
last_ping
.map(|x| x.to_string())
.unwrap_or_else(|| "no ping".to_string()),
timeout_seconds,
job.same_worker
)),
true,
same_worker_tx_never_used,
"",
worker_name,
send_result_never_used,
#[cfg(feature = "benchmark")]
&mut crate::bench::BenchmarkIter::new(),
)
.await;
}
}
async fn handle_zombie_flows(db: &PgPool, transition_timeout_seconds: &str) -> error::Result<()> {
let flows = sqlx::query!(
r#"
SELECT
id AS "id!", workspace_id AS "workspace_id!", parent_job, is_flow_step,
flow_status AS "flow_status: Box<str>", last_ping, same_worker
FROM v2_as_queue
WHERE running = true AND suspend = 0 AND suspend_until IS null AND scheduled_for <= now()
AND (job_kind = 'flow' OR job_kind = 'flowpreview' OR job_kind = 'flownode')
AND last_ping IS NOT NULL AND last_ping < NOW() - ($1 || ' seconds')::interval
AND canceled = false
"#,
transition_timeout_seconds
)
.fetch_all(db)
.await?;
for flow in flows {
let status = flow
.flow_status
.as_deref()
.and_then(|x| serde_json::from_str::<FlowStatus>(x).ok());
if !flow.same_worker.unwrap_or(false)
&& status.is_some_and(|s| {
s.modules
.get(0)
.is_some_and(|x| matches!(x, FlowStatusModule::WaitingForPriorSteps { .. }))
})
{
let error_message = format!(
"Zombie flow detected: {} in workspace {}. It hasn't started yet, restarting it.",
flow.id, flow.workspace_id
);
tracing::error!(error_message);
report_critical_error(error_message, db.clone(), Some(&flow.workspace_id), None).await;
// if the flow hasn't started and is a zombie, we can simply restart it
let mut tx = db.begin().await?;
let concurrency_key =
sqlx::query_scalar!("SELECT key FROM concurrency_key WHERE job_id = $1", flow.id)
.fetch_optional(&mut *tx)
.await?;
if let Some(key) = concurrency_key {
sqlx::query!(
"UPDATE concurrency_counter SET job_uuids = job_uuids - $2 WHERE concurrency_id = $1",
key,
flow.id.hyphenated().to_string()
)
.execute(&mut *tx)
.await?;
}
sqlx::query!(
"UPDATE v2_job_queue SET running = false, started_at = null
WHERE id = $1 AND canceled_by IS NULL",
flow.id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
} else {
let id = flow.id.clone();
let last_ping = flow.last_ping.clone();
let now = now_from_db(db).await?;
let reason = format!(
"{} was hanging in between 2 steps. Last ping: {last_ping:?} (now: {now})",
if flow.is_flow_step.unwrap_or(false) && flow.parent_job.is_some() {
format!("Flow was cancelled because subflow {id}")
} else {
format!("Flow {id} was cancelled because it")
}
);
report_critical_error(reason.clone(), db.clone(), Some(&flow.workspace_id), None).await;
cancel_zombie_flow_job(db, flow.id, &flow.workspace_id, reason).await?;
}
}
let flows2 = sqlx::query!(
r#"
DELETE
FROM parallel_monitor_lock
WHERE last_ping IS NOT NULL AND last_ping < NOW() - ($1 || ' seconds')::interval
RETURNING parent_flow_id, job_id, last_ping, (SELECT workspace_id FROM v2_job_queue q
WHERE q.id = parent_flow_id AND q.running = true AND q.canceled_by IS NULL
) AS workspace_id
"#,
transition_timeout_seconds
)
.fetch_all(db)
.await?;
for flow in flows2 {
if let Some(parent_flow_workspace_id) = flow.workspace_id {
tracing::error!(
"parallel Zombie flow detected: {} in workspace {}. Last ping was: {:?}.",
flow.parent_flow_id,
parent_flow_workspace_id,
flow.last_ping
);
cancel_zombie_flow_job(db, flow.parent_flow_id, &parent_flow_workspace_id, format!(
"Flow {} cancelled as one of the parallel branch {} was unable to make the last transition ",
flow.parent_flow_id,
flow.job_id
))
.await?;
} else {
tracing::info!("releasing lock for parallel flow: {}", flow.parent_flow_id);
}
}
Ok(())
}
async fn cancel_zombie_flow_job(
db: &Pool<Postgres>,
id: Uuid,
workspace_id: &str,
message: String,
) -> Result<(), error::Error> {
let mut tx = db.begin().await?;
tracing::error!(
"zombie flow detected: {} in workspace {}. Cancelling it.",
id,
workspace_id
);
(tx, _) = cancel_job(
"monitor",
Some(message),
id,
workspace_id,
tx,
db,
true,
false,
)
.await?;
tx.commit().await?;
Ok(())
}