feat: cap queued jobs per concurrency key on cloud (#10197)

* feat: cap queued jobs per concurrency key on cloud

* fix: close preprocessed-flow bypass and bound concurrency cap scan

* fix: only cap concurrency keys with an active concurrent_limit

* chore: only load concurrency key cap setting when cloud hosted

* fix: reject queued-job import on cloud
This commit is contained in:
Ruben Fiszel
2026-07-20 12:33:40 +02:00
committed by GitHub
parent 1abfe49f7e
commit 71f2d47cb4
17 changed files with 446 additions and 45 deletions
+132 -7
View File
@@ -6281,14 +6281,23 @@ async fn push_inner<'c, 'd>(
.unzip();
if concurrency_settings.concurrent_limit.is_some() {
insert_concurrency_key(
let concurrency_key = resolve_concurrency_key(
workspace_id,
&args,
&runnable_path,
job_kind,
concurrency_settings.concurrency_key.clone(),
);
#[cfg(feature = "cloud")]
if *CLOUD_HOSTED {
check_concurrency_key_queue_cap(&mut *tx, &concurrency_key).await?;
}
insert_resolved_concurrency_key(
&concurrency_key,
&mut *tx,
job_id,
&runnable_path,
workspace_id,
)
.await?;
}
@@ -6617,16 +6626,14 @@ async fn push_inner<'c, 'd>(
Ok((job_id, tx))
}
pub async fn insert_concurrency_key<'d, 'c>(
fn resolve_concurrency_key<'d>(
workspace_id: &str,
args: &PushArgs<'d>,
script_path: &Option<String>,
job_kind: JobKind,
custom_concurrency_key: Option<String>,
db: impl PgExecutor<'c>,
job_id: Uuid,
) -> Result<(), Error> {
let concurrency_key = custom_concurrency_key
) -> String {
custom_concurrency_key
.map(|x| {
let interpolated = interpolate_args(x.clone(), args, workspace_id);
// In cloud mode, enforce workspace isolation by prefixing with workspace
@@ -6648,7 +6655,50 @@ pub async fn insert_concurrency_key<'d, 'c>(
workspace_id,
script_path.as_ref(),
&job_kind,
));
))
}
/// Resolves the concurrency key, applies the cloud-only queue-depth cap, then registers the key.
///
/// `concurrent_limit: None` still registers the key but skips the cap: without a limit nothing
/// serializes the key, so there is no backlog to bound. Callers register keys for tag
/// interpolation alone, so the two are not interchangeable.
///
/// Takes a `Copy` executor because the cap runs a query before the insert on the same one. `push`
/// cannot use this — it holds a `&mut Transaction` — so it performs the same three steps inline.
pub async fn insert_concurrency_key_capped<'d, 'c, E: PgExecutor<'c> + Copy>(
workspace_id: &str,
args: &PushArgs<'d>,
script_path: &Option<String>,
job_kind: JobKind,
custom_concurrency_key: Option<String>,
concurrent_limit: Option<i32>,
db: E,
job_id: Uuid,
) -> Result<(), Error> {
let concurrency_key = resolve_concurrency_key(
workspace_id,
args,
script_path,
job_kind,
custom_concurrency_key,
);
#[cfg(feature = "cloud")]
if *CLOUD_HOSTED && concurrent_limit.is_some() {
check_concurrency_key_queue_cap(db, &concurrency_key).await?;
}
#[cfg(not(feature = "cloud"))]
let _ = concurrent_limit;
insert_resolved_concurrency_key(&concurrency_key, db, job_id, script_path, workspace_id).await
}
async fn insert_resolved_concurrency_key<'c>(
concurrency_key: &str,
db: impl PgExecutor<'c>,
job_id: Uuid,
script_path: &Option<String>,
workspace_id: &str,
) -> Result<(), Error> {
sqlx::query!(
"WITH inserted_concurrency_counter AS (
INSERT INTO concurrency_counter (concurrency_id, job_uuids)
@@ -6666,6 +6716,81 @@ pub async fn insert_concurrency_key<'d, 'c>(
Ok(())
}
/// Counts jobs *waiting* behind `concurrency_key`, scanning at most `limit` rows.
///
/// Three constraints on the query below, each pinned by a test in
/// `tests/concurrency_key_queue_depth_test.rs`:
/// - Any `scheduled_for`: the limiter parks blocked jobs in the future, so a gated backlog is
/// almost entirely future-dated and `scheduled_for <= now()` would never see it.
/// - `running = false`: running jobs keep `ended_at` NULL, and counting them would charge a key
/// for the concurrency it is licensed to use.
/// - `LIMIT` inside the `EXISTS`, not outside: rows whose job left the queue without
/// `add_completed_job` keep `ended_at` NULL forever (the retention sweep only matches
/// `ended_at <= …`), so an outside `LIMIT` would let them be rescanned on every push.
///
/// Takes an already-resolved key and performs no authorization; `pub` only so the integration
/// test can reach it. Never call it with a caller-supplied key — it would leak queue depth
/// across workspaces.
pub async fn concurrency_key_queue_depth<'c>(
db: impl PgExecutor<'c>,
concurrency_key: &str,
limit: i64,
) -> Result<i64, Error> {
sqlx::query_scalar!(
"SELECT count(*) FROM (
SELECT ck.job_id FROM concurrency_key ck
WHERE ck.key = $1 AND ck.ended_at IS NULL
LIMIT $2
) s WHERE EXISTS (
SELECT 1 FROM v2_job_queue q WHERE q.id = s.job_id AND q.running = false
)",
concurrency_key,
limit,
)
.fetch_one(db)
.warn_after_seconds(3)
.await
.map_err(|e| {
Error::internal_err(format!(
"Could not count queued jobs for concurrency_key={concurrency_key}: {e:#}"
))
})
.map(|c| c.unwrap_or(0))
}
/// Rejects the push when `concurrency_key` already has `CONCURRENCY_KEY_MAX_QUEUED` jobs queued.
///
/// Caller must runtime-gate this on `*CLOUD_HOSTED`, and must call it from *every push path*
/// that registers a concurrency key: a flow with a preprocessor is pushed with `concurrent_limit`
/// cleared and only registers its key later from `worker_flow`, so gating `push` alone leaves
/// trigger-driven flows uncapped.
///
/// `add_batch_jobs` writes keys directly and is deliberately exempt: it is superadmin-only, and a
/// superadmin can set the cap to `0` anyway. `import_queued_jobs` is likewise exempt because it
/// is rejected outright on cloud.
#[cfg(feature = "cloud")]
async fn check_concurrency_key_queue_cap<'c>(
db: impl PgExecutor<'c>,
concurrency_key: &str,
) -> Result<(), Error> {
let cap = windmill_common::worker::CONCURRENCY_KEY_MAX_QUEUED
.load(std::sync::atomic::Ordering::Relaxed);
if cap == 0 {
return Ok(());
}
let cap = cap as i64;
let depth = concurrency_key_queue_depth(db, concurrency_key, cap).await?;
if depth >= cap {
return Err(Error::QuotaExceeded(format!(
"Too many jobs queued behind concurrency key '{concurrency_key}': at least {depth} \
jobs are already waiting and the limit is {cap}. Jobs sharing a concurrency key run \
at most `concurrent_limit` at a time, so this queue is growing faster than it can \
drain. Cancel the backlog, slow down the caller, or raise the concurrency limit."
)));
}
Ok(())
}
// pub async fn insert_debounce_key<'d, 'c>(
// workspace_id: &str,
// args: &PushArgs<'d>,