backend: rework job termination/cancellation after v2

This commit is contained in:
Abel Lucas
2025-02-06 10:49:16 +01:00
parent df62925894
commit bf6bcfeded
35 changed files with 546 additions and 710 deletions
+267 -305
View File
@@ -6,12 +6,12 @@
* LICENSE-AGPL for a copy of the license.
*/
use std::{borrow::Borrow, collections::HashMap, sync::Arc, vec};
use std::{borrow::Borrow, collections::HashMap, collections::HashSet, sync::Arc, vec};
use anyhow::Context;
use async_recursion::async_recursion;
use chrono::{DateTime, Duration, Utc};
use futures::future::TryFutureExt;
use futures::{stream, Stream, StreamExt, TryFutureExt, TryStreamExt};
use itertools::Itertools;
#[cfg(feature = "prometheus")]
use prometheus::IntCounter;
@@ -19,14 +19,13 @@ use regex::Regex;
use reqwest::Client;
use serde::{ser::SerializeMap, Serialize};
use serde_json::{json, value::RawValue};
use sqlx::{types::Json, FromRow, Pool, Postgres, Transaction};
use sqlx::{types::Json, FromRow, PgExecutor, Pool, Postgres, Transaction};
use tokio::{sync::RwLock, time::sleep};
use ulid::Ulid;
use uuid::Uuid;
use windmill_audit::audit_ee::{audit_log, AuditAuthor};
use windmill_audit::ActionKind;
use windmill_common::utils::now_from_db;
use windmill_common::{
auth::{fetch_authed_from_permissioned_as, permissioned_as_to_username},
cache::{self, FlowData},
@@ -121,174 +120,169 @@ const SCHEDULE_ERROR_HANDLER_USER_EMAIL: &str = "schedule_error_handler@windmill
#[cfg(any(feature = "enterprise", feature = "cloud"))]
const SCHEDULE_RECOVERY_HANDLER_USER_EMAIL: &str = "schedule_recovery_handler@windmill.dev";
#[derive(Clone, Debug)]
pub struct CanceledBy {
pub username: Option<String>,
pub reason: Option<String>,
}
pub async fn cancel_single_job<'c>(
pub async fn cancel(
db: &DB,
jobs: &[Uuid],
workspace_id: &str,
username: &str,
reason: Option<String>,
job_running: Arc<QueuedJob>,
w_id: &str,
mut tx: Transaction<'c, Postgres>,
db: &Pool<Postgres>,
force_cancel: bool,
) -> error::Result<(Transaction<'c, Postgres>, Option<Uuid>)> {
if force_cancel || (job_running.parent_job.is_none() && !job_running.running) {
let username = username.to_string();
let w_id = w_id.to_string();
let db = db.clone();
let job_running = job_running.clone();
tokio::task::spawn(async move {
let reason: String = 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});
append_logs(
&job_running.id,
w_id.to_string(),
format!("canceled by {username}: (force cancel: {force_cancel})"),
&db,
)
.await;
let add_job = add_completed_job_error(
&db,
&job_running,
job_running.mem_peak.unwrap_or(0),
Some(CanceledBy { username: Some(username.to_string()), reason: Some(reason) }),
e,
"server",
false,
None,
)
.await;
if let Err(e) = add_job {
tracing::error!("Failed to add canceled job: {}", e);
}
});
reason: &str,
force: bool,
authed: bool,
) -> error::Result<Vec<Uuid>> {
let mut roots;
// On force cancel, prepend flow root jobs to the list:
let jobs = if !force {
jobs
} else {
let id: Option<Uuid> = sqlx::query_scalar!(
"UPDATE v2_job_queue SET canceled_by = $1, canceled_reason = $2, scheduled_for = now(), suspend = 0 WHERE id = $3 AND workspace_id = $4 AND (canceled_by IS NULL OR canceled_reason != $2) RETURNING id",
username,
reason,
job_running.id,
w_id
roots = sqlx::query_scalar!(
"SELECT flow_innermost_root_job AS \"root!\" FROM v2_job
WHERE id = ANY($1) AND flow_innermost_root_job IS NOT NULL",
jobs
)
.fetch_optional(&mut *tx)
.fetch_all(db)
.await?;
if let Some(id) = id {
tracing::info!("Soft cancelling job {}", id);
}
}
Ok((tx, Some(job_running.id)))
}
pub async fn cancel_job<'c>(
username: &str,
reason: Option<String>,
id: Uuid,
w_id: &str,
mut tx: Transaction<'c, Postgres>,
db: &Pool<Postgres>,
force_cancel: bool,
require_anonymous: bool,
) -> error::Result<(Transaction<'c, Postgres>, Option<Uuid>)> {
let job = get_queued_job_tx(id, &w_id, &mut tx).await?;
if job.is_none() {
return Ok((tx, None));
}
if require_anonymous && job.as_ref().unwrap().created_by != "anonymous" {
return Err(Error::BadRequest(
"You are not logged in and this job was not created by an anonymous user like you so you cannot cancel it".to_string(),
));
}
let mut job = job.unwrap();
if force_cancel {
// if force canceling a flow step, make sure we force cancel from the highest parent
loop {
if job.parent_job.is_none() {
break;
}
match get_queued_job_tx(job.parent_job.unwrap(), &w_id, &mut tx).await? {
Some(j) => {
job = j;
}
None => break,
}
}
}
// prevent cancelling a future tick of a schedule
if let Some(schedule_path) = job.schedule_path.as_ref() {
let now = now_from_db(&mut *tx).await?;
if job.scheduled_for > now {
return Err(Error::BadRequest(
format!(
"Cannot cancel a future tick of a schedule, cancel the schedule direcly ({})",
schedule_path
)
.to_string(),
roots.extend(jobs.iter().cloned());
&roots
};
// Check if the user is authorized to cancel authed jobs:
if !authed {
let authed_jobs = sqlx::query_scalar!(
"SELECT COUNT(*) FROM v2_job WHERE id = ANY($1) AND created_by != 'anonymous'",
jobs
)
.fetch_one(db)
.await?
.unwrap_or_default();
if authed_jobs > 0 {
return Err(Error::NotAuthorized(
"Only authenticated users can cancel authed jobs".into(),
));
}
}
let job = Arc::new(job);
// get all children
let mut jobs = vec![job.id];
let mut jobs_to_cancel = vec![];
while !jobs.is_empty() {
let p_job = jobs.pop();
let new_jobs = sqlx::query_scalar!(
"SELECT id AS \"id!\" FROM v2_job WHERE parent_job = $1 AND workspace_id = $2",
p_job,
w_id
)
.fetch_all(&mut *tx)
let batches = jobs.chunks(5000).map(|batch| batch.to_vec()).collect_vec();
let batches = stream::iter(batches.into_iter())
.map(|batch| cancel_inner(db, batch, workspace_id, username, reason, force, authed))
.buffer_unordered(32)
.try_collect::<Vec<_>>()
.await?;
jobs.extend(new_jobs.clone());
jobs_to_cancel.extend(new_jobs);
}
jobs.reverse();
Ok(batches.into_iter().flatten().collect())
}
let (ntx, _) = cancel_single_job(
async fn cancel_inner(
db: &DB,
jobs: Vec<Uuid>,
workspace_id: &str,
username: &str,
reason: &str,
force: bool,
authed: bool,
) -> error::Result<Vec<Uuid>> {
// 1. Lock & soft cancel all queued jobs:
// This query both acquires an exclusive row-level lock on the job and soft cancels it.
// It also returns whenever the job is trivial (i.e. not running, no parent, no trigger).
// NOTE:
// - The `canceled_by` field is used to prevent the job from being canceled multiple times.
// - Schedule next ticks aren't cancelable.
let mut tx = db.begin().await?;
let mut st = sqlx::query!(
"UPDATE v2_job_queue q SET
canceled_by = $1,
canceled_reason = $2,
scheduled_for = NOW(),
suspend = 0
FROM v2_job j
WHERE q.id = ANY($3) AND j.id = q.id AND q.workspace_id = $4
AND canceled_by IS NULL
AND (j.trigger IS DISTINCT FROM 'schedule' OR running = true)
RETURNING q.id, (q.running = false AND parent_job IS NULL AND trigger IS NULL) AS trivial",
username,
reason.clone(),
job.clone(),
w_id,
tx,
db,
force_cancel,
reason,
&jobs,
workspace_id
)
.await?;
tx = ntx;
// cancel children
for job_id in jobs_to_cancel {
let job = get_queued_job_tx(job_id, &w_id, &mut tx).await?;
if let Some(job) = job {
let (ntx, _) = cancel_single_job(
username,
reason.clone(),
Arc::new(job),
w_id,
tx,
db,
force_cancel,
)
.await?;
tx = ntx;
.fetch(&mut *tx)
.map(|result| result.map(|row| (row.id, row.trivial)));
let (mut jobs, mut trivial_jobs) = (
Vec::with_capacity(jobs.len()),
Vec::with_capacity(jobs.len()),
);
while let Some((id, trivial)) = st.try_next().await? {
jobs.push(id);
if trivial.unwrap_or(false) {
trivial_jobs.push(id);
}
}
Ok((tx, Some(id)))
if jobs.is_empty() {
return Ok(jobs);
}
drop(st);
// 2. Terminate trivial jobs in batch:
let error = json!({
"error": {
"message": format!("Job canceled by {username}"),
"name": "Canceled",
"reason": reason,
"canceler": username
}
});
let result = Json(&error);
let trivial_jobs = terminate(
&mut *tx,
&trivial_jobs,
None,
false,
false,
result,
None,
None,
)
.map_ok(|status| status.id)
.try_collect::<HashSet<_>>()
.await?;
// 3. Commit:
tx.commit().await?;
// 4. Complete non-trivial jobs in parallel:
// This help workers draining out canceled jobs faster.
let cancel = |id: Uuid| async move {
// 4.1. Cancel children jobs:
let children = sqlx::query_scalar!("SELECT id FROM v2_job WHERE parent_job = $1", id)
.fetch_all(db)
.await?;
if !children.is_empty() {
let _ = cancel(db, &children, workspace_id, username, reason, force, authed).await?;
}
if !force {
// 4.2. Wait for the job to complete:
use tokio::time;
let wait_completed_fut = async move {
while !job_is_complete(db, id, workspace_id).await? {
sleep(time::Duration::from_millis(100)).await;
}
Ok::<_, Error>(())
};
match time::timeout(time::Duration::from_secs(2), wait_completed_fut).await {
Ok(Ok(_)) => return Ok::<_, Error>(()),
Ok(Err(err)) => return Err(err),
Err(_) => { /* ignore timeout */ }
}
}
// 4.2. Force complete the job:
if let Some(job) = get_queued_job(&id, workspace_id, db).await? {
add_completed_job(&db, &job, false, false, result, None, 0, false, None).await?;
}
Ok::<_, Error>(())
};
let _ = stream::iter(jobs.iter().cloned().filter(|id| !trivial_jobs.contains(id)))
.map(|id| async move {
if let Err(err) = cancel(id).await {
tracing::error!("Failed to complete job {id}: {err:#}");
}
})
.buffer_unordered(64)
.collect::<()>()
.await;
// 5. Return successfully canceled jobs:
Ok(jobs)
}
/* TODO retry this? */
@@ -355,35 +349,17 @@ async fn cancel_persistent_script_jobs_internal<'c>(
w_id: &str,
db: &Pool<Postgres>,
) -> error::Result<Vec<Uuid>> {
let mut tx = db.begin().await?;
// we could have retrieved the job IDs in the first query where we retrieve the hashes, but just in case a job was inserted in the queue right in-between the two above query, we re-do the fetch here
let jobs_to_cancel = sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM v2_as_queue WHERE workspace_id = $1 AND script_path = $2 AND canceled = false",
let jobs_to_cancel = sqlx::query_scalar!(
"SELECT id as \"id!\" FROM v2_as_queue WHERE workspace_id = $1 AND script_path = $2 AND canceled = false",
w_id,
script_path
)
.bind(w_id)
.bind(script_path)
.fetch_all(&mut *tx)
.fetch_all(db)
.await?;
// Then we cancel all the jobs currently in the queue, one by one
for queued_job_id in jobs_to_cancel.clone() {
let (new_tx, _) = cancel_job(
username,
reason.clone(),
queued_job_id,
w_id,
tx,
db,
false,
false,
)
.await?;
tx = new_tx;
}
tx.commit().await?;
return Ok(jobs_to_cancel);
let reason = reason.unwrap_or_else(|| "cancel persistent scripts".into());
cancel(db, &jobs_to_cancel, w_id, username, &reason, false, true).await
}
#[derive(Serialize, Debug)]
@@ -489,11 +465,9 @@ pub async fn add_completed_job_error(
db: &Pool<Postgres>,
queued_job: &QueuedJob,
mem_peak: i32,
canceled_by: Option<CanceledBy>,
e: serde_json::Value,
_worker_name: &str,
flow_is_done: bool,
duration: Option<i64>,
) -> Result<WrappedError, Error> {
#[cfg(feature = "prometheus")]
register_metric(
@@ -529,9 +503,8 @@ pub async fn add_completed_job_error(
Json(&result),
None,
mem_peak,
canceled_by,
flow_is_done,
duration,
None,
)
.await?;
Ok(result)
@@ -541,6 +514,66 @@ lazy_static::lazy_static! {
pub static ref GLOBAL_ERROR_HANDLER_PATH_IN_ADMINS_WORKSPACE: Option<String> = std::env::var("GLOBAL_ERROR_HANDLER_PATH_IN_ADMINS_WORKSPACE").ok();
}
struct TerminationStatus {
pub id: Uuid,
pub canceled: bool,
pub duration_ms: i64,
}
fn terminate<'c, T: Serialize + ValidableJson>(
e: impl PgExecutor<'c> + 'c,
id: &[Uuid],
duration: Option<i64>,
skipped: bool,
success: bool,
result: Json<&T>,
result_columns: Option<&[String]>,
memory_peak: Option<i32>,
) -> impl Stream<Item = Result<TerminationStatus, sqlx::Error>> + 'c {
sqlx::query_as!(
TerminationStatus,
"WITH queued AS (
DELETE FROM v2_job_queue
WHERE id = ANY($1::UUID[])
RETURNING
id, workspace_id, started_at, worker, canceled_by, canceled_reason, extras
), queued_and_runtime AS (
SELECT queued.*, GREATEST($7, memory_peak) AS memory_peak, flow_status,
workflow_as_code_status
FROM queued
LEFT JOIN v2_job_runtime USING (id)
LEFT JOIN v2_job_status USING (id)
) INSERT INTO v2_job_completed (
id, workspace_id, started_at, worker, memory_peak, flow_status, workflow_as_code_status,
result, result_columns, canceled_by, canceled_reason, extras,
duration_ms,
status
) SELECT
id, workspace_id, started_at, worker, memory_peak, flow_status, workflow_as_code_status,
$5, $6, canceled_by, canceled_reason, extras,
COALESCE($2::BIGINT, CASE
WHEN started_at IS NULL THEN 0
ELSE (EXTRACT('epoch' FROM NOW()) - EXTRACT('epoch' FROM started_at)) * 1000
END) AS duration_ms,
CASE
WHEN canceled_by IS NOT NULL THEN 'canceled'::job_status
WHEN $3::BOOLEAN THEN 'skipped'::job_status
WHEN $4::BOOLEAN THEN 'success'::job_status
ELSE 'failure'::job_status
END AS status
FROM queued_and_runtime
RETURNING id, status = 'canceled' AS \"canceled!\", duration_ms",
/* $1 */ id,
/* $2 */ duration,
/* $3 */ skipped,
/* $4 */ success,
/* $5 */ result as Json<&T>,
/* $6 */ result_columns as Option<&[String]>,
/* $7 */ memory_peak,
)
.fetch(e)
}
pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
db: &Pool<Postgres>,
queued_job: &QueuedJob,
@@ -549,7 +582,6 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
result: Json<&T>,
result_columns: Option<Vec<String>>,
mem_peak: i32,
canceled_by: Option<CanceledBy>,
flow_is_done: bool,
duration: Option<i64>,
) -> Result<Uuid, Error> {
@@ -564,11 +596,9 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
}
let result_columns = result_columns.as_ref();
let _job_id = queued_job.id;
let (opt_uuid, _duration, _skip_downstream_error_handlers) = (|| async {
let job_id = queued_job.id;
let (opt_uuid, canceled, _duration, _skip_downstream_error_handlers) = (|| async {
let mut tx = db.begin().await?;
let job_id = queued_job.id;
// tracing::error!("1 {:?}", start.elapsed());
tracing::debug!(
@@ -577,47 +607,36 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
serde_json::to_string(&result).unwrap_or_else(|_| "".to_string())
);
let mem_peak = mem_peak.max(queued_job.mem_peak.unwrap_or(0));
// add_time!(bench, "add_completed_job query START");
let _duration = sqlx::query_scalar!(
"INSERT INTO v2_job_completed AS cj
( workspace_id
, id
, started_at
, duration_ms
, result
, result_columns
, canceled_by
, canceled_reason
, flow_status
, workflow_as_code_status
, memory_peak
, status
, worker
)
SELECT q.workspace_id, q.id, started_at, COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000), $3, $10, $5, $6,
flow_status, workflow_as_code_status,
$8, CASE WHEN $4::BOOL THEN 'canceled'::job_status
WHEN $7::BOOL THEN 'skipped'::job_status
WHEN $2::BOOL THEN 'success'::job_status
ELSE 'failure'::job_status END AS status,
q.worker
FROM v2_job_queue q LEFT JOIN v2_job_status USING (id) WHERE q.id = $1
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3 RETURNING duration_ms AS \"duration_ms!\"",
/* $1 */ queued_job.id,
/* $2 */ success,
/* $3 */ result as Json<&T>,
/* $4 */ canceled_by.is_some(),
/* $5 */ canceled_by.clone().map(|cb| cb.username).flatten(),
/* $6 */ canceled_by.clone().map(|cb| cb.reason).flatten(),
/* $7 */ skipped,
/* $8 */ if mem_peak > 0 { Some(mem_peak) } else { None },
/* $9 */ duration,
/* $10 */ result_columns as Option<&Vec<String>>,
// acquire an exclusive row-level lock for this job:
let lock = sqlx::query_scalar!(
"SELECT NULL AS \"lock!: ()\" FROM v2_job_queue WHERE id = $1 FOR UPDATE",
job_id
)
.fetch_one(&mut *tx)
.fetch_optional(&mut *tx)
.await?;
if let None = lock {
tracing::warn!(
"Job {} already completed, discarding result `{}`",
job_id,
serde_json::to_string(&result).unwrap_or_default()
);
return Ok((None, false, 0, false));
}
let TerminationStatus { canceled, duration_ms: _duration, .. } = terminate(
&mut *tx,
&[job_id],
duration,
skipped,
success,
result,
result_columns.map(Vec::as_slice),
if mem_peak > 0 { Some(mem_peak) } else { None },
)
.next()
.await
.ok_or_else(|| Error::NotFound(format!("Job {job_id}")))?
.map_err(|e| Error::internal_err(format!("Could not add completed job {job_id}: {e:#}")))?;
if let Some(labels) = result.wm_labels() {
@@ -634,20 +653,19 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
.map_err(|e| Error::InternalErr(format!("Could not update job labels: {e:#}")))?;
}
// `workflow_as_code`:
if !queued_job.is_flow_step {
if let Some(parent_job) = queued_job.parent_job {
let _ = sqlx::query_scalar!(
"UPDATE v2_job_status SET
workflow_as_code_status = jsonb_set(
jsonb_set(
COALESCE(workflow_as_code_status, '{}'::jsonb),
array[$1],
COALESCE(workflow_as_code_status->$1, '{}'::jsonb)
),
array[$1, 'duration_ms'],
to_jsonb($2::bigint)
)
WHERE id = $3",
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
jsonb_set(
COALESCE(workflow_as_code_status, '{}'::jsonb),
array[$1],
COALESCE(workflow_as_code_status->$1, '{}'::jsonb)
),
array[$1, 'duration_ms'],
to_jsonb($2::bigint)
) WHERE id = $3",
&queued_job.id.to_string(),
_duration,
parent_job
@@ -663,7 +681,6 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
// 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 {
@@ -728,10 +745,9 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
AND flow_status->'modules'->0->>'job' = $1
)
)
FROM v2_job_completed WHERE id = $2 AND workspace_id = $3",
FROM v2_job_status WHERE id = $2",
Uuid::nil().to_string(),
&queued_job.id,
&queued_job.workspace_id
&queued_job.id
).fetch_optional(&mut *tx).await?.flatten().unwrap_or(false);
if schedule_next_tick {
@@ -747,7 +763,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
match err {
Error::QuotaExceeded(_) => (),
// scheduling next job failed and could not disable schedule => make zombie job to retry
_ => return Ok((Some(job_id), 0, true)),
_ => return Ok((Some(job_id), canceled, 0, true)),
}
};
}
@@ -858,7 +874,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
"inserted completed job: {} (success: {success})",
queued_job.id
);
Ok((None, _duration, _skip_downstream_error_handlers)) as windmill_common::error::Result<(Option<Uuid>, i64, bool)>
Result::Ok((None, canceled, _duration, _skip_downstream_error_handlers))
})
.retry(
ConstantBuilder::default()
@@ -996,13 +1012,8 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
);
}
if let Err(err) = send_error_to_workspace_handler(
&queued_job,
canceled_by.is_some(),
db,
Json(&result),
)
.await
if let Err(err) =
send_error_to_workspace_handler(&queued_job, canceled, db, Json(&result)).await
{
match err {
Error::QuotaExceeded(_) => {}
@@ -1021,7 +1032,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
}
}
if !queued_job.is_flow_step && queued_job.job_kind == JobKind::Script && canceled_by.is_none() {
if !queued_job.is_flow_step && queued_job.job_kind == JobKind::Script && !canceled {
if let Some(hash) = queued_job.script_hash {
let p = sqlx::query_scalar!(
"SELECT restart_unless_cancelled FROM script WHERE hash = $1 AND workspace_id = $2",
@@ -2579,39 +2590,6 @@ async fn extract_result_from_job_result(
}
}
pub async fn delete_job<'c>(
mut tx: Transaction<'c, Postgres>,
w_id: &str,
job_id: Uuid,
) -> windmill_common::error::Result<Transaction<'c, Postgres>> {
#[cfg(feature = "prometheus")]
if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
QUEUE_DELETE_COUNT.inc();
}
let job_removed = sqlx::query_scalar!(
"DELETE FROM v2_job_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<bool> {
Ok(sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job_completed WHERE id = $1 AND workspace_id = $2)",
@@ -2623,22 +2601,6 @@ pub async fn job_is_complete(db: &DB, id: Uuid, w_id: &str) -> error::Result<boo
.unwrap_or(false))
}
async fn get_queued_job_tx<'c>(
id: Uuid,
w_id: &str,
tx: &mut Transaction<'c, Postgres>,
) -> error::Result<Option<QueuedJob>> {
sqlx::query_as::<_, QueuedJob>(
"SELECT *
FROM v2_as_queue WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(w_id)
.fetch_optional(&mut **tx)
.await
.map_err(Into::into)
}
pub async fn get_queued_job(id: &Uuid, w_id: &str, db: &DB) -> error::Result<Option<QueuedJob>> {
sqlx::query_as::<_, QueuedJob>(
"SELECT *