Files
windmill/backend/windmill-api/src/db_health.rs
T
c2deea13b7 fix(security): a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr) (#10124)
* fix(security): a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr)

Privilege escalation: an app/flow/schedule/trigger execution policy's `on_behalf_of`
(which a `wm_deployers` member can set) could point at a superadmin email. The
resulting job `WM_TOKEN` then passed the email-based superadmin checks, granting
instance superadmin. `forbid_superadmin_job_token` only guarded ~15 of ~75 routes.

Fix at the token layer: a WM_TOKEN must never satisfy a superadmin gate,
regardless of whose email it runs as (sentinel OR a real superadmin).

- `ApiAuthed` gains a `job_id` field, stamped once in `AuthCache::get_opt_job_authed`
  from the resolved token's job_id (correct even on cache hits).
- `require_super_admin(db, email)` -> `require_super_admin(db, &ApiAuthed)`, rejects
  `authed.job_id.is_some()`. `require_super_admin_email` kept for the few internal
  callers without an ApiAuthed.
- `is_super_admin_authed(db, &ApiAuthed)` for the boolean `is_super_admin_email`
  authorization branches on request handlers (workspace deletion, fork drops,
  dev-workspace attach/archive, object-storage SSRF exemption, custom dbname, EE GHES
  + connected repositories, ...). Migrate ~75 sites (OSS + EE).
- CUSTOM_INSTANCE_DB reads the *authenticated* job_id, not the caller-supplied
  `?job_id` query param. Worker-tag check takes a precomputed job-aware `is_super_admin`
  on the request path.

Execution-time on-behalf checks (scheduled/flow worker-tag, Cloud enqueue quota,
is_devops_email) are hardened in a follow-up — see
docs/followup-onbehalf-execution-privilege-hardening.md.

Regression tests: a superadmin-email WM_TOKEN is rejected on `require_super_admin`
routes, on `DELETE /workspaces/delete/{w}` (403, workspace preserved), and on the
CUSTOM_INSTANCE_DB lookup with no `?job_id` (401); real superadmin tokens still succeed.

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

* fix: cap devops role at workspace admin and reject reserved on_behalf_of identities

Extends the job-token cap with three pieces:

- `require_devops_role` takes `&ApiAuthed` and rejects job tokens.
  `is_devops_email` is true for superadmin emails, so every worker-management,
  instance-config and service-log route was reachable by the same superadmin
  `WM_TOKEN` that `require_super_admin` already rejects.
- A `job_id` claim that does not parse as a uuid rejects the token rather than
  resolving to `None`, which would clear the job provenance and uncap it. Applies
  to the internal JWT and the external `jwt_ext_` path.
- Defense in depth at store time: `validate_on_behalf_of` refuses the reserved
  internal sentinels as an `on_behalf_of` on apps/flows/scripts/schedules/triggers,
  and app execution refuses a policy carrying one — covering already-persisted and
  forked-app rows that predate the cap. Deploying on behalf of a real user,
  including a real superadmin, stays allowed; the cap handles that at execution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(mcp): preserve job-token provenance when minting the proxy JWT

The MCP endpoint-tool proxy re-mints a JWT from the caller's ApiAuthed to
forward the proxied request, but passed job_id: None. A job's WM_TOKEN is
capped at workspace admin (GHSA-hfh4-cx4h-3fcr); dropping the job_id here
re-minted an uncapped token that satisfies require_super_admin /
require_devops_role on the proxied route (e.g. listWorkers exposing worker
IPs, job/workspace IDs, and sensitive tags).

Carry api_authed.job_id into create_jwt_token. Adds an in-module regression
that decodes the forwarded JWT and asserts the job_id is preserved for a job
caller and absent for a non-job caller.

Reported by Codex CI review (P1) on #10124.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: cap the admin-or-devops gate at workspace admin for job tokens

require_admin_or_devops (the EE critical-alerts endpoints) grants when the
caller is a workspace admin OR an instance devops. is_devops_email is true
for superadmins, so a WM_TOKEN running on-behalf of a superadmin who is not a
member of the target workspace could clear the devops branch and read/ack that
workspace's critical alerts (GHSA-hfh4-cx4h-3fcr). This gate takes a bare
email, not an ApiAuthed, so the token-layer cap could not see it.

Thread the caller's job-token provenance and reject the devops branch for job
tokens, matching require_devops_role. The workspace-admin branch stays allowed
— that is the cap ceiling. Adds an enterprise-gated regression proving the
bypass is closed and a real superadmin token still clears the gate.

Found while auditing the PR for bare-email gates the choke-point cap misses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: cap instance-global is_admin gates at workspace admin for job tokens

Three instance-global routes gate on the caller's own `is_admin` claim, which
`ApiAuthed.is_admin` carries into a WM_TOKEN (it is a workspace-admin claim,
true for superadmins too). A job token is capped at workspace admin
(GHSA-hfh4-cx4h-3fcr), so its is_admin claim must not authorize instance
actions on a route with no workspace binding:

- `unarchive_workspace` — unarchive an arbitrary workspace by id
- `prune_concurrency_group` — delete a global concurrency group
- `list_worker_groups` — return unobfuscated `env_vars_static` (may hold secrets)

