fix(alerts): build the gate index concurrently, void marks on cancellation

The index on v2_job_queue ran as an ordinary CREATE INDEX inside the
migration transaction, holding the locks taken by the preceding ALTERs
while scanning a live, actively written queue. Register the migration in
OVERRIDDEN_MIGRATIONS so it is rewritten to CREATE INDEX CONCURRENTLY and
executed as top-level statements, matching the other queue indexes.

Soft cancellation also leaves a stale gate mark on a job the pull path
will hand straight to a worker, so a cancelled job now counts as ungated
whatever mark it carries.

Emitter and limiter changes live in windmill-ee-private (see
ee-repo-ref.txt bump).
This commit is contained in:
Ruben Fiszel
2026-07-20 15:17:26 +00:00
parent e3a3dc764f
commit e7af397e84
4 changed files with 41 additions and 3 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COUNT(*) FILTER (WHERE overdue AND gated IS NOT TRUE) AS ungated_count,\n COUNT(*) FILTER (WHERE overdue AND gated) AS gated_count,\n MIN(scheduled_for) FILTER (WHERE overdue AND gated IS NOT TRUE)\n AS oldest_ungated\n FROM (\n SELECT scheduled_for,\n scheduled_for <= NOW() - $2::interval AS overdue,\n concurrency_gate_id = ANY($3::text[]) AS gated\n FROM v2_job_queue\n WHERE tag = $1 AND running = false\n ) q\n ",
"query": "\n SELECT COUNT(*) FILTER (WHERE overdue AND gated IS NOT TRUE) AS ungated_count,\n COUNT(*) FILTER (WHERE overdue AND gated) AS gated_count,\n MIN(scheduled_for) FILTER (WHERE overdue AND gated IS NOT TRUE)\n AS oldest_ungated\n FROM (\n SELECT scheduled_for,\n scheduled_for <= NOW() - $2::interval AS overdue,\n canceled_by IS NULL\n AND concurrency_gate_id = ANY($3::text[]) AS gated\n FROM v2_job_queue\n WHERE tag = $1 AND running = false\n ) q\n ",
"describe": {
"columns": [
{
@@ -32,5 +32,5 @@
null
]
},
"hash": "428bd28304df56528604da2a5f83f0e989cb96acbd2eaff99e29110d96625f64"
"hash": "37bdc3c62326be091b0b09fac5dd6135d8451abe7b9f97b668998e456a1af639"
}
+1 -1
View File
@@ -1 +1 @@
d3445d03c1f103f7fc5432a7d898a5ae8db728ee
e1e73f4da9349b2aa58d4e113ffae2cb3dc6646c
+3
View File
@@ -93,6 +93,9 @@ lazy_static::lazy_static! {
(20260710073406, include_str!(
"../../migrations/20260710073406_index_v2_job_parent_job.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
(20260720055519, include_str!(
"../../migrations/20260720055519_concurrency_gated_flag.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
].into_iter().collect();
}
@@ -63,6 +63,20 @@ mod tests {
queue_jobs_on_tag(db, TAG, n, gate).await
}
/// Soft-cancelled jobs keep whatever mark they carried, but the pull path
/// skips the limiter for them, so they are waiting for a worker.
async fn queue_canceled_jobs(db: &Pool<Postgres>, n: usize, gate: Option<(&str, i64)>) {
queue_jobs_on_tag(db, TAG, n, gate).await;
sqlx::query!(
"UPDATE v2_job_queue SET canceled_by = 'canceller'
WHERE tag = $1 AND canceled_by IS NULL",
TAG,
)
.execute(db)
.await
.expect("cancel jobs");
}
async fn queue_jobs_on_tag(
db: &Pool<Postgres>,
tag: &str,
@@ -220,4 +234,25 @@ mod tests {
"a gate still being refreshed on another tag must keep holding this tag's jobs"
);
}
/// Cancellation makes the limiter inapplicable, so a mark left from before it
/// was cancelled no longer describes anything. If its gate happens to stay
/// live elsewhere, keying off the mark alone excludes a job that is squarely
/// waiting for a worker.
#[ignore = "requires database setup - run with --ignored flag"]
#[sqlx::test(migrations = "../migrations")]
async fn canceled_jobs_are_not_held_by_a_live_gate(db: Pool<Postgres>) {
free_the_lock(&db).await;
configure_alert(&db, 100).await;
queue_canceled_jobs(&db, 200, Some((BUSY_GATE, 1))).await;
jobs_waiting_alerts(&db).await;
let messages = alert_messages(&db).await;
assert_eq!(
messages.len(),
1,
"cancelled jobs bypass the limiter and must still alert, got {messages:?}"
);
}
}