mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 08:00:59 +00:00
22eadab67d
* perf: resolve the worker external IP in the background `run_workers` awaited `external_ip::get_ip()` — an HTTPS GET to hub.windmill.dev — before spawning any worker, so every worker process paid that round trip before its first job pull. Measured on a CE debug build it was 120-450 ms of a ~200-500 ms startup, and behind a firewall the call does not fail fast: it burns its whole 5 s connect timeout, on every process start. That cost is per-job under EXIT_AFTER_N_JOBS. The value is informational (it is only written to `worker_ping.ip`, which the workers list displays so users can whitelist the address), so nothing needs to wait on it. It now resolves into a process-wide cache off the startup path, and `WORKER_EXTERNAL_IP` supplies it explicitly for deployments that know their egress address or have no egress at all. Until it resolves the ping carries no IP, which `insert_ping_query` now COALESCEs so a reclaimed row keeps the address the previous process wrote instead of being blanked. The main loop reports the IP as soon as it lands rather than on the next periodic tick, so a short-lived process still records it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep unknown worker IPs out of the whitelist alert Review follow-ups: - `WhitelistIp` filtered only the `'unretrievable IP'` sentinel, so the `'NO IP'` one a pending or failed lookup now leaves in the row would be offered as an address to whitelist. It filters both. - Register `WORKER_EXTERNAL_IP` in `ENV_SETTINGS` so operators can confirm from the instance settings view that it took effect. - The worker tracked whether it had reported the IP by re-reading the cache after each ping rather than remembering what the ping carried, so a lookup landing mid-ping marked it reported without it reaching the row. The value is read once and threaded through `insert_ping` / `update_worker_ping_full`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: report a sentinel IP once the lookup has definitively failed Keeping the previous process's address on a reclaimed `worker_ping` row is right while the lookup is still in flight, but not once it has failed: the row would advertise an address nothing has confirmed, and the whitelist alert would offer it. A failed lookup now reports `UNKNOWN_IP`, leaving NULL to mean "in flight". `WORKER_EXTERNAL_IP` is rejected when longer than the `varchar(50)` column rather than panicking the worker on its initial ping, which is a hard failure. Adds the regression guard for the `ON CONFLICT` semantics: reverting to `ip = EXCLUDED.ip` would compile and blank every reclaimed row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the agent initial ping acceptable to older servers An agent worker routinely runs against a server of a different version, and one predating the background lookup rejects an initial ping carrying no IP — which `run_worker` turns into a panic, so a newly upgraded agent would crash-loop against it. The not-resolved-yet case goes over the wire as the sentinel instead, and the server maps it back so a reclaimed row still keeps its address while resolution is pending. Also documents `ip` as the one conditional exception to `insert_ping_query`'s "only `started_at` and `jobs_executed` survive a restart", and adds `WORKER_EXTERNAL_IP` to the README env-var table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: deliver the resolved IP to servers that only take it at registration A server predating the background lookup applies `ip` from the initial ping only, and ignores it on the periodic ones. An agent registering before its lookup resolves would therefore keep the sentinel forever on such a server, where it used to report its real address. It registers a second time once the address is known, skipping that when the address is still unknown, when the server is reached over SQL and needs no second registration, or once a job has run, since registering clears the row's current job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: re-register the resolved IP even after a job has run Gating the second registration on "this process has not run a job yet" meant an agent that pulled queued work before its lookup resolved never delivered the address to a server that only takes one at registration. No job of the worker is in flight where that runs, so the gate bought nothing beyond the last job's id, which the next job refills. Documents the two cases where WORKER_EXTERNAL_IP stops being an optimisation and becomes the only way to report an address: an agent against such a server, and a process shorter-lived than the lookup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * revert: drop the WORKER_EXTERNAL_IP escape hatch Supplying the address by hand skips the hub lookup, which is not something to make easy. Resolving it in the background is what keeps it off the startup path; opting out of it is a separate decision this does not need to take. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: distinguish an IP never established from one that could not be retrieved `NO IP` was doing double duty: the column default for a row whose lookup has not resolved, and the marker for one that failed. An operator reading the workers list could not tell "not resolved yet" from "this instance cannot reach the hub", and the latter is the actionable one. A failed lookup now reports `unretrievable IP`, which is also what it reported before the lookup moved off the startup path. That leaves `NO IP` meaning only "no address established", which is what an agent sends while its lookup is in flight and what the server maps back to "unresolved" — so the wire sentinel no longer collides with the failure marker, and an agent delivers the failure to a server that only reads an IP at registration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
489 lines
16 KiB
Rust
489 lines
16 KiB
Rust
use backon::{BackoffBuilder, ConstantBuilder, Retryable};
|
|
use tracing::Instrument;
|
|
use uuid::Uuid;
|
|
use windmill_common::{
|
|
agent_workers::{PingJobStatus, PingJobStatusResponse},
|
|
cache,
|
|
external_ip::UNKNOWN_IP,
|
|
worker::{
|
|
get_memory, get_vcpus, get_windmill_memory_usage, get_worker_memory_usage,
|
|
insert_ping_query, update_job_ping_query, update_worker_ping_from_job_query,
|
|
update_worker_ping_main_loop_query, Connection, Ping, PingType, NATIVE_MODE_RESOLVED,
|
|
WORKER_CONFIG, WORKER_GROUP,
|
|
},
|
|
KillpillSender, DB,
|
|
};
|
|
|
|
use crate::{
|
|
agent_workers::UPDATE_PING_URL,
|
|
common::{OccupancyMetrics, OccupancyResult},
|
|
};
|
|
|
|
pub(crate) async fn update_worker_ping_full(
|
|
conn: &Connection,
|
|
read_cgroups: bool,
|
|
jobs_executed: i32,
|
|
worker_name: &str,
|
|
hostname: &str,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
killpill_tx: &KillpillSender,
|
|
ip: Option<&str>,
|
|
) {
|
|
let wc = WORKER_CONFIG.load();
|
|
let tags = wc.worker_tags.clone();
|
|
let native_mode = wc.native_mode;
|
|
drop(wc);
|
|
|
|
let memory_usage = get_worker_memory_usage();
|
|
let wm_memory_usage = get_windmill_memory_usage();
|
|
|
|
let (vcpus, memory) = if read_cgroups {
|
|
(get_vcpus(), get_memory())
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
let OccupancyResult {
|
|
occupancy_rate,
|
|
occupancy_rate_15s,
|
|
occupancy_rate_5m,
|
|
occupancy_rate_30m,
|
|
} = occupancy_metrics.update_occupancy_metrics();
|
|
|
|
let ping_start = std::time::Instant::now();
|
|
if let Err(e) = (|| {
|
|
update_worker_ping_full_inner(
|
|
conn,
|
|
jobs_executed,
|
|
&worker_name,
|
|
&tags,
|
|
memory_usage,
|
|
wm_memory_usage,
|
|
vcpus,
|
|
memory,
|
|
occupancy_rate,
|
|
occupancy_rate_15s,
|
|
occupancy_rate_5m,
|
|
occupancy_rate_30m,
|
|
native_mode,
|
|
ip,
|
|
)
|
|
})
|
|
.retry(
|
|
ConstantBuilder::default()
|
|
.with_delay(std::time::Duration::from_secs(2))
|
|
.with_max_times(10)
|
|
.build(),
|
|
)
|
|
.notify(|err, dur| {
|
|
tracing::error!(
|
|
worker = %worker_name, hostname = %hostname,
|
|
"retrying updating worker ping in {dur:#?}, err: {err:#?}"
|
|
);
|
|
})
|
|
.sleep(tokio::time::sleep)
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
worker = %worker_name, hostname = %hostname,
|
|
"failed to update worker ping, exiting: {}", e);
|
|
killpill_tx.send();
|
|
}
|
|
let db_latency_ms = ping_start.elapsed().as_millis();
|
|
tracing::info!(
|
|
worker = %worker_name, hostname = %hostname,
|
|
"ping update, memory: container={}MB, windmill={}MB, db_latency={}ms",
|
|
memory_usage.unwrap_or_default() / (1024 * 1024),
|
|
wm_memory_usage.unwrap_or_default() / (1024 * 1024),
|
|
db_latency_ms
|
|
);
|
|
}
|
|
|
|
async fn update_worker_ping_full_inner(
|
|
conn: &Connection,
|
|
jobs_executed: i32,
|
|
worker_name: &str,
|
|
tags: &[String],
|
|
memory_usage: Option<i64>,
|
|
wm_memory_usage: Option<i64>,
|
|
vcpus: Option<i64>,
|
|
memory: Option<i64>,
|
|
occupancy_rate: f32,
|
|
occupancy_rate_15s: Option<f32>,
|
|
occupancy_rate_5m: Option<f32>,
|
|
occupancy_rate_30m: Option<f32>,
|
|
native_mode: bool,
|
|
ip: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
match conn {
|
|
Connection::Sql(db) => {
|
|
update_worker_ping_main_loop_query(
|
|
worker_name,
|
|
tags,
|
|
vcpus,
|
|
memory,
|
|
Some(jobs_executed),
|
|
Some(occupancy_rate),
|
|
memory_usage,
|
|
wm_memory_usage,
|
|
occupancy_rate_15s,
|
|
occupancy_rate_5m,
|
|
occupancy_rate_30m,
|
|
native_mode,
|
|
ip,
|
|
db,
|
|
)
|
|
.await?;
|
|
}
|
|
Connection::Http(client) => {
|
|
client
|
|
.post::<_, ()>(
|
|
UPDATE_PING_URL,
|
|
None,
|
|
&Ping {
|
|
last_job_executed: None,
|
|
last_job_workspace_id: None,
|
|
worker_instance: None,
|
|
ip: ip.map(str::to_string),
|
|
tags: Some(tags.to_vec()),
|
|
dw: None,
|
|
dws: None,
|
|
jobs_executed: Some(jobs_executed),
|
|
occupancy_rate: Some(occupancy_rate),
|
|
occupancy_rate_15s: Some(occupancy_rate_15s.unwrap_or(0.0)),
|
|
occupancy_rate_5m: Some(occupancy_rate_5m.unwrap_or(0.0)),
|
|
occupancy_rate_30m: Some(occupancy_rate_30m.unwrap_or(0.0)),
|
|
version: None,
|
|
vcpus: vcpus,
|
|
memory: memory,
|
|
memory_usage: get_worker_memory_usage(),
|
|
wm_memory_usage: get_windmill_memory_usage(),
|
|
job_isolation: None,
|
|
native_mode: Some(native_mode),
|
|
ping_type: PingType::MainLoop,
|
|
},
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Registers the worker in `worker_ping` and returns the number of jobs already attributed
|
|
/// to that worker name, which is 0 unless this process reclaims the row of an earlier one.
|
|
pub async fn insert_ping(
|
|
worker_instance: &str,
|
|
worker_name: &str,
|
|
ip: Option<&str>,
|
|
db: &Connection,
|
|
) -> anyhow::Result<i32> {
|
|
let (tags, dw, dws, native_mode) = {
|
|
let wc = (**WORKER_CONFIG.load()).clone();
|
|
(
|
|
wc.worker_tags,
|
|
wc.dedicated_worker
|
|
.as_ref()
|
|
.map(|x| format!("{}:{}", x.workspace_id, x.path)),
|
|
wc.dedicated_workers.as_ref().map(|workers| {
|
|
workers
|
|
.iter()
|
|
.map(|x| format!("{}:{}", x.workspace_id, x.path))
|
|
.collect::<Vec<_>>()
|
|
}),
|
|
wc.native_mode,
|
|
)
|
|
};
|
|
|
|
let vcpus = get_vcpus();
|
|
let memory = get_memory();
|
|
|
|
let job_isolation = if crate::is_sandboxing_enabled() && crate::NSJAIL_AVAILABLE.is_some() {
|
|
Some("nsjail".to_string())
|
|
} else if crate::is_unshare_enabled() && crate::UNSHARE_PATH.is_some() {
|
|
Some("unshare".to_string())
|
|
} else {
|
|
Some("none".to_string())
|
|
};
|
|
|
|
match db {
|
|
Connection::Sql(db) => {
|
|
return insert_ping_query(
|
|
worker_instance,
|
|
worker_name,
|
|
WORKER_GROUP.as_str(),
|
|
ip,
|
|
tags.as_slice(),
|
|
dw,
|
|
dws.as_deref(),
|
|
windmill_common::utils::GIT_VERSION,
|
|
vcpus,
|
|
memory,
|
|
job_isolation,
|
|
native_mode,
|
|
db,
|
|
)
|
|
.await;
|
|
}
|
|
Connection::Http(client) => {
|
|
client
|
|
.post::<_, ()>(
|
|
UPDATE_PING_URL,
|
|
None,
|
|
&Ping {
|
|
last_job_executed: None,
|
|
last_job_workspace_id: None,
|
|
worker_instance: Some(worker_instance.to_string()),
|
|
// Servers older than the background lookup reject an initial ping with
|
|
// no IP, and an agent worker routinely runs against one, so the
|
|
// not-resolved-yet case goes over the wire as the sentinel.
|
|
ip: Some(ip.unwrap_or(UNKNOWN_IP).to_string()),
|
|
tags: Some(tags.to_vec()),
|
|
dw: dw,
|
|
dws: dws,
|
|
jobs_executed: None,
|
|
occupancy_rate: None,
|
|
occupancy_rate_15s: None,
|
|
occupancy_rate_5m: None,
|
|
occupancy_rate_30m: None,
|
|
version: Some(windmill_common::utils::GIT_VERSION.to_string()),
|
|
vcpus: vcpus,
|
|
memory: memory,
|
|
memory_usage: get_worker_memory_usage(),
|
|
wm_memory_usage: get_windmill_memory_usage(),
|
|
job_isolation,
|
|
native_mode: Some(native_mode),
|
|
ping_type: PingType::Initial,
|
|
},
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
// The agent ping endpoint answers with nothing, so an agent worker always starts its
|
|
// counter from zero.
|
|
Ok(0)
|
|
}
|
|
|
|
pub async fn update_worker_ping_from_job(
|
|
conn: &Connection,
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
worker_name: &str,
|
|
memory_usage: Option<i64>,
|
|
wm_memory_usage: Option<i64>,
|
|
occupancy: Option<OccupancyResult>,
|
|
) -> anyhow::Result<()> {
|
|
let occupancy_rate = occupancy.as_ref().map(|x| x.occupancy_rate);
|
|
let occupancy_rate_15s = occupancy.as_ref().and_then(|x| x.occupancy_rate_15s);
|
|
let occupancy_rate_5m = occupancy.as_ref().and_then(|x| x.occupancy_rate_5m);
|
|
let occupancy_rate_30m = occupancy.as_ref().and_then(|x| x.occupancy_rate_30m);
|
|
|
|
let job_isolation = if crate::is_sandboxing_enabled() && crate::NSJAIL_AVAILABLE.is_some() {
|
|
Some("nsjail".to_string())
|
|
} else if crate::is_unshare_enabled() && crate::UNSHARE_PATH.is_some() {
|
|
Some("unshare".to_string())
|
|
} else {
|
|
Some("none".to_string())
|
|
};
|
|
|
|
match conn.clone() {
|
|
Connection::Sql(ref db) => {
|
|
update_worker_ping_from_job_query(
|
|
job_id,
|
|
w_id,
|
|
worker_name,
|
|
memory_usage,
|
|
wm_memory_usage,
|
|
occupancy_rate,
|
|
occupancy_rate_15s,
|
|
occupancy_rate_5m,
|
|
occupancy_rate_30m,
|
|
job_isolation,
|
|
db,
|
|
)
|
|
.await?;
|
|
}
|
|
Connection::Http(client) => {
|
|
client
|
|
.post::<Ping, ()>(
|
|
UPDATE_PING_URL,
|
|
None,
|
|
&Ping {
|
|
last_job_executed: Some(job_id.clone()),
|
|
last_job_workspace_id: Some(w_id.to_string()),
|
|
ping_type: PingType::Job,
|
|
worker_instance: None,
|
|
ip: None,
|
|
tags: None,
|
|
dw: None,
|
|
dws: None,
|
|
version: None,
|
|
vcpus: None,
|
|
memory: None,
|
|
memory_usage: memory_usage,
|
|
wm_memory_usage: wm_memory_usage,
|
|
jobs_executed: None,
|
|
occupancy_rate: occupancy_rate,
|
|
occupancy_rate_15s: occupancy_rate_15s,
|
|
occupancy_rate_5m: occupancy_rate_5m,
|
|
occupancy_rate_30m: occupancy_rate_30m,
|
|
job_isolation,
|
|
native_mode: Some(
|
|
NATIVE_MODE_RESOLVED.load(std::sync::atomic::Ordering::Relaxed),
|
|
),
|
|
},
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn ping_job_status(
|
|
conn: &Connection,
|
|
job_id: &Uuid,
|
|
mem_peak: Option<i32>,
|
|
current_mem: Option<i32>,
|
|
) -> anyhow::Result<PingJobStatusResponse> {
|
|
match conn {
|
|
Connection::Sql(ref db) => update_job_ping_query(job_id, db, mem_peak).await,
|
|
Connection::Http(client) => {
|
|
client
|
|
.post(
|
|
&format!("/api/agent_workers/ping_job_status/{}", job_id),
|
|
None,
|
|
&PingJobStatus { mem_peak, current_mem },
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keeps the job's ping fresh during phases that run before the executor's own polling
|
|
/// loop starts (volume setup, s3object materialization, ...). Without it, a slow wait or
|
|
/// download with no ping in between can exceed ZOMBIE_JOB_TIMEOUT (default 60s) and get
|
|
/// the job falsely restarted as a zombie.
|
|
pub(crate) struct JobPingHeartbeat(tokio::task::JoinHandle<()>);
|
|
|
|
impl JobPingHeartbeat {
|
|
pub(crate) fn start(conn: &Connection, job_id: Uuid, context: &'static str) -> Self {
|
|
let conn = conn.clone();
|
|
JobPingHeartbeat(tokio::spawn(async move {
|
|
// 10s stays well under the 60s zombie timeout
|
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
|
|
loop {
|
|
interval.tick().await;
|
|
if let Err(e) = ping_job_status(&conn, &job_id, None, None).await {
|
|
tracing::warn!("failed to ping job {job_id} during {context}: {e}");
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl Drop for JobPingHeartbeat {
|
|
fn drop(&mut self) {
|
|
self.0.abort();
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn queue_vacuum(conn: &Connection, worker_name: &str, hostname: &str) {
|
|
match conn {
|
|
Connection::Sql(db) => {
|
|
let db2 = db.clone();
|
|
let current_span = tracing::Span::current();
|
|
let worker_name = worker_name.to_string();
|
|
let hostname = hostname.to_string();
|
|
windmill_common::log_context::spawn_with_log_context(async move {
|
|
async move {
|
|
tracing::info!(worker = %worker_name, hostname = %hostname, "vacuuming queue");
|
|
if let Err(e) = sqlx::query!("VACUUM (SKIP_LOCKED) v2_job_queue, v2_job_runtime, v2_job_status, job_perms")
|
|
.execute(&db2)
|
|
.await
|
|
{
|
|
tracing::error!(worker = %worker_name, hostname = %hostname, "failed to vacuum queue: {}", e);
|
|
}
|
|
tracing::info!(worker = %worker_name, hostname = %hostname, "vacuumed queue");
|
|
}
|
|
.instrument(current_span)
|
|
.await
|
|
});
|
|
}
|
|
Connection::Http(_) => {
|
|
// do nothing in http mode
|
|
()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
|
pub struct TagAndConcurrencyKey {
|
|
pub tag: Option<String>,
|
|
pub concurrency_key: Option<String>,
|
|
pub concurrent_limit: Option<i32>,
|
|
pub concurrency_time_window_s: Option<i32>,
|
|
pub version: Option<i64>,
|
|
}
|
|
|
|
pub async fn get_tag_and_concurrency(job_id: &Uuid, db: &DB) -> Option<TagAndConcurrencyKey> {
|
|
let r = sqlx::query_as!(
|
|
TagAndConcurrencyKey,
|
|
"
|
|
WITH j AS (
|
|
SELECT
|
|
raw_flow->>'concurrency_key' as concurrency_key,
|
|
raw_flow->>'concurrency_time_window_s' as concurrency_time_window_s,
|
|
raw_flow->>'concurrency_limit' as concurrent_limit,
|
|
runnable_path,
|
|
runnable_id as version FROM v2_job
|
|
WHERE id = $1
|
|
)
|
|
SELECT tag, j.concurrency_key, j.concurrency_time_window_s::int, j.concurrent_limit::int, j.version
|
|
FROM flow, j
|
|
WHERE path = j.runnable_path
|
|
",
|
|
job_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
if let Some(tag_and_concurrency_key) = r {
|
|
if tag_and_concurrency_key.concurrency_key.as_ref().is_some()
|
|
|| tag_and_concurrency_key.version.as_ref().is_none()
|
|
{
|
|
return Some(tag_and_concurrency_key);
|
|
} else {
|
|
let version = tag_and_concurrency_key.version.unwrap();
|
|
|
|
let r = cache::flow::fetch_version_lite(db, version).await;
|
|
let flow = match r {
|
|
Ok(data) => Ok(data),
|
|
Err(_) => cache::flow::fetch_version(db, version).await,
|
|
};
|
|
let flow_value = flow.map(|f| f.value().clone()).ok();
|
|
|
|
let concurrency_key = flow_value
|
|
.as_ref()
|
|
.and_then(|fv| fv.concurrency_settings.concurrency_key.to_owned());
|
|
|
|
let concurrent_limit = flow_value
|
|
.as_ref()
|
|
.and_then(|fv| fv.concurrency_settings.concurrent_limit);
|
|
|
|
let concurrent_time_window_s = flow_value
|
|
.as_ref()
|
|
.and_then(|fv| fv.concurrency_settings.concurrency_time_window_s);
|
|
|
|
Some(TagAndConcurrencyKey {
|
|
tag: tag_and_concurrency_key.tag,
|
|
concurrency_key,
|
|
concurrent_limit,
|
|
concurrency_time_window_s: concurrent_time_window_s,
|
|
version: None,
|
|
})
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|