Add job-token-aware `is_instance_admin` / `require_instance_admin` helpers (the
same shape as `require_super_admin` / `require_devops_role`) and use them at
these three sites. Workspace-scoped `require_admin(authed.is_admin, ...)` gates
are intentionally left unchanged — a workspace-admin job token is within the
cap there. Regression added covering all three; verified it lets a WM_TOKEN
unarchive/leak without the fix and is blocked with it.

Reported by Codex CI review (P1) on #10124.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(mcp): drop orphaned path_field_renames from EndpointTool test helper

The merge with main adopted main's mcp path-substitution refactor (#10162),
which removed the `path_field_renames` field from `EndpointTool` and its
consumer (`substitute_path_params` no longer takes per-field path renames).
main's `runner.rs` `ep` test helper still constructed the struct with
`path_field_renames: None`, so the workspace test build (cargo test --all,
which compiles windmill-mcp's own #[cfg(test)] module under the `server`
feature) failed with E0560. A plain `cargo check` does not compile that test
module, so it only surfaced in CI's cargo_test.

Remove the orphaned field to match the struct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: describe the sentinel-rejection policy the forged-identity test asserts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: complete ApiAuthed initializers in feature-gated tests after merge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: stop job tokens minting credentials that shed their provenance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: cap the MCP OAuth approval mint at the same elevated-job-token gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: cap the self-service password reset at the elevated-job-token gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: cap app embed/SDK mints and scope widening at the elevated-job-token gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: keep job tokens from destroying the account they run on behalf of

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: deny job tokens a foreign-workspace admin claim and workspace ejection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: keep the follow-up inventory in the PR instead of the repo

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: make the session workspace status gate job-token aware

session_workspace_status derived its superadmin branch from a bare email
check, so a job token carrying a superadmin identity resolved the existence
of workspaces it has no relationship with rather than seeing them as
deleted. Switch to is_super_admin_authed, matching every other instance
gate reached from a request ApiAuthed.

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

* revert: leave the global concurrency-group listing on the plain admin gate

The listing exposes concurrency keys across workspaces, which is metadata
rather than a capability, and it 401s rather than degrading. Keep the guard
on the prune route next to it, which is the destructive one.

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

* fix: keep the instance-admin gate on the global concurrency listing

The listing spans every workspace's concurrency keys, and the gate rejects
only job tokens: the !is_admin branch is the pre-existing check, so
workspaced tokens and interactive admins are unaffected.

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

* chore: update ee-repo-ref to d30af67d38954f9012f7bad08da23e347344b4c6

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

Previous ee-repo-ref: 7870573dbc3360f99bada143f094c67dce0d9e9c

New ee-repo-ref: d30af67d38954f9012f7bad08da23e347344b4c6

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-19 22:33:46 +02:00

