From 2ce11cea3be04ff4d7ff7e7f1ff1e3d2d9b8c67e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 28 Aug 2025 18:56:57 +0200 Subject: [PATCH] prevent past due team plans to overuse (#6487) * prevent past due team plans to overuse * nit * update ee ref --- ...d2ea9245f05e772f3601c99312c13ed65ae1a.json | 16 ---- ...a6aa59599f852cc17b16323fc627b6ad8671e.json | 16 ---- ...6242a39dd40186f1dbb48ad3018bd9f6913ec.json | 34 +++++++ ...a2e60c9c24decb2a6b41125247bbf741e9c25.json | 46 ---------- ...1789afb8250adde6f38f458b115e787ed876f.json | 58 ++++++++++++ ...50aa180b29bbe931d948f6e61976f71b7cdb9.json | 22 ----- backend/ee-repo-ref.txt | 2 +- ...250827102435_add_team_plan_status.down.sql | 7 ++ ...20250827102435_add_team_plan_status.up.sql | 18 ++++ backend/src/main.rs | 2 +- backend/windmill-api/openapi.yaml | 10 +++ backend/windmill-api/src/workspaces.rs | 4 +- backend/windmill-common/src/workspaces.rs | 43 +++++++-- backend/windmill-queue/src/jobs.rs | 90 +++++++++++-------- backend/windmill-worker/src/common.rs | 5 +- .../components/settings/PremiumInfo.svelte | 11 ++- .../src/routes/(root)/(logged)/+layout.svelte | 35 +++++++- 17 files changed, 263 insertions(+), 156 deletions(-) delete mode 100644 backend/.sqlx/query-0156016836adeb2714d99811e4ad2ea9245f05e772f3601c99312c13ed65ae1a.json delete mode 100644 backend/.sqlx/query-03e213d2934991c57af64b5ae94a6aa59599f852cc17b16323fc627b6ad8671e.json create mode 100644 backend/.sqlx/query-124e67b0cee1baa6295846db4ad6242a39dd40186f1dbb48ad3018bd9f6913ec.json delete mode 100644 backend/.sqlx/query-4b8132b04e454eddfe6724c6cc3a2e60c9c24decb2a6b41125247bbf741e9c25.json create mode 100644 backend/.sqlx/query-ac4a375035049304db7ae084b2d1789afb8250adde6f38f458b115e787ed876f.json delete mode 100644 backend/.sqlx/query-d768bbc46f8a9c4289b918c88ca50aa180b29bbe931d948f6e61976f71b7cdb9.json create mode 100644 backend/migrations/20250827102435_add_team_plan_status.down.sql create mode 100644 backend/migrations/20250827102435_add_team_plan_status.up.sql diff --git a/backend/.sqlx/query-0156016836adeb2714d99811e4ad2ea9245f05e772f3601c99312c13ed65ae1a.json b/backend/.sqlx/query-0156016836adeb2714d99811e4ad2ea9245f05e772f3601c99312c13ed65ae1a.json deleted file mode 100644 index af706cdac9..0000000000 --- a/backend/.sqlx/query-0156016836adeb2714d99811e4ad2ea9245f05e772f3601c99312c13ed65ae1a.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets) \n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets \n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "0156016836adeb2714d99811e4ad2ea9245f05e772f3601c99312c13ed65ae1a" -} diff --git a/backend/.sqlx/query-03e213d2934991c57af64b5ae94a6aa59599f852cc17b16323fc627b6ad8671e.json b/backend/.sqlx/query-03e213d2934991c57af64b5ae94a6aa59599f852cc17b16323fc627b6ad8671e.json deleted file mode 100644 index eee4c4d493..0000000000 --- a/backend/.sqlx/query-03e213d2934991c57af64b5ae94a6aa59599f852cc17b16323fc627b6ad8671e.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets) \n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets \n\n FROM script WHERE hash = $2 AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "03e213d2934991c57af64b5ae94a6aa59599f852cc17b16323fc627b6ad8671e" -} diff --git a/backend/.sqlx/query-124e67b0cee1baa6295846db4ad6242a39dd40186f1dbb48ad3018bd9f6913ec.json b/backend/.sqlx/query-124e67b0cee1baa6295846db4ad6242a39dd40186f1dbb48ad3018bd9f6913ec.json new file mode 100644 index 0000000000..51a1ec68cb --- /dev/null +++ b/backend/.sqlx/query-124e67b0cee1baa6295846db4ad6242a39dd40186f1dbb48ad3018bd9f6913ec.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n w.premium,\n COALESCE(cw.is_past_due, false) as \"is_past_due!\",\n cw.max_tolerated_executions\n FROM\n workspace w\n LEFT JOIN cloud_workspace_settings cw ON cw.workspace_id = w.id\n WHERE\n w.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "premium", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "is_past_due!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "max_tolerated_executions", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null, + true + ] + }, + "hash": "124e67b0cee1baa6295846db4ad6242a39dd40186f1dbb48ad3018bd9f6913ec" +} diff --git a/backend/.sqlx/query-4b8132b04e454eddfe6724c6cc3a2e60c9c24decb2a6b41125247bbf741e9c25.json b/backend/.sqlx/query-4b8132b04e454eddfe6724c6cc3a2e60c9c24decb2a6b41125247bbf741e9c25.json deleted file mode 100644 index ab5b04afc6..0000000000 --- a/backend/.sqlx/query-4b8132b04e454eddfe6724c6cc3a2e60c9c24decb2a6b41125247bbf741e9c25.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT owner, premium, usage.usage as \"usage?\", workspace_settings.customer_id, workspace_settings.plan FROM workspace LEFT JOIN workspace_settings ON workspace_settings.workspace_id = $1 LEFT JOIN usage ON usage.id = $1 AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND usage.is_workspace IS true WHERE workspace.id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "owner", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "premium", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "usage?", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "customer_id", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "plan", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - true, - true - ] - }, - "hash": "4b8132b04e454eddfe6724c6cc3a2e60c9c24decb2a6b41125247bbf741e9c25" -} diff --git a/backend/.sqlx/query-ac4a375035049304db7ae084b2d1789afb8250adde6f38f458b115e787ed876f.json b/backend/.sqlx/query-ac4a375035049304db7ae084b2d1789afb8250adde6f38f458b115e787ed876f.json new file mode 100644 index 0000000000..9e3e7cb863 --- /dev/null +++ b/backend/.sqlx/query-ac4a375035049304db7ae084b2d1789afb8250adde6f38f458b115e787ed876f.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n owner, \n premium, \n usage.usage as \"usage?\", \n workspace_settings.customer_id, \n workspace_settings.plan, \n COALESCE(cw.is_past_due, false) as \"is_past_due!\", \n cw.max_tolerated_executions\n FROM workspace\n LEFT JOIN workspace_settings \n ON workspace_settings.workspace_id = $1\n LEFT JOIN usage \n ON usage.id = $1\n AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)\n AND usage.is_workspace IS true\n LEFT JOIN cloud_workspace_settings cw\n ON cw.workspace_id = $1\n WHERE workspace.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "owner", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "premium", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "usage?", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "customer_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "plan", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "is_past_due!", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "max_tolerated_executions", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + null, + true + ] + }, + "hash": "ac4a375035049304db7ae084b2d1789afb8250adde6f38f458b115e787ed876f" +} diff --git a/backend/.sqlx/query-d768bbc46f8a9c4289b918c88ca50aa180b29bbe931d948f6e61976f71b7cdb9.json b/backend/.sqlx/query-d768bbc46f8a9c4289b918c88ca50aa180b29bbe931d948f6e61976f71b7cdb9.json deleted file mode 100644 index 22b38a5217..0000000000 --- a/backend/.sqlx/query-d768bbc46f8a9c4289b918c88ca50aa180b29bbe931d948f6e61976f71b7cdb9.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT premium FROM workspace WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "premium", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "d768bbc46f8a9c4289b918c88ca50aa180b29bbe931d948f6e61976f71b7cdb9" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 111a3f2d18..dfa2a01850 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -6396854336ae27fb14ccb792d80c31ff614b2afa \ No newline at end of file +d16e52d570f10dfdabb04a9061fe7ebdbf5a2aa1 \ No newline at end of file diff --git a/backend/migrations/20250827102435_add_team_plan_status.down.sql b/backend/migrations/20250827102435_add_team_plan_status.down.sql new file mode 100644 index 0000000000..980b70d425 --- /dev/null +++ b/backend/migrations/20250827102435_add_team_plan_status.down.sql @@ -0,0 +1,7 @@ +-- Add down migration script here +DROP FUNCTION notify_team_plan_status_change; +DROP TRIGGER notify_team_plan_status_change ON cloud_workspace_settings; + +ALTER TABLE cloud_workspace_settings + DROP COLUMN is_past_due, + DROP COLUMN max_tolerated_executions; \ No newline at end of file diff --git a/backend/migrations/20250827102435_add_team_plan_status.up.sql b/backend/migrations/20250827102435_add_team_plan_status.up.sql new file mode 100644 index 0000000000..1b2007bfe7 --- /dev/null +++ b/backend/migrations/20250827102435_add_team_plan_status.up.sql @@ -0,0 +1,18 @@ +-- Add up migration script here +ALTER TABLE cloud_workspace_settings + ADD COLUMN is_past_due BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN max_tolerated_executions INTEGER; + +CREATE OR REPLACE FUNCTION notify_team_plan_status_change() +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('notify_workspace_premium_change', NEW.workspace_id); -- reuse the same channel as the one used for workspace premium change => clear cache + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER team_plan_status_change_trigger +AFTER UPDATE OF is_past_due, max_tolerated_executions ON cloud_workspace_settings +FOR EACH ROW +EXECUTE FUNCTION notify_team_plan_status_change(); \ No newline at end of file diff --git a/backend/src/main.rs b/backend/src/main.rs index 9b80e58eef..be0b86ec46 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -857,7 +857,7 @@ Windmill Community Edition {GIT_VERSION} "notify_workspace_premium_change" => { let workspace_id = n.payload(); tracing::info!("Workspace premium change detected, invalidating workspace premium cache: {}", workspace_id); - windmill_common::workspaces::IS_PREMIUM_CACHE.remove(workspace_id); + windmill_common::workspaces::TEAM_PLAN_CACHE.remove(workspace_id); }, "notify_runnable_version_change" => { let payload = n.payload(); diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c2471cebfb..b0afbfb217 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2034,6 +2034,11 @@ paths: - workspace parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: skip_subscription_fetch + in: query + description: skip fetching subscription status from stripe + schema: + type: boolean responses: "200": @@ -2051,9 +2056,14 @@ paths: type: string status: type: string + is_past_due: + type: boolean + max_tolerated_executions: + type: number required: - premium - owner + - is_past_due /w/{workspace}/workspaces/threshold_alert: get: diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 78052b8ed8..9e3c7dc25e 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -415,7 +415,9 @@ async fn is_premium( ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; #[cfg(feature = "cloud")] - let premium = windmill_common::workspaces::is_premium_workspace(&_db, &_w_id).await; + let premium = windmill_common::workspaces::get_team_plan_status(&_db, &_w_id) + .await + .premium; #[cfg(not(feature = "cloud"))] let premium = false; Ok(Json(premium)) diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 5d7d3d9967..c120e3c39a 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -86,22 +86,47 @@ impl Default for GitSyncSettings { } } +#[derive(Clone)] +pub struct TeamPlanStatus { + pub premium: bool, + pub is_past_due: bool, + pub max_tolerated_executions: Option, +} + lazy_static::lazy_static! { - pub static ref IS_PREMIUM_CACHE: Cache = Cache::new(5000); + pub static ref TEAM_PLAN_CACHE: Cache = Cache::new(5000); } #[cfg(feature = "cloud")] -pub async fn is_premium_workspace(_db: &crate::DB, _w_id: &str) -> bool { - let cached = IS_PREMIUM_CACHE.get(_w_id); +pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> TeamPlanStatus { + let cached = TEAM_PLAN_CACHE.get(_w_id); if let Some(cached) = cached { return cached; } - let premium = sqlx::query_scalar!("SELECT premium FROM workspace WHERE id = $1", _w_id) - .fetch_one(_db) - .await - .unwrap_or(false); - IS_PREMIUM_CACHE.insert(_w_id.to_string(), premium); - premium + let team_plan_info = sqlx::query_as!( + TeamPlanStatus, + r#" + SELECT + w.premium, + COALESCE(cw.is_past_due, false) as "is_past_due!", + cw.max_tolerated_executions + FROM + workspace w + LEFT JOIN cloud_workspace_settings cw ON cw.workspace_id = w.id + WHERE + w.id = $1 + "#, + _w_id + ) + .fetch_one(_db) + .await + .unwrap_or_else(|_| TeamPlanStatus { + premium: false, + is_past_due: false, + max_tolerated_executions: None, + }); + TEAM_PLAN_CACHE.insert(_w_id.to_string(), team_plan_info.clone()); + team_plan_info } #[derive(Deserialize, Serialize, Debug)] diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 09b2a39695..3667239119 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -1419,8 +1419,9 @@ fn apply_completed_job_cloud_usage( let email2 = email.clone(); tokio::task::spawn(async move { let additional_usage = _duration / 1000; - let premium_workspace = - windmill_common::workspaces::is_premium_workspace(&db, &w_id).await; + let premium_workspace = windmill_common::workspaces::get_team_plan_status(&db, &w_id) + .await + .premium; tokio::time::timeout(std::time::Duration::from_secs(10), async move { let _ = sqlx::query!( "INSERT INTO usage (id, is_workspace, month_, usage) @@ -3636,8 +3637,8 @@ pub async fn push<'c, 'd>( ) -> Result<(Uuid, Transaction<'c, Postgres>), Error> { #[cfg(feature = "cloud")] if *CLOUD_HOSTED { - let premium_workspace = - windmill_common::workspaces::is_premium_workspace(_db, workspace_id).await; + let team_plan_status = + windmill_common::workspaces::get_team_plan_status(_db, workspace_id).await; // we track only non flow steps let (workspace_usage, user_usage) = if !matches!( job_payload, @@ -3655,7 +3656,7 @@ pub async fn push<'c, 'd>( .await .map_err(|e| Error::internal_err(format!("updating usage: {e:#}")))?; - let user_usage = if !premium_workspace { + let user_usage = if !team_plan_status.premium { Some(sqlx::query_scalar!( "INSERT INTO usage (id, is_workspace, month_, usage) VALUES ($1, FALSE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 1) @@ -3678,7 +3679,7 @@ pub async fn push<'c, 'd>( Ok((None, None)) }?; - if !premium_workspace { + if !team_plan_status.premium || team_plan_status.is_past_due { let is_super_admin = sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", email) .fetch_optional(_db) @@ -3686,7 +3687,8 @@ pub async fn push<'c, 'd>( .unwrap_or(false); if !is_super_admin { - if email != ERROR_HANDLER_USER_EMAIL + if !team_plan_status.premium + && email != ERROR_HANDLER_USER_EMAIL && email != SCHEDULE_ERROR_HANDLER_USER_EMAIL && email != SCHEDULE_RECOVERY_HANDLER_USER_EMAIL && email != "worker@windmill.dev" @@ -3765,43 +3767,53 @@ pub async fn push<'c, 'd>( .flatten() .unwrap_or(1) }; + if team_plan_status.premium { + // team plan is premium but past due, we check if the workspace has exceeded the max tolerated executions + if team_plan_status.max_tolerated_executions.is_none() + || workspace_usage > team_plan_status.max_tolerated_executions.unwrap() + { + return Err(error::Error::QuotaExceeded(format!( + "Workspace {workspace_id} team plan is past due and isn't allowed to run any more jobs. Please fix your payment method in the workspace settings." + ))); + } + } else { + if workspace_usage > MAX_FREE_EXECS + && !matches!(job_payload, JobPayload::Dependencies { .. }) + && !matches!(job_payload, JobPayload::FlowDependencies { .. }) + && !matches!(job_payload, JobPayload::AppDependencies { .. }) + { + return Err(error::Error::QuotaExceeded(format!( + "Workspace {workspace_id} has exceeded the free usage limit of {MAX_FREE_EXECS} that applies outside of premium workspaces." + ))); + } - if workspace_usage > MAX_FREE_EXECS - && !matches!(job_payload, JobPayload::Dependencies { .. }) - && !matches!(job_payload, JobPayload::FlowDependencies { .. }) - && !matches!(job_payload, JobPayload::AppDependencies { .. }) - { - return Err(error::Error::QuotaExceeded(format!( - "Workspace {workspace_id} has exceeded the free usage limit of {MAX_FREE_EXECS} that applies outside of premium workspaces." - ))); - } + let in_queue_workspace = sqlx::query_scalar!( + "SELECT COUNT(id) FROM v2_job_queue WHERE workspace_id = $1", + workspace_id + ) + .fetch_one(_db) + .await? + .unwrap_or(0); - let in_queue_workspace = sqlx::query_scalar!( - "SELECT COUNT(id) FROM v2_job_queue WHERE workspace_id = $1", - workspace_id - ) - .fetch_one(_db) - .await? - .unwrap_or(0); + if in_queue_workspace > MAX_FREE_EXECS as i64 { + return Err(error::Error::QuotaExceeded(format!( + "Workspace {workspace_id} has exceeded the jobs in queue limit of {MAX_FREE_EXECS} that applies outside of premium workspaces." + ))); + } - if in_queue_workspace > MAX_FREE_EXECS as i64 { - return Err(error::Error::QuotaExceeded(format!( - "Workspace {workspace_id} has exceeded the jobs in queue limit of {MAX_FREE_EXECS} that applies outside of premium workspaces." - ))); - } - - let concurrent_runs_workspace = sqlx::query_scalar!( + let concurrent_runs_workspace = sqlx::query_scalar!( "SELECT COUNT(id) FROM v2_job_queue WHERE running = true AND workspace_id = $1", - workspace_id - ) - .fetch_one(_db) - .await? - .unwrap_or(0); + workspace_id + ) + .fetch_one(_db) + .await? + .unwrap_or(0); - if concurrent_runs_workspace > MAX_FREE_CONCURRENT_RUNS as i64 { - return Err(error::Error::QuotaExceeded(format!( - "Workspace {workspace_id} has exceeded the concurrent runs limit of {MAX_FREE_CONCURRENT_RUNS} that applies outside of premium workspaces." - ))); + if concurrent_runs_workspace > MAX_FREE_CONCURRENT_RUNS as i64 { + return Err(error::Error::QuotaExceeded(format!( + "Workspace {workspace_id} has exceeded the concurrent runs limit of {MAX_FREE_CONCURRENT_RUNS} that applies outside of premium workspaces." + ))); + } } } } diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 2b7523ecad..11718f3cf5 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -680,11 +680,12 @@ pub async fn resolve_job_timeout( let mut warn_msg: Option = None; #[cfg(feature = "cloud")] let cloud_premium_workspace = *CLOUD_HOSTED - && windmill_common::workspaces::is_premium_workspace( + && windmill_common::workspaces::get_team_plan_status( _conn.as_sql().expect("cloud cannot use http connection"), _w_id, ) - .await; + .await + .premium; #[cfg(not(feature = "cloud"))] let cloud_premium_workspace = false; diff --git a/frontend/src/lib/components/settings/PremiumInfo.svelte b/frontend/src/lib/components/settings/PremiumInfo.svelte index 1ed4b2fb12..bd7a91a026 100644 --- a/frontend/src/lib/components/settings/PremiumInfo.svelte +++ b/frontend/src/lib/components/settings/PremiumInfo.svelte @@ -30,6 +30,8 @@ seatsFromExtraComps: number usedSeats: number owner: string + is_past_due: boolean + max_tolerated_executions?: number } | undefined = undefined const plans = { @@ -157,8 +159,13 @@ {#if premiumInfo?.status === 'past_due'}

- Your last invoice is unpaid. Please update your payment method in the customer portal to - prevent account downgrade and the interruption of your job executions. + {#if premiumInfo.max_tolerated_executions === undefined || premiumInfo.usage > premiumInfo.max_tolerated_executions} + Your last invoice is unpaid, you cannot run any more jobs. Please update your payment + method in the customer portal to continue running jobs. + {:else} + Your last invoice is unpaid. Please update your payment method in the customer portal to + prevent the interruption of your job executions. + {/if}

{/if} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 0969a4df3f..d03689e3d1 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -11,7 +11,7 @@ UserService, WorkspaceService } from '$lib/gen' - import { capitalize, classNames, getModifierKey } from '$lib/utils' + import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils' import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte' import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte' import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte' @@ -305,6 +305,30 @@ muteSettings = { global: g_muted, workspace: ws_muted } } + + async function checkTeamPlanStatus(workspace: string) { + const premiumInfo = await WorkspaceService.getPremiumInfo({ + workspace, + skipSubscriptionFetch: true // won't load subscription status from stripe but only the past due status from db + }) + if (premiumInfo.is_past_due) { + if ( + premiumInfo.max_tolerated_executions === undefined || + (premiumInfo.usage ?? 0) > premiumInfo.max_tolerated_executions + ) { + sendUserToast( + 'Your last invoice is unpaid, you cannot run any more jobs. Please update your payment method in the workspace settings to continue running jobs.', + true + ) + } else { + sendUserToast( + 'Your last invoice is unpaid. Please update your payment method in the workspace settings to prevent the interruption of your job executions.', + true + ) + } + } + } + $effect(() => { $page.url && userSettings != undefined && untrack(() => onQueryChangeUserSettings()) }) @@ -350,6 +374,15 @@ mountModal = false } }) + + $effect(() => { + if (isCloudHosted()) { + const workspace = $workspaceStore + if (workspace) { + checkTeamPlanStatus(workspace) + } + } + })