mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 16:05:43 +00:00
* Add concurrency_key table and field to payload Add a new table custom_concurrency_key for storing concurrency keys on a per job basis. Add the field to the job payload to populate the table on creation of a new job. * Rename to custom_concurrency_key Be more explicit about the nature of the option variable by calling it a more appropriate name. * Delete concurrency_keys on delete_expire_items * Propagate db errors up * Add ref to EE changes * update sqlx * Add concurrency_key to tests * Revert renaming of FlowValue field FlowValue field concurrency_key must be named concurrency_key for serialization purposes. * Remove debug print statement * Get concurrency_key from Job Whether it is a Completed job or a queued one, add a ways to get the associated concurrency key * Add endpoint to get concurrency_key * Add endpoint and button to get job concurrency_key Add a line with a link on the FlowMetadata that goes to the concurrency_groups page. add endpoint to get the concurrency_key of a job * Merge concurrency_key tables into one migration + change all related querrys * Add concurrency plot to the runs page * Prepare sqlx * Add concurrency Key filter on UI * Prepare sqlx * Prepare sqlx * Fix filter and order query The limit makes more sense if we cut the older rows, so order by started_at * Factor interpolation to reuse on concurrency key Factor the arg interpolation logic into a function and finish the processing of concurrency key before insertion * Remove old concurrency key processing logic * Second transaction with userdb To send all concurrency intervals but revealing only uuids of accessible jobs, make a second transaction with the userdb. * Remove old second endpoint the intervals endpoint now also gets the concurrencyt key information for all jobs * Show external jobs * Put filters into a dropdown Create a ToggleButtonMore and put elements into a dropdown * Add toggle between two graphs * Add filter functionality to concurrency graph * Improve concurrency graph front * Fix concurrency groups page * Prepare sqlx * Add ref to ee * Change migration to create new table instead Instead of renaming the custom_concurrency_key_ended atble, we create a new table and we will delete custom_concurrency_key_ended in the future when it is no longer linked to any jobs * Do small UI improvements * Fix range fusion by not filtering past jobs Instead of filtering jobs in the backend on a startedAfter value, limit the query to 1000 and query all possible towards the past to get a good context for the graph in most sane situations * Improve frontend UI
368 lines
12 KiB
Rust
368 lines
12 KiB
Rust
use crate::db::{ApiAuthed, DB};
|
|
use crate::jobs::{
|
|
filter_list_completed_query, filter_list_queue_query, ListCompletedQuery, ListQueueQuery,
|
|
};
|
|
use crate::users::check_scopes;
|
|
use axum::extract::Path;
|
|
use axum::routing::{delete, get};
|
|
use axum::{extract::Query, Extension, Json};
|
|
use serde::Deserialize;
|
|
|
|
use axum::Router;
|
|
|
|
use serde::Serialize;
|
|
use sql_builder::bind::Bind;
|
|
use sql_builder::SqlBuilder;
|
|
use sqlx::postgres::PgRow;
|
|
use sqlx::{FromRow, Row};
|
|
use uuid::Uuid;
|
|
use windmill_common::db::UserDB;
|
|
use windmill_common::error::Error::{InternalErr, PermissionDenied};
|
|
use windmill_common::error::JsonResult;
|
|
use windmill_common::utils::require_admin;
|
|
|
|
pub fn global_service() -> Router {
|
|
Router::new()
|
|
.route("/list", get(list_concurrency_groups))
|
|
.route("/prune/*concurrency_key", delete(prune_concurrency_group))
|
|
.route("/:job_id/key", get(get_concurrency_key))
|
|
}
|
|
|
|
pub fn workspaced_service() -> Router {
|
|
Router::new().route("/intervals", get(get_concurrent_intervals))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ConcurrencyGroups {
|
|
concurrency_key: String,
|
|
total_running: i64,
|
|
}
|
|
|
|
async fn list_concurrency_groups(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
) -> JsonResult<Vec<ConcurrencyGroups>> {
|
|
require_admin(authed.is_admin, &authed.username)?;
|
|
|
|
let concurrency_counts = sqlx::query_as::<_, (String, i64)>(
|
|
"SELECT concurrency_id, (select COUNT(*) from jsonb_object_keys(job_uuids)) as n_job_uuids FROM concurrency_counter",
|
|
).fetch_all(&db)
|
|
.await?;
|
|
|
|
let mut concurrency_groups: Vec<ConcurrencyGroups> = vec![];
|
|
for (concurrency_key, count) in concurrency_counts {
|
|
concurrency_groups.push(ConcurrencyGroups {
|
|
concurrency_key: concurrency_key.clone(),
|
|
total_running: count,
|
|
})
|
|
}
|
|
|
|
return Ok(Json(concurrency_groups));
|
|
}
|
|
|
|
async fn prune_concurrency_group(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path(concurrency_key): Path<String>,
|
|
) -> JsonResult<()> {
|
|
if !authed.is_admin {
|
|
return Err(PermissionDenied(
|
|
"Only administrators can delete concurrency groups".to_string(),
|
|
));
|
|
}
|
|
let mut tx = db.begin().await?;
|
|
|
|
let concurrency_group = sqlx::query_as::<_, (String, i64)>(
|
|
"SELECT concurrency_id, (select COUNT(*) from jsonb_object_keys(job_uuids)) as n_job_uuids FROM concurrency_counter WHERE concurrency_id = $1 FOR UPDATE",
|
|
)
|
|
.bind(concurrency_key.clone())
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
let n_job_uuids = concurrency_group.map(|cg| cg.1).unwrap_or_default();
|
|
|
|
if n_job_uuids > 0 {
|
|
tx.commit().await?;
|
|
return Err(InternalErr(
|
|
"Concurrency group is currently in use, unable to remove it. Retry later.".to_string(),
|
|
));
|
|
}
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM concurrency_counter WHERE concurrency_id = $1",
|
|
concurrency_key.clone(),
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM concurrency_key WHERE key = $1",
|
|
concurrency_key.clone(),
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(Json(()))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ConcurrencyIntervals {
|
|
concurrency_key: Option<String>,
|
|
running_jobs: Vec<RunningJobDuration>,
|
|
completed_jobs: Vec<CompletedJobDuration>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CompletedJobDuration {
|
|
job_id: Option<Uuid>,
|
|
concurrency_key: Option<String>,
|
|
started_at: chrono::DateTime<chrono::Utc>,
|
|
ended_at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
impl<'r> FromRow<'r, PgRow> for CompletedJobDuration {
|
|
fn from_row(row: &'r PgRow) -> Result<Self, sqlx::Error> {
|
|
let duration_ms: i64 = row.try_get("duration_ms")?;
|
|
let started_at = row.try_get("started_at")?;
|
|
let ended_at: chrono::DateTime<chrono::Utc> =
|
|
started_at + std::time::Duration::from_millis(duration_ms.try_into().unwrap());
|
|
Ok(Self {
|
|
job_id: row.try_get("id")?,
|
|
concurrency_key: row.try_get("key")?,
|
|
started_at,
|
|
ended_at,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, FromRow)]
|
|
struct RunningJobDuration {
|
|
#[sqlx(rename = "id")]
|
|
job_id: Option<Uuid>,
|
|
#[sqlx(rename = "key")]
|
|
concurrency_key: Option<String>,
|
|
started_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ConcurrentIntervalsParams {
|
|
concurrency_key: Option<String>,
|
|
row_limit: Option<i64>,
|
|
}
|
|
|
|
fn join_concurrency_key<'c>(concurrency_key: Option<&String>, mut sqlb: SqlBuilder) -> SqlBuilder {
|
|
match concurrency_key {
|
|
Some(key) => sqlb
|
|
.join("concurrency_key")
|
|
.on_eq("id", "job_id")
|
|
.and_where_eq("key", "?".bind(key)),
|
|
None => sqlb.left().join("concurrency_key").on_eq("id", "job_id"),
|
|
};
|
|
|
|
sqlb
|
|
}
|
|
|
|
async fn get_concurrent_intervals(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path(w_id): Path<String>,
|
|
Query(iq): Query<ConcurrentIntervalsParams>,
|
|
Query(lq): Query<ListCompletedQuery>,
|
|
) -> JsonResult<ConcurrencyIntervals> {
|
|
check_scopes(&authed, || format!("listjobs"))?;
|
|
|
|
let row_limit = iq.row_limit.unwrap_or(1000);
|
|
let concurrency_key = iq.concurrency_key;
|
|
|
|
let lq_copy = lq.clone();
|
|
|
|
let lqq = ListQueueQuery {
|
|
script_path_start: lq_copy.script_path_start,
|
|
script_path_exact: lq_copy.script_path_exact,
|
|
script_hash: lq_copy.script_hash,
|
|
created_by: lq_copy.created_by,
|
|
started_before: lq_copy.started_before,
|
|
started_after: lq_copy.started_after,
|
|
created_before: lq_copy.created_before,
|
|
created_after: lq_copy.created_after,
|
|
created_or_started_before: lq_copy.created_or_started_before,
|
|
created_or_started_after: lq_copy.created_or_started_after,
|
|
running: lq_copy.running,
|
|
parent_job: lq_copy.parent_job,
|
|
order_desc: Some(true),
|
|
job_kinds: lq_copy.job_kinds,
|
|
suspended: lq_copy.suspended,
|
|
args: lq_copy.args,
|
|
tag: lq_copy.tag,
|
|
schedule_path: lq_copy.schedule_path,
|
|
scheduled_for_before_now: lq_copy.scheduled_for_before_now,
|
|
all_workspaces: lq_copy.all_workspaces,
|
|
is_flow_step: lq_copy.is_flow_step,
|
|
has_null_parent: lq_copy.has_null_parent,
|
|
is_not_schedule: lq_copy.is_not_schedule,
|
|
};
|
|
let mut sqlb_q = SqlBuilder::select_from("queue")
|
|
.fields(&["id", "key", "started_at"])
|
|
.order_by("created_at", lq.order_desc.unwrap_or(true))
|
|
.limit(row_limit)
|
|
.clone();
|
|
let mut sqlb_c = SqlBuilder::select_from("completed_job")
|
|
.fields(&["id", "key", "started_at", "duration_ms"])
|
|
.order_by("created_at", lq.order_desc.unwrap_or(true))
|
|
.limit(row_limit)
|
|
.clone();
|
|
|
|
sqlb_q = join_concurrency_key(concurrency_key.as_ref(), sqlb_q);
|
|
sqlb_c = join_concurrency_key(concurrency_key.as_ref(), sqlb_c);
|
|
|
|
sqlb_q.and_where_is_not_null("started_at");
|
|
|
|
// When we have a concurrency key defined, fetch jobs from other workspaces
|
|
// as obscured unless we're in the admins workspace. This is to show the
|
|
// potential concurrency races without showing jobs that don't belong to
|
|
// the workspace.
|
|
let sqlb_all_workspaces: Option<(SqlBuilder, SqlBuilder)> =
|
|
if concurrency_key.is_some() && w_id != "admins" {
|
|
Some((
|
|
filter_list_queue_query(
|
|
sqlb_q.clone(),
|
|
&ListQueueQuery { all_workspaces: Some(true), ..lqq.clone() },
|
|
"admins",
|
|
),
|
|
filter_list_completed_query(
|
|
sqlb_c.clone(),
|
|
&ListCompletedQuery { all_workspaces: Some(true), ..lq.clone() },
|
|
"admins",
|
|
),
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
sqlb_q = filter_list_queue_query(sqlb_q, &lqq, w_id.as_str());
|
|
sqlb_c = filter_list_completed_query(sqlb_c, &lq, w_id.as_str());
|
|
|
|
let sql_q = sqlb_q.query()?;
|
|
let sql_c = sqlb_c.query()?;
|
|
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
let running_jobs_user: Vec<RunningJobDuration> = if lq.success.is_none() {
|
|
sqlx::query_as(&sql_q).fetch_all(&mut *tx).await?
|
|
} else {
|
|
vec![]
|
|
};
|
|
let completed_jobs_user: Vec<CompletedJobDuration> = if lq.running.is_none() {
|
|
sqlx::query_as(&sql_c).fetch_all(&mut *tx).await?
|
|
} else {
|
|
vec![]
|
|
};
|
|
tx.commit().await?;
|
|
|
|
// To avoid infering information through filtering, don't return obscured
|
|
// jobs if the filters are too specific
|
|
let should_fetch_obscured_jobs = match lq {
|
|
ListCompletedQuery {
|
|
script_path_start: None,
|
|
script_path_exact: None,
|
|
script_hash: None,
|
|
created_by: None,
|
|
success: None,
|
|
running: None,
|
|
parent_job: None,
|
|
is_skipped: None | Some(false),
|
|
suspended: None,
|
|
schedule_path: None,
|
|
args: None,
|
|
result: None,
|
|
tag: None,
|
|
scheduled_for_before_now: None,
|
|
has_null_parent: None,
|
|
label: None,
|
|
is_not_schedule: None,
|
|
started_before: _,
|
|
started_after: _,
|
|
created_before: _,
|
|
created_after: _,
|
|
created_or_started_before: _,
|
|
created_or_started_after: _,
|
|
order_desc: _,
|
|
job_kinds: _,
|
|
is_flow_step: _,
|
|
all_workspaces: _,
|
|
} => true,
|
|
_ => false,
|
|
};
|
|
|
|
// This second transaction using the raw db lets us get info for jobs that
|
|
// the user has no access to. Before returning that, we will hide the ids
|
|
if should_fetch_obscured_jobs && concurrency_key.is_some() {
|
|
let (sql_q, sql_c) = if let Some(sqlb) = sqlb_all_workspaces {
|
|
(sqlb.0.query()?, sqlb.1.query()?)
|
|
} else {
|
|
(sql_q, sql_c)
|
|
};
|
|
|
|
let running_jobs_db: Vec<RunningJobDuration> = if lq.success.is_none() {
|
|
sqlx::query_as(&sql_q).fetch_all(&db).await?
|
|
} else {
|
|
vec![]
|
|
};
|
|
let completed_jobs_db: Vec<CompletedJobDuration> = if lq.running.is_none() {
|
|
sqlx::query_as(&sql_c).fetch_all(&db).await?
|
|
} else {
|
|
vec![]
|
|
};
|
|
|
|
let running_jobs = running_jobs_db
|
|
.into_iter()
|
|
.map(|r| {
|
|
if running_jobs_user
|
|
.iter()
|
|
.any(|u| u.job_id.unwrap() == r.job_id.unwrap())
|
|
{
|
|
RunningJobDuration { ..r }
|
|
} else {
|
|
RunningJobDuration { job_id: None, ..r }
|
|
}
|
|
})
|
|
.collect();
|
|
let completed_jobs = completed_jobs_db
|
|
.into_iter()
|
|
.map(|r| {
|
|
if completed_jobs_user
|
|
.iter()
|
|
.any(|u| u.job_id.unwrap() == r.job_id.unwrap())
|
|
{
|
|
CompletedJobDuration { ..r }
|
|
} else {
|
|
CompletedJobDuration { job_id: None, ..r }
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
return Ok(Json(ConcurrencyIntervals {
|
|
concurrency_key,
|
|
running_jobs,
|
|
completed_jobs,
|
|
}));
|
|
}
|
|
|
|
Ok(Json(ConcurrencyIntervals {
|
|
concurrency_key,
|
|
running_jobs: running_jobs_user,
|
|
completed_jobs: completed_jobs_user,
|
|
}))
|
|
}
|
|
|
|
async fn get_concurrency_key(
|
|
Extension(db): Extension<DB>,
|
|
Path(job_id): Path<Uuid>,
|
|
) -> JsonResult<Option<String>> {
|
|
let key = sqlx::query_scalar!("SELECT key FROM concurrency_key WHERE job_id = $1", job_id)
|
|
.fetch_optional(&db)
|
|
.await?;
|
|
Ok(Json(key))
|
|
}
|