feat: pause a worker group without stopping its workers (#11596)

* feat: pause a worker group without stopping its workers

* chore: bump ee ref

* fix: keep dedicated workers running across a pause toggle

* fix: edit a copy of the worker group config in the drawer

* fix: stable priority tag order, keep pause-only configs in the drawer

* fix: keep inherited worker tags when saving from the drawer

* fix: keep an explicit empty tag list when leaving dedicated mode

* fix: keep inherited tags when creating a config from the drawer

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 6f23819e947b18878000e580ddefccfd01a73166

This commit updates the EE repository reference after PR #853 was merged in windmill-ee-private.

Previous ee-repo-ref: 6cf802bdbe17b4a66757016515044b8a9ef9ef19

New ee-repo-ref: 6f23819e947b18878000e580ddefccfd01a73166

Automated by sync-ee-ref workflow.

* feat: confirm before applying a worker config change that restarts workers

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: pin the ee commit that compiles against main

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: retrigger ci after the github outage dropped the push event

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-10-07 17:28:29 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent 14381cefee
commit 95c4b3096c
10 changed files with 340 additions and 20 deletions
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(config->'paused' = 'true'::jsonb, false) FROM config WHERE name = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "coalesce",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "91fdd565bd210f8aa4c6d5f5e309573aefaeb5dbc6ac07f5e664bc8fe2cb2683"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(config->'paused' = 'true'::jsonb, false) FROM config WHERE name = $1 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "coalesce",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "a6d355b3121f199f3827c05aaa7c8f780ab4c9d3d373d3cd0bb489e0d173040c"
}
+1 -1
View File
@@ -1 +1 @@
0b78e2c6fefcbad3c465bf75ebfd6ebd6912ad19
1600d49ad5513af8b54ad65688d3d1c7edc977d9
+8 -1
View File
@@ -427,6 +427,8 @@ pub async fn initial_load(
native_mode,
// an agent worker never reads its group's config, only its token
object_store_cache_config: None,
// nor whether it is paused: the server refuses it jobs instead
paused: false,
}));
}
}
@@ -5689,8 +5691,13 @@ pub async fn reload_worker_config(db: &DB, tx: KillpillSender, kill_if_change: b
.as_ref()
.is_some_and(|dws| !dws.is_empty());
// Pausing or resuming must not restart the workers, dedicated ones included: a reload
// whose only difference is the flag skips every restart below.
let only_pause_changed = wc.paused != config.paused
&& WorkerConfig { paused: wc.paused, ..config.clone() } == **wc;
if **wc != config || has_dedicated {
if kill_if_change {
if kill_if_change && !only_pause_changed {
if has_dedicated
|| wc.dedicated_worker != config.dedicated_worker
|| wc.dedicated_workers != config.dedicated_workers
+71
View File
@@ -0,0 +1,71 @@
//! In its own test binary: it flips the process-wide `WORKER_CONFIG`, which would stall every
//! other worker test sharing the process.
use futures::StreamExt;
use sqlx::{Pool, Postgres};
use std::time::Duration;
use windmill_common::{
jobs::{JobPayload, RawCode},
scripts::ScriptLang,
worker::{Connection, WORKER_CONFIG},
};
use windmill_test_utils::*;
fn set_paused(paused: bool) {
let mut wc = (**WORKER_CONFIG.load()).clone();
wc.paused = paused;
WORKER_CONFIG.store(std::sync::Arc::new(wc));
}
#[sqlx::test(fixtures("base"))]
async fn paused_worker_leaves_jobs_queued_until_resumed(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
set_paused(true);
let id = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: "echo done".to_string(),
path: None,
lock: None,
language: ScriptLang::Bash,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
tag: None,
}))
.push(&db)
.await;
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(
Connection::Sql(db.clone()),
async {
tokio::time::sleep(Duration::from_secs(3)).await;
let running =
sqlx::query_scalar::<_, bool>("SELECT running FROM v2_job_queue WHERE id = $1")
.bind(id)
.fetch_optional(&db)
.await
.unwrap();
assert_eq!(
running,
Some(false),
"a paused worker must not pull the job"
);
set_paused(false);
while completed.next().await != Some(id) {}
},
port,
)
.await;
assert!(completed_job(id, &db).await.success);
Ok(())
}
+26 -2
View File
@@ -224,12 +224,13 @@ async fn update_config(
#[cfg(not(feature = "enterprise"))]
let config = if name.starts_with("worker__") {
// In CE, only allow setting worker_tags, cache_clear, init_bash, and native_mode
// In CE, only allow setting worker_tags, cache_clear, init_bash, native_mode and paused
serde_json::json!({
"worker_tags": config.get("worker_tags"),
"cache_clear": config.get("cache_clear"),
"init_bash": config.get("init_bash"),
"native_mode": config.get("native_mode")
"native_mode": config.get("native_mode"),
"paused": config.get("paused")
})
} else {
config
@@ -272,6 +273,18 @@ async fn update_config(
}
let mut tx = db.begin().await?;
let was_paused = if name.starts_with("worker__") {
sqlx::query_scalar!(
"SELECT COALESCE(config->'paused' = 'true'::jsonb, false) FROM config WHERE name = $1 FOR UPDATE",
&name
)
.fetch_optional(&mut *tx)
.await?
.flatten()
.unwrap_or(false)
} else {
false
};
sqlx::query!(
"INSERT INTO config (name, config) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET config = EXCLUDED.config",
&name,
@@ -291,6 +304,17 @@ async fn update_config(
)
.await?;
tx.commit().await?;
if name.starts_with("worker__") {
let paused = config.get("paused").and_then(|v| v.as_bool()) == Some(true);
if paused != was_paused {
windmill_common::feature_usage::log_feature_usage(
"worker_group",
"pause_toggle",
if paused { "pause" } else { "resume" },
);
}
}
Ok(format!("Updated config {name}"))
}
@@ -963,6 +963,9 @@ pub struct WorkerGroupConfig {
pub autoscaling: Option<AutoscalingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_mode: Option<bool>,
/// Workers of the group pull no new job while this is set; queued jobs wait.
#[serde(skip_serializing_if = "Option::is_none")]
pub paused: Option<bool>,
/// Object store this group's dependency cache uses instead of the instance one. Same shape
/// as the instance `object_store_cache_config`.
#[serde(skip_serializing_if = "Option::is_none")]
+59 -7
View File
@@ -477,6 +477,7 @@ lazy_static::lazy_static! {
env_vars: Default::default(),
native_mode: false,
object_store_cache_config: Default::default(),
paused: false,
});
pub static ref WORKER_PULL_QUERIES: arc_swap::ArcSwap<Vec<String>> = arc_swap::ArcSwap::from_pointee(vec![]);
@@ -844,11 +845,14 @@ pub fn make_suspended_pull_query(tags: &[String]) -> String {
}
// pub async fn make_suspended
pub async fn store_suspended_pull_query(wc: &WorkerConfig) {
if wc.worker_tags.len() == 0 {
tracing::error!("Empty tags in worker tags, skipping");
return;
}
let query = make_suspended_pull_query(&wc.worker_tags);
// An empty tag list must clear the query: keeping the previous one would leave a reloaded
// worker pulling suspended jobs for tags it no longer listens to.
let query = if wc.worker_tags.is_empty() {
tracing::error!("Empty tags in worker tags, pulling no suspended jobs");
String::new()
} else {
make_suspended_pull_query(&wc.worker_tags)
};
WORKER_SUSPENDED_PULL_QUERY.store(std::sync::Arc::new(query));
}
@@ -922,6 +926,42 @@ pub async fn store_pull_query(wc: &WorkerConfig) {
WORKER_PULL_QUERIES_FAIRNESS.store(std::sync::Arc::new(fairness_queries));
}
const WORKER_GROUP_PAUSED_CACHE_TTL: Duration = Duration::from_secs(10);
lazy_static::lazy_static! {
static ref WORKER_GROUP_PAUSED_CACHE: quick_cache::sync::Cache<String, (bool, std::time::Instant)> =
quick_cache::sync::Cache::new(1000);
}
/// Whether the config of `worker_group` is paused, as the server sees it. A worker that reads
/// its own config knows from `WORKER_CONFIG`; this is for the server deciding on behalf of
/// workers that do not, and is cached since they ask on every pull.
pub async fn is_worker_group_paused(db: &DB, worker_group: &str) -> bool {
if let Some((paused, at)) = WORKER_GROUP_PAUSED_CACHE.get(worker_group) {
if at.elapsed() < WORKER_GROUP_PAUSED_CACHE_TTL {
return paused;
}
}
let paused = match sqlx::query_scalar!(
"SELECT COALESCE(config->'paused' = 'true'::jsonb, false) FROM config WHERE name = $1",
format!("worker__{worker_group}")
)
.fetch_optional(db)
.await
{
Ok(paused) => paused.flatten().unwrap_or(false),
Err(e) => {
tracing::error!("Could not read whether worker group {worker_group} is paused: {e:#}");
return false;
}
};
WORKER_GROUP_PAUSED_CACHE.insert(
worker_group.to_string(),
(paused, std::time::Instant::now()),
);
paused
}
lazy_static::lazy_static! {
pub static ref WINDMILL_DIR: String = {
let dir = std::env::var("WINDMILL_DIR")
@@ -2470,6 +2510,12 @@ pub async fn load_worker_config(
.collect();
priority_tags_sorted.push(PriorityTags { priority: 0, tags: Vec::from_iter(all_tags_set) }); // push the tags that were not listed as high priority with a priority = 0
priority_tags_sorted.sort_by_key(|elt| Reverse(elt.priority)); // sort by priority DESC
// The buckets come out of hash maps: without a fixed order two loads of the same
// config compare unequal, and a reload reads that as a config change.
for bucket in priority_tags_sorted.iter_mut() {
bucket.tags.sort();
}
} else {
// if no priority is used, push all tags with a priority to 0
priority_tags_sorted.push(PriorityTags { priority: 0, tags: worker_tags.clone() });
@@ -2539,6 +2585,7 @@ pub async fn load_worker_config(
env_vars: resolved_env_vars,
native_mode,
object_store_cache_config: config.object_store_cache_config,
paused: config.paused.unwrap_or(false),
})
}
@@ -2629,6 +2676,7 @@ pub struct WorkerConfigOpt {
pub env_vars_allowlist: Option<Vec<String>>,
pub native_mode: Option<bool>,
pub object_store_cache_config: Option<serde_json::Value>,
pub paused: Option<bool>,
}
impl Default for WorkerConfigOpt {
@@ -2648,6 +2696,7 @@ impl Default for WorkerConfigOpt {
env_vars_allowlist: Default::default(),
native_mode: Default::default(),
object_store_cache_config: Default::default(),
paused: Default::default(),
}
}
}
@@ -2670,12 +2719,15 @@ pub struct WorkerConfig {
/// in the group config. Raw JSON: `windmill-common` cannot depend on the object store crate
/// that parses it, and comparing the raw value is what tells a reload the store changed.
pub object_store_cache_config: Option<serde_json::Value>,
/// The group pulls no job from the queue: its workers finish what they hold, keep pinging
/// and leave queued jobs for when it is resumed.
pub paused: bool,
}
impl std::fmt::Debug for WorkerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "WorkerConfig {{ worker_tags: {:?}, priority_tags_sorted: {:?}, dedicated_worker: {:?}, dedicated_workers: {:?}, init_bash: {:?}, periodic_script_bash: {:?}, periodic_script_interval_seconds: {:?}, cache_clear: {:?}, additional_python_paths: {:?}, pip_local_dependencies: {:?}, env_vars: {:?}, native_mode: {:?}, object_store_cache_config: {} }}",
self.worker_tags, self.priority_tags_sorted, self.dedicated_worker, self.dedicated_workers, self.init_bash, self.periodic_script_bash, self.periodic_script_interval_seconds, self.cache_clear, self.additional_python_paths, self.pip_local_dependencies, self.env_vars.iter().map(|(k, v)| format!("{}: {}{} ({} chars)", k, &v[..3.min(v.len())], "***", v.len())).collect::<Vec<String>>().join(", "), self.native_mode,
write!(f, "WorkerConfig {{ worker_tags: {:?}, priority_tags_sorted: {:?}, dedicated_worker: {:?}, dedicated_workers: {:?}, init_bash: {:?}, periodic_script_bash: {:?}, periodic_script_interval_seconds: {:?}, cache_clear: {:?}, additional_python_paths: {:?}, pip_local_dependencies: {:?}, env_vars: {:?}, native_mode: {:?}, paused: {:?}, object_store_cache_config: {} }}",
self.worker_tags, self.priority_tags_sorted, self.dedicated_worker, self.dedicated_workers, self.init_bash, self.periodic_script_bash, self.periodic_script_interval_seconds, self.cache_clear, self.additional_python_paths, self.pip_local_dependencies, self.env_vars.iter().map(|(k, v)| format!("{}: {}{} ({} chars)", k, &v[..3.min(v.len())], "***", v.len())).collect::<Vec<String>>().join(", "), self.native_mode, self.paused,
// holds bucket credentials
self.object_store_cache_config.as_ref().map(|_| "***").unwrap_or("None"))
}
+4
View File
@@ -3424,6 +3424,10 @@ pub async fn run_worker(
tokio::time::sleep(Duration::from_millis(200)).await;
continue;
}
} else if WORKER_CONFIG.load().paused {
// Checked after the same-worker channel: a flow this worker already runs
// still needs it for its remaining same-worker steps.
Ok(None)
} else {
match &conn {
Connection::Sql(db) => {
+124 -9
View File
@@ -104,6 +104,7 @@
autoscaling?: AutoscalingConfig
native_mode?: boolean
object_store_cache_config?: ObjectStoreConfig
paused?: boolean
} = $state({})
function loadNConfig() {
@@ -111,10 +112,9 @@
? config.worker_tags != undefined ||
config.dedicated_worker != undefined ||
config.dedicated_workers != undefined
? config
: {
worker_tags: []
}
? // a copy: editing the draft must not touch the saved config it is compared against
$state.snapshot(config)
: { ...$state.snapshot(config), worker_tags: [] }
: {
worker_tags: []
}
@@ -212,6 +212,7 @@
periodic_script_interval_seconds?: number
native_mode?: boolean
object_store_cache_config?: ObjectStoreConfig
paused?: boolean
}
activeWorkers: number
customTags: string[] | undefined
@@ -268,8 +269,10 @@
if (!config && !nconfig) return false
if (!config || !nconfig) return true
const cleaned1 = cleanValueProperties(config)
const cleaned2 = cleanValueProperties(nconfig)
// Compared without empty values: the draft is filled with empty defaults a saved
// config may lack, which are not an edit.
const cleaned1 = cleanWorkerGroupConfig(cleanValueProperties(config))
const cleaned2 = cleanWorkerGroupConfig(cleanValueProperties(nconfig))
return (
orderedJsonStringify(replaceFalseWithUndefined(cleaned1)) !==
@@ -298,6 +301,47 @@
let openDelete = $state(false)
let openClean = $state(false)
let openRestart = $state(false)
let pendingConfig: typeof nconfig | undefined = $state()
// Mirrors `reload_worker_config` in backend/src/monitor.rs, which decides the restart. It
// compares the body that is sent rather than the draft, and only the fields a worker loads.
const RESTART_FIELDS = [
'dedicated_worker',
'dedicated_workers',
'init_bash',
'periodic_script_bash',
'periodic_script_interval_seconds',
'native_mode'
] as const
const OTHER_WORKER_FIELDS = [
'worker_tags',
'priority_tags',
'additional_python_paths',
'pip_local_dependencies',
'object_store_cache_config'
] as const
function restartsWorkers(body: typeof nconfig): boolean {
if (activeWorkers === 0) return false
const saved: typeof nconfig = config ?? {}
const comparable = (c: typeof nconfig, k: keyof typeof nconfig) =>
orderedJsonStringify(replaceFalseWithUndefined(cleanWorkerGroupConfig({ v: c[k] })))
const changed = (k: keyof typeof nconfig) => comparable(saved, k) !== comparable(body, k)
const restartFieldChanged = RESTART_FIELDS.some(changed)
const onlyPauseChanged =
changed('paused') &&
!restartFieldChanged &&
!OTHER_WORKER_FIELDS.some(changed) &&
!hasEnvVarChanges
const dedicated =
body.dedicated_worker != undefined || (body.dedicated_workers?.length ?? 0) > 0
return !onlyPauseChanged && (dedicated || restartFieldChanged)
}
async function saveConfig(body: typeof nconfig) {
await ConfigService.updateConfig({ name: 'worker__' + name, requestBody: body })
sendUserToast('Configuration set')
dispatch('reload')
}
// Compute hashed tags for display (actual tags used by the worker)
let hashedDedicatedTags: Map<string, string> = $state(new Map())
@@ -399,6 +443,31 @@
</div>
</ConfirmationModal>
<ConfirmationModal
open={pendingConfig != undefined}
title="Apply changes and restart workers"
confirmationText="Apply and restart"
on:canceled={() => {
pendingConfig = undefined
}}
on:confirmed={async () => {
const body = pendingConfig
pendingConfig = undefined
if (body) {
await saveConfig(body)
}
}}
>
<div class="flex flex-col w-full space-y-4">
<span
>Applying these changes will restart the {pluralize(activeWorkers, 'worker')} of worker group '{name}'.
Workers finish their running job, shut down gracefully and are expected to be restarted by
their supervisor.</span
>
<span>Pausing or resuming a worker group alone does not restart its workers.</span>
</div>
</ConfirmationModal>
<ConfirmationModal
open={openRestart}
title="Restart workers"
@@ -733,6 +802,32 @@
</div>
{/if}
{#if nconfig !== undefined}
<div class="mt-8"></div>
<Label label="Pause">
{#snippet header()}
<Tooltip>
{#snippet text()}
A paused worker group stops pulling jobs, including suspended jobs that are resumed.
Jobs already running finish, queued jobs stay in the queue until the group is resumed.
Workers pick the change up within about 10 seconds, without restarting.
{/snippet}
</Tooltip>
{/snippet}
<Toggle
size="sm"
options={{ right: 'Pause this worker group' }}
checked={nconfig?.paused === true}
on:change={(ev) => {
if (nconfig !== undefined) {
nconfig.paused = ev.detail ? true : undefined
}
}}
disabled={!canEditConfig}
/>
</Label>
{/if}
<div class="mt-8"></div>
<Label
@@ -1216,9 +1311,19 @@
}
})
await ConfigService.updateConfig({ name: 'worker__' + name, requestBody: nconfig })
sendUserToast('Configuration set')
dispatch('reload')
// A group without saved tags inherits them from the worker's environment, and
// an explicit empty list would override that: only send one the user asked for.
const inheritsTags =
config?.worker_tags == undefined &&
config?.dedicated_worker == undefined &&
config?.dedicated_workers == undefined &&
nconfig.worker_tags?.length === 0
const body = inheritsTags ? { ...nconfig, worker_tags: undefined } : nconfig
if (restartsWorkers(body)) {
pendingConfig = body
} else {
await saveConfig(body)
}
}}
disabled={(!hasChanges &&
!hasEnvVarChanges &&
@@ -1258,6 +1363,16 @@
{#if isNativeMode}
<Badge color="blue" small>Native</Badge>
{/if}
{#if config?.paused === true}
<Badge color="yellow" small>
Paused
<Tooltip>
{#snippet text()}
Workers of this group pull no new jobs. Queued jobs wait until the group is resumed.
{/snippet}
</Tooltip>
</Badge>
{/if}
</div>
{#if vcpus_memory?.vcpus}