mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
+2![claude[bot]](https://secure.gravatar.com/avatar/1b30942c176ccc9aa137c4834447eb3d?d=identicon&s=40)




![windmill-internal-app[bot]](https://secure.gravatar.com/avatar/97c881546ab633ac78aab2496d73f33a?d=identicon&s=40)



7155a0bb96
* feat: add workspace asset graph view Workspace-wide canvas of assets and their producer/consumer scripts, reachable from the assets page. Left-to-right layered layout via d3-dag sugiyama, rendered with @xyflow/svelte (same stack as the flow editor). GET /w/:ws/assets/graph returns deduped nodes + edges. Follow-ups: filters (kind/folder/search), node detail drawer, inline script edit from a clicked node. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * all * all * all * update * all * all * all * feat(pipeline): output-kind picker and per-(lang, output) templates Add a third stage to PipelineInsertMenu that asks what kind of asset the new script will produce (datatable / ducklake / s3 parquet / s3 object / none). The picked kind drives a real wmill SDK skeleton — typed datatable inserts, ducklake CREATE+INSERT, s3 parquet COPY, etc. — with the upstream asset auto-wired as the input source when added from an asset node. Reorder languages to bun → duckdb → python → sql so data-shaped languages surface first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * all * chore(main): release 1.693.4 (#8994) * chore(main): release 1.693.4 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * feat: ansible delegate_to_git_repo install_requirements, dynamic fields, --limit (#8997) * feat: ansible delegate_to_git_repo install_requirements, dynamic fields, --limit Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: include .yaml variants in collections/roles requirements lookup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): only preserve case for raw-app runnableIds, not app/flow summaries (#9000) * fix(cli): only preserve case for raw-app runnableIds, not app/flow summaries PR #8940 stopped lowercasing in sanitizeForFilesystem to fix #8939, where a raw-app runnableId like CamelCaseTSRunnable produced a CamelCase YAML metadata file but a lowercased code file, making them desync and register as duplicate runnables on push. That fix overshot. sanitizeForFilesystem is also reached by newPathAssigner, which serves normal apps and flows where the input is the script's human summary ("Get Users Data") rather than an identifier. There the on-disk filename is the only artifact — there's no companion YAML to keep in sync — so lowercasing was the right behavior. Removing it changed both the on-disk filename and the !inline reference in app.yaml / flow.yaml from get_users_data.inline_script.ts to Get_Users_Data.inline_script.ts on the next pull, surfacing as unwanted case churn for users updating to 1.693.x. Add a preserveCase option to sanitizeForFilesystem (default false → lowercase). newRawAppPathAssigner opts in; newPathAssigner stays on the default. Update unit tests accordingly and add an end-to-end raw-app round-trip in raw_app_sync.test.ts that pushes a CamelCase backend runnable, pulls it back, and asserts both YAML and code file preserve case with no lowercase orphan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(cli): use readdir for exact-case orphan check on Windows The CamelCase round-trip test used fileExists("camelcasetsrunnable.ts") to assert no lowercase orphan was produced, which false-positives on Windows since the filesystem is case-insensitive and resolves the lookup to the existing CamelCaseTSRunnable.ts. Switch to readdir + toContain so the exact on-disk casing is compared identically on Linux and Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): wmill-lock.yaml auto-fill + --rehash-only + path-prefix dedup (#8978) * fix(cli): canonical lockfile hashes + lock upgrade migration to v3 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): use __app_hash subpath in rehash missing-entry check Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): run sync pull lockfile auto-fill regardless of changes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: regenerate system prompts for new lock and rehash-only commands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): address review feedback on lock upgrade Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): drop v3 marker; always run fallback; fail-fast on unknown lockfile version Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): drop yaml-round-trip legacy hash variant; recover via --rehash-only Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): include legacy hash in script push staleness warning check Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * revert(cli): drop canonical hash formula; keep raw-bytes hashing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * perf(cli): reuse change-tracker map for sync pull lockfile auto-fill Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): address review feedback on rehash-only Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(cli): pin lockfile hash + yaml format and cover regression cases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(cli): byte-stable snapshot tests for flow.yaml format Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(cli): add app and script-metadata yaml snapshot fixtures Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): address claude review on rehash-only Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(cli): factorize script-path to remote-path derivation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): address claude + cubic review (dry-run mutation, rehash short-circuit) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(cli): make rehash a subcommand and factorize fs walks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): normalize line endings in yaml snapshot tests for windows ci Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): address review feedback on rehash + auto-fill - Flat-layout scripts now clearGlobalLock before rehash write so legacy ./-prefixed duplicates get cleaned up (matches flow/app behavior). - Add MalformedLockfileError; sync pull auto-fill re-throws it alongside UnknownLockVersionError instead of silently warning + continuing. - Document the legacy step-removal false-negative in isFlowDirectlyStale / isAppDirectlyStale and the categorizeLocalFiles ignore-filter invariant. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * fix: use otel.status_message for OTLP Status.message on failed jobs (#8995) tracing-opentelemetry only recognizes otel.status_code and otel.status_message as fields that map to the OTLP Status proto. The previously-used otel.status_description fell through to the generic attribute recorder, leaving Status.message unset and preventing OTLP consumers from filtering spans on error status. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: route email trigger path through standard info channel (#8996) * docs(skill): document email triggers and S3 attachments Add an "Email triggers" section to the triggers skill covering the local-part config, the parsed_email/raw_email/email_extra_args payload, the URL-style extras convention, where to find trigger_path (only with a preprocessor, at event.trigger_path), and — most importantly — that binary attachments are uploaded to the workspace S3 bucket and surface as `{ s3: "windmill_emails/<job_id>/attachments/<filename>" }`. Scripts must use wmill.loadS3File / wmill.load_s3_file to read them. Also pulls EmailTrigger into the schema mappings so a real `email_trigger.schema.yaml` is generated, and adds Email/Azure to the trigger kinds list in the CLI agent guidance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref for email trigger path fix Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 26184ab7a4aadfc529dcedf038aa08d36c7ad381 This commit updates the EE repository reference after PR #553 was merged in windmill-ee-private. Previous ee-repo-ref: 318a46897a605dc9be3817901f35ba5a99a0a525 New ee-repo-ref: 26184ab7a4aadfc529dcedf038aa08d36c7ad381 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> * update git sync version to 1.693.5 * fix: pair PG arg type with actual Rust binding to keep query_typed_raw safe (#8999) * fix: pair PG arg type with actual Rust binding to keep query_typed_raw safe Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(pg): wrap encoder errors with arg context, add fallback test Followups on #8999 review: - Wrap rust-postgres "error serializing parameter N" failures with the arg name, JSON value kind, and asserted Postgres type plus a hint about an explicit cast — so users see actionable context instead of an opaque WrongType. - Drift-prevention meta-test: assert otyp_to_pg_type and convert_val agree on the Type for every recognised arg_t when the JSON value matches its natural Rust kind. Catches future drift if either side changes. - Integration test for the prepare + query_raw fallback path: confirms unrecognised arg_t (custom enum) is routed through prepare and the server-resolved type appears in the failure surface — flips into a test failure if a regression accidentally routes unrecognised types through query_typed_raw. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): add otyp_inferred flag + regex-based placeholder renumbering Two follow-ups from the review of #8999: 1. **Issue #1 (Number/Bool + explicit text decl in WHERE)** Add `Arg::otyp_inferred: bool` to the parser. The PG SQL parser sets it `true` only at the "no info → fall back to text" site (bare `$N`, no inline cast, no `-- $N (TYPE)` decl). All other arg sources keep it `false`. In `convert_val` this flag distinguishes: - explicit text-like target (`-- $1 (text)` or `$1::text`) — coerce `Bool`/`Number` → `Box<String>` so `WHERE text_col = $1` works (`text = text` operator). Pre-#8988 behaviour, restored. - parser-default text (bare `$N`) — bind the value's natural Rust type so the regression case (`Value::Bool` against a real `bool` column via `CAST AS bool`) keeps working. `Arg` is in `windmill-parser`; the new field has `#[serde(default)]` so persisted signatures stay backward-compatible. 2. **Issue #4 ($5/$50 substring rewrite collision)** Replace the per-index `String::replace` chain (which turned `$50` into `$10` when oidx=5 was processed first) with a single regex pass. `\d+` is greedy, so `$5` and `$50` match as distinct units; indices outside the mapping are left intact. 3. Tests: - parser: `test_parse_pgsql_otyp_inferred_flag` covers bare/inline- cast/decl/mixed shapes. - executor unit: `convert_val_bool_against_every_arg_t` and `convert_val_*_number_*` split each text-like target into explicit vs inferred expectations. - executor unit: `renumber_sparse_placeholders_no_collision`. - integration: `test_postgresql_arg_type_combinations` adds 4 cases covering decl(text)+Number/Bool in WHERE, bare $1+Bool, and sparse positional args ($5/$50). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg+sdk): enum support, extended String arms, position-aware $N rewrite, SDK quality Backend: 1. **`AnyTextValue` ToSql/FromSql wrapper**: vanilla `tokio_postgres`'s `ToSql for String` / `FromSql for String` reject `Kind::Enum` and `Kind::Domain` even though the wire format is plain UTF-8. The wrapper accepts those kinds in both directions. End result: explicit `$1::my_enum` / `CAST($1 AS my_enum)` casts now round-trip without the ugly `CAST($1::text AS my_enum)` workaround, AND `SELECT enum_col` results come back as JSON strings instead of erroring at the FromSql layer. 2. **#10 — Value::String → numeric/real/double/oid/bool**. Without these arms, a string-encoded value (`"3.14"`, `"true"`) for a non-text / non-temporal arg_t fell through to `Box<String> + TEXT`, which then failed at the server (no implicit cast text→numeric in expression context). Now strings are parsed into the matching native type with clear error messages on parse failure. 3. **Position-aware `$N` rewrite**: replaces the regex-based renumbering (which fixed the `$5/$50` substring collision but still walked through string literals and comments, mangling `'price: $5'` etc.) with a walk over `parse_pg_statement_arg_positions` — the same string/comment/dollar-quote-aware tokenizer used for index discovery. Adds `parse_pg_statement_arg_positions` to the parser's public API. SDK: 4. **BigInt support**: `JSON.stringify(BigInt)` throws. The SDK now stringifies bigints before serialisation; the executor accepts numeric strings into BIGINT arg slots via the existing `Value::String → INT8` parsing arm. SDK-side `inferSqlType` is split so `BigInt` always resolves to `BIGINT` (was reaching `Number.isInteger(BigInt)` which returns false → wrong default). 5. **Homogeneous array auto-tag**: `${[1,2,3]}` against an `int[]` column now emits `$1::BIGINT[]` instead of `$1::JSON`. Detection covers primitive types only (number / bigint / string / boolean); mixed or nested arrays still fall back to JSON. Mixed int/float widens to `DOUBLE PRECISION[]`. 6. **`.query()` positional bug**: previously the `.query()` method abused the template-tag builder, which appended `$N::TYPE` after the user's literal SQL string instead of binding by position (`SELECT $1, $2` became `SELECT $1, $2$1::BIGINT`). Now `.query()` builds the executor-shaped content directly: a `-- $N argN (TYPE)` declaration block followed by the user's SQL verbatim. Tests: - Parser: `test_parse_pg_statement_arg_positions_skips_strings_and_comments` asserts string literals, comments, and dollar-quoted blocks don't produce positions (so renumbering doesn't mangle them). - Executor unit: `renumber_sparse_placeholders_no_collision_no_string_mangling` uses the new position-aware path and includes string-literal + comment + `$$…$$` cases. Existing convert_val tests grow to cover new String→numeric/real/double/oid/bool arms. - Integration: `test_postgresql_arg_type_combinations` adds 13 cases (enum round-trip both directions, string→numeric/real/double/bool/oid, string-literal `$N` non-mangling). The prepare-fallback test now asserts SUCCESS (not failure) for enum encoding via AnyTextValue. - SDK: new `typescript-client/tests/sqlUtils.test.ts` (42 tests) exhaustively covering inferSqlType primitives + arrays, parseTypeAnnotation, datatable() template tag (with all the new shapes — BigInt, homogeneous arrays, RawSql, schema preamble), datatable().query() positional, and ducklake() shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): replace DISCARD ALL with curated reset (preserves typeinfo cache) Found while exhaustively probing custom-type DX: every cached-connection reuse was running `DISCARD ALL`, whose included `DEALLOCATE ALL` deallocates *all* prepared statements server-side — including the typeinfo statements that tokio_postgres caches per-Client to resolve custom enum / domain Oids. tokio_postgres still held `Statement` objects whose names the server had forgotten, so the next custom-type query failed with intermittent "prepared statement \"sN\" does not exist" errors. The failure was easy to reproduce: any sequence that forced typeinfo lookup for two different custom-type kinds on the same cached connection (e.g. enum followed by domain) would hit it. Replace `DISCARD ALL` with a curated reset that explicitly targets the state we actually care about, *without* touching prepared statements: RESET ALL — GUC parameters (search_path, application _name, statement_timeout, …) RESET SESSION AUTHORIZATION — undoes both `SET SESSION AUTHORIZATION` and `SET ROLE` (RESET ALL does NOT — these aren't GUC parameters, so without this an elevated role from a previous job would silently leak) UNLISTEN * — drops LISTEN registrations CLOSE ALL — closes open cursors Trade-off: temp tables, advisory locks (session-scoped), and user-created PREPARE statements may persist across cached-connection reuse — rare in datatable / PG-script workloads. tokio_postgres's typeinfo cache survives intact, so custom enum / domain queries are fast on subsequent reuse. Tests: - `test_postgresql_custom_types_on_cached_connection` — runs 10× alternating enum + domain queries on a cached connection. Pre-fix this failed with `prepared statement "sN" does not exist` after the first reuse; post-fix passes. - `test_postgresql_set_role_does_not_leak_across_cached_connection` — switches `SET ROLE` and `SET SESSION AUTHORIZATION` to a non-postgres role, then runs a follow-up job and asserts current_user/session_user are restored. Specifically catches the case where someone might switch back to `RESET ALL` alone (which doesn't cover SET ROLE / SESSION AUTHORIZATION) and silently introduce a permission-leak vector. - All existing session-isolation tests (`test_postgresql_cached_connection_resets_session`, `test_postgresql_single_worker_session_isolation`, `test_postgresql_100_jobs_cached`) continue to pass. Found via end-to-end probing of datatable / PG-script DX, not previously covered: the existing isolation tests only did `SET ROLE postgres`, the connecting user, so the leak was invisible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): address PR #8999 review (cubic + claude) cubic (P1, real bug): - `convert_vec_val` for `timetz` array asserted `Type::TIMETZ_ARRAY`, but chrono `NaiveTime` only encodes for TIME (same caveat as the scalar arm). Switch to `Type::TIME_ARRAY`; rely on PG's implicit `time→timetz` assignment cast at the column site. Add an explicit unit test. claude (#1, silent failure → explicit error): - `Bool` + explicit `(char)` / `(character)` decl previously silently bound BOOL, hoping the server would cast at the use site — but PG has no implicit `bool→char` and the resulting error ("operator does not exist: bool = char") was opaque. Now error at bind time with an actionable hint to use `bool` decl or pass the value as a "t"/"f" string. claude (#2, asymmetry doc): - Object/Array still coerce to text on `matches!(typ, Typ::Str(_))` (covers both explicit AND inferred-default text), unlike Bool/Number which key on `explicit_text_target`. The asymmetry is intentional (no implicit `jsonb → text` cast in expression context vs PG having implicit `bool/int → text` casts) — added a body comment so future maintainers don't try to "align" them. claude (#3, perf): - `parse_pg_statement_arg_indices` and `parse_pg_statement_arg_positions` walked the SQL tokenizer twice. Fold into a single pass that derives the index set from the position list. claude (#4, fmt drift): - `cargo fmt` over the parser crates I touched with perl scripts in the earlier commit (windmill-parser-{sql,bash,ts,go,php,java,csharp,nu,py, rust,graphql,yaml,r}). Net cosmetic. claude (#5, parseTypeAnnotation): - One-line caveat in the SDK's `parseTypeAnnotation` that the returned string is presence-only (e.g. `${x}::DOUBLE PRECISION` returns `"DOUBLE"`, `CAST(${x} AS int)` returns `"int)"` — neither matches a real PG type, but the only consumer just checks `!== undefined`). While here — discovered + fixed independently while exhaustively probing DX: - **Replace `DISCARD ALL` with curated reset** (`RESET ALL; RESET SESSION AUTHORIZATION; UNLISTEN *; CLOSE ALL;`). DISCARD's `DEALLOCATE ALL` killed tokio_postgres' typeinfo cache, producing intermittent `prepared statement "sN" does not exist` errors on custom-type queries after cached-conn reuse. New regression tests: `test_postgresql_custom_types_on_cached_connection` and `test_postgresql_set_role_does_not_leak_across_cached_connection` (the latter catches the case where someone might switch back to `RESET ALL` alone and silently introduce a permission-leak vector — RESET ALL doesn't cover SET ROLE / SET SESSION AUTHORIZATION). - **ISO-8601 timestamp results** (`pg_cell_to_json_value`). Pre-fix `TIMESTAMP` was rendered with a space separator ("2024-01-15 10:30:00") and `TIMESTAMPTZ` with " UTC" suffix ("2024-01-15 10:30:00 UTC") — neither parseable by `date-fns parseISO`, JavaScript `new Date()` is lenient enough to handle them but several frontend `App*Input.svelte` components use parseISO and fail silently. Switched to ISO-8601 with `T` separator and `+00:00` offset; arg-parsing path still accepts the legacy " UTC" suffix for back-compat. Test coverage: - 17/17 unit (`pg_executor::tests`) - 9/9 integration (`backend/tests/worker.rs`, `test_postgresql_*`) - 27/27 parser (`windmill-parser-sql`) - 42/42 SDK (`typescript-client/tests/sqlUtils.test.ts`) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): bounded one-shot warning on numeric precision loss + ISO-8601 + NaN handling Found while probing PG-script DX with millions of numeric cells: 1. **Numeric precision-loss warning**: `numeric` results are still serialised as JSON Number (back-compat — switching to JSON String would silently break user code doing arithmetic on results), but we now detect `Decimal -> f64 -> Decimal` round-trip failure and emit a single job-log warning recommending a `::text` cast in the SQL. Bounded by `NUMERIC_PRECISION_CHECK_BUDGET = 256` cells per query (one atomic load + one fetch_sub on the hot path; first lossy value short-circuits to a single load thereafter). Worst-case overhead on a 1M-cell numeric-heavy query: ~25µs of checks + 5ns × N atomic loads (vs. ~100ms unbounded). 2. **ISO-8601 timestamps**: `pg_cell_to_json_value` previously returned `"2024-01-15 10:30:00"` (TIMESTAMP) and `"2024-01-15 10:30:00 UTC"` (TIMESTAMPTZ) — neither parseable by date-fns `parseISO`, which is what the apps `App*Input.svelte` components use, so timestamp values silently failed to round-trip into date pickers. Switch to ISO-8601 (`T` separator + `+00:00` offset) on the result side; arg-parser continues to accept the legacy `" UTC"`-suffixed format for back-compat. 3. **Float NaN / Infinity results**: `Number::from_f64` returns None for NaN / ±Inf, which `pg_cell_to_json_value` was raising as "invalid json-float" — failing the *entire* query if any cell held one of these special values. Now serialise them as JSON strings ("NaN", "Infinity", "-Infinity") and let the rest of the row come through. Arg-side: `s.parse::<f64>()` already accepts the same strings. Tests: - `decimal_fits_f64_losslessly_predicate` — covers fits / doesn't-fit cases for the precision-loss predicate. - `precision_check_budget_caps_per_query_overhead` — locks in the budget cap and the loss-flag short-circuit. - All 9 PG integration tests + 17 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): add pg_advisory_unlock_all to reset; warn on missing args; honor decl defaults While probing PG-script DX further found three more frictions: 1. **Advisory lock leak** (cubic P2): switching from `DISCARD ALL` to `RESET ALL; RESET SESSION AUTHORIZATION; UNLISTEN *; CLOSE ALL;` meant session-scoped advisory locks (`pg_advisory_lock`) leaked across cached-connection reuse. Add `SELECT pg_advisory_unlock_all()` to the chain — `DISCARD ALL` covered this implicitly via `DISCARD PLANS / DEALLOCATE / pg_advisory_unlock_all` and we lost it in the switch. 2. **Missing-arg silent NULL**: an arg declared in the SQL (e.g. `-- $1 amount (numeric)`) but not provided in the args object was bound as NULL with no error / warning. Misspelling the key in the args object silently produced a row of NULLs — a notorious DX debugging trap. Now: collect the names of declared-but-missing args during dispatch and emit a single one-shot warning to the job logs at end-of-query naming each one. Bound NULL is preserved for back-compat. 3. **Declaration defaults ignored**: `-- $1 a (int) = 5` carries `arg.default = Some(Number(5))`, but the dispatch fell straight to NULL when the arg was missing. Now: respect the default — user-supplied value > declaration default > NULL. Also fixes the warning logic above (only warn for args that *don't* have a default). Tests: existing 19 unit + 9 integration pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): multi-word PG types with [] suffix lost the array-ness; array arms accept stringified values Two more frictions found while probing SDK end-to-end against a real datatable resource: 1. **Multi-word array types lose the [] suffix in the parser**. `transform_types_with_spaces` recognises aliases for "double precision", "character varying", "timestamp with time zone", etc. but its return type was `&'a str` — only the bare alias, never with a trailing `[]`. The `RE_CODE_PGSQL` regex's `\w+` captures stop at the first space, so the regex's own `(?:\[\])?` array-suffix branch sees only `"double"` (not `"double precision[]"`); the `[]` was silently lost. Result: `$1::double precision[]` (which the SDK now emits for homogeneous float arrays via the new auto-tag) routed through `Value::Array → Type::JSONB` and the server failed with "cannot cast type jsonb to double precision[]". Fix: switch `transform_types_with_spaces` to return `Cow<'a, str>` and re-check the trailing bytes after a multi-word match. If they start with `[]`, return `format!("{alias}[]")` — Owned. Single-word types and the no-match path keep returning Borrowed slices, so no allocation in the hot path. 2. **Array arms in `convert_vec_val` rejected stringified values for numeric / int* / bool / oid / real / double**. The scalar `convert_val` already parses strings into the matching native type for these arg_ts, but the array variant only accepted JSON-native counterparts. Sending `["1.5", "2.5", "3.5"]` against `$1::numeric[]` (e.g. via `unnest` for bulk loading, or `JSON.stringify(BigInt[])` round-trip) failed with "Mixed types in array". Now the array arms mirror the scalar ones — `as_<native>().or_else(|| as_str().and_then(parse))` — so both shapes round-trip cleanly. Tests: 19 unit + 9 integration pass; existing parser tests cover the multi-word array forms (the regex-cap behaviour didn't break for single-word types, and Cow plumbing is transparent to all callers). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(parsers): add otyp_inferred field to Arg literals in tests + 3 missed src files CI failures: the perl-driven sweep that added `otyp_inferred: false` to every `Arg { ... }` literal when I introduced the field in the parser schema covered `src/lib.rs` files but missed: - parsers/windmill-parser-bash/src/lib.rs (mass-edited but a later format pass un-applied a few sites) - parsers/windmill-parser-go/src/lib.rs (same) - parsers/windmill-parser-graphql/src/lib.rs (same) - parsers/windmill-parser-nu/tests/tests.rs (test file — not swept the first time) - parsers/windmill-parser-ts/tests/tests.rs (test file — same) Also tightened the regex to handle `oidx: None` without the trailing comma (some test files had the field as the last initialiser line). `cargo build --features <CI feature combo> --workspace --all-targets` is clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sdk): Date → TIMESTAMPTZ; NaN / ±Infinity → string Two more frictions found while running the actual SDK end-to-end against a live datatable resource: 1. **JS `Date`** fell into the typeof "object" branch and was tagged `::JSON`. It worked accidentally for `${date}::timestamptz` via PG's `json → text → timestamptz` implicit cast chain, but `${date}` against a `timestamptz` column without a user-supplied cast bound the value as a JSON string and the comparison `timestamptz = json` failed. Now: `inferSqlType` recognises `Date` and tags `::TIMESTAMPTZ`; `serializeArgValue` emits `Date.toISOString()` so the executor's `Value::String → TIMESTAMPTZ` arm parses it cleanly. 2. **JS `NaN` / `±Infinity`** silently became NULL. `JSON.stringify(NaN)` returns `"null"` per the JS spec, so the value reached the executor as JSON null — the SDK's `::DOUBLE PRECISION` tag then bound a NULL double. Fix: detect non-finite numbers in `serializeArgValue` and stringify them as `"NaN" / "Infinity" / "-Infinity"`. The executor's `Value::String → FLOAT8` arm (`f64::from_str`) accepts these literals directly, and the result-side already renders the values as JSON strings (matching round-trip). SDK unit tests grow from 42 → 44 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(pg): integration coverage for multi-word arrays + stringified array elements Locks in the two array fixes from the previous commit (`fix(pg): multi-word PG types with [] suffix lost the array-ness`) with end-to-end cases in `test_postgresql_arg_type_combinations`: - `double precision[]`, `character varying[]`, `timestamp without time zone[]` — verifies the parser keeps the `[]` suffix after multi-word alias resolution. - `numeric[]` / `int[]` / `bool[]` from stringified primitives — verifies the array arms of `convert_vec_val` apply the same string-coercion the scalar arms do. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: fix indentation drift on otyp_inferred lines cargo fmt cleanup of leftover indentation where the perl-driven sweep that introduced the otyp_inferred field landed at the wrong column. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * feat: support assigning a worker tag to app inline scripts (#9002) * feat: support assigning a worker tag to app/raw-app inline scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: omit empty tag field from inline script raw_code payload Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: shrink tag popover width --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * feat(pipeline): 2-col picker, draft path edit, save-all + leave guard Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * all * all * update * fix(cli): forward HEADERS env var on every backend fetch call (#9075) Several `fetch()` callers in the CLI bypassed `OpenAPI.HEADERS` and skipped the `HEADERS` env var, causing requests to fail behind auth gateways like Cloudflare Access (same shape as #6421): - `pushScript()` `/scripts/create` and `/scripts/create_snapshot` — regressed in #8936 when the call switched from `wmill.createScript()` (SDK) to a raw `fetch` for the `skip_if_noop` query param. - Script preview `/jobs/run/preview_bundle`. - App dev `/jobs_u/getupdate_sse` SSE stream. - `wmill docs` `/api/inkeep`. All four now spread `getHeaders()` and call `detectAuthGatewayChallenge()` so a Cloudflare/SSO challenge surfaces a clear error instead of an opaque JSON parse failure. Adds `test/headers_env_var.test.ts`: spins up an auth-gateway proxy that 403s requests missing `CF-Access-Client-Id` / `CF-Access-Client-Secret` and otherwise reverse-proxies to the test backend, then runs `wmill sync push` of a fresh script through the proxy. Negative case (no `HEADERS` env) verifies the proxy actually gates; positive case asserts every request including `/scripts/create` reaches the backend with the headers attached. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): add --parallel flag to generate-metadata (#9074) * feat(cli): add --parallel flag to generate-metadata * fix(cli): validate --parallel input and harden flush ordering * perf(flows): skip flow_env DB+transform work when no resolution is needed (#9078) * fix(cli-tests): stabilize flow lock-gen race + Windows path (#9080) * fix(cli-tests): stabilize flow lock-gen race + Windows path Three CLI test failures on the latest main, all flaky on CI: 1. `Mixed Case Paths: pull and push flow with capitalized folder` and `Integration: Mixed scripts and flows with nonDottedPaths are idempotent`: flow create/update queues an async FlowDependencies job that fills inline-script lockfiles and rewrites flow.value. The tests pulled/pushed before the worker finished, so dry-run idempotency saw phantom `*.inline_script.lock` adds and `flow.yaml` edits. Added a `waitForFlowDependencyJob` helper that polls `/flows/get` for the latest `dependency_job` and `/jobs_u/completed/get` until it lands, and called it after each API/CLI flow write in both tests. 2. `HEADERS env var is forwarded on every CLI fetch` (Windows-only, added in #9075): the new test built the CLI entrypoint via `new URL("..", import.meta.url).pathname`, which yields `/C:/...` on Windows and `Bun.spawn` rejected before reaching the proxy, leaving `rejectedRequests.length` at 0. Switched to `fileURLToPath` + `node:path.join` to match `cargo_backend.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli-tests): use /flows/deployment_status to actually wait for dep job CI reviewers (Claude, Codex) flagged the prior `waitForFlowDependencyJob` as a no-op: it read `flow.dependency_job` from `/api/w/{ws}/flows/get`, but `Flow` / `FlowWithStarred` (backend/windmill-types/src/flows.rs:20-60) do not include that field. The helper exited on the first iteration without polling. Switch to `/api/w/{ws}/flows/deployment_status/p/{path}`, which returns `{ lock_error_logs, job_id }`. `job_id` is the FlowDependencies UUID written into `deployment_metadata` in the same tx as the dep-job push (backend/windmill-api-flows/src/flows.rs:660-672 and :1275-1292), so by the time the create/update API call returns, the response carries the latest dep-job UUID. Then poll `/jobs_u/completed/get/{job_id}` as before. Local runtime for `mixed_case_paths.test.ts` jumps from ~9s to ~32s, confirming the helper now actually waits instead of returning immediately. The 404 short-circuit in `sync_pull_push.test.ts` still works — `get_deployment_status` returns 404 when the flow is absent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(flows): cache resolved flow_env per flow execution (#9079) * perf(flows): cache resolved flow_env per flow execution * perf(flows): tighten flow_env cache cap to 1024 and clarify memory note * perf(flows): don't cache transient flow_env resolution failures * chore(main): release 1.698.0 (#9076) * chore(main): release 1.698.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * fix: reject root-rooted paths in ansible playbook validator on windows (#9081) * fix(native-triggers): serialize Google channel renewal across replicas (#9060) * fix(native-triggers): serialize Google channel renewal across replicas `sync_all_triggers` runs every 5 minutes on every windmill-app replica with no leader election. Multiple replicas were each rotating the webhook token, creating a new Google watch channel, and racing the trigger UPDATE — leaving the loser's new token (in `token`) and channel (in Google) orphaned. Cloud was accumulating ~5 leaked tokens/week without the silent best-effort `delete_token_by_hash` ever logging a warning. Wrap each per-trigger renewal in a transaction and acquire the row with `SELECT … FOR UPDATE SKIP LOCKED`. Contending replicas skip the row instead of duplicating the work. The lock spans `rotate_webhook_token` → Google API call → `update_native_trigger_service_config` and is only released on commit. Re-checks `should_renew_channel` after acquiring the lock so a replica that committed seconds earlier doesn't trigger a duplicate renewal. The pattern matches existing batch-cleanup paths in `monitor.rs` (job-retention sweep) and other `FOR UPDATE SKIP LOCKED` call sites. Also logs at `debug!` when `delete_token_by_hash` finds no matching row, so future investigations can distinguish "deleted" from "not found" without changing the `Ok(false)` contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fixup! fix(native-triggers): serialize Google channel renewal across replicas * fixup! fix(native-triggers): serialize Google channel renewal across replicas fixup! fix(native-triggers): serialize Google channel renewal across replicas Address claude review: - #5: per-skip log info -> debug (expected outcome under SKIP LOCKED) - #2: warn moved out of delete_token_by_hash to the call site that knows the expected state (try_renew_channel_locked); other callers are race-prone and shouldn't warn - #3: NULL service_config now warns (anomalous case) - #4: post-Google-API DB-update + commit failures log distinctly so the channel-orphan case is grep-able Plus: add 14d expiry to Google webhook tokens via ServiceName::webhook_token_expiration, mint fresh ephemeral-webhook-{service}-{rd5} labels at create + rotate so the existing 'ephemeral-' filter excludes them from user-token email/critical-alert paths (no filter changes in 3 places). Orphans now self-clean via the existing expiry sweep in monitor.rs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fixup! fix(native-triggers): serialize Google channel renewal across replicas fixup! fix(native-triggers): serialize Google channel renewal across replicas Address second-round review: - Claude #1 (P2): username_override_from_label now strips the 'ephemeral-' prefix for ephemeral-webhook-* labels, so created_by stays webhook-{service}-{rd5} instead of changing to label-ephemeral-webhook-... (preserves audit/job-list filter compatibility) - Codex (P2): updated renew_channel doc — labels are no longer copied; rotate mints fresh ephemeral-webhook-google-{rd5} with 14d expiration - Claude #3 (optional): test_rotate_webhook_token now asserts the rotated Google token has an ephemeral-webhook-google-* label and a populated expiration Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fixup! fix(native-triggers): serialize Google channel renewal across replicas fixup! fix(native-triggers): serialize Google channel renewal across replicas Reconsider the previous fixup: stripping the 'ephemeral-' prefix made created_by no longer match token.label exactly, defeating the linking purpose. Just allowlist 'ephemeral-webhook-' alongside the other recognized webhook/email/ws prefixes — created_by becomes ephemeral-webhook-google-XXXXX, matching token.label exactly. The 'ephemeral-' substring also informs operators that this is a system-managed auto-expiring token vs a user-managed webhook trigger. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): bump svelte version in `wmill app new` template (#9084) * fix(cli): bump svelte version in `wmill app new` template The svelte5 template pinned `svelte` to `5.45.2`, but the Svelte compiler bundled in `wmill app dev` emits `$.delegated('click', ...)` calls. The `delegated` export was added later, so 5.45.2 doesn't have it — esbuild warns `Import "delegated" will always be undefined`, replaces the call with `void 0`, and the page crashes at first event-handler bind (white screen). Bump to `^5.55.5` so the compiler and runtime stay in sync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): bump svelte version in raw_apps UI template Mirror the CLI fix: the UI's `Add raw app` flow scaffolds a package.json with `svelte: "5.45.2"`. That works today only because the bundled rolldown worker also pins 5.45.2 — when the worker is upgraded past 5.51.1, the compiler will emit `$.delegated()` and the runtime won't have it, producing the same white-page crash that hit the CLI. 5.55.5 still exports `event` (used by the current bundled compiler), so this is forward-compatible: it works with the 5.45.2 compiler now and won't break when the worker is upgraded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(flows): gate flow_env resolve on expr text and share cache with handle_flow (#9085) * feat: parse windmill_failure field to tag run as failure (#9073) * feat: parse windmill_failure field in job result to tag run as failure * feat: preserve top-level fields when windmill_failure tags a run as failure * fix: address review findings on windmill_manual_failure * refactor: rename windmill_manual_failure to wm_failure and add wm_* aliases * fix: prefer injected ManualFailure error over sibling name/message in OTel * fix: hide _ENTRYPOINT_OVERRIDE jobs from script/flow history panel (#9088) * fix(flows): populate error handler input args from failure picker (#9087) * fix(flows): populate error handler input args from failure picker * style(flows): fix indentation in failure-step branch * fix(python): verify wheel RECORD on cache pull/install, finalize piptar (#9090) The Python per-package dependency cache could persist an incomplete wheel extraction with `.valid.windmill` set, then propagate that broken artifact to every worker through the object store. Customer hit this on argon2-cffi==25.1.0 (missing argon2/_utils.py), and previously on botocore/httpx (truncated tars). Symptom is a runtime ImportError that looks like a missing dependency declaration rather than a Windmill bug. Three changes that together stop the propagation: 1. After `pull_from_tar`, parse the wheel's `<dist-info>/RECORD` and confirm every listed path exists on disk before writing `.valid.windmill`. On failure, wipe the directory and fall through to a fresh local install — the next install also self-heals the broken object-store entry by pushing a fresh tar. 2. After `uv pip install` succeeds, run the same RECORD check before queuing the piptar upload or writing `.valid.windmill`. A bad install never becomes the source of a broken tar in the object store. 3. Finalize the tar (`drop(tar.into_inner()?)`) before reading its bytes for upload, so we never push an unfinalized archive (no end-of-archive marker) to the object store. Verified with a 60-package end-to-end integration test (first-fill → clear-local-cache → re-pull-from-objectstore → corrupt-objectstore-tar → detect-and-self-heal). All 27 packages on the live test pulled cleanly, and the deliberately corrupted argon2-cffi tar was caught with the exact expected log line ("wheel RECORD lists files missing on disk: argon2/_utils.py") and replaced with a fresh tar. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(main): release 1.699.0 (#9082) * chore(main): release 1.699.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * feat(cli): auto-infer args for `wmill app push` (#9091) Run `wmill app push` from inside an app folder (e.g. `f/foo/my_app.app/`) with no args. The local path defaults to CWD, and the remote path is derived from CWD relative to `wmill.yaml`, with `.app`/`.raw_app`/ `__app`/`__raw_app` suffixes stripped. Either, both, or neither positional argument can be passed. Also resolves `file_path` against the user's original CWD before `resolveWorkspace` may chdir to the wmill.yaml root, so a relative `file_path` argument is interpreted from where the user invoked the command (previously it could resolve against the wrong directory). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * all * fix(pipeline): live-update graph for annotations and body assets * fix(pipeline): persist draft body edits across node switches * fix(pipeline): persist live writes per draft to keep output node fresh after switch * feat(pipeline): animate graph edges only while a runnable is executing * feat(pipeline): add run button on script nodes + recomputing hint on preview * feat(pipeline): compact preview layout, two-way Test/Run sync * fix(pipeline): test button cross-browser placement (no overflow trick) * style(log-viewer): replace took/mem-peak labels with timer/cpu icons * style(log-viewer): hyphenate Auto-scroll label and prevent wrapping * style(log-viewer): lowercase auto-scroll label, force vertical scrollbar * style(log-viewer): force horizontal scrollbar instead of vertical * fix(log-viewer): scope overflow-x to top bar so pre doesn't drive panel width * fix(pipeline): overlay live body-asset writes for persisted scripts too * fix(pipeline): persist inferred body assets at save so edges survive page reload * fix(pipeline): snapshot live draft writes at persist time so they survive reload * fix(pipeline): keep inferred body writes on the canvas across selection changes * fix(pipeline): untrack inferredWrites cache mutation to break effect loop * fix(pipeline): refetch asset graph after persisted-script save * feat(pipeline): optional AI prompt when creating a pipeline script * all * all * test: cover asset-trigger dispatch end-to-end through worker * feat(pipeline): split-button Test with optional downstream cascade * feat(pipeline): cascade option on graph Run + match button heights * style(pipeline): match caret bg/text to Test button's accent-secondary * feat(pipeline): split Run pill on graph node exposes cascade option * feat: live run activity + status badges in pipeline asset graph - folder-scoped queue poll lights up the downstream asset-trigger cascade (not just the launched script); zero requests at rest, catch-up for fast hops, auto-disarm when idle - per-runnable node badge: last-run status + session run count - animate unsaved/live-parsed edges (was unconditionally suppressed) - background-pane click no longer clears selection - run-bridge guarded so node selection/save no longer triggers a test Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: live activity log, optimistic badges, node-avoiding graph edges - collapsible folder activity log (PipelineEventLog): live job feed, polls only while open/active, slow idle cadence, capped + pruned - composable: observe mode + events list + run-count anchored to graph-open time (pre-existing history excluded) - optimistic node badge: launched script shows running instantly via the zero-latency activeRunnable hint, keeps the polled run count - activity pane height capped (min(18rem,40vh)) then scrolls - route asset-graph edges through sugiyama-computed waypoints so they go around nodes instead of under them; bezier fallback for adjacent-layer / draft-overlay edges Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: prefetch all folder script assets so graph is stable on load On pipeline load, eagerly infer body assets for every persisted folder script and seed the existing inferredWritesByPath overlay, instead of only filling it when a node is selected. Scripts whose persisted asset rows are missing (e.g. object-form writeS3File) now have their edges from first paint, so clicking a node no longer re-layouts the graph. One-shot per (workspace, base-graph) load, untracked map reads, generation-cancelled, pool-capped fetches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf: guard no-op poll re-layout; dedupe write-asset extraction - skip reactive ids/states/events reassignment when unchanged, so an idle poll tick no longer re-runs the full sugiyama layout every 3-6s - bound countedJobIds (rebuilt from eventsById in lockstep with prune) - extract shared extractWrites() helper, replacing 4 copy-pasted write-asset filter/map blocks in the pipeline page - compute activeRunnable node-id once, reuse for the active-edge set and the optimistic badge (flattened ternary); trim narrating docs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: live read-lineage overlay for inferred body assets Renaming e.g. duckdb read_parquet('s3://...') / loadS3File now updates the asset->reader edge live instead of only after Save re-derives the persisted asset rows. - extractReads() (+ shared refsByAccess) mirroring extractWrites - inferredReadsByPath sticky cache, filled by handleAssetsChange and the load prefetch alongside writes - replace the write-only overlay loop with one overlayLineage(map, access) helper invoked for both 'w' and 'r' (net DRY) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: detect S3 assets passed as SDK object arg in ts parser Mirrors merged PR #9181 so feat/asset-graph-view is self-contained (local origin/main is stale and lacks it). Object/{ s3, storage } form of writeS3File/loadS3File is now detected, not only the bare s3:// string literal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: regenerate wasm Cargo.lock + frontend package-lock Lockfile churn from local wasm-pack (asset target) + npm operations during the asset-graph work. No source/dependency-intent change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: revert to bezier graph edges; add parsing-assets hint The sugiyama-waypoint routing looked worse than the original; revert AssetGraphEdge/assetGraphLayout to the pre-routing bezier logic (same as the flow editor's BaseEdge) and drop the now-unused route plumbing from the canvas. Add a small 'Parsing assets…' hint shown while the load-time prefetch sweep is still inferring folder scripts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: extract pure resolveGraph merge + unit tests Move the ~230-line graphWithDraft precedence/merge (base < session- inferred < draft-seeded < open-script-live, +read/write/annotation overlays, +dedup) out of the 1648-line route into a pure, testable resolveGraph() module; the route's graphWithDraft is now a thin $derived. Behaviour extracted verbatim. 10 unit tests cover the precedence matrix. Phase 1 of the state/render split. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: graph controls top-right, lift minimap, hide Save when unchanged Controls -> top-right horizontal, no lock toggle; MiniMap !mb-10 so it clears the activity bar; hide the per-script Save button when the script is already at its latest save point (drafts still show Create). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: scope runtime-asset prune by id to spare static lineage rows prune_runtime_assets deleted by (workspace_id, path, kind) tuple, so trimming surplus usage_kind='job' rows for an s3 path also wiped the static usage_kind='script'/'flow' producer rows for the same path — silently breaking the asset-trigger cascade (fetch_producer_writes found no writes; downstream never dispatched; required band-aid re-syncs). Delete the surplus job rows by id instead; the inner query is already scoped to usage_kind='job'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: don't re-pulse already-running jobs after they finish The catch-up pulse re-added a completed job to the active set if its start was within the (lagging) lookback window — even one we'd already animated the whole time it ran — keeping its edges lit ~a poll interval past completion (~5s after a 3.5s test). Track job ids seen in-flight and skip the pulse for them; it still fires for hops whose whole lifetime fell between two polls. Bound the set in lockstep with eventsById; cleared on dispose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: don't catch-up-pulse the runnable launched from the graph If the poll never sampled a launched run's in-flight window, the catch-up pulse re-flashed its edges one tick after it correctly stopped (the page already animated it zero-latency via activeRunnable). arm(launchedId) records the launched runnable id; catch-up skips it. Cascade hops (other ids) still pulse. launchedIds cleared on stop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: nudge graph controls left to clear panel toggle Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: partition value resolver + asset-cascade propagation windmill-common/partition: pure resolver — time kinds (tz/format/start anchor) + dynamic $.a.b JSONPath; 9 unit tests. asset_dispatch: read the producer's resolved partition and thread it into every cascaded subscriber's args + trigger.partition, so a chain resolves once at the top. No migration (cascade needs no spec lookup). Stage 1+3 of pipeline partition runtime; run-start resolution is Stage 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: show args form in compact pipeline preview when script has inputs AssetGraphDetailsPane keeps the compact (hideArgs) preview but, via a new previewPanel.argsAboveLogs flag, renders a compact SchemaForm between the floating Test button and the logs/result panel when the script declares inputs (e.g. a partitioned script needing a `partition` arg). The preview pane also grows ~18pts so the args form doesn't shrink logs/result. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: parser join-mode (`// trigger all`) + script_trigger.join_all Stage A: JoinMode{Any(default),All} + `// trigger any|all` directive in parse_pipeline_annotations; TriggerSpec::is_partition_bearing() (path contains {partition}); join_mode threaded through all 4 asset-parser crates (ts/py/sql/yaml). Stage B: reversible migration adds script_trigger.join_all; insert_script_trigger writes it; deploy path sets it from the parsed annotation. No reader yet (AND-join dispatch is the next stage) so runtime behaviour is unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: resolve pipeline partition at job execution time Stage C: in handle_code_execution_job, once the script content is loaded, parse the // partitioned annotation (free here) and resolve the concrete partition once — schedule fire-time (scheduled_for anchor, not wall-clock) for time kinds, triggering payload for dynamic. The value is injected into the in-memory args the body sees (via a shadowed job clone) and persisted back to v2_job.args so dispatch_asset_triggers propagates the same value down the cascade. Already-set (explicit/backfill/cascade) partitions are never re-resolved (run identity immutable); unresolvable partitioned runs fail with a clear error. Integration test exercises the full worker loop + cascade propagation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: AND-join barrier for partitioned pipeline subscribers Stage D: a // trigger all subscriber no longer fires on any input. New join_pending_inputs slot table keyed (workspace, subscriber, partition); fetch_subscribers now returns join_all and the dispatch loop records each partition-bearing input arrival, pushing the subscriber once only when every partition-bearing input it declares is present for that partition. Per-partition slots, cleared on fire (re-accumulate, no double-fire), skew-immune (unlike debounce). Case-3 guard: an unpartitioned producer or a reference (non-{partition}) input never fires a partitioned join. Integration test covers wait/fire/isolation/no-double-fire. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: opt-in // debounce for asset-cascade subscribers (parser + schema) Stage E1+E2. Parser: script-level // debounce <dur> + per-// on debounce=<dur> override (edge wins, else script default, else none = fan-out, unchanged); TriggerSpec::Asset carries the per-edge override; split_trailing_kv_opts separates the ref from trailing key=val opts. Schema/deploy: reversible migration adds script_trigger.debounce_s; parse_duration_secs (bare int or <n>s|m|h|d, fail-safe on garbage) resolves the effective per-edge window at deploy and writes it per row. No reader yet (dispatch wiring is E3) so runtime is unchanged. New unit tests for the parser directive and duration parsing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: apply opt-in debounce to asset-cascade subscriber dispatch Stage E3. fetch_subscribers now also returns debounce_s; push_subscriber builds real DebouncingSettings (delay + a (subscriber, partition) key, so distinct partitions never collapse and latest-in-window falls out) instead of ::default() when the edge opted in. Default stays no-debounce (fan-out — the prior deliberate behaviour, now overridable rather than reversed). Wiring test asserts the dispatched job carries the configured window/key and an undebounced edge carries none. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: atomic AND-join gate + preserve resolved partition; drop scratch artifacts Addresses local-review findings before PR: - P1: record_and_check_join_slot was a non-atomic check-then-act on a pooled connection; concurrent completion of a subscriber's last two partition-bearing inputs on different workers could double-dispatch. Now one transaction guarded by a tx-scoped advisory lock keyed on (workspace, subscriber, partition) so the gate fires exactly once. - P2: the preprocessed-args overwrite in result_processor replaced args wholesale, dropping a partition resolved by resolve_partition_for_job; the UPDATE now preserves an existing persisted partition key. - P2: gate resolve_partition_for_job on a cheap code.contains check so non-pipeline script jobs skip the annotation scan on the hot path. - P2: remove 40 scratch screenshot PNGs, a flicker-debug script and a local scheduler lock accidentally committed; gitignore the lock. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: AND-join fires once under concurrent upstream completion Regression for the check-then-act race fixed by the advisory-locked transactional gate: releases N producer dispatches simultaneously via a barrier and asserts the AND subscriber is pushed exactly once and the slot is cleared. The invariant holds for the correct gate regardless of interleaving; a non-atomic regression fails it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: fuller partitioned join + multi-hop pipeline coverage Exercises a complex pipeline combining options end to end: two partitioned producers fanning into a // trigger all join, then a multi-hop downstream chain. Asserts the resolved partition propagates unchanged at every hop, chain depth increments per hop, the AND barrier fires exactly once, and a second partition opens an independent slot with no cross-partition bleed across the whole graph. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: simplify pipeline code per review (dedup, single-parse, constant) - ParseAssetsOutput::new() collapses the 6-line annotation copy-paste across the 4 asset-parser crates to one call site. - asset_dispatch: parse the cascade trigger object once and pass it to the depth/partition readers instead of deserializing it twice; add a TRIGGER_ARG constant for the previously stringly-typed key (3 sites). - scripts deploy: drop a redundant debounce_default clone. No behavior change; 29 parser + 6 dispatch integration tests green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: reap abandoned AND-join slots after a TTL (default 60d, per-slot) join_pending_inputs slots are normally cleared when the join fires; partial slots whose inputs never all arrive (upstream removed/renamed, one-off dynamic partition key, permanent skew) would otherwise leak. windmill_queue::asset_dispatch::reap_stale_join_slots, called from the monitor's delete_expired_items loop, deletes a (workspace, subscriber, partition) slot only when its MOST RECENT row is older than JOIN_SLOT_TTL_SECS (60d) — per-slot, never per-row, so a legitimately slow join is not corrupted mid-accumulation. Conservative default; per-join configurable TTL via the annotation is a planned follow-up. Test covers stale-reaped / fresh-kept / mixed-slot-kept. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * update * feat: path-less native trigger markers + missing-trigger placeholder * feat: pipeline // tag and // retry annotations + dispatch_event log * fix: derive test-pane min from split-axis dimension (height in bottom layout) * feat: show last run logs/result when a script node is selected * fix: backfill asset rows from script.assets for pre-feature scripts * feat: job-id link + dispatch popover above script log/result * style: drop 'dispatched' label, keep just the check icon * fix: drop tag picker from pipeline script editor (set via // tag annotation) * Nicer UI * refactor: move google ai proxy handling to windmill-ai (#9260) * refactor: add ai proxy execution mode * refactor: move google ai proxy handling * refactor: share google ai request building * fix: early return should consider failure_module result (#9241) * fix(flows): flag noLogs jobs and lazily resolve them in log panel (#9099) * fix(flows): flag noLogs jobs and lazily resolve them in log panel * fix appending to flag * fix: preserve WM_LOGS_SKIPPED sentinel on SSE/replay completion pickMoreCompleteLogs resolved both sentinel and undefined to '', so the SSE completion event (whose job field is fetched .without_logs()) would clobber the sentinel placed by flagSkippedLogs. The module log panel then saw '' instead of the sentinel, defeating the lazy-resolve path. Also wire onLogsResolved on the OutputPickerInner inline LogViewer so a lazy resolve writes back to flowStateStore.previewLogs, matching ModulePreviewResultViewer and avoiding repeated fetches on remount. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(main): release 1.705.0 (#9229) * chore(main): release 1.705.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * chore: add playwright mcp for frontend verification (#9269) * feat: CLI datatable serve / psql (#9267) * feat(cli): add datatable list and run commands * feat(cli): render datatable query results as a table * feat(cli): serve datatables as a postgres-wire endpoint * feat(cli): add 'datatable psql' to launch psql against the proxy * feat(cli): route datatable serve by client-supplied database name * override database list + password option * fix: support extended queries in datatable serve * fix: correct cloud size threshold log and parse CLI descriptions with parens/trailing comma * refactor: extract raw_output envelope encoding into pg_raw_output module --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * oom_adj nit * feat: add UV_PYTHON_INSTALL_MIRROR env and instance setting (#9271) * feat: add UV_PYTHON_INSTALL_MIRROR env and instance setting Allows operators to point `uv python install` at a private mirror of the python-build-standalone releases. Configurable via the `UV_PYTHON_INSTALL_MIRROR` env var or the `uv_python_install_mirror` instance setting, with the env var as the boot fallback and the instance setting taking precedence at reload. Fixes WIN-1966 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: hoist uv_python_install_mirror binding above sandboxing branch The non-sandboxed uv pip install branch referenced a binding that was only declared inside the sandboxed branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: neutral placeholder for uv_python_install_mirror The previous placeholder was the default public URL the setting is meant to redirect away from. A neutral example mirror URL is clearer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(indexer): tell admins when ingress routes search to wrong pod (#9274) * [ee] fix(indexer): tell admins when ingress routes search to wrong pod When the IndexReader is absent on the pod handling a search request but another pod is actively holding the indexer lock, the EE handler now returns a tailored error pointing at the ingress/load-balancer configuration instead of the generic "indexer not running" message. The indexer status endpoint reads the DB lock so it reports "running" from any pod, but search endpoints need the in-memory IndexReader that only exists on the lock holder. In multi-replica deployments this looks like the indexer is healthy but every search 404s. Companion: windmill-labs/windmill-ee-private#TBD Fixes WIN-1968. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817 This commit updates the EE repository reference after PR #586 was merged in windmill-ee-private. Previous ee-repo-ref: 7dd43d1850813071cc18ba49ba090583e7321f4b New ee-repo-ref: eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> * feat(cli): add `wmill init prompts` and custom override slot (#9266) * feat(cli): add `wmill init prompts` and custom override slot Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): replace init prompts with refresh prompts + AGENTS.md/AGENTS.cli.md split Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): dedupe claude skills via @-includes and add prompts freshness check Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): drop migration-choice flags from `refresh prompts` Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(cli): add 'Running and previewing local changes' section to AGENTS.cli.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): write full skill content to .claude/, drop @-include wrapper Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): reconcile CLAUDE.md the same way as AGENTS.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): address PR review nits — argv parsing, lazy import, comment detection, error propagation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add yolo mode for ai chat tools (#9258) * feat: add yolo mode for ai chat tools * nit * fix: align chat footer controls * feat: add ai chat autonomy modes * feat: add autonomy mode dropdown * fix: highlight yolo autonomy icon * fix: auto accept flow edits * fix: hide unsupported autonomy modes * fix: handle auto-accept flow editor races * fix(debugger): add non-root user support to Dockerfile (#9277) Mirrors the main Windmill Dockerfile pattern: creates a windmill user (UID/GID 1000) and makes cache/work directories world-writable so the image runs cleanly under Kubernetes securityContext.runAsNonRoot or runAsUser: 1000 without permission errors on Bun, pip, or windmill cache writes. Fixes WIN-1969 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path (#9276) * fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path The AI proxy handler accepts an X-Resource-Path header to override the configured workspace AI provider. When supplied, the handler loaded the resource value from the resource table using the root DB pool with no resources:read scope check, so any authenticated workspace user could point X-Resource-Path at a restricted AI resource (e.g. one in a folder they cannot read) and the proxy would use that resource's provider credentials for the outbound AI request. For user-supplied resource paths, now require resources:read:{path} scope and fetch the resource through user_db.begin(&authed) so RLS enforces the same folder/group boundary as the resource API. The RLS- scoped $var: resolution stays in place as defense in depth. The admin-configured workspace/instance ai_config path is unchanged. Fixes WIN-1971 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(ai): regression test for X-Resource-Path RLS enforcement Cover all four cases: - non-admin pointing X-Resource-Path at a restricted resource is rejected - non-admin pointing it at a resource they own still works - admin can point it at any resource - workspace-configured proxy flow (no X-Resource-Path) is unchanged Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add userdraft listing primitives (#9268) * feat: add userdraft listing primitives * fix: cancel stale userdraft discard writes * docs: remove global ai userdraft plan * feat(nsjail): optional disk-backed /tmp via instance setting (#9272) * feat(nsjail): optional disk-backed /tmp via instance setting * test(nsjail): unit-test tmp mount resolver and narrow visibility * refactor(nsjail): switch tmp backing to select + conditional UI * ui(nsjail): make tmpfs the visible default in /tmp backing select * fix(nsjail): refuse preexisting jail_tmp to block symlink escape * fix(nsjail): allow jail_tmp reuse on sequential nsjail calls Codex flagged that python/ruby/rust executors invoke nsjail twice per job_dir (install then run). The previous resolver treated any preexisting jail_tmp as hostile and silently fell back to tmpfs on the second call, so disk-backed mode never reached the main script run for those langs. Use symlink_metadata().is_dir() to distinguish a real directory left by an earlier call in the same job_dir (safe to reuse) from a symlink or other entity (still refused, as the codebase-tar escape requires). Also loosen the frontend visibility predicate: only hide nsjail settings when job_isolation is explicitly 'none' or 'unshare', so deployments that enable nsjail via DISABLE_NSJAIL=false with no DB setting can still see the controls. * chore(main): release 1.706.0 (#9270) * chore(main): release 1.706.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * fix(nsjail): gate unix-symlink test behind cfg(unix) for Windows build (#9280) The disk_backed_refuses_preexisting_symlink_at_jail_tmp test calls std::os::unix::fs::symlink directly, which doesn't exist on Windows targets. Without a cfg gate, `cargo check --tests` fails on Windows with E0433. Other symlink call sites in this crate (php_executor, bun_executor, rust_executor, etc.) already follow this pattern. Fixes WIN-1972 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Reduce slim image vulnerability surface (#9279) * Reduce slim image vulnerability surface * chore(docker): drop apt-get upgrade -y from slim images apt-get upgrade hurts build reproducibility (same Dockerfile + same commit at different times produces divergent images) and trips hadolint DL3005. The freshness it buys is dominated by simply rebuilding against the periodically-refreshed debian:bookworm-slim base image. The --no-install-recommends and apt-list cleanup wins are kept. --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev> * fix(git-sync): bump to hub/28234 with stateless gpg.program wrapper (WIN-1974) (#9282) * fix(git-sync): revert LATEST_GIT_SYNC_SCRIPT_PATH to hub/28230 to restore GPG-signed deploys (WIN-1974) hub/28231 (PR #9230) is the "thin" script that hands the actual `git commit` to the CLI's hidden `sync git-deploy`. The hub script still does the GPG setup (import key into a fresh GNUPGHOME, dummy `gpg -bsau` to warm the agent passphrase cache, then `git config user.signingkey` + `commit.gpgsign` locally), but the commit no longer runs in the same `git_push` flow — it runs minutes later inside the CLI after workspace API resolution, zip pull, file extraction, and lockfile autofill. By the time the spawned `git commit` asks gpg-agent for the cached passphrase, the cache state is no longer reliable (or the spawned `gpg` ends up talking to a fresh agent), so signing fails non-interactively with `gpg failed to sign the data`. hub/28230 is hub/28217's in-script logic rebuilt with windmill-cli@1.703.3: the GPG setup and the in-script `sh_run("git commit ...")` happen back-to-back in `git_push`, so the cache is always fresh. It preserves wm_deploy / fork branch behavior, the EE deployment-callback `main()` signature is unchanged, and the only min-version check in EE (`is_script_meets_min_version(28103)`) is comfortably below 28230 — so this revert is safe. Forward fix (separate PR): publish a new thin script that, alongside the existing GPG setup, writes a `gpg.program` wrapper using `--pinentry-mode loopback --passphrase-file` so signing is independent of the agent's cache state. Re-bump past 28231 then. Fixes WIN-1974 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(git-sync): check in source-of-truth for the next hub script (gpg.program wrapper) This is the script that will be published to hub.windmill.dev once verified on a customer GPG-signed deploy. It replaces hub/28231's agent-cache pre-warm (`gpg -bsau` with --passphrase) with a stateless gpg.program wrapper + chmod-600 passphrase file. Every git-invoked gpg call goes through the wrapper, which always uses --pinentry-mode loopback (and --passphrase-file when a passphrase exists). Signing no longer depends on gpg-agent having a cached passphrase by the time the CLI's `git commit` runs — which closes WIN-1974. Not wired in yet: LATEST_GIT_SYNC_SCRIPT_PATH stays on hub/28230 until this script is uploaded and the new hub id is known. This file is checked in so the diff is reviewable, future bumps have a source of truth, and a CLI regression test can `cat` it for fixture parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): skip format/pattern validation for $var/$res/$jsonvar references in ArgInput A resource field with a `pattern` constraint (e.g. the gpg_key.private_key field, whose pattern enforces a `-----BEGIN PGP PRIVATE KEY BLOCK-----` prefix) rejects values like `$var:u/me/gpg-private-key` with an "invalid format" error in the resource editor — even though `$var:`/`$res:`/`$jsonvar:` are placeholders the backend resolves at runtime, not the actual string that needs to match the regex. Bail out of all format/pattern checks (email, ipv4, ipv6, uuid, custom pattern) when the value is one of these references. Required/numeric bounds/array checks still apply since they're shape-level, not regex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(git-sync): bump LATEST_GIT_SYNC_SCRIPT_PATH to hub/28234 (gpg.program-wrapper fix) hub/28234 is the forward fix for WIN-1974: replaces hub/28231's agent-cache pre-warm (which became stale by the time the CLI's `git commit` ran) with a stateless `gpg.program` wrapper that uses `--pinentry-mode loopback` (and `--passphrase-file` when a passphrase exists) on every gpg invocation. Bundled CLI is windmill-cli@1.705.0. Verified via reproducer at /tmp/git-sync-diff/test-gpg-fix.sh: deliberately killing gpg-agent between GPG setup and `git commit` reproduces the customer's `gpg failed to sign the data` error verbatim under the old flow, and the wrapper signs through it. Holds for passphrase-protected keys, split-subkey [C]+[S] layouts, and unprotected keys. Drops the local source-of-truth copy (`hub-scripts/`) — hub is canonical now that 28234 is published. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(git-sync): drop verbose comment above LATEST_GIT_SYNC_SCRIPT_PATH The git history (this PR) carries the why; the constant name + value carry the what. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): wmill sync git-deploy stops committing; caller owns commit+push (#9284) Single contract for the deployment-callback path: the CLI does branch checkout + pull, the caller (hub script in production, test in test) does git add + commit + push. This restores the WIN-1974 invariant — GPG setup and `git commit` run back-to-back in the same process, so the agent's pre-warmed passphrase cache is still warm at sign time — without needing a `--skip-commit` flag for the hub case and a default "also-commit" for everything else. Same behavior in every call site. Changes: - sync.ts: drop the gitSyncDeployPush call from pull()'s deploy path (both the onlyCreateBranch fast-return and the post-pull commit). `gitSyncDeployPush` stays exported for any caller that wants the same commit/push semantics — just not invoked by the CLI subcommand. - gitsync_promotion.test.ts: e2e test now does its own git add + commit + push after `wmill sync git-deploy`, mirroring what the hub script does in production. Same regression coverage (wm_deploy branch created in Case A, main untouched; main updated in Case B, no new wm_deploy). CLI typecheck unchanged (two pre-existing TarAsZip errors at lines 2578/3307, present before this PR). All 743 unit tests still pass. The accompanying hub script (option-C — CLI for branch+pull, script for commit+push) lives at /tmp/git-sync-diff/sync-script-to-git-repo-windmill.option-C.ts. Once published, a follow-up bumps LATEST_GIT_SYNC_SCRIPT_PATH to its id. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * bump git sync to 28236 * fix: fork compare visibility for non-admins and stale-token superadmins (#9283) * fix: use fork-scoped authed for fork visibility in compare_workspaces * test: add EE end-to-end repro for fork rename visibility * chore: restore concurrency_locks sqlx cache lost in cleanup * test: add regression for stale-superadmin-token fork visibility bug * chore: update sqlx cache for new test queries * chore(main): release 1.706.1 (#9281) * chore(main): release 1.706.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> * feat: add wmill job rerun subcommand (#9275) * feat: add wmill job rerun subcommand * feat: add wmill job restart subcommand for flow restart-at-step * chore(system_prompts): point plugin skills sync at plugins/windmill/ (#9287) * chore(system_prompts): point plugin skills sync at plugins/windmill/ The plugin checkout's plugin folder is being renamed from `plugins/windmill-code-plugin/` to `plugins/windmill/` to shorten the slash-command namespace and align with the matching Cursor plugin layout. Paired with windmill-labs/windmill-claude-plugin#8. That PR must merge first so the next sync run finds the new folder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(system_prompts): update plugin-dir example to plugins/windmill Co-authored-by: centdix <centdix@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: centdix <centdix@users.noreply.github.com> * fix(cli): wmill sync pull updates wmill-lock.yaml for raw apps (#9289) * fix: flow recording teardown crash + rename package to @windmill-labs/components (#9288) * fix: guard against null recording during FlowRecordingReplay teardown Navigating away from a flow recording inside a workspace file-tree view threw `TypeError: Cannot read properties of null (reading 'flow')` from FlowGraphViewer once during the teardown tick. Svelte 5 compiles child component props as live getters that close over `$$props.recording.flow`. When `recording` flips to null on the parent's navigation, an outer `{#if !recording?.flow}` doesn't stop those getters from firing one more time as derived effects re-evaluate before the unmount lands — so the getter dereferences null and throws. Fix at the two layers where the deref actually happens: - FlowRecordingReplay: use `recording?.flow` at the binding sites (FlowViewer + graph-snippet FlowGraphViewer) so the compiler emits an optional-chained getter, and guard the snippet branch with `{:else if recording?.flow}` so it doesn't mount when there's nothing to show. - FlowGraphViewer: finish the optional chaining the rest of the file already used everywhere else (`flow?.value?.skip_expr`, `flow?.value?.cache_ttl`, `flow?.schema`). When the upstream binding returns undefined during teardown, the graph degrades to an empty frame instead of crashing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rename package to @windmill-labs/components - frontend/package.json: rename `windmill-components` → `@windmill-labs/components` - frontend/publish.sh: drop the in-place sed rename dance; the checked-in name now matches what's published, so `npm run package && npm publish` is enough - frontend/package-lock.json, system_prompts/auto-generated/prompts.d.ts: regenerated by `npm run package` under the new name Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * default script name * save logic * Keyboard nav * finish keynav * nits * CI fix * nit stop propagation * Merge branch 'main' into feat/asset-graph-view * commit * update * fix: cropped save button on small screens * progress * managed scheduled removed * all * progress * feat: add data upload pipeline trigger with auto S3 picker Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: avoid pane editor remount flicker when deploying a pipeline draft Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: show only the edited script's I/O in the asset graph, not the saved version's Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: derive script asset rows server-side at deploy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: shared fixture corpus keeps annotation parsers in parity Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: dev-run draft pipeline chains, live badges, deploy drift warning Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: ungate cascade producers, squash pipeline migrations Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop committed cli-sync fixtures and stray screenshots Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: show skip-asset-dispatch flag as badge instead of args row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: pipeline view mode default with activity feed, drafts overlay chip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: treat DROP TABLE as table-level write in sql asset parser Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: wmill datatable create + actionable sql extension error Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: ephemeral data-pipelines demo sync repo zip for handoff Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: wmill pipeline list/show renders the asset DAG in the terminal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * nits * nits * nits * nits * fix: defer draft persist-back past the batch so discard sticks first click Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: band-reserving tidy-tree asset graph layout with join breakpoints Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: route skip-layer and long graph edges around occupied columns Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: seed s3 template outputs with canonical leading-slash paths Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * all * feat: bundle data-pipeline drafts into the DB-backed user draft system Pipeline drafts were browser-only (localStorage `pipeline-<folder>`), so they didn't sync across devices, weren't server-visible, and never showed in the drafts list. Store them instead as one per-user `draft` row of a new `data_pipeline` kind, keyed at the folder (`f/<folder>/data_pipeline`), holding the same `{ drafts, activeDraftPath }` bundle. Stage 1 — backend kind: add `data_pipeline` to DRAFT_KIND (migration) and `UserDraftItemKind` (deployed_table=None, private). The list/update handlers and folder-path access check already cover a backing-table-less kind. Stage 2 — sync: add `GET /drafts/get_own/{kind}/{path}` so an editor with no deployed-overlay GET can load its own draft. The pipeline page now hydrates from the DB on mount (one-time localStorage import for in-flight drafts) and persists via UserDraftDbSyncer (debounce + optimistic-concurrency), keeping a localStorage crash mirror. Stage 3 — surface: the drafts review page renders the bundle as a "pipeline" row that opens `/pipeline/<folder>` (open-only; excluded from bulk deploy). Verified end-to-end in-browser: DB-seeded draft hydrates to "Edit (1)", edits persist back, and the row shows with Open pipeline / Discard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: pipeline Activity panel grouping, run↔graph highlight, deploy-conflict handling Activity panel (view mode): - Group cascade runs by the connected component of the asset-dispatch graph (new GET /jobs/asset_dispatch_edges over the dispatch_event table, incl. join_pending inputs), headed by the earliest originating run + its trigger, with a "+N" chip for joins fed by multiple triggers. - Success/failure count histogram with drag-to-filter brushing, an always-on time axis + per-bar tooltips, a Reset, and Last hour/24h/48h/7/30/90d ranges. - Node run-count/status badges now derive from the same merged historic+live events the panel shows (previously session-only). Run ↔ graph highlight: - Hovering a run row (or a group header → the whole cascade) rings the node(s), animates their incident edges, and borders the adjacent assets in the edge hue (blue write / gray read); expanding a run pins a soft-blue ring. - Switching edit→view re-surfaces the Activity feed. Deploy: - Live-content autosave for the open pipeline draft + an autosave indicator. - Re-saving a script now chains off the hash just created instead of a stale parent_hash (fixes the "lineage must be linear" error on a second save), and a genuine concurrent deploy opens a keep-mine / view-latest conflict modal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: pipeline editor badge requires asset-parse, not just main-function parse A pipeline script's asset lineage is load-bearing — a deploy that can't parse assets silently records no edges. The editor "parsable" dot only reflected inferArgs (the main function), so a body the asset parser rejects (e.g. a trailing `/////` in DuckDB) still showed green and deployed with empty lineage. ScriptEditor gains `requireValidAssets` (set by the pipeline pane); when on, the EditorBar badge is green only if BOTH the main function and inferAssets parse, with the tooltip distinguishing "Main function not parsable" / "Assets not parsable" / "Parsable". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: route asset-graph edges around nodes that sit in their path Edges could draw straight through an unrelated node (a join fan-out or long cross-component edge), making it ambiguous whether that node shared the input. AssetGraphEdge only saw its own endpoints, so it could only detour the near-vertical same-column skip case. The canvas now (once per layout, O(edges × nodes) — no per-frame cost) samples each edge's straight run against every non-incident node center and, on a crossing, passes a clear gutter lane to the edge via `data.detourX`; AssetGraphEdge routes the rounded-orthogonal detour through it. Verified: 0 edge↔node box crossings on the orders pipeline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: deploy pipeline drafts with freshly-inferred assets, not a stale snapshot "Save all" spread `...draft.script` into createScript, which carries a `assets` snapshot that isn't refreshed when the body is edited. So a renamed/removed output (e.g. an old `CREATE TABLE exciting_en32z9` later changed to `exciting_880909`) was re-deployed as a phantom write edge and lingered as an orphan asset on the graph — shown with no producer, and shifting position on click as the graph re-derived. saveDraft now re-runs inferAssets on the current body and passes the result as `assets`, overriding the snapshot — mirroring the per-pane save. The backend clears+reinserts from the sent set, so a re-deploy drops the stale rows. Verified: deploying with the fresh asset set removes the orphan from the graph. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: collect upstream reads from CTAS and CREATE VIEW in SQL asset parser `CREATE TABLE x AS SELECT … FROM y` (and `CREATE VIEW`) recorded only the write to x — the source read of y was silently dropped. Table-level reads are gathered in the `Statement::Query` arm via handle_table_with_joins; the generic table-factor visitor only picks up read-functions and string literals, not plain `FROM <table>` references. The AS-query of a CTAS isn't a `Statement::Query`, so its FROM tables were never walked. On the pipeline canvas this meant a `datatable://…` upstream consumed by a CTAS step showed no read node/edge — the step looked like it produced its output from nothing. Factor the Query arm's read collection into handle_query_reads and call it from the CreateTable (when it has an AS-query) and CreateView arms, balancing the cte_name_stack push in post_visit_statement. Updated the drop_then_create test (which had pinned the old drop-the-read behavior) and added CTAS + CREATE VIEW read coverage. Verified against the rebuilt asset wasm: the live editor now infers the read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * update * updates * refactor: dedup asset-graph code, squash migrations, drop artifacts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: gate asset dispatch on a cached per-workspace producer set Cache the producer-path→writes map per workspace and invalidate it from the asset-clear paths via the notify_event polling system, so a top-level script/preview completion that isn't an asset producer costs an in-memory lookup instead of a per-completion query. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: remove dead unquote fn that failed backend check under -D warnings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: green the frontend check (pin published wasm-asset, fix type errors) Pin windmill-parser-wasm-asset to the published 1.728.1 (was a file: link to a gitignored, CI-unbuilt pkg-asset). Exclude test files from svelte-check (the parity test reads a backend fixture via node:fs, which the browser app tsconfig has no @types/node for; vitest still runs them). Fix pre-existing branch type errors: drop the unsupported 2nd getScriptByPath arg, cast script.schema to Schema for inferArgs, coerce has_preprocessor to a definite boolean, and wrap the cancelJob handler so it isn't possibly-undefined. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: move pipeline partition resolution to ee-private (free-CE) Partition resolution becomes a private module (partition_ee in windmill-ee-private, hidden from the public repo) with an OSS no-op fallback (partition_oss); call sites resolve via the aliased windmill_common::partition. Not enterprise-gated — free to run in CE. Bumps ee-repo-ref to the ee branch carrying partition_ee. Verified building in default, private, and private,enterprise (offline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: move asset-cascade join/debounce/retry to ee-private (free-CE) Join barrier, debounce, and retry become the private windmill_queue::cascade module (cascade_ee in windmill-ee-private); OSS gets cascade_oss no-op fallbacks (plain OR fan-out). Core cascade stays public. Bumps ee-repo-ref. Verified default/private/private,enterprise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: skeleton enterprise pipeline freshness + backfill (TODO, ee-private) Gated windmill_common::pipeline_advanced (private; pipeline_advanced_ee) with OSS fallback; entry points return a clear not-implemented error. Deploy surfaces a TODO when a script declares // freshness. Bumps ee-repo-ref. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: repair asset_trigger_dispatch test after cascade carve-out + cache its queries Stage-2 moved reap_stale_join_slots to windmill_queue::cascade; update the integration test's import. Also commit the test's sqlx query cache (was never prepared with --tests, so SQLX_OFFLINE cargo test failed pre-existing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: invalidate producer-cache in asset dispatch tests (mirror deploy) The tests seed asset rows directly and run no notify poller, so the per-workspace producer cache went stale across tests → 0 dispatched. Clear it at the seed point, as a deploy would via notify_event. All 8 asset_trigger_dispatch tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to ba677ea142011462ad4dfe77e8375a6dd274cdef This commit updates the EE repository reference after PR #619 was merged in windmill-ee-private. Previous ee-repo-ref: 925c350cff55d3ea738d9e2e4098d9ce4bdda418 New ee-repo-ref: ba677ea142011462ad4dfe77e8375a6dd274cdef Automated by sync-ee-ref workflow. * test: disable producer cache in asset dispatch tests (isolated-DB safe) The .remove(WS) approach still raced: #[sqlx::test] gives each test its own DB but they share one workspace id, so the WS-keyed process-global cache clobbered across DBs under concurrent threads. Add an ASSET_PRODUCER_CACHE_DISABLED test hook and set it in the tests so every dispatch reads its own DB. 8/8 pass at --test-threads=10. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: replace asset-cascade depth cap with cycle detection The hardcoded MAX_CHAIN_DEPTH=5 truncated legitimate deep pipelines (silently — the check returned before event logging). Replace it with per-edge cycle detection: carry the producer lineage in trigger.chain and skip only a subscriber already in the chain, recording a visible cycle_detected dispatch_event. Acyclic pipelines of any depth now cascade fully; a high MAX_CHAIN_LEN backstop guards against runaway. Tests + UI label updated; 8/8 pass at --test-threads=10. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update dispatch_event reason examples (depth_cap → cycle_detected) Comment-only; the migration is idempotent and already in the potentially_stale self-heal list, so the checksum change re-applies cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: park cascade retry (P1 dead-end) + clear stale script_triggers on rename Two deploy-path fixes: - Retry is parked: a retried subscriber is wrapped in a SingleStepFlow, whose run is a flow step and ineligible for asset dispatch, so it would silently dead-end the cascade (P1). Stop persisting retry to script_trigger and warn at deploy; TODO(pipeline-retry) to re-enable once dispatch handles flow-wrapped producers. (Dispatch plumbing kept + still tested via direct seeding.) - Rename leaves stale script_trigger rows: clear was keyed on ns.path only, so old-path '// on' edges lingered and could trigger a script later recreated at that path. Also clear the old path on rename (assets already handled via the parent-hash clear). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> Co-authored-by: hugocasa <hugo@casademont.ch> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Arnaud <31803803+Araden14@users.noreply.github.com> Co-authored-by: Diego Imbert <diego@windmill.dev> Co-authored-by: centdix <40307056+centdix@users.noreply.github.com> Co-authored-by: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Aldrin Jenson <aldrinjenson@gmail.com> Co-authored-by: centdix <centdix@users.noreply.github.com>
4477 lines
162 KiB
Rust
4477 lines
162 KiB
Rust
#[cfg(feature = "oauth2")]
|
|
use std::collections::HashMap;
|
|
use std::{
|
|
fmt::Display,
|
|
ops::Mul,
|
|
str::FromStr,
|
|
sync::{
|
|
atomic::{AtomicU16, Ordering},
|
|
Arc, Mutex,
|
|
},
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
|
use futures::{stream::FuturesUnordered, StreamExt};
|
|
use serde::{de::DeserializeOwned, Deserialize};
|
|
use sqlx::{Pool, Postgres};
|
|
use tokio::{
|
|
join,
|
|
sync::{mpsc, RwLock},
|
|
time::timeout,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
#[cfg(feature = "embedding")]
|
|
use windmill_api::embeddings::update_embeddings_db;
|
|
use windmill_api::{
|
|
jobs::TIMEOUT_WAIT_RESULT, DEFAULT_BODY_LIMIT, IS_SECURE, REQUEST_SIZE_LIMIT, SAML_METADATA,
|
|
SCIM_TOKEN,
|
|
};
|
|
|
|
#[cfg(feature = "native_trigger")]
|
|
use windmill_api::native_triggers::sync::sync_all_triggers;
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
use windmill_common::ee_oss::low_disk_alerts;
|
|
#[cfg(feature = "enterprise")]
|
|
use windmill_common::ee_oss::{jobs_waiting_alerts, worker_groups_alerts};
|
|
|
|
#[cfg(feature = "oauth2")]
|
|
use windmill_common::global_settings::OAUTH_SETTING;
|
|
use windmill_common::otel_oss::{
|
|
otel_incr_zombie_delete_count, otel_incr_zombie_restart_count, otel_set_db_pool,
|
|
otel_set_queue_count, otel_set_queue_running_count,
|
|
};
|
|
use windmill_common::{
|
|
agent_workers::DECODED_AGENT_TOKEN,
|
|
apps::APP_WORKSPACED_ROUTE,
|
|
auth::create_token_for_owner,
|
|
ee_oss::CriticalErrorChannel,
|
|
email_oss::send_email_if_possible,
|
|
error,
|
|
flow_status::{FlowStatus, FlowStatusModule},
|
|
global_settings::{
|
|
AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING,
|
|
BUN_INSTALL_MIN_RELEASE_AGE_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
|
|
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
|
|
CRITICAL_ERROR_CHANNELS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
|
|
DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_PASSWORD_LOGIN, DISABLE_PASSWORD_LOGIN_SETTING,
|
|
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
|
|
FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING,
|
|
HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
|
|
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
|
|
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
|
|
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
|
|
OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
|
|
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING,
|
|
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
|
|
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
|
|
SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING,
|
|
SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING,
|
|
STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING,
|
|
UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING,
|
|
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
|
|
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
|
|
},
|
|
indexer::load_indexer_config,
|
|
jwt::JWT_SECRET,
|
|
oauth2::REQUIRE_PREEXISTING_USER_FOR_OAUTH,
|
|
server::load_smtp_config,
|
|
tracing_init::JSON_FMT,
|
|
users::truncate_token,
|
|
utils::{empty_as_none, now_from_db, rd_string, report_critical_error, Mode, HUB_API_SECRET},
|
|
worker::{
|
|
load_env_vars, load_init_bash_from_env, load_periodic_bash_script_from_env,
|
|
load_periodic_bash_script_interval_from_env, load_whitelist_env_vars_from_env,
|
|
load_worker_config, reload_custom_tags_setting, store_pull_query,
|
|
store_suspended_pull_query, Connection, WorkerConfig, DEFAULT_TAGS_PER_WORKSPACE,
|
|
DEFAULT_TAGS_WORKSPACES, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, INDEXER_CONFIG,
|
|
PREVIEW_TAGS_OVERRIDE, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR, WORKER_CONFIG,
|
|
WORKER_GROUP, WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED,
|
|
WORKSPACE_FAIRNESS_MAX_PERCENT, WORKSPACE_FAIRNESS_MIN_TOTAL,
|
|
},
|
|
KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE,
|
|
CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB,
|
|
DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED,
|
|
MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED,
|
|
SERVICE_LOG_RETENTION_SECS, STORE_AUDIT_LOGS_S3,
|
|
};
|
|
use windmill_common::{
|
|
client::AuthedClient,
|
|
global_settings::{
|
|
APP_WORKSPACED_ROUTE_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE,
|
|
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
|
},
|
|
};
|
|
#[cfg(feature = "parquet")]
|
|
use windmill_object_store::reload_object_store_setting;
|
|
use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload};
|
|
use windmill_worker::{
|
|
result_processor::handle_job_error, JobCompletedSender, JobIsolationLevel,
|
|
OtelTracingProxySettings, SameWorkerSender, WorkspaceRegistryMap, BUNFIG_INSTALL_SCOPES,
|
|
BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR,
|
|
JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML,
|
|
NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB,
|
|
NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL,
|
|
PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, SANDBOX_IMAGE_CACHE_MAX_MB,
|
|
SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY,
|
|
SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY,
|
|
UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES,
|
|
};
|
|
|
|
#[cfg(feature = "parquet")]
|
|
use windmill_object_store::ObjectStoreReload;
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
use crate::ee_oss::verify_license_key;
|
|
|
|
use crate::ee_oss::set_license_key;
|
|
|
|
#[cfg(feature = "prometheus")]
|
|
lazy_static::lazy_static! {
|
|
|
|
static ref QUEUE_ZOMBIE_RESTART_COUNT: prometheus::IntCounter = prometheus::register_int_counter!(
|
|
"queue_zombie_restart_count",
|
|
"Total number of jobs restarted due to ping timeout."
|
|
)
|
|
.unwrap();
|
|
static ref QUEUE_ZOMBIE_DELETE_COUNT: prometheus::IntCounter = prometheus::register_int_counter!(
|
|
"queue_zombie_delete_count",
|
|
"Total number of jobs deleted due to their ping timing out in an unrecoverable state."
|
|
)
|
|
.unwrap();
|
|
|
|
static ref QUEUE_COUNT: prometheus::IntGaugeVec = prometheus::register_int_gauge_vec!(
|
|
"queue_count",
|
|
"Number of jobs in the queue",
|
|
&["tag"]
|
|
).unwrap();
|
|
|
|
static ref QUEUE_RUNNING_COUNT: prometheus::IntGaugeVec = prometheus::register_int_gauge_vec!(
|
|
"queue_running_count",
|
|
"Number of running jobs in the queue",
|
|
&["tag"]
|
|
).unwrap();
|
|
|
|
}
|
|
lazy_static::lazy_static! {
|
|
static ref ZOMBIE_JOB_TIMEOUT: String = std::env::var("ZOMBIE_JOB_TIMEOUT")
|
|
.ok()
|
|
.and_then(|x| x.parse::<String>().ok())
|
|
.unwrap_or_else(|| "60".to_string());
|
|
|
|
static ref FLOW_ZOMBIE_TRANSITION_TIMEOUT: String = std::env::var("FLOW_ZOMBIE_TRANSITION_TIMEOUT")
|
|
.ok()
|
|
.and_then(|x| x.parse::<String>().ok())
|
|
.unwrap_or_else(|| "60".to_string());
|
|
|
|
|
|
pub static ref RESTART_ZOMBIE_JOBS: bool = std::env::var("RESTART_ZOMBIE_JOBS")
|
|
.ok()
|
|
.and_then(|x| x.parse::<bool>().ok())
|
|
.unwrap_or(true);
|
|
|
|
pub static ref DISABLE_ZOMBIE_JOBS_MONITORING: bool = std::env::var("DISABLE_ZOMBIE_JOBS_MONITORING")
|
|
.ok()
|
|
.and_then(|x| x.parse::<bool>().ok())
|
|
.unwrap_or(false);
|
|
|
|
pub static ref WORKERS_NAMES: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
|
|
|
|
static ref QUEUE_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
|
|
static ref QUEUE_RUNNING_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
|
|
static ref OTEL_QUEUE_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
|
|
static ref OTEL_QUEUE_RUNNING_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
|
|
static ref DISABLE_CONCURRENCY_LIMIT: bool = std::env::var("DISABLE_CONCURRENCY_LIMIT").is_ok_and(|s| s == "true");
|
|
|
|
//legacy typo
|
|
static ref STALE_JOB_THRESHOLD_MINUTES: Option<u64> = std::env::var("STALE_JOB_THRESHOLD_MINUTES").or_else(|_| std::env::var("STALE_JOB_THRESHOLD_MINUTES"))
|
|
|
|
.ok()
|
|
.and_then(|x| x.parse::<u64>().ok());
|
|
|
|
/// Batch size for job cleanup deletion queries. Default: 10000.
|
|
/// Larger values delete more jobs per batch but hold locks longer.
|
|
static ref JOB_CLEANUP_BATCH_SIZE: i64 = std::env::var("JOB_CLEANUP_BATCH_SIZE")
|
|
.ok()
|
|
.and_then(|x| x.parse::<i64>().ok())
|
|
.unwrap_or(20000);
|
|
|
|
/// Maximum number of batches to process per cleanup iteration. Default: 10.
|
|
/// Set to 0 for unlimited (process until no expired jobs remain).
|
|
static ref JOB_CLEANUP_MAX_BATCHES: i32 = std::env::var("JOB_CLEANUP_MAX_BATCHES")
|
|
.ok()
|
|
.and_then(|x| x.parse::<i32>().ok())
|
|
.unwrap_or(20);
|
|
}
|
|
|
|
pub async fn initial_load(
|
|
conn: &Connection,
|
|
tx: KillpillSender,
|
|
worker_mode: bool,
|
|
server_mode: bool,
|
|
#[cfg(feature = "parquet")] disable_s3_store: bool,
|
|
) {
|
|
if let Err(e) = reload_base_url_setting(&conn).await {
|
|
tracing::error!("Error loading base url: {:?}", e)
|
|
}
|
|
|
|
if let Some(db) = conn.as_sql() {
|
|
if let Err(e) = reload_critical_error_channels_setting(&db).await {
|
|
tracing::error!("Could loading critical error emails setting: {:?}", e);
|
|
}
|
|
}
|
|
|
|
if let Err(e) = load_metrics_enabled(conn).await {
|
|
tracing::error!("Error loading expose metrics: {e:#}");
|
|
}
|
|
|
|
if let Err(e) = load_metrics_debug_enabled(conn).await {
|
|
tracing::error!("Error loading expose debug metrics: {e:#}");
|
|
}
|
|
|
|
if let Err(e) = reload_critical_alert_mute_ui_setting(conn).await {
|
|
tracing::error!("Error loading critical alert mute ui setting: {e:#}");
|
|
}
|
|
|
|
if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await {
|
|
tracing::error!("Error loading critical alerts on token expiry setting: {e:#}");
|
|
}
|
|
|
|
if let Some(db) = conn.as_sql() {
|
|
if let Err(e) = load_tag_per_workspace_enabled(db).await {
|
|
tracing::error!("Error loading default tag per workpsace: {e:#}");
|
|
}
|
|
|
|
if let Err(e) = load_tag_per_workspace_workspaces(db).await {
|
|
tracing::error!("Error loading default tag per workpsace workspaces: {e:#}");
|
|
}
|
|
|
|
if let Err(e) = load_fork_workspace_tag_append_fork_suffix(db).await {
|
|
tracing::error!("Error loading fork workspace tag append fork suffix: {e:#}");
|
|
}
|
|
|
|
if let Err(e) = load_preview_tags_override(db).await {
|
|
tracing::error!("Error loading preview tags override: {e:#}");
|
|
}
|
|
|
|
// Workspace fairness (cloud-only). Load the percentage/duration/min knobs
|
|
// *before* the enabled flag so that `load_workspace_fairness_enabled` reads
|
|
// current values when re-storing the pull queries.
|
|
if let Err(e) = load_workspace_fairness_max_percent(db).await {
|
|
tracing::error!("Error loading workspace fairness max percent: {e:#}");
|
|
}
|
|
if let Err(e) = load_workspace_fairness_duration_secs(db).await {
|
|
tracing::error!("Error loading workspace fairness duration secs: {e:#}");
|
|
}
|
|
if let Err(e) = load_workspace_fairness_min_total(db).await {
|
|
tracing::error!("Error loading workspace fairness min total: {e:#}");
|
|
}
|
|
if let Err(e) = load_workspace_fairness_enabled(db).await {
|
|
tracing::error!("Error loading workspace fairness enabled: {e:#}");
|
|
}
|
|
}
|
|
|
|
if server_mode {
|
|
if let Some(db) = conn.as_sql() {
|
|
load_require_preexisting_user(db).await;
|
|
load_disable_password_login(db).await;
|
|
if let Err(e) = reload_critical_alerts_on_db_oversize(db).await {
|
|
tracing::error!(
|
|
"Error reloading critical alerts on db oversize setting: {:?}",
|
|
e
|
|
)
|
|
}
|
|
windmill_common::min_version::store_min_keep_alive_version(db).await;
|
|
reload_instance_events_webhook_setting(db).await;
|
|
}
|
|
}
|
|
|
|
if worker_mode {
|
|
load_keep_job_dir(conn).await;
|
|
match conn {
|
|
Connection::Sql(db) => {
|
|
reload_worker_config(&db, tx, false).await;
|
|
}
|
|
Connection::Http(_) => {
|
|
// TODO: reload worker config from http
|
|
let worker_tags = DECODED_AGENT_TOKEN
|
|
.as_ref()
|
|
.map(|x| x.tags.clone())
|
|
.unwrap_or_default();
|
|
// we only check from env as native_mode is not stored in the token
|
|
// NATIVE_MODE_RESOLVED is already set in main.rs during startup
|
|
let native_mode = windmill_common::worker::is_native_mode_from_env();
|
|
WORKER_CONFIG.store(std::sync::Arc::new(WorkerConfig {
|
|
worker_tags,
|
|
env_vars: load_env_vars(
|
|
load_whitelist_env_vars_from_env(),
|
|
&std::collections::HashMap::new(),
|
|
),
|
|
priority_tags_sorted: vec![],
|
|
dedicated_worker: None,
|
|
dedicated_workers: None,
|
|
init_bash: load_init_bash_from_env(),
|
|
periodic_script_bash: load_periodic_bash_script_from_env(),
|
|
periodic_script_interval_seconds: load_periodic_bash_script_interval_from_env(),
|
|
cache_clear: None,
|
|
additional_python_paths: None,
|
|
pip_local_dependencies: None,
|
|
native_mode,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await {
|
|
tracing::error!("Error reloading hub base url: {:?}", e)
|
|
}
|
|
|
|
if let Some(db) = conn.as_sql() {
|
|
if let Err(e) = reload_jwt_secret_setting(db).await {
|
|
tracing::error!("Could not reload jwt secret setting: {:?}", e);
|
|
}
|
|
|
|
if let Err(e) = reload_custom_tags_setting(db).await {
|
|
tracing::error!("Error reloading custom tags: {:?}", e)
|
|
}
|
|
|
|
if let Err(e) = reload_app_workspaced_route_setting(db).await {
|
|
tracing::error!("Error reloading app workspaced route: {:?}", e)
|
|
}
|
|
|
|
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
|
|
tracing::error!("Error reloading http route workspaced route: {:?}", e)
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "parquet")]
|
|
if !disable_s3_store {
|
|
if let Some(db) = conn.as_sql() {
|
|
let db2 = db.clone();
|
|
match reload_object_store_setting(db).await {
|
|
ObjectStoreReload::Later => {
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_secs(10)).await;
|
|
match reload_object_store_setting(&db2).await {
|
|
ObjectStoreReload::Later => {
|
|
tracing::error!("Giving up on loading object store setting");
|
|
}
|
|
ObjectStoreReload::Never => {
|
|
tracing::info!("Object store setting successfully loaded");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
ObjectStoreReload::Never => (),
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(db) = conn.as_sql() {
|
|
reload_smtp_config(db).await;
|
|
}
|
|
|
|
reload_hub_api_secret_setting(&conn).await;
|
|
|
|
if server_mode {
|
|
reload_retention_period_setting(&conn).await;
|
|
reload_audit_log_retention_days_setting(&conn).await;
|
|
reload_store_audit_logs_s3_setting(&conn).await;
|
|
// Env-var enable has no settings-row xmin and no runtime enable event;
|
|
// anchor the export cursor at startup so rows committed before the
|
|
// first export tick are not skipped (no-op when a settings row exists
|
|
// or a checkpoint is already present). Audit-log S3 export is an
|
|
// Enterprise feature; the core logic lives in `crate::ee` (OSS gets a
|
|
// no-op), gated here on a valid Enterprise license.
|
|
#[cfg(feature = "parquet")]
|
|
if STORE_AUDIT_LOGS_S3.load(std::sync::atomic::Ordering::Relaxed)
|
|
&& matches!(
|
|
windmill_common::ee_oss::get_license_plan().await,
|
|
windmill_common::ee_oss::LicensePlan::Enterprise
|
|
)
|
|
{
|
|
if let Some(db) = conn.as_sql() {
|
|
crate::ee_oss::anchor_audit_logs_s3_checkpoint_env_var(&db).await;
|
|
}
|
|
}
|
|
reload_request_size(&conn).await;
|
|
reload_saml_metadata_setting(&conn).await;
|
|
reload_scim_token_setting(&conn).await;
|
|
|
|
// Ensure audit partitions exist before any requests arrive
|
|
if let Some(db) = conn.as_sql() {
|
|
manage_audit_partitions(&db, audit_log_retention_days().await).await;
|
|
}
|
|
}
|
|
|
|
if worker_mode {
|
|
reload_job_default_timeout_setting(&conn).await;
|
|
reload_job_isolation_setting(&conn).await;
|
|
reload_nsjail_tmpfs_size_setting(&conn).await;
|
|
reload_nsjail_tmp_backing_setting(&conn).await;
|
|
reload_sandbox_image_max_size_setting(&conn).await;
|
|
reload_sandbox_image_cache_max_setting(&conn).await;
|
|
reload_sandbox_image_pull_policy_setting(&conn).await;
|
|
reload_sandbox_image_default_registry_setting(&conn).await;
|
|
reload_sandbox_registry_auth_setting(&conn).await;
|
|
reload_extra_pip_index_url_setting(&conn).await;
|
|
reload_pip_index_url_setting(&conn).await;
|
|
reload_uv_index_strategy_setting(&conn).await;
|
|
reload_uv_exclude_newer_setting(&conn).await;
|
|
reload_uv_python_install_mirror_setting(&conn).await;
|
|
reload_bun_install_min_release_age_setting(&conn).await;
|
|
reload_npm_config_registry_setting(&conn).await;
|
|
reload_bunfig_install_scopes_setting(&conn).await;
|
|
reload_npmrc_setting(&conn).await;
|
|
reload_instance_python_version_setting(&conn).await;
|
|
reload_nuget_config_setting(&conn).await;
|
|
reload_powershell_repo_url_setting(&conn).await;
|
|
reload_powershell_repo_pat_setting(&conn).await;
|
|
reload_maven_repos_setting(&conn).await;
|
|
reload_maven_settings_xml_setting(&conn).await;
|
|
reload_no_default_maven_setting(&conn).await;
|
|
reload_ruby_repos_setting(&conn).await;
|
|
reload_cargo_registries_setting(&conn).await;
|
|
reload_workspace_registries_setting(&conn).await;
|
|
}
|
|
}
|
|
|
|
pub async fn load_metrics_enabled(conn: &Connection) -> error::Result<()> {
|
|
let metrics_enabled =
|
|
load_value_from_global_settings_with_conn(conn, EXPOSE_METRICS_SETTING, true).await;
|
|
match metrics_enabled {
|
|
Ok(Some(serde_json::Value::Bool(t))) => METRICS_ENABLED.store(t, Ordering::Relaxed),
|
|
_ => (),
|
|
};
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct OtelSetting {
|
|
metrics_enabled: Option<bool>,
|
|
logs_enabled: Option<bool>,
|
|
tracing_enabled: Option<bool>,
|
|
#[serde(default, deserialize_with = "empty_as_none")]
|
|
otel_exporter_otlp_endpoint: Option<String>,
|
|
#[serde(default, deserialize_with = "empty_as_none")]
|
|
otel_exporter_otlp_headers: Option<String>,
|
|
#[serde(default, deserialize_with = "empty_as_none")]
|
|
otel_exporter_otlp_protocol: Option<String>,
|
|
#[serde(default, deserialize_with = "empty_as_none")]
|
|
otel_exporter_otlp_compression: Option<String>,
|
|
}
|
|
|
|
pub async fn load_otel(db: &DB) {
|
|
let otel = load_value_from_global_settings(db, OTEL_SETTING).await;
|
|
if let Ok(v) = otel {
|
|
if let Some(v) = v {
|
|
let deser = serde_json::from_value::<OtelSetting>(v);
|
|
if let Ok(o) = deser {
|
|
let metrics_enabled = o.metrics_enabled.unwrap_or_else(|| {
|
|
std::env::var("OTEL_METRICS_ENABLED")
|
|
.map(|x| x.parse::<bool>().unwrap_or(false))
|
|
.unwrap_or(false)
|
|
});
|
|
let logs_enabled = o.logs_enabled.unwrap_or_else(|| {
|
|
std::env::var("OTEL_LOGS_ENABLED")
|
|
.map(|x| x.parse::<bool>().unwrap_or(false))
|
|
.unwrap_or(false)
|
|
});
|
|
let tracing_enabled = o.tracing_enabled.unwrap_or_else(|| {
|
|
std::env::var("OTEL_TRACING_ENABLED")
|
|
.map(|x| x.parse::<bool>().unwrap_or(false))
|
|
.unwrap_or(false)
|
|
});
|
|
|
|
OTEL_METRICS_ENABLED.store(metrics_enabled, Ordering::Relaxed);
|
|
OTEL_LOGS_ENABLED.store(logs_enabled, Ordering::Relaxed);
|
|
OTEL_TRACING_ENABLED.store(tracing_enabled, Ordering::Relaxed);
|
|
|
|
let endpoint = if let Some(endpoint) = o.otel_exporter_otlp_endpoint {
|
|
unsafe {
|
|
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint.clone());
|
|
}
|
|
Some(endpoint.clone())
|
|
} else {
|
|
std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok()
|
|
};
|
|
|
|
let headers = if let Some(headers) = o.otel_exporter_otlp_headers {
|
|
unsafe {
|
|
std::env::set_var("OTEL_EXPORTER_OTLP_HEADERS", headers.clone());
|
|
}
|
|
Some(headers.clone())
|
|
} else {
|
|
std::env::var("OTEL_EXPORTER_OTLP_HEADERS").ok()
|
|
};
|
|
|
|
if let Some(protocol) = o.otel_exporter_otlp_protocol {
|
|
unsafe {
|
|
std::env::set_var("OTEL_EXPORTER_OTLP_PROTOCOL", protocol);
|
|
}
|
|
}
|
|
if let Some(compression) = o.otel_exporter_otlp_compression {
|
|
unsafe {
|
|
std::env::set_var("OTEL_EXPORTER_OTLP_COMPRESSION", compression);
|
|
}
|
|
}
|
|
println!("OTEL settings loaded: tracing ({tracing_enabled}), logs ({logs_enabled}), metrics ({metrics_enabled}), endpoint ({:?}), headers defined: ({})",
|
|
endpoint, headers.is_some());
|
|
} else {
|
|
tracing::error!("Error deserializing otel settings");
|
|
}
|
|
}
|
|
} else {
|
|
tracing::error!("Error loading otel settings: {}", otel.unwrap_err());
|
|
}
|
|
}
|
|
|
|
pub async fn load_tag_per_workspace_enabled(db: &DB) -> error::Result<()> {
|
|
let metrics_enabled =
|
|
load_value_from_global_settings(db, DEFAULT_TAGS_PER_WORKSPACE_SETTING).await;
|
|
|
|
match metrics_enabled {
|
|
Ok(Some(serde_json::Value::Bool(t))) => {
|
|
DEFAULT_TAGS_PER_WORKSPACE.store(t, Ordering::Relaxed)
|
|
}
|
|
_ => (),
|
|
};
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn load_tag_per_workspace_workspaces(db: &DB) -> error::Result<()> {
|
|
let workspaces = load_value_from_global_settings(db, DEFAULT_TAGS_WORKSPACES_SETTING).await;
|
|
|
|
match workspaces {
|
|
Ok(Some(serde_json::Value::Array(t))) => {
|
|
let workspaces = t
|
|
.iter()
|
|
.filter_map(|x| x.as_str())
|
|
.map(|x| x.to_string())
|
|
.collect::<Vec<String>>();
|
|
DEFAULT_TAGS_WORKSPACES.store(std::sync::Arc::new(Some(workspaces)));
|
|
}
|
|
Ok(None) => {
|
|
DEFAULT_TAGS_WORKSPACES.store(std::sync::Arc::new(None));
|
|
}
|
|
_ => (),
|
|
};
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn load_preview_tags_override(db: &DB) -> error::Result<()> {
|
|
let value = load_value_from_global_settings(db, PREVIEW_TAGS_OVERRIDE_SETTING).await;
|
|
|
|
match value {
|
|
Ok(Some(serde_json::Value::Bool(t))) => PREVIEW_TAGS_OVERRIDE.store(t, Ordering::Relaxed),
|
|
_ => (),
|
|
};
|
|
Ok(())
|
|
}
|
|
|
|
// Upper bound on the duration window. Postgres `make_interval(secs => $1::int4)` is the consumer
|
|
// downstream, so this stays comfortably below `i32::MAX` and the subsequent `u32 -> i32` cast in
|
|
// `workspace_fairness::refresh_overloaded` cannot wrap into a negative interval (which would
|
|
// silently turn `now() - interval` into a future timestamp and disable the completed-jobs half
|
|
// of the activity signal). A day is the practical ceiling for a "rolling window" knob.
|
|
const WORKSPACE_FAIRNESS_DURATION_SECS_MAX: u64 = 86_400;
|
|
|
|
/// Min-total floor is a counting threshold; cap at `u32::MAX` to make wraparound impossible
|
|
/// while still leaving more headroom than any realistic cluster will need.
|
|
const WORKSPACE_FAIRNESS_MIN_TOTAL_MAX: u64 = u32::MAX as u64;
|
|
|
|
// Defaults used when a fairness knob is unset (row missing or row deleted via NULL/empty value).
|
|
// Must stay in sync with the `AtomicU32::new(...)` initialisers in `windmill-common/src/worker.rs`
|
|
// so a process that has never seen the setting reads the same value as one that just saw it
|
|
// cleared.
|
|
const WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT: u32 = 50;
|
|
const WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT: u32 = 10;
|
|
const WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT: u32 = 4;
|
|
|
|
pub async fn load_workspace_fairness_enabled(db: &DB) -> error::Result<()> {
|
|
// Match the convention used by `load_preview_tags_override` /
|
|
// `load_fork_workspace_tag_append_fork_suffix`: on transient DB errors, leave the in-memory
|
|
// atomic untouched rather than silently toggling the feature off across the whole cluster
|
|
// (which would also trigger an unnecessary `store_pull_query` rebuild — exactly when DB load
|
|
// is probably highest).
|
|
let new_enabled =
|
|
match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_ENABLED_SETTING).await? {
|
|
Some(serde_json::Value::Bool(t)) => t,
|
|
// Setting unset / non-bool → explicit off.
|
|
_ => false,
|
|
};
|
|
let prev = WORKSPACE_FAIRNESS_ENABLED.swap(new_enabled, Ordering::Relaxed);
|
|
// Re-store the pull queries so the fairness variants appear/disappear in
|
|
// lockstep with the toggle.
|
|
if prev != new_enabled {
|
|
let wc = windmill_common::worker::WORKER_CONFIG.load_full();
|
|
store_pull_query(&wc).await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn load_workspace_fairness_max_percent(db: &DB) -> error::Result<()> {
|
|
// Distinguish three outcomes:
|
|
// - `Err(_)`: transient DB issue. Leave the atomic alone (don't clobber a known-good value
|
|
// because of a network blip during a notify-event propagation).
|
|
// - `Ok(None)` or `Ok(Some(invalid))`: setting is unset / explicitly cleared / corrupt.
|
|
// Restore the default so a deletion via the admin UI actually takes effect at runtime
|
|
// instead of leaving the stale in-memory value pinned until restart.
|
|
// - `Ok(Some(valid))`: clamp and store.
|
|
match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING).await? {
|
|
Some(serde_json::Value::Number(n)) => {
|
|
let v = n
|
|
.as_u64()
|
|
.map(|u| u.clamp(1, 100) as u32)
|
|
.unwrap_or(WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT);
|
|
WORKSPACE_FAIRNESS_MAX_PERCENT.store(v, Ordering::Relaxed);
|
|
}
|
|
_ => {
|
|
WORKSPACE_FAIRNESS_MAX_PERCENT
|
|
.store(WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT, Ordering::Relaxed);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn load_workspace_fairness_duration_secs(db: &DB) -> error::Result<()> {
|
|
// See `load_workspace_fairness_max_percent` for the Err / None / invalid policy.
|
|
match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING).await? {
|
|
Some(serde_json::Value::Number(n)) => {
|
|
// Clamp to the safe range before narrowing. The downstream `u32 -> i32` cast in
|
|
// `workspace_fairness::refresh_overloaded` makes any value above `i32::MAX` toxic
|
|
// (sign flip → negative interval → silent disable of the completed-jobs scan).
|
|
let v = n
|
|
.as_u64()
|
|
.map(|u| u.clamp(1, WORKSPACE_FAIRNESS_DURATION_SECS_MAX) as u32)
|
|
.unwrap_or(WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT);
|
|
WORKSPACE_FAIRNESS_DURATION_SECS.store(v, Ordering::Relaxed);
|
|
}
|
|
_ => {
|
|
WORKSPACE_FAIRNESS_DURATION_SECS
|
|
.store(WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT, Ordering::Relaxed);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn load_workspace_fairness_min_total(db: &DB) -> error::Result<()> {
|
|
// See `load_workspace_fairness_max_percent` for the Err / None / invalid policy.
|
|
match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING).await? {
|
|
Some(serde_json::Value::Number(n)) => {
|
|
// Clamp before narrowing — same reasoning as `_duration_secs`, just for the
|
|
// counting threshold rather than the interval.
|
|
let v = n
|
|
.as_u64()
|
|
.map(|u| u.min(WORKSPACE_FAIRNESS_MIN_TOTAL_MAX) as u32)
|
|
.unwrap_or(WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT);
|
|
WORKSPACE_FAIRNESS_MIN_TOTAL.store(v, Ordering::Relaxed);
|
|
}
|
|
_ => {
|
|
WORKSPACE_FAIRNESS_MIN_TOTAL
|
|
.store(WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT, Ordering::Relaxed);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn load_fork_workspace_tag_append_fork_suffix(db: &DB) -> error::Result<()> {
|
|
let value =
|
|
load_value_from_global_settings(db, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING).await;
|
|
|
|
match value {
|
|
Ok(Some(serde_json::Value::Bool(t))) => {
|
|
FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX.store(t, Ordering::Relaxed)
|
|
}
|
|
Ok(None) => FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX.store(false, Ordering::Relaxed),
|
|
_ => (),
|
|
};
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error::Result<()> {
|
|
if let Ok(Some(serde_json::Value::Bool(t))) =
|
|
load_value_from_global_settings_with_conn(conn, CRITICAL_ALERT_MUTE_UI_SETTING, true).await
|
|
{
|
|
CRITICAL_ALERT_MUTE_UI_ENABLED.store(t, Ordering::Relaxed);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reload_critical_alerts_on_token_expiry_setting(
|
|
conn: &Connection,
|
|
) -> error::Result<()> {
|
|
if let Ok(Some(serde_json::Value::Bool(t))) = load_value_from_global_settings_with_conn(
|
|
conn,
|
|
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
|
|
true,
|
|
)
|
|
.await
|
|
{
|
|
CRITICAL_ALERTS_ON_TOKEN_EXPIRY.store(t, Ordering::Relaxed);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn load_metrics_debug_enabled(conn: &Connection) -> error::Result<()> {
|
|
let metrics_enabled =
|
|
load_value_from_global_settings_with_conn(conn, EXPOSE_DEBUG_METRICS_SETTING, true).await;
|
|
match metrics_enabled {
|
|
Ok(Some(serde_json::Value::Bool(t))) => {
|
|
METRICS_DEBUG_ENABLED.store(t, Ordering::Relaxed);
|
|
//_RJEM_MALLOC_CONF=prof:true,prof_active:false,lg_prof_interval:30,lg_prof_sample:21,prof_prefix:/tmp/jeprof
|
|
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
|
|
if std::env::var("_RJEM_MALLOC_CONF").is_ok() {
|
|
if let Err(e) = set_prof_active(t) {
|
|
tracing::error!("Error setting jemalloc prof_active: {e:?}");
|
|
}
|
|
}
|
|
}
|
|
_ => (),
|
|
};
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
|
|
#[derive(Debug, Clone)]
|
|
pub struct MallctlError {
|
|
#[allow(unused)]
|
|
pub code: i32,
|
|
}
|
|
|
|
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
|
|
fn set_prof_active(new_value: bool) -> Result<(), MallctlError> {
|
|
let option_name = std::ffi::CString::new("prof.active").unwrap();
|
|
|
|
tracing::info!("Setting jemalloc prof_active to {}", new_value);
|
|
let result = unsafe {
|
|
tikv_jemalloc_sys::mallctl(
|
|
option_name.as_ptr(), // const char *name
|
|
std::ptr::null_mut(), // void *oldp
|
|
std::ptr::null_mut(), // size_t *oldlenp
|
|
&new_value as *const _ as *mut _, // void *newp
|
|
std::mem::size_of_val(&new_value), // size_t newlen
|
|
)
|
|
};
|
|
|
|
if result != 0 {
|
|
return Err(MallctlError { code: result });
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
|
|
pub fn bytes_to_mb(bytes: u64) -> f64 {
|
|
const BYTES_PER_MB: f64 = 1_048_576.0;
|
|
bytes as f64 / BYTES_PER_MB
|
|
}
|
|
|
|
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
|
|
pub async fn monitor_mem() {
|
|
use std::time::Duration;
|
|
use tikv_jemalloc_ctl::{epoch, stats};
|
|
|
|
tokio::spawn(async move {
|
|
// Obtain a MIB for the `epoch`, `stats.allocated`, and
|
|
// `atats.resident` keys:
|
|
let e = match epoch::mib() {
|
|
Ok(mib) => mib,
|
|
Err(e) => {
|
|
tracing::error!("Error getting jemalloc epoch mib: {:?}", e);
|
|
return;
|
|
}
|
|
};
|
|
let allocated = match stats::allocated::mib() {
|
|
Ok(mib) => mib,
|
|
Err(e) => {
|
|
tracing::error!("Error getting jemalloc allocated mib: {:?}", e);
|
|
return;
|
|
}
|
|
};
|
|
let resident = match stats::resident::mib() {
|
|
Ok(mib) => mib,
|
|
Err(e) => {
|
|
tracing::error!("Error getting jemalloc resident mib: {:?}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
loop {
|
|
// Many statistics are cached and only updated
|
|
// when the epoch is advanced:
|
|
match e.advance() {
|
|
Ok(_) => {
|
|
// Read statistics using MIB key:
|
|
let allocated = allocated.read().unwrap_or_default();
|
|
let resident = resident.read().unwrap_or_default();
|
|
tracing::info!(
|
|
"{} mb allocated/{} mb resident",
|
|
bytes_to_mb(allocated as u64),
|
|
bytes_to_mb(resident as u64)
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error advancing jemalloc epoch: {:?}", e);
|
|
}
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn sleep_until_next_minute_start_plus_one_s() {
|
|
let now = Utc::now();
|
|
let next_minute = now + Duration::from_secs(60 - now.timestamp() as u64 % 60 + 1);
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(
|
|
next_minute.timestamp() as u64 - now.timestamp() as u64,
|
|
))
|
|
.await;
|
|
}
|
|
|
|
use windmill_common::tracing_init::TMP_WINDMILL_LOGS_SERVICE;
|
|
async fn find_two_highest_files(hostname: &str) -> (Option<String>, Option<String>) {
|
|
let log_dir = format!("{}/{}/", *TMP_WINDMILL_LOGS_SERVICE, hostname);
|
|
let rd_dir = tokio::fs::read_dir(log_dir).await;
|
|
if let Ok(mut log_files) = rd_dir {
|
|
let mut highest_file: Option<String> = None;
|
|
let mut second_highest_file: Option<String> = None;
|
|
while let Ok(Some(file)) = log_files.next_entry().await {
|
|
let file_name = file
|
|
.file_name()
|
|
.to_str()
|
|
.map(|x| x.to_string())
|
|
.unwrap_or_default();
|
|
if file_name > highest_file.clone().unwrap_or_default() {
|
|
second_highest_file = highest_file;
|
|
highest_file = Some(file_name);
|
|
}
|
|
}
|
|
(highest_file, second_highest_file)
|
|
} else {
|
|
tracing::error!(
|
|
"Error reading log files: {}, {:#?}",
|
|
*TMP_WINDMILL_LOGS_SERVICE,
|
|
rd_dir.unwrap_err()
|
|
);
|
|
(None, None)
|
|
}
|
|
}
|
|
|
|
fn get_worker_group(mode: &Mode) -> Option<String> {
|
|
let worker_group = WORKER_GROUP.clone();
|
|
if worker_group.is_empty() || mode == &Mode::Server || mode == &Mode::Indexer {
|
|
None
|
|
} else {
|
|
Some(worker_group)
|
|
}
|
|
}
|
|
|
|
pub fn send_logs_to_object_store(conn: &Connection, hostname: &str, mode: &Mode) {
|
|
let conn = conn.clone();
|
|
let hostname = hostname.to_string();
|
|
let mode = mode.clone();
|
|
let worker_group = get_worker_group(&mode);
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
sleep_until_next_minute_start_plus_one_s().await;
|
|
loop {
|
|
interval.tick().await;
|
|
let (_, snd_highest_file) = find_two_highest_files(&hostname).await;
|
|
send_log_file_to_object_store(
|
|
&hostname,
|
|
&mode,
|
|
&worker_group,
|
|
&conn,
|
|
snd_highest_file,
|
|
false,
|
|
)
|
|
.await;
|
|
}
|
|
});
|
|
}
|
|
|
|
pub async fn send_current_log_file_to_object_store(conn: &Connection, hostname: &str, mode: &Mode) {
|
|
tracing::info!("Sending current log file to object store");
|
|
let (highest_file, _) = find_two_highest_files(hostname).await;
|
|
let worker_group = get_worker_group(&mode);
|
|
send_log_file_to_object_store(hostname, mode, &worker_group, conn, highest_file, true).await;
|
|
}
|
|
|
|
fn get_now_and_str() -> (NaiveDateTime, String) {
|
|
let ts = Utc::now().naive_utc();
|
|
(
|
|
ts,
|
|
ts.format(windmill_common::tracing_init::LOG_TIMESTAMP_FMT)
|
|
.to_string(),
|
|
)
|
|
}
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref LAST_LOG_FILE_SENT: Arc<Mutex<Option<NaiveDateTime>>> = Arc::new(Mutex::new(None));
|
|
}
|
|
|
|
async fn send_log_file_to_object_store(
|
|
hostname: &str,
|
|
mode: &Mode,
|
|
worker_group: &Option<String>,
|
|
conn: &Connection,
|
|
snd_highest_file: Option<String>,
|
|
use_now: bool,
|
|
) {
|
|
if let Some(highest_file) = snd_highest_file {
|
|
//parse datetime frome file xxxx.yyyy-MM-dd-HH-mm
|
|
let (ts, ts_str) = if use_now {
|
|
get_now_and_str()
|
|
} else {
|
|
highest_file
|
|
.split(".")
|
|
.last()
|
|
.and_then(|x| {
|
|
NaiveDateTime::parse_from_str(
|
|
x,
|
|
windmill_common::tracing_init::LOG_TIMESTAMP_FMT,
|
|
)
|
|
.ok()
|
|
.map(|y| (y, x.to_string()))
|
|
})
|
|
.unwrap_or_else(get_now_and_str)
|
|
};
|
|
|
|
let exists = LAST_LOG_FILE_SENT.lock().map(|last_log_file_sent| {
|
|
last_log_file_sent
|
|
.map(|last_log_file_sent| last_log_file_sent >= ts)
|
|
.unwrap_or(false)
|
|
});
|
|
|
|
if exists.unwrap_or(false) {
|
|
return;
|
|
}
|
|
|
|
#[cfg(feature = "parquet")]
|
|
let s3_client = windmill_object_store::get_object_store().await;
|
|
#[cfg(feature = "parquet")]
|
|
if let Some(s3_client) = s3_client {
|
|
let path = std::path::Path::new(&*TMP_WINDMILL_LOGS_SERVICE)
|
|
.join(hostname)
|
|
.join(&highest_file);
|
|
|
|
//read file as byte stream
|
|
let bytes = tokio::fs::read(&path).await;
|
|
if let Err(e) = bytes {
|
|
tracing::error!("Error reading log file: {:?}", e);
|
|
return;
|
|
}
|
|
let path = windmill_object_store::object_store_reexports::Path::from_url_path(format!(
|
|
"{}{hostname}/{highest_file}",
|
|
windmill_common::tracing_init::LOGS_SERVICE
|
|
));
|
|
if let Err(e) = path {
|
|
tracing::error!("Error creating log file path: {:?}", e);
|
|
return;
|
|
}
|
|
if let Err(e) = s3_client.put(&path.unwrap(), bytes.unwrap().into()).await {
|
|
tracing::error!("Error sending logs to object store: {:?}", e);
|
|
}
|
|
}
|
|
|
|
let (ok_lines, err_lines) = read_log_counters(ts_str);
|
|
|
|
if let Some(db) = conn.as_sql() {
|
|
match timeout(Duration::from_secs(10), sqlx::query!("INSERT INTO log_file (hostname, mode, worker_group, log_ts, file_path, ok_lines, err_lines, json_fmt)
|
|
VALUES ($1, $2::text::LOG_MODE, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (hostname, log_ts) DO UPDATE SET ok_lines = log_file.ok_lines + $6, err_lines = log_file.err_lines + $7",
|
|
hostname, mode.to_string(), worker_group.clone(), ts, highest_file, ok_lines as i64, err_lines as i64, *JSON_FMT)
|
|
.execute(db)).await {
|
|
Ok(Ok(_)) => {
|
|
if let Err(e) = LAST_LOG_FILE_SENT.lock().map(|mut last_log_file_sent| {
|
|
last_log_file_sent.replace(ts);
|
|
}) {
|
|
tracing::error!("Error updating last log file sent: {:?}", e);
|
|
}
|
|
tracing::info!("Log file sent: {}", highest_file);
|
|
}
|
|
Ok(Err(e)) => {
|
|
tracing::error!("Error inserting log file: {:?}", e);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error inserting log file, timeout elapsed: {:?}", e);
|
|
}
|
|
}
|
|
} else {
|
|
// tracing::warn!("Not sending log file to object store in agent mode");
|
|
()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_log_counters(ts_str: String) -> (usize, usize) {
|
|
let counters = windmill_common::tracing_init::LOG_COUNTING_BY_MIN.read();
|
|
let mut ok_lines = 0;
|
|
let mut err_lines = 0;
|
|
if let Ok(ref c) = counters {
|
|
let counter = c.get(&ts_str);
|
|
if let Some(counter) = counter {
|
|
ok_lines = counter.non_error_count;
|
|
err_lines = counter.error_count;
|
|
} else {
|
|
// println!("no counter found for {ts_str}");
|
|
}
|
|
} else {
|
|
println!("Error reading log counters 2");
|
|
}
|
|
(ok_lines, err_lines)
|
|
}
|
|
|
|
pub async fn load_keep_job_dir(conn: &Connection) {
|
|
let value = load_value_from_global_settings_with_conn(conn, KEEP_JOB_DIR_SETTING, true).await;
|
|
match value {
|
|
Ok(Some(serde_json::Value::Bool(t))) => KEEP_JOB_DIR.store(t, Ordering::Relaxed),
|
|
Err(e) => {
|
|
tracing::error!("Error loading keep job dir metrics: {e:#}");
|
|
}
|
|
_ => (),
|
|
};
|
|
}
|
|
|
|
pub async fn reload_otel_tracing_proxy_setting(conn: &Connection) {
|
|
match load_value_from_global_settings_with_conn(conn, OTEL_TRACING_PROXY_SETTING, true).await {
|
|
Ok(Some(settings)) => match serde_json::from_value::<OtelTracingProxySettings>(settings) {
|
|
Ok(new_settings) => {
|
|
let mut current = OTEL_TRACING_PROXY_SETTINGS.write().await;
|
|
if current.enabled != new_settings.enabled
|
|
|| current.enabled_languages != new_settings.enabled_languages
|
|
|| current.no_proxy_hosts != new_settings.no_proxy_hosts
|
|
{
|
|
tracing::info!(
|
|
"OTEL tracing proxy settings changed: enabled={}, languages={:?}, no_proxy_hosts={:?}",
|
|
new_settings.enabled,
|
|
new_settings.enabled_languages,
|
|
new_settings.no_proxy_hosts,
|
|
);
|
|
*current = new_settings;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error parsing OTEL tracing proxy settings: {e:#}");
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::error!("Error loading OTEL tracing proxy setting: {e:#}");
|
|
}
|
|
_ => (),
|
|
};
|
|
}
|
|
|
|
pub async fn load_require_preexisting_user(db: &DB) {
|
|
let value =
|
|
load_value_from_global_settings(db, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING).await;
|
|
match value {
|
|
Ok(Some(serde_json::Value::Bool(t))) => {
|
|
REQUIRE_PREEXISTING_USER_FOR_OAUTH.store(t, Ordering::Relaxed)
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error loading keep job dir metrics: {e:#}");
|
|
}
|
|
_ => (),
|
|
};
|
|
}
|
|
|
|
pub async fn load_disable_password_login(db: &DB) {
|
|
let value = load_value_from_global_settings(db, DISABLE_PASSWORD_LOGIN_SETTING).await;
|
|
match value {
|
|
Ok(Some(serde_json::Value::Bool(t))) => DISABLE_PASSWORD_LOGIN.store(t, Ordering::Relaxed),
|
|
Ok(None) => DISABLE_PASSWORD_LOGIN.store(false, Ordering::Relaxed),
|
|
Err(e) => {
|
|
tracing::error!("Error loading disable_password_login setting: {e:#}");
|
|
}
|
|
_ => (),
|
|
};
|
|
}
|
|
|
|
struct LogFile {
|
|
file_path: String,
|
|
hostname: String,
|
|
}
|
|
|
|
struct TokenRow {
|
|
token_prefix: Option<String>,
|
|
label: Option<String>,
|
|
email: Option<String>,
|
|
workspace_id: Option<String>,
|
|
}
|
|
|
|
async fn report_token_expiration(db: &DB, token: &TokenRow, expired: bool) {
|
|
if !windmill_common::auth::is_user_token(token.label.as_deref()) {
|
|
return;
|
|
}
|
|
let prefix = token.token_prefix.as_deref().unwrap_or("??????????");
|
|
let email_addr = token.email.as_deref().unwrap_or("unknown");
|
|
let token_desc = match token.label.as_deref() {
|
|
Some(l) if !l.is_empty() => format!("'{l}' ({prefix}****)"),
|
|
_ => format!("{prefix}****"),
|
|
};
|
|
|
|
let (alert_message, email_subject, email_body) = if expired {
|
|
(
|
|
format!(
|
|
"API token {token_desc} of '{email_addr}' has expired and been deleted"
|
|
),
|
|
"Windmill: Your API token has expired",
|
|
format!(
|
|
"Your API token {token_desc} has expired and been deleted.\n\nPlease create a new token if you still need API access."
|
|
),
|
|
)
|
|
} else {
|
|
(
|
|
format!("API token {token_desc} of '{email_addr}' is expiring soon"),
|
|
"Windmill: Your API token is expiring soon",
|
|
format!(
|
|
"Your API token {token_desc} is expiring soon.\n\nPlease rotate or renew your token to avoid service disruption."
|
|
),
|
|
)
|
|
};
|
|
|
|
tracing::info!("{}", alert_message);
|
|
if CRITICAL_ALERTS_ON_TOKEN_EXPIRY.load(Ordering::Relaxed) {
|
|
report_critical_error(
|
|
alert_message,
|
|
db.clone(),
|
|
token.workspace_id.as_deref(),
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
if let Some(email) = &token.email {
|
|
send_email_if_possible(email_subject, &email_body, email);
|
|
}
|
|
}
|
|
|
|
pub async fn delete_expired_items(db: &DB) -> () {
|
|
let expired_tokens_r = sqlx::query_as!(
|
|
TokenRow,
|
|
"DELETE FROM token WHERE expiration <= now()
|
|
RETURNING token_prefix, label, email, workspace_id",
|
|
)
|
|
.fetch_all(db)
|
|
.await;
|
|
|
|
match expired_tokens_r {
|
|
Ok(tokens) => {
|
|
if !tokens.is_empty() {
|
|
tracing::info!("deleted {} expired tokens", tokens.len());
|
|
for t in &tokens {
|
|
report_token_expiration(db, t, true).await;
|
|
}
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("Error deleting token: {}", e.to_string()),
|
|
}
|
|
|
|
let pip_resolution_r = sqlx::query_scalar!(
|
|
"DELETE FROM pip_resolution_cache WHERE expiration <= now() RETURNING hash",
|
|
)
|
|
.fetch_all(db)
|
|
.await;
|
|
|
|
match pip_resolution_r {
|
|
Ok(res) => {
|
|
if res.len() > 0 {
|
|
tracing::info!("deleted {} pip_resolution: {:?}", res.len(), res)
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("Error deleting pip_resolution: {}", e.to_string()),
|
|
}
|
|
|
|
// Clean up expired MCP OAuth refresh tokens
|
|
let mcp_refresh_tokens_r: std::result::Result<Vec<i64>, _> = sqlx::query_scalar(
|
|
"DELETE FROM mcp_oauth_refresh_token WHERE expires_at <= now() RETURNING id",
|
|
)
|
|
.fetch_all(db)
|
|
.await;
|
|
|
|
match mcp_refresh_tokens_r {
|
|
Ok(ids) => {
|
|
if ids.len() > 0 {
|
|
tracing::info!("deleted {} expired MCP OAuth refresh tokens", ids.len())
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("Error deleting MCP OAuth refresh tokens: {}", e.to_string()),
|
|
}
|
|
|
|
let deleted_cache = sqlx::query_scalar!(
|
|
"DELETE FROM resource WHERE resource_type = 'cache' AND to_timestamp((value->>'expire')::int) < now() RETURNING path",
|
|
)
|
|
.fetch_all(db)
|
|
.await;
|
|
|
|
match deleted_cache {
|
|
Ok(res) => {
|
|
if res.len() > 0 {
|
|
tracing::info!("deleted {} cache resource: {:?}", res.len(), res)
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("Error deleting cache resource {}", e.to_string()),
|
|
}
|
|
|
|
let deleted_expired_variables = sqlx::query_scalar!(
|
|
"DELETE FROM variable WHERE expires_at IS NOT NULL AND expires_at < now() RETURNING path",
|
|
)
|
|
.fetch_all(db)
|
|
.await;
|
|
|
|
match deleted_expired_variables {
|
|
Ok(res) => {
|
|
if res.len() > 0 {
|
|
tracing::info!("deleted {} expired variables {:?}", res.len(), res)
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("Error deleting cache resource {}", e.to_string()),
|
|
}
|
|
|
|
match sqlx::query_as!(
|
|
LogFile,
|
|
"DELETE FROM log_file WHERE log_ts <= now() - ($1::bigint::text || ' s')::interval RETURNING file_path, hostname",
|
|
SERVICE_LOG_RETENTION_SECS,
|
|
)
|
|
.fetch_all(db)
|
|
.await
|
|
{
|
|
Ok(log_files_to_delete) => {
|
|
let paths = log_files_to_delete
|
|
.iter()
|
|
.map(|f| format!("{}/{}", f.hostname, f.file_path))
|
|
.collect();
|
|
delete_log_files_from_disk_and_store(paths, &*TMP_WINDMILL_LOGS_SERVICE, windmill_common::tracing_init::LOGS_SERVICE).await;
|
|
|
|
}
|
|
Err(e) => tracing::error!("Error deleting log file: {:?}", e),
|
|
}
|
|
|
|
let audit_retention_days = audit_log_retention_days().await;
|
|
let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24;
|
|
|
|
// Clean up old (non-partitioned) audit table — will eventually be empty and dropped
|
|
if let Err(e) = sqlx::query_scalar!(
|
|
"DELETE FROM audit WHERE timestamp <= now() - ($1::bigint::text || ' s')::interval",
|
|
audit_retention_secs,
|
|
)
|
|
.fetch_all(db)
|
|
.await
|
|
{
|
|
tracing::error!("Error deleting audit log: {:?}", e);
|
|
}
|
|
|
|
if let Err(e) = sqlx::query_scalar!(
|
|
"DELETE FROM autoscaling_event WHERE applied_at <= now() - ($1::bigint::text || ' s')::interval",
|
|
30 * 24 * 60 * 60, // 30 days
|
|
)
|
|
.fetch_all(db)
|
|
.await
|
|
{
|
|
tracing::error!("Error deleting autoscaling event on CE: {:?}", e);
|
|
}
|
|
|
|
if let Err(e) = windmill_queue::cascade::reap_stale_join_slots(db).await {
|
|
tracing::error!("Error reaping stale join_pending_inputs slots: {:?}", e);
|
|
}
|
|
|
|
match sqlx::query_scalar!(
|
|
"DELETE FROM agent_token_blacklist WHERE expires_at <= now() RETURNING token",
|
|
)
|
|
.fetch_all(db)
|
|
.await
|
|
{
|
|
Ok(deleted_tokens) => {
|
|
if deleted_tokens.len() > 0 {
|
|
tracing::info!(
|
|
"deleted {} expired blacklisted agent tokens: {:?}",
|
|
deleted_tokens.len(),
|
|
deleted_tokens
|
|
);
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("Error deleting expired blacklisted agent tokens: {:?}", e),
|
|
}
|
|
|
|
match sqlx::query_scalar!(
|
|
"DELETE FROM mcp_oauth_server_code WHERE expires_at <= now() RETURNING code",
|
|
)
|
|
.fetch_all(db)
|
|
.await
|
|
{
|
|
Ok(deleted_codes) => {
|
|
if deleted_codes.len() > 0 {
|
|
tracing::info!(
|
|
"deleted {} expired MCP OAuth authorization codes",
|
|
deleted_codes.len()
|
|
);
|
|
}
|
|
}
|
|
Err(e) => tracing::error!(
|
|
"Error deleting expired MCP OAuth authorization codes: {:?}",
|
|
e
|
|
),
|
|
}
|
|
|
|
let job_retention_secs = JOB_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed);
|
|
if job_retention_secs > 0 {
|
|
let batch_size = *JOB_CLEANUP_BATCH_SIZE;
|
|
let max_batches = *JOB_CLEANUP_MAX_BATCHES;
|
|
let cleanup_start = Instant::now();
|
|
let mut total_deleted = 0u64;
|
|
let mut batch_num = 0i32;
|
|
|
|
// Process batches until no more expired jobs or max batches reached
|
|
loop {
|
|
if max_batches > 0 && batch_num >= max_batches {
|
|
tracing::debug!(
|
|
"Job cleanup: reached max batches limit ({}), will continue next iteration",
|
|
max_batches
|
|
);
|
|
break;
|
|
}
|
|
|
|
// Each batch runs in its own transaction to avoid long-running locks
|
|
let batch_result = delete_expired_jobs_batch(db, job_retention_secs, batch_size).await;
|
|
|
|
match batch_result {
|
|
Ok(deleted_count) => {
|
|
if deleted_count == 0 {
|
|
// No more expired jobs to delete
|
|
break;
|
|
}
|
|
total_deleted += deleted_count as u64;
|
|
batch_num += 1;
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error in job cleanup batch {}: {:?}", batch_num, e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if total_deleted > 0 {
|
|
tracing::info!(
|
|
"Job cleanup completed: deleted {} jobs in {} batches, took {:?}",
|
|
total_deleted,
|
|
batch_num,
|
|
cleanup_start.elapsed()
|
|
);
|
|
}
|
|
|
|
// Clean up concurrency keys separately (not tied to specific job IDs)
|
|
if let Err(e) = sqlx::query!(
|
|
"DELETE FROM concurrency_key WHERE ended_at <= now() - ($1::bigint::text || ' s')::interval",
|
|
job_retention_secs
|
|
)
|
|
.execute(db)
|
|
.await
|
|
{
|
|
tracing::error!("Error deleting custom concurrency key: {:?}", e);
|
|
}
|
|
}
|
|
|
|
match windmill_common::trashbin::delete_expired_trash(db).await {
|
|
Ok(count) => {
|
|
if count > 0 {
|
|
tracing::info!("deleted {} expired trash items", count);
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("Error deleting expired trash items: {}", e.to_string()),
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
async fn cleanup_scheduled_job_deletions(db: &Pool<Postgres>) {
|
|
const BATCH_SIZE: i64 = 1000;
|
|
const MAX_BATCHES: i32 = 10;
|
|
|
|
let mut total_deleted = 0u64;
|
|
for batch_num in 0..MAX_BATCHES {
|
|
let mut tx = match db.begin().await {
|
|
Ok(tx) => tx,
|
|
Err(e) => {
|
|
tracing::error!("Error starting transaction for scheduled job deletion: {e:?}");
|
|
break;
|
|
}
|
|
};
|
|
|
|
let rows = match sqlx::query_scalar!(
|
|
"DELETE FROM job_delete_schedule
|
|
WHERE job_id IN (
|
|
SELECT job_id FROM job_delete_schedule
|
|
WHERE delete_at <= now()
|
|
ORDER BY delete_at
|
|
LIMIT $1
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
RETURNING job_id",
|
|
BATCH_SIZE,
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
tracing::error!("Error in scheduled job deletion batch {batch_num}: {e:?}");
|
|
break;
|
|
}
|
|
};
|
|
|
|
if rows.is_empty() {
|
|
break;
|
|
}
|
|
|
|
let job_ids = rows;
|
|
let count = job_ids.len() as u64;
|
|
|
|
let cleanup_result: Result<(), sqlx::Error> = async {
|
|
sqlx::query!(
|
|
"UPDATE v2_job SET args = '{}'::jsonb WHERE id = ANY($1)",
|
|
&job_ids,
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!(
|
|
"UPDATE v2_job_completed SET result = '{}'::jsonb WHERE id = ANY($1)",
|
|
&job_ids,
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!(
|
|
"UPDATE job_logs SET logs = '##DELETED##' WHERE job_id = ANY($1)",
|
|
&job_ids,
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
if let Err(e) = cleanup_result {
|
|
// Roll back so schedule rows survive for retry on next cycle
|
|
tracing::error!("Error cleaning job data in batch {batch_num}, rolling back: {e:?}");
|
|
break;
|
|
}
|
|
|
|
if let Err(e) = tx.commit().await {
|
|
tracing::error!("Error committing scheduled job deletion batch {batch_num}: {e:?}");
|
|
break;
|
|
}
|
|
|
|
total_deleted += count;
|
|
}
|
|
|
|
if total_deleted > 0 {
|
|
tracing::info!("Scheduled job deletion: cleaned {total_deleted} jobs");
|
|
}
|
|
}
|
|
|
|
pub async fn check_expiring_tokens(db: &DB) {
|
|
// Find tokens expiring within 7 days that still have a pending notification row.
|
|
// The notification table stores token_hash (not plaintext) so the join works
|
|
// even after the hash migration makes token.token nullable.
|
|
let expiring_tokens_r = sqlx::query_as!(
|
|
TokenRow,
|
|
"DELETE FROM token_expiry_notification n
|
|
USING token t
|
|
WHERE n.token_hash = t.token_hash
|
|
AND n.expiration > now()
|
|
AND n.expiration <= now() + interval '7 days'
|
|
RETURNING t.token_prefix, t.label, t.email, t.workspace_id",
|
|
)
|
|
.fetch_all(db)
|
|
.await;
|
|
|
|
match expiring_tokens_r {
|
|
Ok(tokens) => {
|
|
for t in &tokens {
|
|
report_token_expiration(db, t, false).await;
|
|
}
|
|
if !tokens.is_empty() {
|
|
tracing::info!("Sent expiration warnings for {} token(s)", tokens.len());
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("Error checking expiring tokens: {}", e),
|
|
}
|
|
|
|
// Clean up notification rows whose expiration has passed
|
|
if let Err(e) = sqlx::query!("DELETE FROM token_expiry_notification WHERE expiration <= now()")
|
|
.execute(db)
|
|
.await
|
|
{
|
|
tracing::error!("Error cleaning up expired token notifications: {}", e);
|
|
}
|
|
}
|
|
|
|
/// Delete a batch of expired jobs with LIMIT and SKIP LOCKED for high-scale environments.
|
|
/// Uses a single transaction per batch to minimize lock duration.
|
|
/// Returns the number of jobs deleted in this batch.
|
|
async fn delete_expired_jobs_batch(
|
|
db: &DB,
|
|
job_retention_secs: i64,
|
|
batch_size: i64,
|
|
) -> error::Result<usize> {
|
|
let mut tx = db.begin().await?;
|
|
|
|
// Fetch active ROOT job IDs that started before the retention period. We only care about
|
|
// these because their child jobs could be old enough to be deletion candidates.
|
|
// Jobs started after the retention period can't have children old enough to delete.
|
|
let active_root_job_ids: Vec<Uuid> = sqlx::query_scalar!(
|
|
"SELECT q.id FROM v2_job_queue q
|
|
JOIN v2_job j ON j.id = q.id
|
|
WHERE j.parent_job IS NULL
|
|
AND j.created_at <= now() - ($1::bigint::text || ' s')::interval",
|
|
job_retention_secs
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas
|
|
// ORDER BY completed_at ensures we delete oldest jobs first
|
|
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
|
|
"DELETE FROM v2_job_completed
|
|
WHERE id IN (
|
|
SELECT jc.id FROM v2_job_completed jc
|
|
LEFT JOIN v2_job j ON j.id = jc.id
|
|
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
|
|
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)
|
|
ORDER BY jc.completed_at ASC
|
|
LIMIT $2
|
|
FOR UPDATE OF jc SKIP LOCKED
|
|
)
|
|
RETURNING id",
|
|
job_retention_secs,
|
|
batch_size,
|
|
&active_root_job_ids
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
let deleted_count = deleted_jobs.len();
|
|
|
|
if deleted_count > 0 {
|
|
tracing::debug!(
|
|
"Deleting batch of {} expired jobs (retention: {}s)",
|
|
deleted_count,
|
|
job_retention_secs
|
|
);
|
|
|
|
if let Err(e) = sqlx::query!(
|
|
"DELETE FROM job_stats WHERE job_id = ANY($1)",
|
|
&deleted_jobs
|
|
)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
tracing::error!("Error deleting job stats: {:?}", e);
|
|
}
|
|
|
|
match sqlx::query_scalar!(
|
|
"DELETE FROM job_logs WHERE job_id = ANY($1) RETURNING log_file_index",
|
|
&deleted_jobs
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(log_file_index) => {
|
|
let paths = log_file_index
|
|
.into_iter()
|
|
.filter_map(|opt| opt)
|
|
.flat_map(|inner_vec| inner_vec.into_iter())
|
|
.collect();
|
|
delete_log_files_from_disk_and_store(paths, &*WINDMILL_DIR, "").await;
|
|
}
|
|
Err(e) => tracing::error!("Error deleting job logs: {:?}", e),
|
|
}
|
|
|
|
if let Err(e) = sqlx::query!("DELETE FROM v2_job WHERE id = ANY($1)", &deleted_jobs)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
tracing::error!("Error deleting job: {:?}", e);
|
|
}
|
|
|
|
// Should already be deleted but just in case
|
|
if let Err(e) = sqlx::query!(
|
|
"DELETE FROM job_result_stream_v2 WHERE job_id = ANY($1)",
|
|
&deleted_jobs
|
|
)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
tracing::error!("Error deleting job result stream: {:?}", e);
|
|
}
|
|
}
|
|
|
|
tx.commit().await?;
|
|
|
|
Ok(deleted_count)
|
|
}
|
|
|
|
async fn delete_log_files_from_disk_and_store(
|
|
paths_to_delete: Vec<String>,
|
|
tmp_dir: &str,
|
|
_s3_prefix: &str,
|
|
) {
|
|
// S3 bulk delete (batched via delete_stream — on S3 this uses the DeleteObjects
|
|
// API, up to 1000 objects per request).
|
|
#[cfg(feature = "parquet")]
|
|
{
|
|
let should_del_from_store =
|
|
MONITOR_LOGS_ON_OBJECT_STORE.load(std::sync::atomic::Ordering::Relaxed);
|
|
if should_del_from_store {
|
|
if let Some(os) = windmill_object_store::get_object_store().await {
|
|
let s3_paths: Vec<_> = paths_to_delete
|
|
.iter()
|
|
.map(|p| {
|
|
windmill_object_store::object_store_reexports::Path::from(format!(
|
|
"{}{}",
|
|
_s3_prefix, p
|
|
))
|
|
})
|
|
.map(Ok)
|
|
.collect();
|
|
let stream = futures::stream::iter(s3_paths).boxed();
|
|
let mut result = os.delete_stream(stream);
|
|
while let Some(r) = result.next().await {
|
|
if let Err(e) = r {
|
|
tracing::error!("Failed to delete from object store: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Disk delete in parallel.
|
|
let delete_futures = FuturesUnordered::new();
|
|
for path in paths_to_delete {
|
|
delete_futures.push(async move {
|
|
let disk_path = std::path::Path::new(tmp_dir).join(&path);
|
|
if tokio::fs::metadata(&disk_path).await.is_ok() {
|
|
if let Err(e) = tokio::fs::remove_file(&disk_path).await {
|
|
tracing::error!(
|
|
"Failed to delete from disk {}: {e}",
|
|
disk_path.to_string_lossy()
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
let _: Vec<_> = delete_futures.collect().await;
|
|
}
|
|
|
|
pub async fn reload_instance_events_webhook_setting(db: &DB) {
|
|
use windmill_common::global_settings::INSTANCE_EVENTS_WEBHOOK_SETTING;
|
|
use windmill_common::webhook::INSTANCE_EVENTS_WEBHOOK;
|
|
|
|
let value = load_value_from_global_settings(db, INSTANCE_EVENTS_WEBHOOK_SETTING).await;
|
|
match value {
|
|
Ok(Some(serde_json::Value::String(s))) if !s.is_empty() => {
|
|
INSTANCE_EVENTS_WEBHOOK.store(std::sync::Arc::new(Some(s)));
|
|
}
|
|
Ok(None) | Ok(Some(serde_json::Value::Null)) | Ok(Some(serde_json::Value::String(_))) => {
|
|
// Fall back to env var if DB has no value
|
|
INSTANCE_EVENTS_WEBHOOK.store(std::sync::Arc::new(
|
|
std::env::var("INSTANCE_EVENTS_WEBHOOK").ok(),
|
|
));
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error loading instance_events_webhook setting: {e:#}");
|
|
}
|
|
_ => (),
|
|
};
|
|
}
|
|
|
|
pub async fn reload_scim_token_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(conn, SCIM_TOKEN_SETTING, "SCIM_TOKEN", SCIM_TOKEN.clone())
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_timeout_wait_result_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
TIMEOUT_WAIT_RESULT_SETTING,
|
|
"TIMEOUT_WAIT_RESULT",
|
|
TIMEOUT_WAIT_RESULT.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_saml_metadata_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
SAML_METADATA_SETTING,
|
|
"SAML_METADATA",
|
|
SAML_METADATA.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_extra_pip_index_url_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
EXTRA_PIP_INDEX_URL_SETTING,
|
|
"PIP_EXTRA_INDEX_URL",
|
|
PIP_EXTRA_INDEX_URL.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_pip_index_url_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
PIP_INDEX_URL_SETTING,
|
|
"PIP_INDEX_URL",
|
|
PIP_INDEX_URL.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_uv_index_strategy_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
UV_INDEX_STRATEGY_SETTING,
|
|
"UV_INDEX_STRATEGY",
|
|
UV_INDEX_STRATEGY.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_uv_exclude_newer_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
UV_EXCLUDE_NEWER_SETTING,
|
|
"UV_EXCLUDE_NEWER",
|
|
UV_EXCLUDE_NEWER.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_uv_python_install_mirror_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
UV_PYTHON_INSTALL_MIRROR_SETTING,
|
|
"UV_PYTHON_INSTALL_MIRROR",
|
|
UV_PYTHON_INSTALL_MIRROR.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_bun_install_min_release_age_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
BUN_INSTALL_MIN_RELEASE_AGE_SETTING,
|
|
"BUN_INSTALL_MIN_RELEASE_AGE",
|
|
BUN_INSTALL_MIN_RELEASE_AGE.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_instance_python_version_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
INSTANCE_PYTHON_VERSION_SETTING,
|
|
"INSTANCE_PYTHON_VERSION",
|
|
INSTANCE_PYTHON_VERSION.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_npm_config_registry_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
NPM_CONFIG_REGISTRY_SETTING,
|
|
"NPM_CONFIG_REGISTRY",
|
|
NPM_CONFIG_REGISTRY.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_bunfig_install_scopes_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
BUNFIG_INSTALL_SCOPES_SETTING,
|
|
"BUNFIG_INSTALL_SCOPES",
|
|
BUNFIG_INSTALL_SCOPES.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_npmrc_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(conn, NPMRC_SETTING, "NPMRC", NPMRC.clone()).await;
|
|
}
|
|
|
|
pub async fn reload_nuget_config_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
NUGET_CONFIG_SETTING,
|
|
"NUGET_CONFIG",
|
|
NUGET_CONFIG.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_powershell_repo_url_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
POWERSHELL_REPO_URL_SETTING,
|
|
"POWERSHELL_REPO_URL",
|
|
POWERSHELL_REPO_URL.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_powershell_repo_pat_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
POWERSHELL_REPO_PAT_SETTING,
|
|
"POWERSHELL_REPO_PAT",
|
|
POWERSHELL_REPO_PAT.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_maven_repos_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
windmill_common::global_settings::MAVEN_REPOS_SETTING,
|
|
"MAVEN_REPOS",
|
|
MAVEN_REPOS.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_maven_settings_xml_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
windmill_common::global_settings::MAVEN_SETTINGS_XML_SETTING,
|
|
"MAVEN_SETTINGS_XML",
|
|
MAVEN_SETTINGS_XML.clone(),
|
|
)
|
|
.await;
|
|
|
|
if !cfg!(feature = "enterprise") {
|
|
return;
|
|
}
|
|
|
|
let settings_xml = MAVEN_SETTINGS_XML.read().await.clone();
|
|
match settings_xml {
|
|
Some(ref content) if !content.trim().is_empty() => {
|
|
let m2_dir = format!("{}/.m2", *JAVA_HOME_DIR);
|
|
if let Err(e) = tokio::fs::create_dir_all(&m2_dir).await {
|
|
tracing::error!("Failed to create .m2 directory: {e:#}");
|
|
return;
|
|
}
|
|
let settings_path = format!("{m2_dir}/settings.xml");
|
|
if let Err(e) = tokio::fs::write(&settings_path, content).await {
|
|
tracing::error!("Failed to write Maven settings.xml: {e:#}");
|
|
}
|
|
}
|
|
_ => {
|
|
let settings_path = format!("{}/.m2/settings.xml", *JAVA_HOME_DIR);
|
|
let _ = tokio::fs::remove_file(&settings_path).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn reload_no_default_maven_setting(conn: &Connection) {
|
|
let value = load_value_from_global_settings_with_conn(
|
|
conn,
|
|
windmill_common::global_settings::NO_DEFAULT_MAVEN_SETTING,
|
|
true,
|
|
)
|
|
.await;
|
|
match value {
|
|
Ok(Some(serde_json::Value::Bool(t))) => NO_DEFAULT_MAVEN.store(t, Ordering::Relaxed),
|
|
Err(e) => {
|
|
tracing::error!("Error loading no default maven repository: {e:#}");
|
|
}
|
|
_ => (),
|
|
};
|
|
}
|
|
|
|
pub async fn reload_ruby_repos_setting(conn: &Connection) {
|
|
reload_url_list_setting_with_tracing(
|
|
conn,
|
|
windmill_common::global_settings::RUBY_REPOS_SETTING,
|
|
"RUBY_REPOS",
|
|
windmill_worker::RUBY_REPOS.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_cargo_registries_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
windmill_common::global_settings::CARGO_REGISTRIES_SETTING,
|
|
"CARGO_REGISTRIES",
|
|
CARGO_REGISTRIES.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_workspace_registries_setting(conn: &Connection) {
|
|
let value = load_value_from_global_settings_with_conn(
|
|
conn,
|
|
windmill_common::global_settings::WORKSPACE_REGISTRIES_SETTING,
|
|
true,
|
|
)
|
|
.await;
|
|
match value {
|
|
Ok(Some(v)) => match serde_json::from_value::<WorkspaceRegistryMap>(v) {
|
|
Ok(parsed) => {
|
|
tracing::info!(
|
|
"Loaded workspace registries for {} workspaces",
|
|
parsed.len()
|
|
);
|
|
*WORKSPACE_REGISTRIES.write().await = Some(parsed);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error parsing workspace_registries setting: {e:#}");
|
|
}
|
|
},
|
|
Ok(None) => {
|
|
*WORKSPACE_REGISTRIES.write().await = None;
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error loading workspace_registries setting: {e:#}");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn reload_hub_api_secret_setting(conn: &Connection) {
|
|
match load_option_setting_value::<String>(conn, HUB_API_SECRET_SETTING, "HUB_API_SECRET").await
|
|
{
|
|
Ok(v) => HUB_API_SECRET.store(std::sync::Arc::new(v)),
|
|
Err(e) => tracing::error!("Error reloading setting HUB_API_SECRET: {:?}", e),
|
|
}
|
|
}
|
|
|
|
pub async fn reload_retention_period_setting(conn: &Connection) {
|
|
match load_setting_value::<i64>(
|
|
conn,
|
|
RETENTION_PERIOD_SECS_SETTING,
|
|
"JOB_RETENTION_SECS",
|
|
60 * 60 * 24 * 30,
|
|
|x| x,
|
|
)
|
|
.await
|
|
{
|
|
Ok(v) => JOB_RETENTION_SECS.store(v, Ordering::Relaxed),
|
|
Err(e) => tracing::error!("Error reloading retention period: {:?}", e),
|
|
}
|
|
}
|
|
|
|
pub async fn reload_audit_log_retention_days_setting(conn: &Connection) {
|
|
match load_setting_value::<i64>(
|
|
conn,
|
|
AUDIT_LOG_RETENTION_DAYS_SETTING,
|
|
"AUDIT_LOG_RETENTION_DAYS",
|
|
0, // 0 means use default: 365 for EE, 14 for CE
|
|
|x| x,
|
|
)
|
|
.await
|
|
{
|
|
Ok(v) => AUDIT_LOG_RETENTION_DAYS.store(v, Ordering::Relaxed),
|
|
Err(e) => tracing::error!("Error reloading audit log retention days: {:?}", e),
|
|
}
|
|
}
|
|
|
|
pub async fn reload_delete_logs_periodically_setting(conn: &Connection) {
|
|
match load_setting_value::<bool>(
|
|
conn,
|
|
MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
|
|
"MONITOR_LOGS_ON_OBJECT_STORE",
|
|
false,
|
|
|x| x,
|
|
)
|
|
.await
|
|
{
|
|
Ok(v) => MONITOR_LOGS_ON_OBJECT_STORE.store(v, Ordering::Relaxed),
|
|
Err(e) => tracing::error!("Error reloading retention period: {:?}", e),
|
|
}
|
|
}
|
|
|
|
pub async fn reload_store_audit_logs_s3_setting(conn: &Connection) {
|
|
match load_setting_value::<bool>(
|
|
conn,
|
|
STORE_AUDIT_LOGS_S3_SETTING,
|
|
"STORE_AUDIT_LOGS_S3",
|
|
false,
|
|
|x| x,
|
|
)
|
|
.await
|
|
{
|
|
Ok(v) => STORE_AUDIT_LOGS_S3.store(v, Ordering::Relaxed),
|
|
Err(e) => tracing::error!("Error reloading store_audit_logs_s3 setting: {:?}", e),
|
|
}
|
|
}
|
|
|
|
pub async fn reload_job_default_timeout_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
JOB_DEFAULT_TIMEOUT_SECS_SETTING,
|
|
"JOB_DEFAULT_TIMEOUT_SECS",
|
|
JOB_DEFAULT_TIMEOUT.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_nsjail_tmpfs_size_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
NSJAIL_TMPFS_SIZE_MB_SETTING,
|
|
"NSJAIL_TMPFS_SIZE_MB",
|
|
NSJAIL_TMPFS_SIZE_MB.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_nsjail_tmp_backing_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
NSJAIL_TMP_BACKING_SETTING,
|
|
"NSJAIL_TMP_BACKING",
|
|
NSJAIL_TMP_BACKING.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_sandbox_image_max_size_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
SANDBOX_IMAGE_MAX_SIZE_MB_SETTING,
|
|
"SANDBOX_IMAGE_MAX_SIZE_MB",
|
|
SANDBOX_IMAGE_MAX_SIZE_MB.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_sandbox_image_cache_max_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
|
|
"SANDBOX_IMAGE_CACHE_MAX_MB",
|
|
SANDBOX_IMAGE_CACHE_MAX_MB.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_sandbox_image_pull_policy_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
SANDBOX_IMAGE_PULL_POLICY_SETTING,
|
|
"SANDBOX_IMAGE_PULL_POLICY",
|
|
SANDBOX_IMAGE_PULL_POLICY.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_sandbox_image_default_registry_setting(conn: &Connection) {
|
|
reload_option_setting_with_tracing(
|
|
conn,
|
|
SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING,
|
|
"SANDBOX_IMAGE_DEFAULT_REGISTRY",
|
|
SANDBOX_IMAGE_DEFAULT_REGISTRY.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn reload_sandbox_registry_auth_setting(conn: &Connection) {
|
|
// Secret-aware: the value is a raw docker/podman auth.json with credentials, so
|
|
// it must never be logged. Load directly (the generic reload_option_setting path
|
|
// logs the value via load_option_setting_value) and only log a redacted message.
|
|
let q =
|
|
match load_value_from_global_settings_with_conn(conn, SANDBOX_REGISTRY_AUTH_SETTING, true)
|
|
.await
|
|
{
|
|
Ok(q) => q,
|
|
Err(e) => {
|
|
tracing::error!("Error reloading setting SANDBOX_REGISTRY_AUTH: {e:?}");
|
|
return;
|
|
}
|
|
};
|
|
let value = q.and_then(|q| serde_json::from_value::<String>(q).ok());
|
|
let configured = value.as_ref().is_some_and(|v| !v.trim().is_empty());
|
|
*SANDBOX_REGISTRY_AUTH.write().await = value;
|
|
tracing::info!("Loaded setting SANDBOX_REGISTRY_AUTH (redacted), configured={configured}");
|
|
}
|
|
|
|
pub async fn reload_job_isolation_setting(conn: &Connection) {
|
|
let value =
|
|
match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await {
|
|
Ok(Some(v)) => JobIsolationLevel::from_str(v.as_str().unwrap_or("")),
|
|
Ok(None) => JobIsolationLevel::Undefined,
|
|
Err(e) => {
|
|
tracing::error!("Error reloading job_isolation setting: {:?}", e);
|
|
return;
|
|
}
|
|
};
|
|
let old_value = JobIsolationLevel::from_u8(JOB_ISOLATION.swap(value as u8, Ordering::Relaxed));
|
|
if old_value != value {
|
|
tracing::info!(
|
|
"job_isolation setting changed from {:?} to {:?}",
|
|
old_value,
|
|
value
|
|
);
|
|
}
|
|
if value == JobIsolationLevel::NsjailSandboxing && NSJAIL_AVAILABLE.is_none() {
|
|
tracing::error!(
|
|
"job_isolation is set to nsjail_sandboxing but nsjail is not available on this worker. \
|
|
All jobs will fail until nsjail is installed or the setting is changed."
|
|
);
|
|
}
|
|
if value == JobIsolationLevel::Unshare && UNSHARE_PATH.is_none() {
|
|
tracing::error!(
|
|
"job_isolation is set to unshare but the unshare binary is not available on this worker. \
|
|
Jobs will run without isolation until unshare is installed or the setting is changed."
|
|
);
|
|
}
|
|
}
|
|
|
|
pub async fn reload_request_size(conn: &Connection) {
|
|
if let Err(e) = reload_setting(
|
|
conn,
|
|
REQUEST_SIZE_LIMIT_SETTING,
|
|
"REQUEST_SIZE_LIMIT",
|
|
DEFAULT_BODY_LIMIT,
|
|
REQUEST_SIZE_LIMIT.clone(),
|
|
|x| x.mul(1024 * 1024),
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!("Error reloading retention period: {:?}", e)
|
|
}
|
|
}
|
|
|
|
async fn resolve_license_key_value(conn: &Connection, quiet: bool) -> anyhow::Result<String> {
|
|
let q = load_value_from_global_settings_with_conn(conn, LICENSE_KEY_SETTING, true)
|
|
.await
|
|
.map_err(|err| anyhow::anyhow!("Error reloading license key: {}", err.to_string()))?;
|
|
|
|
let mut value = std::env::var("LICENSE_KEY")
|
|
.ok()
|
|
.and_then(|x| x.parse::<String>().ok())
|
|
.unwrap_or(String::new());
|
|
|
|
if let Some(q) = q {
|
|
if let Ok(v) = serde_json::from_value::<String>(q.clone()) {
|
|
if !quiet {
|
|
tracing::info!(
|
|
"Loaded setting LICENSE_KEY from db config: {}",
|
|
truncate_token(&v)
|
|
);
|
|
}
|
|
value = v;
|
|
} else {
|
|
tracing::error!("Could not parse LICENSE_KEY found: {:#?}", &q);
|
|
}
|
|
};
|
|
Ok(value)
|
|
}
|
|
|
|
pub async fn reload_license_key(conn: &Connection) -> anyhow::Result<()> {
|
|
let value = resolve_license_key_value(conn, false).await?;
|
|
apply_license_key(value, conn).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Applies the key and records it as the last accepted value only when
|
|
/// `set_license_key` actually stored it (validation passed, even if expired).
|
|
/// A rejected key is deliberately not recorded: validation can fail transiently
|
|
/// (DB error during the instance-hash check, offline key validated before
|
|
/// base_url is configured), and recording it would stop
|
|
/// `refetch_license_key_if_invalid` from ever retrying an unchanged key.
|
|
async fn apply_license_key(value: String, conn: &Connection) {
|
|
set_license_key(value.clone(), conn.as_sql()).await;
|
|
#[cfg(feature = "enterprise")]
|
|
if (**windmill_common::ee_oss::LICENSE_KEY.load()).as_str() == value {
|
|
*LAST_ACCEPTED_LICENSE_KEY.lock().unwrap() = Some(value);
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
lazy_static::lazy_static! {
|
|
static ref LAST_INVALID_KEY_REFETCH_AT: std::sync::Mutex<Option<Instant>> =
|
|
std::sync::Mutex::new(None);
|
|
static ref LAST_ACCEPTED_LICENSE_KEY: std::sync::Mutex<Option<String>> =
|
|
std::sync::Mutex::new(None);
|
|
}
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
const INVALID_LICENSE_KEY_REFETCH_INTERVAL: Duration = Duration::from_secs(60);
|
|
|
|
/// When the in-memory license key is invalid, the key in settings may have moved on
|
|
/// (failed initial load, missed `license_key` notification, or renewed/fixed by
|
|
/// another instance) — without this, the stale in-memory key would keep being
|
|
/// flagged invalid until the next full settings reload (12h by default). Refetch
|
|
/// the latest key at most once per `INVALID_LICENSE_KEY_REFETCH_INTERVAL` and
|
|
/// re-apply it unless it matches the last accepted value (an unchanged key that
|
|
/// validated fine and is invalid for another reason — e.g. expired — is not
|
|
/// re-validated in a loop).
|
|
#[cfg(feature = "enterprise")]
|
|
pub async fn refetch_license_key_if_invalid(conn: &Connection) {
|
|
use windmill_common::ee_oss::LICENSE_KEY_VALID;
|
|
|
|
if LICENSE_KEY_VALID.load(std::sync::atomic::Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
{
|
|
let mut last = LAST_INVALID_KEY_REFETCH_AT.lock().unwrap();
|
|
if last.is_some_and(|t| t.elapsed() < INVALID_LICENSE_KEY_REFETCH_INTERVAL) {
|
|
return;
|
|
}
|
|
*last = Some(Instant::now());
|
|
}
|
|
let value = match resolve_license_key_value(conn, true).await {
|
|
Ok(v) => v,
|
|
Err(err) => {
|
|
tracing::error!("Failed to refetch license key while invalid: {err:#}");
|
|
return;
|
|
}
|
|
};
|
|
let changed = LAST_ACCEPTED_LICENSE_KEY.lock().unwrap().as_deref() != Some(value.as_str());
|
|
if changed {
|
|
tracing::info!(
|
|
"In-memory license key is invalid, applying license key from settings: {}",
|
|
truncate_token(&value)
|
|
);
|
|
apply_license_key(value, conn).await;
|
|
}
|
|
}
|
|
|
|
pub async fn reload_option_setting_with_tracing<T: FromStr + DeserializeOwned>(
|
|
conn: &Connection,
|
|
setting_name: &str,
|
|
std_env_var: &str,
|
|
lock: Arc<RwLock<Option<T>>>,
|
|
) {
|
|
if let Err(e) = reload_option_setting(conn, setting_name, std_env_var, lock.clone()).await {
|
|
tracing::error!("Error reloading setting {}: {:?}", setting_name, e)
|
|
}
|
|
}
|
|
|
|
pub async fn load_value_from_global_settings(
|
|
db: &DB,
|
|
setting_name: &str,
|
|
) -> error::Result<Option<serde_json::Value>> {
|
|
let r = sqlx::query!(
|
|
"SELECT value FROM global_settings WHERE name = $1",
|
|
setting_name
|
|
)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.map(|x| x.value);
|
|
Ok(r)
|
|
}
|
|
|
|
pub async fn load_value_from_global_settings_with_conn(
|
|
conn: &Connection,
|
|
setting_name: &str,
|
|
load_from_http: bool,
|
|
) -> anyhow::Result<Option<serde_json::Value>> {
|
|
match conn {
|
|
Connection::Sql(db) => Ok(load_value_from_global_settings(db, setting_name).await?),
|
|
Connection::Http(client) => {
|
|
if load_from_http {
|
|
client
|
|
.get::<Option<serde_json::Value>>(&format!(
|
|
"/api/agent_workers/get_global_setting/{}",
|
|
setting_name
|
|
))
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Error loading setting {}: {}", setting_name, e))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load an optional setting value without writing it anywhere.
|
|
///
|
|
/// Extracted from [`reload_option_setting`] so callers that store the value
|
|
/// in something other than `Arc<RwLock<Option<T>>>` (e.g. `ArcSwap<Option<T>>`,
|
|
/// an `AtomicBool`, etc.) can reuse the load pipeline.
|
|
pub async fn load_option_setting_value<T: FromStr + DeserializeOwned>(
|
|
conn: &Connection,
|
|
setting_name: &str,
|
|
std_env_var: &str,
|
|
) -> error::Result<Option<T>> {
|
|
let force_value = std::env::var(format!("FORCE_{}", std_env_var))
|
|
.ok()
|
|
.and_then(|x| x.parse::<T>().ok());
|
|
|
|
if let Some(force_value) = force_value {
|
|
return Ok(Some(force_value));
|
|
}
|
|
|
|
let q = load_value_from_global_settings_with_conn(conn, setting_name, true).await?;
|
|
|
|
let mut value = std::env::var(std_env_var)
|
|
.ok()
|
|
.and_then(|x| x.parse::<T>().ok());
|
|
|
|
if let Some(q) = q {
|
|
if let Ok(v) = serde_json::from_value::<T>(q.clone()) {
|
|
tracing::info!("Loaded setting {setting_name} from db config: {:#?}", &q);
|
|
value = Some(v)
|
|
} else {
|
|
tracing::error!("Could not parse {setting_name} found: {:#?}", &q);
|
|
}
|
|
};
|
|
|
|
if value.is_none() {
|
|
tracing::info!("Loaded {setting_name} setting to None");
|
|
}
|
|
|
|
Ok(value)
|
|
}
|
|
|
|
pub async fn reload_option_setting<T: FromStr + DeserializeOwned>(
|
|
conn: &Connection,
|
|
setting_name: &str,
|
|
std_env_var: &str,
|
|
lock: Arc<RwLock<Option<T>>>,
|
|
) -> error::Result<()> {
|
|
let value = load_option_setting_value::<T>(conn, setting_name, std_env_var).await?;
|
|
{
|
|
let mut l = lock.write().await;
|
|
*l = value;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reload_url_list_setting_with_tracing(
|
|
conn: &Connection,
|
|
setting_name: &str,
|
|
std_env_var: &str,
|
|
lock: Arc<RwLock<Option<Vec<url::Url>>>>,
|
|
) {
|
|
if let Err(e) = reload_url_list_setting(conn, setting_name, std_env_var, lock.clone()).await {
|
|
tracing::error!("Error reloading setting {}: {:?}", setting_name, e)
|
|
}
|
|
}
|
|
|
|
/// Load an optional URL list setting without writing it anywhere.
|
|
///
|
|
/// Extracted from [`reload_url_list_setting`] so callers that store the
|
|
/// value in something other than `Arc<RwLock<Option<Vec<Url>>>>` (e.g.
|
|
/// `ArcSwap<Option<Vec<Url>>>`) can reuse the parsing pipeline.
|
|
pub async fn load_url_list_setting_value(
|
|
conn: &Connection,
|
|
setting_name: &str,
|
|
std_env_var: &str,
|
|
) -> error::Result<Option<Vec<url::Url>>> {
|
|
// Check for force environment variable
|
|
if let Ok(force_value) = std::env::var(format!("FORCE_{}", std_env_var)) {
|
|
let mut urls = Vec::new();
|
|
for url_str in force_value.trim().split_whitespace() {
|
|
match url::Url::parse(url_str) {
|
|
Ok(url) => urls.push(url),
|
|
Err(e) => {
|
|
return Err(error::Error::BadRequest(format!(
|
|
"Invalid URL in FORCE_{}: '{}': {}",
|
|
std_env_var, url_str, e
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
return Ok(if urls.is_empty() { None } else { Some(urls) });
|
|
}
|
|
|
|
let q = load_value_from_global_settings_with_conn(conn, setting_name, true).await?;
|
|
|
|
// Check regular environment variable
|
|
let mut value = if let Ok(env_value) = std::env::var(std_env_var) {
|
|
let mut urls = Vec::new();
|
|
for url_str in env_value.trim().split_whitespace() {
|
|
match url::Url::parse(url_str) {
|
|
Ok(url) => urls.push(url),
|
|
Err(_) => {
|
|
// Log error but continue, similar to force variable handling
|
|
tracing::error!("Invalid URL in {}: '{}'", std_env_var, url_str);
|
|
}
|
|
}
|
|
}
|
|
if urls.is_empty() {
|
|
None
|
|
} else {
|
|
Some(urls)
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Check database setting
|
|
if let Some(q) = q {
|
|
if let Ok(repos_str) = serde_json::from_value::<String>(q.clone()) {
|
|
let mut urls = Vec::new();
|
|
for url_str in repos_str.trim().split_whitespace() {
|
|
match url::Url::parse(url_str) {
|
|
Ok(url) => urls.push(url),
|
|
Err(e) => {
|
|
tracing::error!("Invalid URL in {}: '{}': {}", setting_name, url_str, e);
|
|
// Continue with other URLs, just skip invalid ones
|
|
}
|
|
}
|
|
}
|
|
tracing::info!(
|
|
"Loaded setting {} from db config: {} URLs",
|
|
setting_name,
|
|
urls.len()
|
|
);
|
|
value = if urls.is_empty() { None } else { Some(urls) };
|
|
} else {
|
|
tracing::error!("Could not parse {} found: {:#?}", setting_name, &q);
|
|
}
|
|
}
|
|
|
|
if value.is_none() {
|
|
tracing::info!("Loaded {} setting to None", setting_name);
|
|
}
|
|
|
|
Ok(value)
|
|
}
|
|
|
|
pub async fn reload_url_list_setting(
|
|
conn: &Connection,
|
|
setting_name: &str,
|
|
std_env_var: &str,
|
|
lock: Arc<RwLock<Option<Vec<url::Url>>>>,
|
|
) -> error::Result<()> {
|
|
let value = load_url_list_setting_value(conn, setting_name, std_env_var).await?;
|
|
{
|
|
let mut l = lock.write().await;
|
|
*l = value;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Load a required setting value without writing it anywhere.
|
|
///
|
|
/// Extracted from [`reload_setting`] so callers that store the value in
|
|
/// something other than `Arc<RwLock<T>>` (e.g. `AtomicI64`, `AtomicBool`,
|
|
/// `ArcSwap<T>`) can reuse the load pipeline.
|
|
pub async fn load_setting_value<T: FromStr + DeserializeOwned + Display>(
|
|
conn: &Connection,
|
|
setting_name: &str,
|
|
std_env_var: &str,
|
|
default: T,
|
|
transformer: fn(T) -> T,
|
|
) -> error::Result<T> {
|
|
let q = load_value_from_global_settings_with_conn(conn, setting_name, true).await?;
|
|
|
|
let mut value = std::env::var(std_env_var)
|
|
.ok()
|
|
.and_then(|x| x.parse::<T>().ok())
|
|
.unwrap_or(default);
|
|
|
|
if let Some(q) = q {
|
|
if let Ok(v) = serde_json::from_value::<T>(q.clone()) {
|
|
tracing::info!("Loaded setting {setting_name} from db config: {:#?}", &q);
|
|
value = transformer(v);
|
|
} else {
|
|
tracing::error!("Could not parse {setting_name} found: {:#?}", &q);
|
|
}
|
|
};
|
|
|
|
Ok(value)
|
|
}
|
|
|
|
pub async fn reload_setting<T: FromStr + DeserializeOwned + Display>(
|
|
conn: &Connection,
|
|
setting_name: &str,
|
|
std_env_var: &str,
|
|
default: T,
|
|
lock: Arc<RwLock<T>>,
|
|
transformer: fn(T) -> T,
|
|
) -> error::Result<()> {
|
|
let value = load_setting_value(conn, setting_name, std_env_var, default, transformer).await?;
|
|
{
|
|
let mut l = lock.write().await;
|
|
*l = value;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(feature = "prometheus")]
|
|
pub async fn monitor_pool(db: &DB) {
|
|
if METRICS_ENABLED.load(Ordering::Relaxed) {
|
|
let db = db.clone();
|
|
tokio::spawn(async move {
|
|
let active_gauge = prometheus::register_int_gauge!(
|
|
"pool_connections_active",
|
|
"Number of active postgresql connections in the pool"
|
|
)
|
|
.unwrap();
|
|
let idle_gauge = prometheus::register_int_gauge!(
|
|
"pool_connections_idle",
|
|
"Number of idle postgresql connections in the pool"
|
|
)
|
|
.unwrap();
|
|
let max_gauge = prometheus::register_int_gauge!(
|
|
"pool_connections_max",
|
|
"Number of max postgresql connections in the pool"
|
|
)
|
|
.unwrap();
|
|
|
|
max_gauge.set(db.options().get_max_connections() as i64);
|
|
loop {
|
|
active_gauge.set(db.size() as i64);
|
|
idle_gauge.set(db.num_idle() as i64);
|
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
pub async fn monitor_pool_otel(db: &DB) {
|
|
if OTEL_METRICS_ENABLED.load(Ordering::Relaxed) {
|
|
let db = db.clone();
|
|
tokio::spawn(async move {
|
|
let max = db.options().get_max_connections() as i64;
|
|
loop {
|
|
otel_set_db_pool(db.size() as i64, db.num_idle() as i64, max);
|
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
pub struct MonitorIteration {
|
|
pub rd_shift: u8,
|
|
pub iter: u64,
|
|
}
|
|
|
|
impl MonitorIteration {
|
|
pub fn should_run(&self, period: u8) -> bool {
|
|
(self.iter + self.rd_shift as u64) % (period as u64) == 0
|
|
}
|
|
}
|
|
|
|
pub async fn monitor_db(
|
|
conn: &Connection,
|
|
base_internal_url: &str,
|
|
server_mode: bool,
|
|
_worker_mode: bool,
|
|
initial_load: bool,
|
|
_killpill_tx: KillpillSender,
|
|
iteration: Option<MonitorIteration>,
|
|
) {
|
|
let zombie_jobs_f = async {
|
|
if server_mode && !initial_load && !*DISABLE_ZOMBIE_JOBS_MONITORING {
|
|
if let Some(db) = conn.as_sql() {
|
|
handle_zombie_jobs(db, base_internal_url, "server").await;
|
|
match handle_zombie_flows(db).await {
|
|
Err(err) => {
|
|
tracing::error!("Error handling zombie flows: {:?}", err);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let stale_jobs_f = async {
|
|
if server_mode && !initial_load {
|
|
if let Some(db) = conn.as_sql() {
|
|
stale_job_cancellation(&db).await;
|
|
}
|
|
}
|
|
};
|
|
|
|
// run every 5 minutes
|
|
let cleanup_concurrency_counters_f = async {
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
|
|
if let Some(db) = conn.as_sql() {
|
|
if let Err(e) = cleanup_concurrency_counters_orphaned_keys(&db).await {
|
|
tracing::error!("Error cleaning up concurrency counters: {:?}", e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// run every 10 minutes
|
|
let cleanup_concurrency_counters_empty_keys_f = async {
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(20) {
|
|
if let Some(db) = conn.as_sql() {
|
|
if let Err(e) = cleanup_concurrency_counters_empty_keys(&db).await {
|
|
tracing::error!("Error cleaning up concurrency counters: {:?}", e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let cleanup_debounce_keys_f = async {
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
|
|
if let Some(db) = conn.as_sql() {
|
|
if let Err(e) = cleanup_debounce_orphaned_keys(&db).await {
|
|
tracing::error!("Error cleaning up debounce keys: {:?}", e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// run every 30s (every iteration)
|
|
let cleanup_debounce_keys_completed_f = async {
|
|
if server_mode && !initial_load {
|
|
if let Some(db) = conn.as_sql() {
|
|
if let Err(e) = cleanup_debounce_keys_for_completed_jobs(&db).await {
|
|
tracing::error!(
|
|
"Error cleaning up debounce keys for completed jobs: {:?}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let cleanup_flow_iterator_data_f = async {
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
|
|
if let Some(db) = conn.as_sql() {
|
|
if let Err(e) = cleanup_flow_iterator_data_orphaned_jobs(&db).await {
|
|
tracing::error!("Error cleaning up flow_iterator_data: {:?}", e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let cleanup_job_live_rows_f = async {
|
|
if server_mode && !initial_load {
|
|
if let Some(db) = conn.as_sql() {
|
|
if let Err(e) = cleanup_job_perms_orphaned(&db).await {
|
|
tracing::error!("Error cleaning up orphaned job_perms: {:?}", e);
|
|
}
|
|
if let Err(e) = cleanup_job_result_stream_orphaned_jobs(&db).await {
|
|
tracing::error!("Error cleaning up orphaned job_result_stream_v2: {:?}", e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// run every hour (60 minutes / 30 seconds = 120)
|
|
let cleanup_worker_group_stats_f = async {
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(120) {
|
|
if let Some(db) = conn.as_sql() {
|
|
match windmill_common::worker_group_job_stats::cleanup_old_stats(db, 60).await {
|
|
Ok(count) if count > 0 => {
|
|
tracing::info!("Deleted {} old worker group job stats rows", count);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error cleaning up worker group job stats: {:?}", e);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// run every hour
|
|
let vacuum_queue_f = async {
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(60) {
|
|
if let Some(db) = conn.as_sql() {
|
|
let instant = Instant::now();
|
|
tracing::info!("vacuuming tables");
|
|
if let Err(e) = vacuuming_tables(&db).await {
|
|
tracing::error!("Error vacuuming v2_job: {:?}", e);
|
|
}
|
|
tracing::info!("vacuum tables done in {}s", instant.elapsed().as_secs());
|
|
}
|
|
}
|
|
};
|
|
|
|
let expired_items_f = async {
|
|
if server_mode && !initial_load {
|
|
if let Some(db) = conn.as_sql() {
|
|
delete_expired_items(&db).await;
|
|
}
|
|
}
|
|
};
|
|
|
|
let verify_license_key_f = async {
|
|
#[cfg(feature = "enterprise")]
|
|
if !initial_load {
|
|
verify_license_key(conn.as_sql()).await;
|
|
refetch_license_key_if_invalid(conn).await;
|
|
}
|
|
};
|
|
|
|
let enforce_offline_caps_f = async {
|
|
#[cfg(feature = "enterprise")]
|
|
if server_mode && !initial_load {
|
|
if let Some(db) = conn.as_sql() {
|
|
// Cheap: one query for workers active in the last 2 minutes.
|
|
if let Err(e) = windmill_common::ee_oss::enforce_offline_caps(db).await {
|
|
tracing::error!("Failed to enforce offline license caps: {e:#}");
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let expose_queue_metrics_f = async {
|
|
if !initial_load && server_mode {
|
|
if let Some(db) = conn.as_sql() {
|
|
expose_queue_metrics(&db).await;
|
|
}
|
|
}
|
|
};
|
|
|
|
let worker_groups_alerts_f = async {
|
|
#[cfg(feature = "enterprise")]
|
|
if server_mode && !initial_load {
|
|
if let Some(db) = conn.as_sql() {
|
|
worker_groups_alerts(&db).await;
|
|
}
|
|
}
|
|
};
|
|
|
|
let jobs_waiting_alerts_f = async {
|
|
#[cfg(feature = "enterprise")]
|
|
if server_mode {
|
|
if let Some(db) = conn.as_sql() {
|
|
jobs_waiting_alerts(&db).await;
|
|
}
|
|
}
|
|
};
|
|
|
|
let low_disk_alerts_f = async {
|
|
#[cfg(feature = "enterprise")]
|
|
if let Some(db) = conn.as_sql() {
|
|
low_disk_alerts(
|
|
&db,
|
|
server_mode,
|
|
_worker_mode,
|
|
WORKERS_NAMES.read().await.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
#[cfg(not(feature = "enterprise"))]
|
|
{
|
|
()
|
|
}
|
|
};
|
|
|
|
let apply_autoscaling_f = async {
|
|
#[cfg(feature = "enterprise")]
|
|
if server_mode && !initial_load {
|
|
if let Some(db) = conn.as_sql() {
|
|
if let Err(e) = windmill_autoscaling::apply_all_autoscaling(db).await {
|
|
tracing::error!("Error applying autoscaling: {:?}", e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let update_min_worker_version_f = async {
|
|
windmill_common::min_version::update_min_version(
|
|
conn,
|
|
_worker_mode,
|
|
WORKERS_NAMES.read().await.clone(),
|
|
initial_load,
|
|
)
|
|
.await;
|
|
};
|
|
|
|
// Run every 5 minutes (10 iterations * 30s = 5 minutes)
|
|
let native_triggers_sync_f = async {
|
|
#[cfg(feature = "native_trigger")]
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
|
|
if let Some(db) = conn.as_sql() {
|
|
match sync_all_triggers(db).await {
|
|
Ok(result) => {
|
|
tracing::debug!(
|
|
"Native triggers sync completed: {} workspaces, {} synced, {} errors",
|
|
result.workspaces_processed,
|
|
result.total_synced,
|
|
result.total_errors
|
|
);
|
|
if result.total_errors > 0 {
|
|
tracing::warn!(
|
|
"Native triggers sync encountered {} errors",
|
|
result.total_errors
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error during native triggers sync: {:#}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Run every 5 minutes (10 iterations * 30s = 5 minutes)
|
|
// Cleanup old notify events (older than 10 minutes)
|
|
let cleanup_notify_events_f = async {
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
|
|
if let Some(db) = conn.as_sql() {
|
|
match windmill_common::notify_events::cleanup_old_events(db, 10).await {
|
|
Ok(count) if count > 0 => {
|
|
tracing::debug!("Cleaned up {} old notify events", count);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error cleaning up notify events: {:?}", e);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Run every hour (10 iterations * 30s = 5 minutes)
|
|
// Check for tokens expiring within 7 days and send alerts
|
|
let check_expiring_tokens_f = async {
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
|
|
if let Some(db) = conn.as_sql() {
|
|
check_expiring_tokens(&db).await;
|
|
}
|
|
}
|
|
};
|
|
|
|
// run every hour (120 iterations * 30s = 3600s)
|
|
let cleanup_stale_server_heartbeats_f = async {
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(120) {
|
|
if let Some(db) = conn.as_sql() {
|
|
match windmill_api::cleanup_stale_server_heartbeats(db).await {
|
|
Ok(count) if count > 0 => {
|
|
tracing::info!(
|
|
"Deleted {} stale server_heartbeat background_task_state rows",
|
|
count
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error cleaning up stale server_heartbeat rows: {:?}", e);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// run every hour (120 iterations * 30s = 3600s)
|
|
let manage_audit_partitions_f = async {
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(120) {
|
|
if let Some(db) = conn.as_sql() {
|
|
manage_audit_partitions(&db, audit_log_retention_days().await).await;
|
|
}
|
|
}
|
|
};
|
|
|
|
// run every ~60s (2 iterations * 30s). Enterprise feature: core logic is
|
|
// in `crate::ee` (OSS gets a no-op stub); gated on a valid Enterprise
|
|
// license, mirroring how `audit_log()` itself is license-aware.
|
|
let export_audit_logs_to_object_store_f = async {
|
|
#[cfg(feature = "parquet")]
|
|
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(2) {
|
|
if let Some(db) = conn.as_sql() {
|
|
if matches!(
|
|
windmill_common::ee_oss::get_license_plan().await,
|
|
windmill_common::ee_oss::LicensePlan::Enterprise
|
|
) {
|
|
crate::ee_oss::export_audit_logs_to_object_store(&db).await;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let cleanup_scheduled_job_deletions_f = async {
|
|
#[cfg(feature = "enterprise")]
|
|
if server_mode && !initial_load {
|
|
if let Some(db) = conn.as_sql() {
|
|
cleanup_scheduled_job_deletions(&db).await;
|
|
}
|
|
}
|
|
};
|
|
|
|
join!(
|
|
expired_items_f,
|
|
zombie_jobs_f,
|
|
stale_jobs_f,
|
|
vacuum_queue_f,
|
|
expose_queue_metrics_f,
|
|
verify_license_key_f,
|
|
enforce_offline_caps_f,
|
|
worker_groups_alerts_f,
|
|
jobs_waiting_alerts_f,
|
|
low_disk_alerts_f,
|
|
apply_autoscaling_f,
|
|
update_min_worker_version_f,
|
|
cleanup_concurrency_counters_f,
|
|
cleanup_concurrency_counters_empty_keys_f,
|
|
cleanup_debounce_keys_f,
|
|
cleanup_debounce_keys_completed_f,
|
|
cleanup_flow_iterator_data_f,
|
|
cleanup_job_live_rows_f,
|
|
cleanup_worker_group_stats_f,
|
|
native_triggers_sync_f,
|
|
cleanup_notify_events_f,
|
|
check_expiring_tokens_f,
|
|
cleanup_stale_server_heartbeats_f,
|
|
manage_audit_partitions_f,
|
|
export_audit_logs_to_object_store_f,
|
|
cleanup_scheduled_job_deletions_f,
|
|
);
|
|
}
|
|
|
|
async fn vacuuming_tables(db: &Pool<Postgres>) -> error::Result<()> {
|
|
sqlx::query!("VACUUM v2_job, v2_job_completed, job_result_stream_v2, job_stats, job_logs, job_perms, concurrency_key, log_file, metrics")
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
|
let last_check = sqlx::query_scalar!(
|
|
"SELECT created_at FROM metrics WHERE id LIKE 'queue_count_%' ORDER BY created_at DESC LIMIT 1"
|
|
)
|
|
.fetch_optional(db)
|
|
.await
|
|
.unwrap_or(Some(chrono::Utc::now()));
|
|
|
|
let metrics_enabled = METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed);
|
|
let save_metrics = last_check
|
|
.map(|last_check| chrono::Utc::now() - last_check > chrono::Duration::seconds(25))
|
|
.unwrap_or(true);
|
|
|
|
if metrics_enabled || save_metrics || OTEL_METRICS_ENABLED.load(Ordering::Relaxed) {
|
|
let queue_counts = windmill_common::queue::get_queue_counts(db).await;
|
|
|
|
#[cfg(feature = "prometheus")]
|
|
if metrics_enabled {
|
|
for q in QUEUE_COUNT_TAGS.read().await.iter() {
|
|
if queue_counts.get(q).is_none() {
|
|
(*QUEUE_COUNT).with_label_values(&[q]).set(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
let otel_enabled = OTEL_METRICS_ENABLED.load(Ordering::Relaxed);
|
|
|
|
if otel_enabled {
|
|
for q in OTEL_QUEUE_COUNT_TAGS.read().await.iter() {
|
|
if queue_counts.get(q).is_none() {
|
|
otel_set_queue_count(q, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(unused_mut)]
|
|
let mut tags_to_watch = vec![];
|
|
#[allow(unused_mut)]
|
|
let mut otel_tags_to_watch = vec![];
|
|
for q in queue_counts {
|
|
let count = q.1;
|
|
let tag = q.0;
|
|
|
|
#[cfg(feature = "prometheus")]
|
|
if metrics_enabled {
|
|
let metric = (*QUEUE_COUNT).with_label_values(&[&tag]);
|
|
metric.set(count as i64);
|
|
tags_to_watch.push(tag.to_string());
|
|
}
|
|
|
|
if otel_enabled {
|
|
otel_tags_to_watch.push(tag.to_string());
|
|
}
|
|
otel_set_queue_count(&tag, count as i64);
|
|
|
|
// save queue_count and delay metrics per tag
|
|
if save_metrics {
|
|
sqlx::query!(
|
|
"INSERT INTO metrics (id, value) VALUES ($1, $2)",
|
|
format!("queue_count_{}", tag),
|
|
serde_json::json!(count)
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.ok();
|
|
if count > 0 {
|
|
sqlx::query!(
|
|
"INSERT INTO metrics (id, value)
|
|
VALUES ($1, to_jsonb((
|
|
SELECT EXTRACT(EPOCH FROM now() - scheduled_for)
|
|
FROM v2_job_queue
|
|
WHERE tag = $2 AND running = false AND scheduled_for <= now() - ('3 seconds')::interval
|
|
ORDER BY priority DESC NULLS LAST, scheduled_for LIMIT 1
|
|
)))",
|
|
format!("queue_delay_{}", tag),
|
|
tag
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.ok();
|
|
}
|
|
}
|
|
}
|
|
if metrics_enabled {
|
|
let mut w = QUEUE_COUNT_TAGS.write().await;
|
|
*w = tags_to_watch;
|
|
}
|
|
if otel_enabled {
|
|
let mut w = OTEL_QUEUE_COUNT_TAGS.write().await;
|
|
*w = otel_tags_to_watch;
|
|
}
|
|
|
|
// Single DB query for running counts, shared by Prometheus and OTel
|
|
let otel_running = otel_enabled;
|
|
#[cfg(feature = "prometheus")]
|
|
let need_running_counts = metrics_enabled || otel_running;
|
|
#[cfg(not(feature = "prometheus"))]
|
|
let need_running_counts = otel_running;
|
|
|
|
if need_running_counts {
|
|
let queue_running_counts = windmill_common::queue::get_queue_running_counts(db).await;
|
|
|
|
#[cfg(feature = "prometheus")]
|
|
if metrics_enabled {
|
|
for q in QUEUE_RUNNING_COUNT_TAGS.read().await.iter() {
|
|
if queue_running_counts.get(q).is_none() {
|
|
(*QUEUE_RUNNING_COUNT).with_label_values(&[q]).set(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
if otel_running {
|
|
for q in OTEL_QUEUE_RUNNING_COUNT_TAGS.read().await.iter() {
|
|
if queue_running_counts.get(q).is_none() {
|
|
otel_set_queue_running_count(q, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(unused_mut, unused_variables)]
|
|
let mut running_tags_to_watch: Vec<String> = vec![];
|
|
#[allow(unused_mut, unused_variables)]
|
|
let mut otel_running_tags_to_watch: Vec<String> = vec![];
|
|
for (tag, count) in &queue_running_counts {
|
|
#[cfg(feature = "prometheus")]
|
|
if metrics_enabled {
|
|
let metric = (*QUEUE_RUNNING_COUNT).with_label_values(&[tag]);
|
|
metric.set(*count as i64);
|
|
running_tags_to_watch.push(tag.to_string());
|
|
}
|
|
|
|
if otel_running {
|
|
otel_set_queue_running_count(tag, *count as i64);
|
|
otel_running_tags_to_watch.push(tag.to_string());
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "prometheus")]
|
|
if metrics_enabled {
|
|
let mut w = QUEUE_RUNNING_COUNT_TAGS.write().await;
|
|
*w = running_tags_to_watch;
|
|
}
|
|
if otel_running {
|
|
let mut w = OTEL_QUEUE_RUNNING_COUNT_TAGS.write().await;
|
|
*w = otel_running_tags_to_watch;
|
|
}
|
|
}
|
|
}
|
|
|
|
// clean queue metrics older than 14 days
|
|
sqlx::query!(
|
|
"DELETE FROM metrics WHERE id LIKE 'queue_%' AND created_at < NOW() - INTERVAL '14 day'"
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.ok();
|
|
}
|
|
|
|
pub async fn reload_smtp_config(db: &Pool<Postgres>) {
|
|
let smtp_config = load_smtp_config(&db).await;
|
|
if let Err(e) = smtp_config {
|
|
tracing::error!("Error reloading smtp config: {:?}", e)
|
|
} else {
|
|
tracing::info!("Reloading smtp config...");
|
|
SMTP_CONFIG.store(std::sync::Arc::new(smtp_config.unwrap()));
|
|
}
|
|
}
|
|
|
|
pub async fn reload_indexer_config(db: &Pool<Postgres>) {
|
|
let indexer_config = load_indexer_config(&db).await;
|
|
if let Err(e) = indexer_config {
|
|
tracing::error!("Error reloading indexer config: {:?}", e)
|
|
} else {
|
|
tracing::info!("Reloading indexer config...");
|
|
INDEXER_CONFIG.store(std::sync::Arc::new(indexer_config.unwrap()));
|
|
}
|
|
}
|
|
|
|
pub async fn reload_worker_config(db: &DB, tx: KillpillSender, kill_if_change: bool) {
|
|
let config = load_worker_config(db, tx.clone()).await;
|
|
if let Err(e) = config {
|
|
tracing::error!("Error reloading worker config: {:?}", e)
|
|
} else {
|
|
let wc = WORKER_CONFIG.load();
|
|
let config = config.unwrap();
|
|
let has_dedicated = config.dedicated_worker.is_some()
|
|
|| config
|
|
.dedicated_workers
|
|
.as_ref()
|
|
.is_some_and(|dws| !dws.is_empty());
|
|
if **wc != config || has_dedicated {
|
|
if kill_if_change {
|
|
if has_dedicated
|
|
|| wc.dedicated_worker != config.dedicated_worker
|
|
|| wc.dedicated_workers != config.dedicated_workers
|
|
{
|
|
tracing::info!("Dedicated worker config changed, sending killpill. Expecting to be restarted by supervisor.");
|
|
let _ = tx.send();
|
|
}
|
|
|
|
if wc.init_bash != config.init_bash {
|
|
tracing::info!("Init bash config changed, sending killpill. Expecting to be restarted by supervisor.");
|
|
let _ = tx.send();
|
|
}
|
|
|
|
if wc.cache_clear != config.cache_clear {
|
|
tracing::info!("Cache clear changed, sending killpill. Expecting to be restarted by supervisor.");
|
|
let _ = tx.send();
|
|
tracing::info!("Waiting 5 seconds to allow others workers to start potential jobs that depend on a potential shared cache volume");
|
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
if let Err(e) = windmill_worker::common::clean_cache().await {
|
|
tracing::error!("Error cleaning the cache: {e:#}");
|
|
}
|
|
}
|
|
|
|
if wc.periodic_script_bash != config.periodic_script_bash {
|
|
tracing::info!("Periodic script bash config changed, sending killpill. Expecting to be restarted by supervisor.");
|
|
let _ = tx.send();
|
|
}
|
|
|
|
if wc.periodic_script_interval_seconds != config.periodic_script_interval_seconds {
|
|
tracing::info!("Periodic script interval config changed, sending killpill. Expecting to be restarted by supervisor.");
|
|
let _ = tx.send();
|
|
}
|
|
|
|
if wc.native_mode != config.native_mode {
|
|
tracing::info!("Native mode config changed, sending killpill. Expecting to be restarted by supervisor.");
|
|
let _ = tx.send();
|
|
}
|
|
}
|
|
drop(wc);
|
|
|
|
tracing::info!("Reloading worker config...");
|
|
store_suspended_pull_query(&config).await;
|
|
store_pull_query(&config).await;
|
|
WORKER_CONFIG.store(std::sync::Arc::new(config));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn load_base_url(conn: &Connection) -> error::Result<String> {
|
|
let q_base_url =
|
|
load_value_from_global_settings_with_conn(conn, BASE_URL_SETTING, false).await?;
|
|
|
|
let std_base_url = std::env::var("BASE_URL")
|
|
.ok()
|
|
.unwrap_or_else(|| "http://localhost".to_string());
|
|
let base_url = if let Some(q) = q_base_url {
|
|
if let Ok(v) = serde_json::from_value::<String>(q.clone()) {
|
|
if v != "" {
|
|
v
|
|
} else {
|
|
std_base_url
|
|
}
|
|
} else {
|
|
tracing::error!(
|
|
"Could not parse base_url setting as a string, found: {:#?}",
|
|
&q
|
|
);
|
|
std_base_url
|
|
}
|
|
} else {
|
|
std_base_url
|
|
};
|
|
BASE_URL.store(std::sync::Arc::new(base_url.clone()));
|
|
Ok(base_url)
|
|
}
|
|
|
|
pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> {
|
|
#[cfg(feature = "oauth2")]
|
|
let oauths = if let Some(db) = conn.as_sql() {
|
|
let q_oauth = load_value_from_global_settings(db, OAUTH_SETTING).await?;
|
|
|
|
if let Some(q) = q_oauth {
|
|
if let Ok(v) = serde_json::from_value::<
|
|
Option<HashMap<String, windmill_api::oauth2_oss::OAuthClient>>,
|
|
>(q.clone())
|
|
{
|
|
v
|
|
} else {
|
|
tracing::error!("Could not parse oauth setting as a json, found: {:#?}", &q);
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let base_url = load_base_url(conn).await?;
|
|
let is_secure = base_url.starts_with("https://");
|
|
|
|
#[cfg(feature = "oauth2")]
|
|
{
|
|
if let Some(db) = conn.as_sql() {
|
|
let clients = windmill_api::oauth2_oss::build_oauth_clients(&base_url, oauths, db).await
|
|
.map_err(|e| tracing::error!("Error building oauth clients (is the oauth.json mounted and in correct format? Use '{}' as minimal oauth.json): {}", "{}", e))
|
|
.unwrap();
|
|
windmill_api::OAUTH_CLIENTS.store(std::sync::Arc::new(clients));
|
|
}
|
|
}
|
|
|
|
IS_SECURE.store(is_secure, Ordering::Relaxed);
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
{
|
|
crate::ee_oss::verify_license_key(conn.as_sql()).await;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn stale_job_cancellation(db: &Pool<Postgres>) {
|
|
if let Some(threshold) = *STALE_JOB_THRESHOLD_MINUTES {
|
|
let stale_jobs = sqlx::query!(
|
|
"SELECT v2_job_queue.id, v2_job.tag, v2_job_queue.scheduled_for, v2_job_queue.workspace_id FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id WHERE running = false AND scheduled_for < now() - ($1 || ' minutes')::interval AND v2_job.trigger_kind IS DISTINCT FROM 'schedule'::job_trigger_kind",
|
|
threshold.to_string()
|
|
)
|
|
.fetch_all(db)
|
|
.await
|
|
.ok()
|
|
.unwrap_or_else(|| vec![]);
|
|
|
|
if !stale_jobs.is_empty() {
|
|
tracing::info!(
|
|
"Cancelling {} stale jobs (> {} minutes old)",
|
|
stale_jobs.len(),
|
|
threshold
|
|
);
|
|
}
|
|
for job in stale_jobs {
|
|
if let Err(e) =
|
|
cancel_stale_job(db, job.id, job.tag, job.workspace_id, job.scheduled_for).await
|
|
{
|
|
tracing::error!("Error cancelling stale job {}: {}", job.id, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn cancel_stale_job(
|
|
db: &Pool<Postgres>,
|
|
id: Uuid,
|
|
tag: String,
|
|
workspace_id: String,
|
|
scheduled_for: DateTime<Utc>,
|
|
) -> error::Result<()> {
|
|
let mut tx = db.begin().await?;
|
|
tracing::error!(
|
|
"Stale job detected: {} in workspace {} with tag {} (scheduled for: {}) . Cancelling it.",
|
|
id,
|
|
workspace_id,
|
|
tag,
|
|
scheduled_for
|
|
);
|
|
(tx, _) = cancel_job(
|
|
"monitor",
|
|
Some(format!(
|
|
"Stale job cancellation (scheduled for: {})",
|
|
scheduled_for
|
|
)),
|
|
id,
|
|
&workspace_id,
|
|
tx,
|
|
db,
|
|
true,
|
|
false,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
const RESTART_LIMIT: i32 = 3;
|
|
|
|
async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, node_name: &str) {
|
|
let mut zombie_jobs_uuid_restart_limit_reached = vec![];
|
|
|
|
if *RESTART_ZOMBIE_JOBS {
|
|
let restarted = sqlx::query!(
|
|
"WITH to_update AS (
|
|
SELECT q.id, q.workspace_id, r.ping, COALESCE(zjc.counter, 0) as counter
|
|
FROM v2_job_queue q
|
|
JOIN v2_job j ON j.id = q.id
|
|
JOIN v2_job_runtime r ON r.id = j.id
|
|
LEFT JOIN zombie_job_counter zjc ON zjc.job_id = q.id
|
|
WHERE ping < now() - ($1 || ' seconds')::interval
|
|
AND running = true
|
|
AND kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')
|
|
AND same_worker = false
|
|
AND q.suspend_until IS NULL
|
|
AND (zjc.counter IS NULL OR zjc.counter <= $2)
|
|
FOR UPDATE of q SKIP LOCKED
|
|
),
|
|
zombie_jobs AS (
|
|
UPDATE v2_job_queue q
|
|
SET running = false, started_at = null
|
|
FROM to_update tu
|
|
WHERE q.id = tu.id AND (tu.counter IS NULL OR tu.counter < $2)
|
|
RETURNING q.id, q.workspace_id, ping, tu.counter
|
|
),
|
|
update_ping AS (
|
|
UPDATE v2_job_runtime r
|
|
SET ping = null
|
|
FROM zombie_jobs zj
|
|
WHERE r.id = zj.id
|
|
),
|
|
increment_counter AS (
|
|
INSERT INTO zombie_job_counter (job_id, counter)
|
|
SELECT id, 1 FROM to_update WHERE counter < $2
|
|
ON CONFLICT (job_id) DO UPDATE
|
|
SET counter = zombie_job_counter.counter + 1
|
|
),
|
|
update_concurrency AS (
|
|
UPDATE concurrency_counter cc
|
|
SET job_uuids = job_uuids - zj.id::text
|
|
FROM zombie_jobs zj
|
|
INNER JOIN concurrency_key ck ON ck.job_id = zj.id
|
|
WHERE cc.concurrency_id = ck.key
|
|
)
|
|
SELECT id AS \"id!\", workspace_id AS \"workspace_id!\", ping, counter + 1 AS counter FROM to_update",
|
|
*ZOMBIE_JOB_TIMEOUT,
|
|
RESTART_LIMIT
|
|
)
|
|
.fetch_all(db)
|
|
.await
|
|
.ok()
|
|
.unwrap_or_else(|| vec![]);
|
|
|
|
#[cfg(feature = "prometheus")]
|
|
if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
|
|
QUEUE_ZOMBIE_RESTART_COUNT.inc_by(restarted.len() as _);
|
|
}
|
|
|
|
otel_incr_zombie_restart_count(restarted.len() as u64);
|
|
|
|
let base_url = (**BASE_URL.load()).clone();
|
|
for r in restarted {
|
|
let last_ping = if let Some(x) = r.ping {
|
|
format!("last ping at {x}")
|
|
} else {
|
|
"no last ping".to_string()
|
|
};
|
|
let url = format!("{}/run/{}?workspace={}", base_url, r.id, r.workspace_id,);
|
|
let restart = r.counter.is_none_or(|x| x < RESTART_LIMIT);
|
|
let (critical_error_message, restart_message) = if restart {
|
|
(
|
|
format!(
|
|
"Zombie job {} on {} ({}) detected, restarting it ({}/{} attempts), last ping: {}",
|
|
r.id,
|
|
r.workspace_id,
|
|
url,
|
|
r.counter.unwrap_or(0) + 1,
|
|
RESTART_LIMIT,
|
|
last_ping
|
|
),
|
|
format!(
|
|
"Restarted job after not receiving job's ping for too long the {} ({}/{} attempts)\n\n",
|
|
last_ping,
|
|
r.counter.unwrap_or(0) + 1,
|
|
RESTART_LIMIT
|
|
)
|
|
)
|
|
} else {
|
|
(
|
|
format!(
|
|
"Zombie job {} on {} ({}) detected, but restart limit ({}) reached, job will be processed as an error, last ping: {}",
|
|
r.id, r.workspace_id, url, RESTART_LIMIT, last_ping
|
|
),
|
|
format!(
|
|
"job's ping was received last at {}, job will be processed as an error since all {} restart attempts failed",
|
|
last_ping, RESTART_LIMIT
|
|
)
|
|
)
|
|
};
|
|
|
|
let _ = sqlx::query!(
|
|
"
|
|
INSERT INTO job_logs (job_id, logs)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (job_id) DO UPDATE SET logs = job_logs.logs || '\n' || EXCLUDED.logs
|
|
WHERE job_logs.job_id = $1",
|
|
r.id,
|
|
restart_message
|
|
)
|
|
.execute(db)
|
|
.await;
|
|
tracing::error!(critical_error_message);
|
|
report_critical_error(
|
|
critical_error_message,
|
|
db.clone(),
|
|
Some(&r.workspace_id),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
if !restart {
|
|
zombie_jobs_uuid_restart_limit_reached.push(r.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
let same_worker_timeout_jobs = {
|
|
let long_same_worker_jobs = sqlx::query!(
|
|
"SELECT worker, array_agg(v2_job_queue.id) as ids FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id LEFT JOIN v2_job_runtime ON v2_job_queue.id = v2_job_runtime.id WHERE v2_job_queue.created_at < now() - ('60 seconds')::interval
|
|
AND running = true AND (ping IS NULL OR ping < now() - ('60 seconds')::interval) AND same_worker = true AND worker IS NOT NULL AND v2_job_queue.suspend_until IS NULL GROUP BY worker",
|
|
)
|
|
.fetch_all(db)
|
|
.await
|
|
.ok()
|
|
.unwrap_or_else(|| vec![]);
|
|
|
|
let worker_ids = long_same_worker_jobs
|
|
.iter()
|
|
.map(|x| x.worker.clone().unwrap_or_default())
|
|
.collect::<Vec<_>>();
|
|
|
|
let long_dead_workers: std::collections::HashSet<String> = sqlx::query_scalar!(
|
|
"WITH worker_ids AS (SELECT unnest($1::text[]) as worker)
|
|
SELECT worker_ids.worker FROM worker_ids
|
|
LEFT JOIN worker_ping ON worker_ids.worker = worker_ping.worker
|
|
WHERE worker_ping.worker IS NULL OR worker_ping.ping_at < now() - ('60 seconds')::interval",
|
|
&worker_ids[..]
|
|
)
|
|
.fetch_all(db)
|
|
.await
|
|
.ok()
|
|
.unwrap_or_else(|| vec![])
|
|
.into_iter()
|
|
.filter_map(|x| x)
|
|
.collect();
|
|
|
|
let mut timeouts: Vec<Uuid> = vec![];
|
|
for worker in long_same_worker_jobs {
|
|
if worker.worker.is_some() && long_dead_workers.contains(&worker.worker.unwrap()) {
|
|
if let Some(ids) = worker.ids {
|
|
timeouts.extend(ids);
|
|
}
|
|
}
|
|
}
|
|
if !timeouts.is_empty() {
|
|
tracing::error!(
|
|
"Failing same worker zombie jobs: {:?}",
|
|
timeouts
|
|
.iter()
|
|
.map(|x| x.hyphenated().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
);
|
|
}
|
|
|
|
timeouts
|
|
};
|
|
|
|
let non_restartable_jobs = if *RESTART_ZOMBIE_JOBS {
|
|
vec![]
|
|
} else {
|
|
sqlx::query_scalar!("SELECT j.id
|
|
FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)
|
|
WHERE r.ping < now() - ($1 || ' seconds')::interval
|
|
AND q.running = true AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') AND j.same_worker = false AND q.suspend_until IS NULL",
|
|
ZOMBIE_JOB_TIMEOUT.as_str())
|
|
.fetch_all(db)
|
|
.await
|
|
.ok()
|
|
.unwrap_or_else(|| vec![])
|
|
};
|
|
|
|
enum ErrorMessage {
|
|
RestartLimit,
|
|
SameWorker,
|
|
RestartDisabled,
|
|
}
|
|
|
|
impl ErrorMessage {
|
|
fn to_string(&self) -> String {
|
|
match self {
|
|
ErrorMessage::RestartLimit => format!("RestartLimit ({})", RESTART_LIMIT),
|
|
ErrorMessage::SameWorker => "SameWorker".to_string(),
|
|
ErrorMessage::RestartDisabled => "RestartDisabled".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
let timeouts = non_restartable_jobs
|
|
.into_iter()
|
|
.map(|x| (x, ErrorMessage::RestartDisabled))
|
|
.chain(
|
|
same_worker_timeout_jobs
|
|
.into_iter()
|
|
.map(|x| (x, ErrorMessage::SameWorker)),
|
|
)
|
|
.chain(
|
|
zombie_jobs_uuid_restart_limit_reached
|
|
.into_iter()
|
|
.map(|x| (x, ErrorMessage::RestartLimit)),
|
|
)
|
|
.collect::<Vec<_>>();
|
|
|
|
#[cfg(feature = "prometheus")]
|
|
if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
|
|
QUEUE_ZOMBIE_DELETE_COUNT.inc_by(timeouts.len() as _);
|
|
}
|
|
|
|
otel_incr_zombie_delete_count(timeouts.len() as u64);
|
|
|
|
for (job_id, error_kind) in timeouts {
|
|
// since the job is unrecoverable, the same worker queue should never be sent anything
|
|
let (same_worker_tx_never_used, _same_worker_rx_never_used) =
|
|
mpsc::channel::<SameWorkerPayload>(1);
|
|
let same_worker_tx_never_used =
|
|
SameWorkerSender(same_worker_tx_never_used, Arc::new(AtomicU16::new(0)));
|
|
let (send_result_never_used, _send_result_rx_never_used) =
|
|
JobCompletedSender::new_never_used();
|
|
|
|
let job = get_queued_job_v2(db, &job_id).await;
|
|
if let Err(e) = job {
|
|
tracing::error!("Error getting queued job: {:?}", e);
|
|
continue;
|
|
}
|
|
if let Some(job) = job.unwrap() {
|
|
let label = if job.permissioned_as != format!("u/{}", job.created_by)
|
|
&& job.permissioned_as != job.created_by
|
|
{
|
|
format!("ephemeral-script-end-user-{}", job.created_by)
|
|
} else {
|
|
"ephemeral-script".to_string()
|
|
};
|
|
let token = create_token_for_owner(
|
|
&db,
|
|
&job.workspace_id,
|
|
&job.permissioned_as,
|
|
&label,
|
|
*SCRIPT_TOKEN_EXPIRY,
|
|
&job.permissioned_as_email,
|
|
&job.id,
|
|
None,
|
|
Some(format!("handle_zombie_jobs")),
|
|
)
|
|
.await
|
|
.expect("could not create job token");
|
|
|
|
let client = AuthedClient::new(
|
|
base_internal_url.to_string(),
|
|
job.workspace_id.to_string(),
|
|
token,
|
|
None,
|
|
);
|
|
|
|
let error_message = format!(
|
|
"Job timed out after no ping from job since {} (ZOMBIE_JOB_TIMEOUT: {}, reason: {:?}).\nThis likely means that the job died on worker {}, OOM are a common reason for worker crashes.\nCheck the workers around the time of the last ping and the exit code if any.",
|
|
job.last_ping.unwrap_or_default(),
|
|
*ZOMBIE_JOB_TIMEOUT,
|
|
error_kind.to_string(),
|
|
job.worker.clone().unwrap_or_default(),
|
|
);
|
|
let memory_peak = job.memory_peak.unwrap_or(0);
|
|
let (_, killpill_rx_never_used) = KillpillSender::new(1);
|
|
let _ = handle_job_error(
|
|
db,
|
|
&client,
|
|
&windmill_queue::MiniCompletedJob::from(job),
|
|
memory_peak,
|
|
None,
|
|
error::Error::ExecutionErr(error_message.clone()),
|
|
matches!(error_kind, ErrorMessage::SameWorker), // unrecoverable if the job is a same worker zombie
|
|
Some(&same_worker_tx_never_used),
|
|
"",
|
|
node_name,
|
|
send_result_never_used,
|
|
&killpill_rx_never_used,
|
|
#[cfg(feature = "benchmark")]
|
|
&mut windmill_common::bench::BenchmarkIter::new(),
|
|
)
|
|
.await;
|
|
|
|
// If handle_job_error failed (e.g. schedule push failure rolled back the tx),
|
|
// the job is still in the queue. Force-complete it to prevent infinite zombie loops.
|
|
if let Err(e) = force_complete_zombie_job(db, &job_id, &error_message).await {
|
|
tracing::error!("Failed to force-complete zombie job {}: {e:#}", job_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Force-complete a zombie job that handle_job_error failed to complete.
|
|
/// This is a minimal fallback: it inserts a failed completed job and deletes
|
|
/// from the queue in a single transaction, without schedule pushing or
|
|
/// error handler logic that could cause the completion to fail.
|
|
async fn force_complete_zombie_job(
|
|
db: &Pool<Postgres>,
|
|
job_id: &Uuid,
|
|
error_message: &str,
|
|
) -> error::Result<()> {
|
|
let still_queued = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1)",
|
|
job_id
|
|
)
|
|
.fetch_one(db)
|
|
.await?
|
|
.unwrap_or(false);
|
|
|
|
if !still_queued {
|
|
return Ok(());
|
|
}
|
|
|
|
tracing::error!(
|
|
"Zombie job {job_id} was not completed by handle_job_error, force-completing it"
|
|
);
|
|
|
|
let error_value = serde_json::json!({
|
|
"message": error_message,
|
|
"name": "ExecutionErr",
|
|
});
|
|
|
|
let mut tx = db.begin().await?;
|
|
|
|
sqlx::query!(
|
|
"INSERT INTO v2_job_completed
|
|
(workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)
|
|
SELECT q.workspace_id, q.id, q.started_at,
|
|
COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint,
|
|
$2::jsonb, r.memory_peak, 'failure'::job_status, q.worker
|
|
FROM v2_job_queue q
|
|
LEFT JOIN v2_job_runtime r ON r.id = q.id
|
|
WHERE q.id = $1
|
|
ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb",
|
|
job_id,
|
|
error_value,
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM v2_job_queue WHERE id = $1", job_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
tracing::info!("Force-completed zombie job {job_id}");
|
|
Ok(())
|
|
}
|
|
|
|
async fn cleanup_concurrency_counters_orphaned_keys(db: &DB) -> error::Result<()> {
|
|
let result = sqlx::query!(
|
|
"
|
|
WITH lockable_counters AS (
|
|
SELECT concurrency_id, job_uuids
|
|
FROM concurrency_counter
|
|
WHERE job_uuids != '{}'::jsonb
|
|
FOR UPDATE SKIP LOCKED
|
|
),
|
|
all_job_uuids AS (
|
|
SELECT DISTINCT jsonb_object_keys(job_uuids) AS job_uuid
|
|
FROM lockable_counters
|
|
),
|
|
orphaned_job_uuids AS (
|
|
SELECT job_uuid
|
|
FROM all_job_uuids
|
|
WHERE job_uuid NOT IN (
|
|
SELECT id::text
|
|
FROM v2_job_queue
|
|
FOR SHARE SKIP LOCKED
|
|
)
|
|
),
|
|
orphaned_array AS (
|
|
SELECT ARRAY(SELECT job_uuid FROM orphaned_job_uuids) AS orphaned_keys
|
|
),
|
|
before_update AS (
|
|
SELECT lc.concurrency_id, lc.job_uuids, oa.orphaned_keys
|
|
FROM lockable_counters lc, orphaned_array oa
|
|
WHERE lc.job_uuids ?| oa.orphaned_keys
|
|
),
|
|
affected_rows AS (
|
|
UPDATE concurrency_counter
|
|
SET job_uuids = job_uuids - orphaned_array.orphaned_keys
|
|
FROM orphaned_array
|
|
WHERE concurrency_counter.concurrency_id IN (
|
|
SELECT concurrency_id FROM before_update
|
|
)
|
|
RETURNING concurrency_id, job_uuids AS updated_job_uuids
|
|
),
|
|
expanded_orphaned AS (
|
|
SELECT bu.concurrency_id,
|
|
bu.job_uuids AS original_job_uuids,
|
|
unnest(bu.orphaned_keys) AS orphaned_key
|
|
FROM before_update bu
|
|
)
|
|
SELECT
|
|
eo.concurrency_id,
|
|
eo.orphaned_key,
|
|
eo.original_job_uuids,
|
|
ar.updated_job_uuids
|
|
FROM expanded_orphaned eo
|
|
JOIN affected_rows ar ON eo.concurrency_id = ar.concurrency_id
|
|
WHERE eo.original_job_uuids ? eo.orphaned_key
|
|
ORDER BY eo.concurrency_id, eo.orphaned_key
|
|
",
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
if result.len() > 0 {
|
|
tracing::info!("Cleaned up {} concurrency counters", result.len());
|
|
for row in result {
|
|
tracing::info!("Concurrency counter cleaned up: concurrency_id: {}, orphaned_key: {:?}, original_job_uuids: {:?}, updated_job_uuids: {:?}", row.concurrency_id, row.orphaned_key, row.original_job_uuids, row.updated_job_uuids);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn cleanup_concurrency_counters_empty_keys(db: &DB) -> error::Result<()> {
|
|
let result = sqlx::query!(
|
|
"
|
|
WITH rows_to_delete AS (
|
|
SELECT concurrency_id
|
|
FROM concurrency_counter
|
|
|
|
WHERE job_uuids = '{}'::jsonb
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
DELETE FROM concurrency_counter
|
|
WHERE concurrency_id IN (SELECT concurrency_id FROM rows_to_delete) RETURNING concurrency_id",
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
if result.len() > 0 {
|
|
tracing::info!(
|
|
"Cleaned up {} empty concurrency counters: {:?}",
|
|
result.len(),
|
|
result
|
|
.iter()
|
|
.map(|x| x.concurrency_id.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_zombie_flows(db: &DB) -> error::Result<()> {
|
|
let flows = sqlx::query!(
|
|
r#"
|
|
SELECT
|
|
j.id AS "id!", j.workspace_id AS "workspace_id!", j.parent_job, j.flow_step_id IS NOT NULL AS "is_flow_step?",
|
|
COALESCE(s.flow_status, s.workflow_as_code_status) AS "flow_status: Box<str>", r.ping AS last_ping, j.same_worker AS "same_worker?",
|
|
q.worker AS "worker?",
|
|
wp.ping_at AS "worker_last_ping?",
|
|
wp.memory_usage AS "worker_memory_usage?",
|
|
wp.wm_memory_usage AS "worker_wm_memory_usage?",
|
|
wp.memory AS "worker_memory_total?",
|
|
wp.worker_group AS "worker_group?",
|
|
wp.wm_version AS "worker_version?",
|
|
wp.current_job_id AS "worker_current_job_id?",
|
|
wp.worker_instance AS "worker_instance?"
|
|
FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)
|
|
LEFT JOIN worker_ping wp ON wp.worker = q.worker
|
|
WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()
|
|
AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode' OR j.kind = 'singlestepflow')
|
|
AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval
|
|
AND q.canceled_by IS NULL
|
|
|
|
"#,
|
|
FLOW_ZOMBIE_TRANSITION_TIMEOUT.as_str()
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
for flow in flows {
|
|
let status = flow
|
|
.flow_status
|
|
.as_deref()
|
|
.and_then(|x| serde_json::from_str::<FlowStatus>(x).ok());
|
|
if !flow.same_worker.unwrap_or(false)
|
|
&& status.is_some_and(|s| {
|
|
s.modules
|
|
.get(0)
|
|
.is_some_and(|x| matches!(x, FlowStatusModule::WaitingForPriorSteps { .. }))
|
|
})
|
|
{
|
|
let error_message = format!(
|
|
"Zombie flow detected: {} in workspace {}. It hasn't started yet, restarting it.",
|
|
flow.id, flow.workspace_id
|
|
);
|
|
tracing::error!(error_message);
|
|
report_critical_error(error_message, db.clone(), Some(&flow.workspace_id), None).await;
|
|
// if the flow hasn't started and is a zombie, we can simply restart it
|
|
let mut tx = db.begin().await?;
|
|
|
|
let concurrency_key =
|
|
sqlx::query_scalar!("SELECT key FROM concurrency_key WHERE job_id = $1", flow.id)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
if let Some(key) = concurrency_key {
|
|
if *DISABLE_CONCURRENCY_LIMIT {
|
|
tracing::warn!("Concurrency limit is disabled, skipping");
|
|
} else {
|
|
sqlx::query!(
|
|
"UPDATE concurrency_counter SET job_uuids = job_uuids - $2 WHERE concurrency_id = $1",
|
|
key,
|
|
flow.id.hyphenated().to_string()
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
sqlx::query!(
|
|
"UPDATE v2_job_queue SET running = false, started_at = null
|
|
WHERE id = $1 AND canceled_by IS NULL",
|
|
flow.id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
} else {
|
|
let id = flow.id.clone();
|
|
let last_ping = flow.last_ping.clone();
|
|
let now = now_from_db(db).await?;
|
|
let base_url = BASE_URL.load();
|
|
let workspace_id = flow.workspace_id.clone();
|
|
|
|
let fmt_mb = |b: i64| format!("{:.1} MB", b as f64 / 1024.0 / 1024.0);
|
|
let worker_ping_stale = flow
|
|
.worker_last_ping
|
|
.map(|wp| (now - wp).num_seconds() > 60);
|
|
// Zombie flows between steps are, in practice, almost always the worker
|
|
// getting OOM-killed mid state-transition. Score the memory signal at
|
|
// the worker's last ping as a fraction of the cgroup limit, taking the
|
|
// larger of the cgroup-wide reading (`worker_memory_usage`) and the
|
|
// windmill process's jemalloc resident (`worker_wm_memory_usage`) — if
|
|
// only one is present, that value wins; if both are present, the larger
|
|
// is the more conservative (higher-signal) choice. If the pod restarted
|
|
// in-place, a replacement worker process comes up under a new windmill
|
|
// worker name; this flow's recorded worker name still points at the
|
|
// dead process whose last ping can be under 60s old, and the memory
|
|
// signal is what lets us catch that window.
|
|
let memory_pct: Option<f64> = flow.worker_memory_total.and_then(|total| {
|
|
if total <= 0 {
|
|
return None;
|
|
}
|
|
let used = flow.worker_memory_usage.max(flow.worker_wm_memory_usage)?;
|
|
Some(used as f64 / total as f64)
|
|
});
|
|
let oom_strong = memory_pct.is_some_and(|p| p >= 0.85);
|
|
let oom_moderate = memory_pct.is_some_and(|p| p >= 0.60);
|
|
let mem_pct_str = memory_pct
|
|
.map(|p| format!("{:.1}% of container limit", (p * 100.0).min(100.0)))
|
|
.unwrap_or_else(|| "memory unknown at last ping".to_string());
|
|
let worker_info = if let Some(worker_name) = flow.worker.as_deref() {
|
|
let mut s = format!("\nWorker handling the flow: {worker_name}");
|
|
match (flow.worker_group.as_deref(), flow.worker_version.as_deref()) {
|
|
(Some(g), Some(v)) => s.push_str(&format!(" (group: {g}, version: {v})")),
|
|
(Some(g), None) => s.push_str(&format!(" (group: {g})")),
|
|
(None, Some(v)) => s.push_str(&format!(" (version: {v})")),
|
|
(None, None) => {}
|
|
}
|
|
if let Some(wp) = flow.worker_last_ping {
|
|
let age = (now - wp).num_seconds();
|
|
let status: String = match (
|
|
worker_ping_stale.unwrap_or(false),
|
|
oom_strong,
|
|
oom_moderate,
|
|
) {
|
|
(true, true, _) => format!(
|
|
"OOM-KILLED — {mem_pct_str} at last ping, worker then stopped pinging"
|
|
),
|
|
(true, false, true) => format!(
|
|
"LIKELY OOM-KILLED — {mem_pct_str} at last ping, worker then stopped pinging"
|
|
),
|
|
(true, false, false) => {
|
|
"WORKER DIED — most likely OOM-killed (no strong memory evidence captured at last ping); less likely: crash, host failure, or network partition".to_string()
|
|
}
|
|
(false, true, _) => format!(
|
|
"OOM-KILLED — {mem_pct_str} at last ping (a replacement worker process may have started in the same pod under a new windmill worker name; this alert references the dead process's record, which pinged just before being killed)"
|
|
),
|
|
(false, false, true) => format!(
|
|
"LIKELY OOM-KILLED — {mem_pct_str} at last ping (a replacement worker process may have started in the same pod under a new windmill worker name)"
|
|
),
|
|
(false, false, false) => {
|
|
"worker still pinging with healthy memory — likely deadlocked or blocking on the state transition".to_string()
|
|
}
|
|
};
|
|
s.push_str(&format!("\nWorker last ping: {wp} ({age}s ago) — {status}"));
|
|
if let Some(cjid) = flow.worker_current_job_id {
|
|
if cjid != flow.id {
|
|
s.push_str(&format!("\nWorker has since moved on to job {cjid}"));
|
|
}
|
|
}
|
|
} else {
|
|
s.push_str("\nWorker last ping: unknown (no worker_ping record)");
|
|
}
|
|
let total_suffix = flow
|
|
.worker_memory_total
|
|
.map(|t| format!(" (total available: {})", fmt_mb(t)))
|
|
.unwrap_or_default();
|
|
match (flow.worker_memory_usage, flow.worker_wm_memory_usage) {
|
|
(Some(host), Some(wm)) => s.push_str(&format!(
|
|
"\nWorker memory at last ping: host={}, wm process={}{}",
|
|
fmt_mb(host),
|
|
fmt_mb(wm),
|
|
total_suffix
|
|
)),
|
|
(Some(host), None) => s.push_str(&format!(
|
|
"\nWorker memory at last ping: host={}{}",
|
|
fmt_mb(host),
|
|
total_suffix
|
|
)),
|
|
(None, Some(wm)) => s.push_str(&format!(
|
|
"\nWorker memory at last ping: wm process={}{}",
|
|
fmt_mb(wm),
|
|
total_suffix
|
|
)),
|
|
(None, None) => {
|
|
if let Some(total) = flow.worker_memory_total {
|
|
s.push_str(&format!("\nWorker total memory: {}", fmt_mb(total)));
|
|
}
|
|
}
|
|
}
|
|
s
|
|
} else {
|
|
"\nWorker handling the flow: unknown (no worker recorded on v2_job_queue)"
|
|
.to_string()
|
|
};
|
|
|
|
let hint: String = match (worker_ping_stale, oom_moderate) {
|
|
(Some(_), true) => format!(
|
|
"\nThis is almost certainly an OOM-kill: container memory at the worker's last ping was at {mem_pct_str}. Raise the worker memory limit (e.g. k8s `resources.limits.memory`) or reduce per-flow memory usage. Confirm via pod restart count (`kubectl describe pod` / `kube_pod_container_status_last_terminated_reason`)."
|
|
),
|
|
(Some(true), false) => {
|
|
"\nWorker stopped pinging and its last memory snapshot did not look high — in practice the overwhelmingly common cause here is still OOM-kill (memory may have spiked between the last ping and the kill, or never been reported). First check pod restart count (`kubectl describe pod` / `kube_pod_container_status_last_terminated_reason`). Less likely: host failure, network partition, or a panic — check worker logs / k8s events around the last ping time.".to_string()
|
|
}
|
|
(Some(false), false) => {
|
|
"\nWorker is still pinging and memory looked healthy at its last ping — most likely a deadlock or blocking call during the state transition. Capture a stack trace (e.g. via SIGQUIT) from the worker process. As a sanity check, also verify pod restart count in case a replacement worker process in the same pod has silently taken over.".to_string()
|
|
}
|
|
(None, _) => String::new(),
|
|
};
|
|
|
|
let service_logs_info = match (flow.worker_instance.as_deref(), flow.worker_last_ping) {
|
|
(Some(host), Some(wlp)) => {
|
|
let after = (wlp - chrono::Duration::seconds(90))
|
|
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
|
let before = (wlp + chrono::Duration::seconds(30))
|
|
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
|
let log_files_result = sqlx::query!(
|
|
"SELECT file_path, log_ts, ok_lines, err_lines
|
|
FROM log_file
|
|
WHERE hostname = $1
|
|
AND log_ts BETWEEN $2::timestamp - interval '90 seconds' AND $2::timestamp + interval '30 seconds'
|
|
ORDER BY log_ts DESC
|
|
LIMIT 10",
|
|
host,
|
|
wlp.naive_utc(),
|
|
)
|
|
.fetch_all(db)
|
|
.await;
|
|
let log_files = match log_files_result {
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"failed to query log_file for hanging-flow diagnostics (host={host}): {e:#}"
|
|
);
|
|
Vec::new()
|
|
}
|
|
};
|
|
if log_files.is_empty() {
|
|
format!(
|
|
"\nService logs: no log_file rows found for hostname '{host}' between {after} and {before}. If service log collection is enabled (requires S3/parquet), try /api/service_logs/list_files?after={after}&before={before}."
|
|
)
|
|
} else {
|
|
let listed = log_files
|
|
.iter()
|
|
.map(|f| {
|
|
format!(
|
|
" - {} (log_ts: {}, ok_lines: {}, err_lines: {}) — GET /api/service_logs/get_log_file/{}/{}",
|
|
f.file_path,
|
|
f.log_ts,
|
|
f.ok_lines.unwrap_or(0),
|
|
f.err_lines.unwrap_or(0),
|
|
host,
|
|
f.file_path,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
format!("\nService logs for worker instance '{host}' around last ping ({after} to {before}) (download URLs require S3/parquet service log collection):\n{listed}")
|
|
}
|
|
}
|
|
_ => String::new(),
|
|
};
|
|
|
|
let reason = format!(
|
|
"{} was hanging in between 2 steps. Last ping: {last_ping:?} (now: {now}){worker_info}{hint}{service_logs_info}",
|
|
if flow.is_flow_step.unwrap_or(false) && flow.parent_job.is_some() {
|
|
format!("Flow was cancelled because subflow {id} ({base_url}/run/{id}?workspace={workspace_id})")
|
|
} else {
|
|
format!("Flow {id} ({base_url}/run/{id}?workspace={workspace_id}) was cancelled because it")
|
|
}
|
|
);
|
|
report_critical_error(reason.clone(), db.clone(), Some(&flow.workspace_id), None).await;
|
|
cancel_zombie_flow_job(db, flow.id, &flow.workspace_id,
|
|
format!(r#"{reason}
|
|
This would happen if a worker was interrupted, killed or crashed while doing a state transition at the end of a job which is always an unexpected behavior that should never happen.
|
|
Please check your worker logs for more details and feel free to report it to the Windmill team on our Discord or support@windmill.dev (response for non EE customers will be best effort) with as much context as possible, ideally:
|
|
- Windmill version
|
|
- Worker logs right after the job referenced has finished running
|
|
- Is the error consistent when running the same flow
|
|
- A minimal flow and its flow.yaml that reproduces the error and that is importable in a fresh workspace
|
|
- Your infra setup (helm, docker-compose, configuration of the workers and their number, memory of the database, etc.)
|
|
"#)).await?;
|
|
}
|
|
}
|
|
|
|
let flows2 = sqlx::query!(
|
|
r#"
|
|
DELETE
|
|
FROM parallel_monitor_lock
|
|
WHERE last_ping IS NOT NULL AND last_ping < NOW() - ($1 || ' seconds')::interval
|
|
RETURNING parent_flow_id, job_id, last_ping, (SELECT workspace_id FROM v2_job_queue q
|
|
WHERE q.id = parent_flow_id AND q.running = true AND q.canceled_by IS NULL
|
|
) AS workspace_id
|
|
"#,
|
|
FLOW_ZOMBIE_TRANSITION_TIMEOUT.as_str()
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
for flow in flows2 {
|
|
if let Some(parent_flow_workspace_id) = flow.workspace_id {
|
|
tracing::error!(
|
|
"parallel Zombie flow detected: {} in workspace {}. Last ping was: {:?}.",
|
|
flow.parent_flow_id,
|
|
parent_flow_workspace_id,
|
|
flow.last_ping
|
|
);
|
|
cancel_zombie_flow_job(db, flow.parent_flow_id, &parent_flow_workspace_id,
|
|
format!("Flow {} cancelled as one of the parallel branch {} was unable to make the last transition ", flow.parent_flow_id, flow.job_id))
|
|
.await?;
|
|
} else {
|
|
tracing::info!("releasing lock for parallel flow: {}", flow.parent_flow_id);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn cancel_zombie_flow_job(
|
|
db: &Pool<Postgres>,
|
|
id: Uuid,
|
|
workspace_id: &str,
|
|
message: String,
|
|
) -> Result<(), error::Error> {
|
|
let mut tx = db.begin().await?;
|
|
tracing::error!(
|
|
"zombie flow detected: {} in workspace {}. Cancelling it.",
|
|
id,
|
|
workspace_id
|
|
);
|
|
(tx, _) = cancel_job(
|
|
"monitor",
|
|
Some(message),
|
|
id,
|
|
workspace_id,
|
|
tx,
|
|
db,
|
|
true,
|
|
false,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reload_hub_base_url_setting(
|
|
conn: &Connection,
|
|
server_mode: bool,
|
|
) -> error::Result<()> {
|
|
let hub_base_url =
|
|
load_value_from_global_settings_with_conn(conn, HUB_BASE_URL_SETTING, true).await?;
|
|
|
|
let base_url = if let Some(q) = hub_base_url {
|
|
if let Ok(v) = serde_json::from_value::<String>(q.clone()) {
|
|
if v != "" {
|
|
v
|
|
} else {
|
|
DEFAULT_HUB_BASE_URL.to_string()
|
|
}
|
|
} else {
|
|
tracing::error!(
|
|
"Could not parse hub_base_url setting as a string, found: {:#?}",
|
|
&q
|
|
);
|
|
DEFAULT_HUB_BASE_URL.to_string()
|
|
}
|
|
} else {
|
|
DEFAULT_HUB_BASE_URL.to_string()
|
|
};
|
|
|
|
let previous = HUB_BASE_URL.load();
|
|
if server_mode {
|
|
#[cfg(feature = "embedding")]
|
|
if let Some(db) = conn.as_sql() {
|
|
if **previous != base_url {
|
|
let disable_embedding = std::env::var("DISABLE_EMBEDDING")
|
|
.ok()
|
|
.map(|x| x.parse::<bool>().unwrap_or(false))
|
|
.unwrap_or(false);
|
|
if !disable_embedding {
|
|
let db_clone = db.clone();
|
|
tokio::spawn(async move {
|
|
update_embeddings_db(&db_clone).await;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
drop(previous);
|
|
HUB_BASE_URL.store(std::sync::Arc::new(base_url));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reload_critical_error_channels_setting(conn: &DB) -> error::Result<()> {
|
|
let critical_error_channels =
|
|
load_value_from_global_settings(conn, CRITICAL_ERROR_CHANNELS_SETTING).await?;
|
|
|
|
let critical_error_channels = if let Some(q) = critical_error_channels {
|
|
if let Ok(v) = serde_json::from_value::<Vec<CriticalErrorChannel>>(q.clone()) {
|
|
v
|
|
} else {
|
|
tracing::error!(
|
|
"Could not parse critical_error_channels setting as an array of channels, found: {:#?}",
|
|
&q
|
|
);
|
|
vec![]
|
|
}
|
|
} else {
|
|
vec![]
|
|
};
|
|
|
|
CRITICAL_ERROR_CHANNELS.store(std::sync::Arc::new(critical_error_channels));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()> {
|
|
let app_workspaced_route =
|
|
load_value_from_global_settings(conn, APP_WORKSPACED_ROUTE_SETTING).await?;
|
|
|
|
let ws_route = match app_workspaced_route {
|
|
Some(serde_json::Value::Bool(ws_route)) => ws_route,
|
|
None => false,
|
|
_ => {
|
|
tracing::error!(
|
|
"Expected {} to be a boolean got: {:?}. Defaulting to false",
|
|
APP_WORKSPACED_ROUTE_SETTING,
|
|
app_workspaced_route
|
|
);
|
|
false
|
|
}
|
|
};
|
|
|
|
APP_WORKSPACED_ROUTE.store(ws_route, Ordering::Relaxed);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> {
|
|
let http_route_workspaced_route =
|
|
load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?;
|
|
|
|
let ws_route = match http_route_workspaced_route {
|
|
Some(serde_json::Value::Bool(ws_route)) => ws_route,
|
|
None => false,
|
|
_ => {
|
|
tracing::error!(
|
|
"Expected {} to be a boolean got: {:?}. Defaulting to false",
|
|
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
|
http_route_workspaced_route
|
|
);
|
|
false
|
|
}
|
|
};
|
|
|
|
let previous = HTTP_ROUTE_WORKSPACED_ROUTE.swap(ws_route, Ordering::Relaxed);
|
|
if previous != ws_route {
|
|
// Bump the HTTP trigger version so the route cache is rebuilt with
|
|
// the updated workspaced_route behavior on the next request.
|
|
sqlx::query!("SELECT nextval('http_trigger_version_seq')")
|
|
.fetch_one(conn)
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<()> {
|
|
#[derive(Deserialize)]
|
|
struct DBOversize {
|
|
#[serde(default)]
|
|
enabled: bool,
|
|
#[serde(default)]
|
|
value: f32,
|
|
}
|
|
let db_oversize_value =
|
|
load_value_from_global_settings(conn, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING).await?;
|
|
|
|
let db_oversize = if let Some(q) = db_oversize_value {
|
|
match serde_json::from_value::<DBOversize>(q.clone()) {
|
|
Ok(DBOversize { enabled: true, value }) => Some(value),
|
|
Ok(_) => None,
|
|
Err(q) => {
|
|
tracing::error!(
|
|
"Could not parse critical_alerts_on_db_oversize setting, found: {:#?}",
|
|
&q
|
|
);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
CRITICAL_ALERTS_ON_DB_OVERSIZE.store(std::sync::Arc::new(db_oversize));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn generate_and_save_jwt_secret(db: &DB) -> error::Result<String> {
|
|
let secret = rd_string(32);
|
|
sqlx::query!(
|
|
"INSERT INTO global_settings (name, value) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
|
|
JWT_SECRET_SETTING,
|
|
serde_json::to_value(&secret).unwrap()
|
|
).execute(db).await?;
|
|
|
|
Ok(secret)
|
|
}
|
|
|
|
pub async fn reload_jwt_secret_setting(db: &DB) -> error::Result<()> {
|
|
let jwt_secret = load_value_from_global_settings(db, JWT_SECRET_SETTING).await?;
|
|
|
|
let jwt_secret = if let Some(q) = jwt_secret {
|
|
if let Ok(v) = serde_json::from_value::<String>(q.clone()) {
|
|
v
|
|
} else {
|
|
tracing::error!("Could not parse jwt_secret setting, generating new one");
|
|
generate_and_save_jwt_secret(db).await?
|
|
}
|
|
} else {
|
|
tracing::info!("Not jwt secret found, generating one");
|
|
generate_and_save_jwt_secret(db).await?
|
|
};
|
|
|
|
JWT_SECRET.store(std::sync::Arc::new(jwt_secret));
|
|
|
|
// The debug signing key is derived from JWT_SECRET, so re-derive it here so
|
|
// rotation propagates to /api/debug/* signing without requiring a restart.
|
|
windmill_api::reload_debug_signing_key().await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn cleanup_debounce_orphaned_keys(db: &DB) -> error::Result<()> {
|
|
let result = sqlx::query!(
|
|
"
|
|
DELETE FROM debounce_key
|
|
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
|
|
RETURNING key,job_id
|
|
",
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
tracing::debug!("Cleaning up debounce keys");
|
|
|
|
if result.len() > 0 {
|
|
tracing::info!("Cleaned up {} debounce keys", result.len());
|
|
for row in result {
|
|
tracing::info!(
|
|
"Debounce key cleaned up: key: {}, job_id: {:?}",
|
|
row.key,
|
|
row.job_id
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn cleanup_debounce_keys_for_completed_jobs(db: &DB) -> error::Result<()> {
|
|
// If min version doesn't support runnable settings, clean up debounce keys for completed jobs
|
|
if !windmill_common::min_version::MIN_VERSION_SUPPORTS_RUNNABLE_SETTINGS_V0
|
|
.met()
|
|
.await
|
|
{
|
|
let result = sqlx::query!(
|
|
"
|
|
DELETE FROM debounce_key
|
|
WHERE job_id IN (SELECT id FROM v2_job_completed)
|
|
RETURNING key,job_id
|
|
",
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
if result.len() > 0 {
|
|
tracing::warn!(
|
|
"Cleaned up {} debounce keys for completed jobs (runnable settings v0 not supported by all workers)",
|
|
result.len()
|
|
);
|
|
for row in result {
|
|
tracing::debug!(
|
|
"Debounce key for completed job cleaned up: key: {}, job_id: {:?}",
|
|
row.key,
|
|
row.job_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
|
|
let result = sqlx::query_scalar!(
|
|
"DELETE FROM job_perms
|
|
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
|
|
RETURNING job_id"
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
if !result.is_empty() {
|
|
tracing::info!("Cleaned up {} orphaned job_perms rows", result.len());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn cleanup_job_result_stream_orphaned_jobs(db: &DB) -> error::Result<()> {
|
|
let result = sqlx::query!(
|
|
"DELETE FROM job_result_stream_v2
|
|
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
|
|
AND job_id NOT IN (
|
|
SELECT id FROM v2_job_completed
|
|
WHERE completed_at > NOW() - INTERVAL '60 seconds'
|
|
)
|
|
RETURNING job_id",
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
if result.len() > 0 {
|
|
tracing::info!(
|
|
"Cleaned up {} orphaned job_result_stream_v2 rows",
|
|
result.len()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn cleanup_flow_iterator_data_orphaned_jobs(db: &DB) -> error::Result<()> {
|
|
let result = sqlx::query!(
|
|
"
|
|
DELETE FROM flow_iterator_data
|
|
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
|
|
RETURNING job_id
|
|
",
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
if result.len() > 0 {
|
|
tracing::info!(
|
|
"Cleaned up {} orphaned flow_iterator_data rows",
|
|
result.len()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn audit_log_retention_days() -> i64 {
|
|
let v = AUDIT_LOG_RETENTION_DAYS.load(std::sync::atomic::Ordering::Relaxed);
|
|
if v > 0 {
|
|
v
|
|
} else if cfg!(feature = "enterprise") {
|
|
365
|
|
} else {
|
|
14
|
|
}
|
|
}
|
|
|
|
async fn manage_audit_partitions(db: &DB, retention_days: i64) {
|
|
let today = chrono::Utc::now().date_naive();
|
|
|
|
// Create partitions for today and the next 3 days
|
|
for days_ahead in 0..=3i64 {
|
|
let date = today + chrono::Duration::days(days_ahead);
|
|
let next_date = date + chrono::Duration::days(1);
|
|
let partition_name = format!("audit_{}", date.format("%Y%m%d"));
|
|
let quoted_name = format!("\"{}\"", partition_name.replace('"', "\"\""));
|
|
let sql = format!(
|
|
"CREATE TABLE IF NOT EXISTS {quoted_name} PARTITION OF audit_partitioned \
|
|
FOR VALUES FROM ('{date}') TO ('{next_date}')"
|
|
);
|
|
if let Err(e) = sqlx::query(&sql).execute(db).await {
|
|
if !e.to_string().contains("already exists") {
|
|
tracing::error!("Error creating audit partition {partition_name}: {e:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drop expired partitions
|
|
let cutoff_date = today - chrono::Duration::days(retention_days);
|
|
|
|
let partitions = sqlx::query_scalar::<_, String>(
|
|
"SELECT c.relname::text \
|
|
FROM pg_inherits i \
|
|
JOIN pg_class c ON c.oid = i.inhrelid \
|
|
WHERE i.inhparent = 'audit_partitioned'::regclass",
|
|
)
|
|
.fetch_all(db)
|
|
.await;
|
|
|
|
match partitions {
|
|
Ok(partitions) => {
|
|
for partition_name in partitions {
|
|
if let Some(date_str) = partition_name.strip_prefix("audit_") {
|
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y%m%d") {
|
|
if date < cutoff_date {
|
|
let quoted_name =
|
|
format!("\"{}\"", partition_name.replace('"', "\"\""));
|
|
let sql = format!("DROP TABLE IF EXISTS {quoted_name}");
|
|
match sqlx::query(&sql).execute(db).await {
|
|
Ok(_) => tracing::info!(
|
|
"Dropped expired audit partition {partition_name}"
|
|
),
|
|
Err(e) => tracing::error!(
|
|
"Error dropping audit partition {partition_name}: {e:?}"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("Error listing audit partitions: {e:?}"),
|
|
}
|
|
}
|