988 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Author: Windmill Labs
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use axum::{
extract::Query,
http::StatusCode,
routing::{get, post},
Extension, Json, Router,
};
use serde::{Deserialize, Serialize};
use windmill_common::error::JsonResult;
use windmill_common::{JOB_RETENTION_SECS_OVERRIDES, JOB_RETENTION_SECS_OVERRIDES_LOADED};
use crate::db::{ApiAuthed, DB};
use crate::utils::require_super_admin;
pub fn global_service() -> Router {
Router::new()
.route("/", get(get_db_health))
.route("/jobs", get(get_db_health_jobs))
.route("/slow_queries", get(get_slow_queries))
.route("/slow_queries/reset", post(reset_slow_queries))
}
// --- Response types ---
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
pub enum HealthLevel {
Green,
Yellow,
Red,
}
#[derive(Serialize)]
pub struct DbHealthResponse {
pub database_size: DatabaseSizeInfo,
pub connection_pool: ConnectionPoolInfo,
pub table_maintenance: Vec<TableMaintenanceInfo>,
pub slow_queries: Option<SlowQueriesInfo>,
pub datatables: Vec<DatatableInfo>,
}
#[derive(Serialize)]
pub struct DbHealthJobsResponse {
pub job_retention: JobRetentionInfo,
pub large_results: LargeResultsInfo,
}
#[derive(Serialize)]
pub struct DatabaseSizeInfo {
pub total_size_bytes: i64,
pub total_size_pretty: String,
pub top_tables: Vec<TableSizeInfo>,
}
#[derive(Serialize)]
pub struct TableSizeInfo {
pub table_name: String,
pub total_size_bytes: i64,
pub total_size_pretty: String,
}
#[derive(Serialize)]
pub struct JobRetentionInfo {
pub oldest_completed_at: Option<chrono::DateTime<chrono::Utc>>,
pub total_completed_jobs: i64,
pub retention_period_secs: Option<i64>,
pub status: HealthLevel,
pub message: String,
}
#[derive(Serialize)]
pub struct LargeResultsInfo {
pub top_large_results: Vec<LargeResultRow>,
pub avg_result_size_bytes: Option<i64>,
}
#[derive(Serialize)]
pub struct LargeResultRow {
pub id: uuid::Uuid,
pub workspace_id: String,
pub runnable_path: Option<String>,
pub result_size_bytes: i64,
pub completed_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Serialize)]
pub struct ConnectionPoolInfo {
pub pg_max_connections: i64,
pub pg_total_connections: i64,
pub pg_active_connections: i64,
pub pg_idle_connections: i64,
pub pg_superuser_reserved_connections: i64,
pub status: HealthLevel,
pub message: String,
/// Connection sizing guidance derived from the live Windmill fleet.
pub sizing: ConnectionSizingInfo,
}
#[derive(Serialize)]
pub struct ConnectionSizingInfo {
/// Live DB-connected worker processes (distinct worker_instance pinged recently).
pub live_worker_instances: i64,
/// Live individual DB-connected workers across all instances.
pub live_workers: i64,
/// Live agent workers (HTTP-only, hold no postgres connections; excluded from the estimate).
pub live_agent_workers: i64,
/// Effective per-server pool ceiling: DATABASE_CONNECTIONS if set, else DEFAULT_MAX_CONNECTIONS_SERVER.
pub server_pool_size: i64,
/// Effective per-worker-instance pool ceiling (single-worker baseline; grows +1 per extra worker unless DATABASE_CONNECTIONS is set).
pub worker_pool_size: i64,
/// The DATABASE_CONNECTIONS override, if this server has one set (caps every process's pool).
pub database_connections_override: Option<i64>,
/// Estimated peak connections opened by all live worker instances.
pub estimated_worker_connections: i64,
/// Recommended max_connections floor (workers + one server + headroom).
pub recommended_max_connections: i64,
/// Per-additional-server increment to add to the recommendation.
pub per_server_increment: i64,
/// Human-readable sizing explanation.
pub message: String,
}
#[derive(Serialize)]
pub struct TableMaintenanceInfo {
pub table_name: String,
pub live_tuples: i64,
pub dead_tuples: i64,
pub dead_ratio: f64,
pub last_autovacuum: Option<chrono::NaiveDateTime>,
pub last_autoanalyze: Option<chrono::NaiveDateTime>,
pub status: HealthLevel,
}
#[derive(Serialize)]
pub struct SlowQueriesInfo {
pub queries: Vec<SlowQueryRow>,
pub message: Option<String>,
/// When stats were last reset (from pg_stat_statements_info.stats_reset, PG 14+)
pub stats_reset: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
pub struct SlowQueryRow {
pub query: String,
pub calls: i64,
pub total_exec_time_ms: f64,
pub mean_exec_time_ms: f64,
}
#[derive(Serialize)]
pub struct DatatableInfo {
pub workspace_id: String,
pub name: String,
pub table_name: String,
pub size_bytes: i64,
pub size_pretty: String,
pub estimated_rows: f64,
}
// --- Handler ---
#[derive(Deserialize)]
struct DbHealthQuery {
/// Max number of recent completed jobs to scan for large results (default 10000)
scan_limit: Option<i64>,
}
#[derive(Deserialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
enum SlowQuerySort {
Total,
Mean,
Calls,
}
impl SlowQuerySort {
fn order_by(&self) -> &'static str {
match self {
SlowQuerySort::Total => "total_exec_time DESC",
SlowQuerySort::Mean => "mean_exec_time DESC",
SlowQuerySort::Calls => "calls DESC",
}
}
}
#[derive(Deserialize)]
struct SlowQueriesQuery {
sort: Option<SlowQuerySort>,
}
async fn get_db_health(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<DbHealthResponse> {
require_super_admin(&db, &authed).await?;
let (database_size, connection_pool, table_maintenance, slow_queries, datatables) = tokio::try_join!(
fetch_database_size(&db),
fetch_connection_pool(&db),
fetch_table_maintenance(&db),
fetch_slow_queries(&db, SlowQuerySort::Total),
fetch_datatables(&db),
)?;
Ok(Json(DbHealthResponse {
database_size,
connection_pool,
table_maintenance,
slow_queries,
datatables,
}))
}
async fn get_db_health_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<DbHealthQuery>,
) -> JsonResult<DbHealthJobsResponse> {
require_super_admin(&db, &authed).await?;
let scan_limit = query.scan_limit.unwrap_or(10_000).clamp(1_000, 1_000_000);
let (job_retention, large_results) = tokio::try_join!(
fetch_job_retention(&db),
fetch_large_results(&db, scan_limit),
)?;
Ok(Json(DbHealthJobsResponse { job_retention, large_results }))
}
async fn get_slow_queries(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<SlowQueriesQuery>,
) -> JsonResult<Option<SlowQueriesInfo>> {
require_super_admin(&db, &authed).await?;
let sort = query.sort.unwrap_or(SlowQuerySort::Total);
Ok(Json(fetch_slow_queries(&db, sort).await?))
}
async fn reset_slow_queries(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> windmill_common::error::Result<StatusCode> {
require_super_admin(&db, &authed).await?;
sqlx::query("SELECT pg_stat_statements_reset()")
.execute(&db)
.await
.map_err(|e| {
windmill_common::error::Error::InternalErr(format!(
"Failed to reset pg_stat_statements (is the extension enabled?): {e}"
))
})?;
Ok(StatusCode::NO_CONTENT)
}
// --- Diagnostic queries ---
async fn fetch_database_size(db: &DB) -> windmill_common::error::Result<DatabaseSizeInfo> {
let row = sqlx::query!(
"SELECT pg_database_size(current_database()) as size_bytes, pg_size_pretty(pg_database_size(current_database())) as size_pretty"
)
.fetch_one(db)
.await?;
let top_tables = sqlx::query_as!(
TableSizeInfo,
r#"SELECT
schemaname || '.' || relname as "table_name!",
pg_total_relation_size(relid) as "total_size_bytes!",
pg_size_pretty(pg_total_relation_size(relid)) as "total_size_pretty!"
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 15"#
)
.fetch_all(db)
.await?;
Ok(DatabaseSizeInfo {
total_size_bytes: row.size_bytes.unwrap_or(0),
total_size_pretty: row.size_pretty.unwrap_or_default(),
top_tables,
})
}
async fn fetch_job_retention(db: &DB) -> windmill_common::error::Result<JobRetentionInfo> {
// Per-workspace retention overrides (EE) make "oldest completed job vs the instance retention"
// wrong as a single global signal: each override workspace has its own effective window, so its
// intentionally-retained jobs must be judged against that window — not the instance one. We
// therefore compute the ratio per scope and report the worst:
// - global scope: oldest job across all non-override workspaces vs the instance retention;
// - each override workspace with a *positive* window: its own oldest job vs its own window;
// - keep-forever (0) overrides: excluded entirely — their jobs are retained forever by design,
// so there is no window to fall behind on.
// The majority (no-override) path keeps the original index-driven `MIN(completed_at)` with no
// performance change; the override paths use the completed_at / (workspace_id, completed_at)
// indexes and only run when overrides exist.
let overrides = JOB_RETENTION_SECS_OVERRIDES.load_full();
let overrides_active = !overrides.is_empty()
&& JOB_RETENTION_SECS_OVERRIDES_LOADED.load(std::sync::atomic::Ordering::Relaxed);
// `true_oldest` is the real table minimum across every workspace — reported verbatim in the
// public `oldest_completed_at` field so the UI's "Oldest job" label stays honest. `global_oldest`
// excludes override workspaces and drives only the global health ratio (override workspaces are
// judged against their own window below).
type OptTs = Option<chrono::DateTime<chrono::Utc>>;
let (true_oldest, global_oldest, total): (OptTs, OptTs, i64) = if !overrides_active {
let r = sqlx::query!(
"SELECT MIN(completed_at) as oldest, COUNT(*) as total FROM v2_job_completed"
)
.fetch_one(db)
.await?;
(r.oldest, r.oldest, r.total.unwrap_or(0))
} else {
// `MIN(...) WHERE workspace_id <> ALL(...)` is still driven by the completed_at index
// (ascending scan, early-stop at the first non-override row); the plain `MIN(...)` is an
// index-only scan. Both are cheap.
let override_ids: Vec<String> = overrides.keys().cloned().collect();
let r = sqlx::query!(
"SELECT
(SELECT MIN(completed_at) FROM v2_job_completed) as true_oldest,
(SELECT MIN(completed_at) FROM v2_job_completed
WHERE workspace_id <> ALL($1::text[])) as global_oldest,
(SELECT COUNT(*) FROM v2_job_completed) as total",
&override_ids,
)
.fetch_one(db)
.await?;
(r.true_oldest, r.global_oldest, r.total.unwrap_or(0))
};
let retention_row =
sqlx::query!("SELECT value FROM global_settings WHERE name = 'retention_period_secs'")
.fetch_optional(db)
.await?;
let retention_period_secs: Option<i64> =
retention_row.map(|r| r.value).and_then(|v| v.as_i64());
// Oldest job per positive-window override workspace (one grouped seek on the
// `(workspace_id, completed_at)` index; only workspaces that actually have rows come back).
let positive_override_ids: Vec<String> = if overrides_active {
overrides
.iter()
.filter(|(_, &secs)| secs > 0)
.map(|(ws, _)| ws.clone())
.collect()
} else {
Vec::new()
};
let mut per_workspace_oldest: Vec<(String, chrono::DateTime<chrono::Utc>)> = Vec::new();
if !positive_override_ids.is_empty() {
let rows = sqlx::query!(
"SELECT workspace_id as \"workspace_id!\", MIN(completed_at) as oldest
FROM v2_job_completed
WHERE workspace_id = ANY($1::text[])
GROUP BY workspace_id",
&positive_override_ids,
)
.fetch_all(db)
.await?;
for r in rows {
if let Some(oldest) = r.oldest {
per_workspace_oldest.push((r.workspace_id, oldest));
}
}
}
// Evaluate each scope independently and report the WORST. Each scope contributes a candidate
// (severity, level, message); the max-severity candidate wins. This keeps the global scope's
// "no retention configured" warning visible even when a healthy override would otherwise mask it.
let now = chrono::Utc::now();
let ratio_status = |scope: String, ratio: f64| -> (u8, HealthLevel, String) {
if ratio <= 2.0 {
(
0,
HealthLevel::Green,
format!("{scope} is {ratio:.1}x the retention period. Cleanup is keeping up."),
)
} else if ratio <= 5.0 {
(
1,
HealthLevel::Yellow,
format!(
"{scope} is {ratio:.1}x the retention period. Cleanup may be falling behind."
),
)
} else {
(2, HealthLevel::Red, format!("{scope} is {ratio:.1}x the retention period. Consider reducing retention or investigating cleanup."))
}
};
let mut candidates: Vec<(u8, HealthLevel, String)> = Vec::new();
// Global scope: judged against the instance retention, or flagged when non-override jobs exist
// (`global_oldest` is `Some`) but no positive instance retention is configured. A `0` instance
// retention means keep-forever globally, so it contributes no candidate.
match (global_oldest, retention_period_secs) {
(Some(oldest_ts), Some(retention_secs)) if retention_secs > 0 => {
let ratio = (now - oldest_ts).num_seconds() as f64 / retention_secs as f64;
candidates.push(ratio_status("Oldest job".to_string(), ratio));
}
(Some(_), None) => candidates.push((
1,
HealthLevel::Yellow,
"No retention_period_secs configured. Old jobs will accumulate.".to_string(),
)),
_ => {}
}
// Each positive-window override, judged against its own window.
for (ws, oldest_ts) in &per_workspace_oldest {
if let Some(&window) = overrides.get(ws) {
if window > 0 {
let ratio = (now - *oldest_ts).num_seconds() as f64 / window as f64;
candidates.push(ratio_status(format!("Workspace {ws} oldest job"), ratio));
}
}
}
let (status, message) = if let Some((_, level, message)) = candidates
.into_iter()
.max_by_key(|(severity, _, _)| *severity)
{
(level, message)
} else if total > 0 {
// Jobs exist but none produced a candidate: every completed job lives in a keep-forever
// scope (instance or override), so it is retained by design rather than overdue.
(
HealthLevel::Green,
"Completed jobs are within their configured retention windows.".to_string(),
)
} else {
(HealthLevel::Green, "No completed jobs found.".to_string())
};
Ok(JobRetentionInfo {
oldest_completed_at: true_oldest,
total_completed_jobs: total,
retention_period_secs,
status,
message,
})
}
async fn fetch_large_results(
db: &DB,
scan_limit: i64,
) -> windmill_common::error::Result<LargeResultsInfo> {
let top_large_results = sqlx::query_as!(
LargeResultRow,
r#"SELECT
c.id as "id!",
c.workspace_id as "workspace_id!",
j.runnable_path as "runnable_path",
pg_column_size(c.result) as "result_size_bytes!",
c.completed_at as "completed_at!"
FROM (
SELECT id, workspace_id, result, completed_at
FROM v2_job_completed
WHERE completed_at > now() - interval '30 days'
AND result IS NOT NULL
ORDER BY completed_at DESC
LIMIT $1
) c
LEFT JOIN v2_job j ON j.id = c.id
WHERE pg_column_size(c.result) > 1024
ORDER BY pg_column_size(c.result) DESC
LIMIT 10"#,
scan_limit
)
.fetch_all(db)
.await?;
let avg_row = sqlx::query!(
r#"SELECT AVG(pg_column_size(result))::bigint as "avg_size"
FROM (
SELECT result FROM v2_job_completed
WHERE completed_at > now() - interval '30 days'
AND result IS NOT NULL
ORDER BY completed_at DESC
LIMIT $1
) sub"#,
scan_limit
)
.fetch_one(db)
.await?;
Ok(LargeResultsInfo { top_large_results, avg_result_size_bytes: avg_row.avg_size })
}
async fn fetch_connection_pool(db: &DB) -> windmill_common::error::Result<ConnectionPoolInfo> {
let max_row = sqlx::query_scalar!(
r#"SELECT setting::bigint as "max!" FROM pg_settings WHERE name = 'max_connections'"#
)
.fetch_one(db)
.await?;
let reserved = sqlx::query_scalar!(
r#"SELECT setting::bigint as "v!" FROM pg_settings WHERE name = 'superuser_reserved_connections'"#
)
.fetch_one(db)
.await
.unwrap_or(0);
let stats_row = sqlx::query!(
r#"SELECT
COUNT(*) as "total!",
COUNT(*) FILTER (WHERE state = 'active') as "active!",
COUNT(*) FILTER (WHERE state = 'idle') as "idle!"
FROM pg_stat_activity
WHERE backend_type = 'client backend'"#
)
.fetch_one(db)
.await?;
// Live Windmill worker fleet: each worker pings worker_ping every ~5s; a
// window of 30s tolerates a missed ping without counting dead workers.
// Agent workers (name prefix "ag-") talk to the API over HTTP and hold no
// postgres pool, so they're excluded from the connection estimate and only
// reported for context; regular DB-connected workers use the "wk-" prefix.
let db_worker_pattern = format!("{}-%", windmill_common::utils::WORKER_NAME_PREFIX);
let agent_worker_pattern = format!("{}-%", windmill_common::utils::AGENT_WORKER_NAME_PREFIX);
let fleet = sqlx::query!(
r#"SELECT
COUNT(*) FILTER (WHERE worker LIKE $1) as "live_workers!",
COUNT(DISTINCT worker_instance) FILTER (WHERE worker LIKE $1) as "live_instances!",
COUNT(*) FILTER (WHERE worker LIKE $2) as "live_agent_workers!"
FROM worker_ping
WHERE ping_at > now() - interval '30 seconds'"#,
db_worker_pattern,
agent_worker_pattern,
)
.fetch_one(db)
.await?;
// Matches how db_connect.rs reads it: when DATABASE_CONNECTIONS is set it caps
// every process's pool (server, indexer, worker) regardless of worker count.
let database_connections_override = std::env::var("DATABASE_CONNECTIONS")
.ok()
.and_then(|n| n.parse::<i64>().ok())
.filter(|n| *n > 0);
let sizing = compute_connection_sizing(
fleet.live_workers,
fleet.live_instances,
fleet.live_agent_workers,
reserved,
database_connections_override,
);
let pg_max = max_row;
let pg_total = stats_row.total;
let pg_active = stats_row.active;
let pg_idle = stats_row.idle;
let utilization = if pg_max > 0 {
pg_total as f64 / pg_max as f64
} else {
0.0
};
let (status, message) = if utilization < 0.8 {
(
HealthLevel::Green,
format!(
"Connection utilization: {:.0}% ({}/{})",
utilization * 100.0,
pg_total,
pg_max
),
)
} else if utilization < 0.95 {
(
HealthLevel::Yellow,
format!(
"Connection utilization is high: {:.0}% ({}/{}). Consider increasing max_connections.",
utilization * 100.0,
pg_total,
pg_max
),
)
} else {
(
HealthLevel::Red,
format!(
"Connections near exhaustion: {:.0}% ({}/{}). Increase max_connections urgently.",
utilization * 100.0,
pg_total,
pg_max
),
)
};
Ok(ConnectionPoolInfo {
pg_max_connections: pg_max,
pg_total_connections: pg_total,
pg_active_connections: pg_active,
pg_idle_connections: pg_idle,
pg_superuser_reserved_connections: reserved,
status,
message,
sizing,
})
}
/// Estimate how many postgres connections the live Windmill fleet can open and
/// derive a recommended `max_connections` floor.
///
/// Pool sizing mirrors `db_connect.rs`: when `DATABASE_CONNECTIONS` is set it
/// caps *every* process's pool (server, indexer, worker) at that value, so each
/// worker instance opens up to that many connections. Otherwise each worker
/// instance shares a pool of `DEFAULT_MAX_CONNECTIONS_WORKER + (workers - 1)`
/// (fleet ceiling `(worker_pool - 1) * instances + workers`) and each server
/// opens up to `DEFAULT_MAX_CONNECTIONS_SERVER`.
///
/// `database_connections_override` is this server's `DATABASE_CONNECTIONS`, our
/// best proxy for the fleet's config. Servers do not ping `worker_ping`, so we
/// can't count them — the recommendation assumes one server and exposes the
/// per-server increment so the operator can add capacity for the rest.
///
/// `live_agent_workers` is reported for context only: agent workers reach the
/// API over HTTP and open no postgres connections, so they never contribute to
/// the estimate.
fn compute_connection_sizing(
live_workers: i64,
live_instances: i64,
live_agent_workers: i64,
reserved: i64,
database_connections_override: Option<i64>,
) -> ConnectionSizingInfo {
// Never recommend below this floor: postgres defaults to 100 and headroom
// for growth/bursts/psql is cheap, so 200 is a safe baseline for any fleet.
const MIN_RECOMMENDED_MAX_CONNECTIONS: i64 = 200;
let default_server_pool = windmill_common::DEFAULT_MAX_CONNECTIONS_SERVER as i64;
let default_worker_pool = windmill_common::DEFAULT_MAX_CONNECTIONS_WORKER as i64;
let (server_pool, worker_pool_size, estimated_worker_connections) =
match database_connections_override {
// Override caps every process identically; per-instance pool is the override.
Some(n) => (n, n, n * live_instances),
None => (
default_server_pool,
default_worker_pool,
(default_worker_pool - 1) * live_instances + live_workers,
),
};
// Workers + one server, plus 20% headroom and the superuser reserve, so the
// recommendation leaves room for psql/monitoring sessions and bursts, then
// floored at MIN_RECOMMENDED_MAX_CONNECTIONS.
let base = estimated_worker_connections + server_pool;
let recommended = ((((base as f64) * 1.20).ceil() as i64) + reserved.max(3))
.max(MIN_RECOMMENDED_MAX_CONNECTIONS);
let pool_source = if database_connections_override.is_some() {
format!("DATABASE_CONNECTIONS={server_pool}")
} else {
"defaults, configurable via DATABASE_CONNECTIONS".to_string()
};
let message = if live_instances == 0 {
format!(
"No live workers detected. Each Windmill server and worker instance opens up to {server_pool} connections ({pool_source}). Size max_connections as (servers + worker instances) × {server_pool} + ~20% headroom, and at least {MIN_RECOMMENDED_MAX_CONNECTIONS}."
)
} else {
let per_instance = if database_connections_override.is_some() {
format!("each instance up to {worker_pool_size}")
} else {
format!("each instance up to {worker_pool_size}, +1 per extra worker")
};
let agent_note = if live_agent_workers > 0 {
format!(
" ({live_agent_workers} agent worker(s) excluded — they use HTTP, not postgres connections.)"
)
} else {
String::new()
};
format!(
"{live_workers} live worker(s) across {live_instances} instance(s) can open up to ~{estimated_worker_connections} connections ({per_instance}; {pool_source}). Each Windmill server adds up to {server_pool}. Recommended max_connections ≥ {recommended} for a single server; add {server_pool} per additional server.{agent_note}"
)
};
ConnectionSizingInfo {
live_worker_instances: live_instances,
live_workers,
live_agent_workers,
server_pool_size: server_pool,
worker_pool_size,
database_connections_override,
estimated_worker_connections,
recommended_max_connections: recommended,
per_server_increment: server_pool,
message,
}
}
async fn fetch_table_maintenance(
db: &DB,
) -> windmill_common::error::Result<Vec<TableMaintenanceInfo>> {
// Aggregate partitioned tables (e.g. audit_YYYYMMDD -> audit_partitioned)
// while keeping non-partitioned tables as-is
let rows = sqlx::query!(
r#"SELECT
table_name as "table_name!",
SUM(live_tuples)::bigint as "live_tuples!",
SUM(dead_tuples)::bigint as "dead_tuples!",
MAX(last_autovacuum) as "last_autovacuum",
MAX(last_autoanalyze) as "last_autoanalyze"
FROM (
SELECT
CASE
WHEN i.inhparent IS NOT NULL THEN schemaname || '.' || p.relname
ELSE schemaname || '.' || s.relname
END as table_name,
COALESCE(n_live_tup, 0) as live_tuples,
COALESCE(n_dead_tup, 0) as dead_tuples,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables s
LEFT JOIN pg_class c ON c.relname = s.relname AND c.relnamespace = (
SELECT oid FROM pg_namespace WHERE nspname = s.schemaname
)
LEFT JOIN pg_inherits i ON i.inhrelid = c.oid
LEFT JOIN pg_class p ON p.oid = i.inhparent
) sub
GROUP BY table_name
HAVING SUM(live_tuples) + SUM(dead_tuples) >= 1000
ORDER BY SUM(dead_tuples) DESC"#
)
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| {
let total = r.live_tuples + r.dead_tuples;
let dead_ratio = if total > 0 {
r.dead_tuples as f64 / total as f64
} else {
0.0
};
let status = if dead_ratio < 0.1 {
HealthLevel::Green
} else if dead_ratio < 0.3 {
HealthLevel::Yellow
} else {
HealthLevel::Red
};
TableMaintenanceInfo {
table_name: r.table_name,
live_tuples: r.live_tuples,
dead_tuples: r.dead_tuples,
dead_ratio,
last_autovacuum: r.last_autovacuum.map(|t| t.naive_utc()),
last_autoanalyze: r.last_autoanalyze.map(|t| t.naive_utc()),
status,
}
})
.collect())
}
async fn fetch_slow_queries(
db: &DB,
sort: SlowQuerySort,
) -> windmill_common::error::Result<Option<SlowQueriesInfo>> {
let ext_exists: bool = sqlx::query_scalar!(
r#"SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') as "exists!""#
)
.fetch_one(db)
.await?;
if !ext_exists {
return Ok(Some(SlowQueriesInfo {
queries: vec![],
message: Some(
"pg_stat_statements extension is not installed. Enable it for slow query insights."
.to_string(),
),
stats_reset: None,
}));
}
// Use raw query since pg_stat_statements may not exist at compile time.
// ORDER BY column is controlled via an enum (SlowQuerySort) so only safe
// whitelisted column names reach the query — no SQL injection risk.
let query = format!(
r#"SELECT
LEFT(query, 500),
calls::bigint,
total_exec_time::float8,
mean_exec_time::float8
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY {}
LIMIT 50"#,
sort.order_by()
);
let rows: Vec<SlowQueryRow> = sqlx::query_as::<_, (String, i64, f64, f64)>(&query)
.fetch_all(db)
.await?
.into_iter()
.map(
|(query, calls, total_exec_time_ms, mean_exec_time_ms)| SlowQueryRow {
query,
calls,
total_exec_time_ms,
mean_exec_time_ms,
},
)
.collect();
// pg_stat_statements_info exists in PG 14+; tolerate its absence
let stats_reset: Option<chrono::DateTime<chrono::Utc>> =
sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
"SELECT stats_reset FROM pg_stat_statements_info",
)
.fetch_one(db)
.await
.ok()
.flatten();
Ok(Some(SlowQueriesInfo {
queries: rows,
message: None,
stats_reset,
}))
}
async fn fetch_datatables(db: &DB) -> windmill_common::error::Result<Vec<DatatableInfo>> {
// Find instance-type datatables from workspace_settings
let rows = sqlx::query!(
r#"SELECT
ws.workspace_id as "workspace_id!",
dt.key as "name!",
dt.value->>'table_name' as "table_name"
FROM workspace_settings ws,
jsonb_each(ws.datatable) dt
WHERE dt.value->>'resource_type' = 'instance'
AND dt.value->>'table_name' IS NOT NULL"#
)
.fetch_all(db)
.await?;
let table_names: Vec<String> = rows.iter().filter_map(|r| r.table_name.clone()).collect();
if table_names.is_empty() {
return Ok(vec![]);
}
// Batch lookup: single query for all table sizes
let size_rows = sqlx::query!(
r#"SELECT
c.relname as "table_name!",
pg_total_relation_size(c.oid) as "size_bytes!",
pg_size_pretty(pg_total_relation_size(c.oid)) as "size_pretty!",
COALESCE(c.reltuples, 0) as "estimated_rows!"
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relname = ANY($1)"#,
&table_names
)
.fetch_all(db)
.await?;
let size_map: std::collections::HashMap<String, _> = size_rows
.into_iter()
.map(|s| (s.table_name.clone(), s))
.collect();
let mut result = Vec::new();
for row in rows {
let table_name = match &row.table_name {
Some(t) => t.clone(),
None => continue,
};
if let Some(s) = size_map.get(&table_name) {
result.push(DatatableInfo {
workspace_id: row.workspace_id,
name: row.name,
table_name,
size_bytes: s.size_bytes,
size_pretty: s.size_pretty.clone(),
estimated_rows: s.estimated_rows as f64,
});
}
}
// Sort by size descending
result.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
Ok(result)
}
#[cfg(test)]
mod tests {
use super::compute_connection_sizing;
#[test]
fn no_workers_reports_defaults_and_floors_at_200() {
let s = compute_connection_sizing(0, 0, 0, 3, None);
assert_eq!(s.live_workers, 0);
assert_eq!(s.live_worker_instances, 0);
assert_eq!(s.live_agent_workers, 0);
assert_eq!(s.estimated_worker_connections, 0);
assert_eq!(s.server_pool_size, 50);
assert_eq!(s.worker_pool_size, 5);
assert_eq!(s.database_connections_override, None);
assert_eq!(s.per_server_increment, 50);
// ceil((0 + 50) * 1.20) + 3 = 63, floored up to 200.
assert_eq!(s.recommended_max_connections, 200);
assert!(s.message.contains("No live workers"));
}
#[test]
fn single_worker_single_instance_floors_at_200() {
let s = compute_connection_sizing(1, 1, 0, 3, None);
// (5 - 1) * 1 instance + 1 worker = 5
assert_eq!(s.estimated_worker_connections, 5);
// ceil((5 + 50) * 1.20) + 3 = 66 + 3 = 69, floored up to 200.
assert_eq!(s.recommended_max_connections, 200);
}
#[test]
fn multi_worker_instances_sum_per_instance_pools() {
// Two instances, 5 workers total: pools are (4 + w_i) summed = 4*2 + 5 = 13.
let s = compute_connection_sizing(5, 2, 0, 3, None);
assert_eq!(s.estimated_worker_connections, 13);
}
#[test]
fn large_fleet_exceeds_floor_with_margin_and_reserve() {
// 300 workers across 10 instances: (5-1)*10 + 300 = 340 worker connections.
let s = compute_connection_sizing(300, 10, 0, 3, None);
assert_eq!(s.estimated_worker_connections, 340);
// ceil((340 + 50) * 1.20) + 3 = ceil(468.0) + 3 = 471, above the 200 floor.
assert_eq!(s.recommended_max_connections, 471);
}
#[test]
fn reserved_above_default_widens_recommendation() {
// Big enough fleet that the floor doesn't mask the reserved contribution.
let base = compute_connection_sizing(300, 10, 0, 3, None);
let high = compute_connection_sizing(300, 10, 0, 20, None);
assert_eq!(
high.recommended_max_connections - base.recommended_max_connections,
17
);
}
#[test]
fn database_connections_override_caps_every_process() {
// DATABASE_CONNECTIONS=100 means each instance opens up to 100, not the
// default 5-based estimate. 5 instances -> 500 worker connections.
let s = compute_connection_sizing(20, 5, 0, 3, Some(100));
assert_eq!(s.server_pool_size, 100);
assert_eq!(s.worker_pool_size, 100);
assert_eq!(s.database_connections_override, Some(100));
assert_eq!(s.estimated_worker_connections, 500);
assert_eq!(s.per_server_increment, 100);
// ceil((500 + 100) * 1.20) + 3 = 720 + 3 = 723.
assert_eq!(s.recommended_max_connections, 723);
assert!(s.message.contains("DATABASE_CONNECTIONS=100"));
}
#[test]
fn agent_workers_are_excluded_from_the_estimate() {
// 50 agent workers alongside 2 DB workers/2 instances: only the DB
// workers count toward connections; the agent count is reported.
let s = compute_connection_sizing(2, 2, 50, 3, None);
assert_eq!(s.live_agent_workers, 50);
// (5 - 1) * 2 + 2 = 10, agent workers contribute nothing.
assert_eq!(s.estimated_worker_connections, 10);
let without_agents = compute_connection_sizing(2, 2, 0, 3, None);
assert_eq!(
s.recommended_max_connections,
without_agents.recommended_max_connections
);
assert!(s.message.contains("50 agent worker(s) excluded"));
}
}