Skip to content

Commit 2d41360

Browse files
improvement(concurrency): limits configurable, docs updates (#5640)
* improvement(concurrency): limits configurable, docs updates * remove dead tests * limits self hosted vars
1 parent e94e514 commit 2d41360

49 files changed

Lines changed: 1299 additions & 371 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/platform/costs.mdx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,18 @@ Team and Enterprise plans unlock shared workspaces that belong to your organizat
332332
| **Max** | 300 | 2,500 |
333333
| **Enterprise** | 600 | 5,000 |
334334

335-
Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits.
335+
Rate limits follow the paid tier: Pro and Pro for Teams share the Pro limits, while Max and Max for Teams share the Max limits.
336+
337+
### Concurrent Executions
338+
339+
| Plan | Concurrent Executions |
340+
|------|-----------------------|
341+
| **Free** | 10 |
342+
| **Pro** | 50 |
343+
| **Max** | 200 |
344+
| **Enterprise** | 1,000 by default (customizable) |
345+
346+
The concurrency limit applies per billing account. For organization-based plans, the limit is shared by all workspaces, members, and API keys under that organization. Teams plans follow their tier: Pro for Teams uses the Pro limit and Max for Teams uses the Max limit. An admitted async execution holds a slot while queued and running; synchronous executions hold one while running. Workflow-in-workflow blocks execute inside the parent run and do not consume another concurrency slot.
336347

337348
### File Storage
338349

@@ -343,7 +354,7 @@ Max (individual) shares the same rate limits as team plans. Team plans (Pro or M
343354
| **Max** | 500 GB |
344355
| **Enterprise** | 500 GB (customizable) |
345356

346-
Team plans (Pro or Max for Teams) use 500 GB.
357+
Storage follows the paid tier: Pro and Pro for Teams share the Pro limit, while Max and Max for Teams share the Max limit. Organization storage is pooled across the organization's workspaces.
347358

348359
### Run Time Limits
349360

apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,29 @@ Configure one provider — the mailer auto-detects in priority order: **Resend
121121
|----------|-------------|
122122
| `AZURE_ACS_CONNECTION_STRING` | Azure Communication Services connection string |
123123

124+
## Limits
125+
126+
Self-hosted deployments (billing disabled) run without plan limits: no rate limits, no execution timeouts, no table or storage caps, and no retention-based data deletion. Each limit can be opted back in individually by explicitly setting its variable.
127+
128+
| Variable | Opts in | Suggested value |
129+
|----------|---------|-----------------|
130+
| `RATE_LIMIT_FREE_SYNC` | Sync executions per minute | `50` |
131+
| `RATE_LIMIT_FREE_ASYNC` | Async executions per minute | `200` |
132+
| `RATE_LIMIT_FREE_API_ENDPOINT` | v1 API endpoint requests per minute | `30` |
133+
| `EXECUTION_TIMEOUT_FREE` | Sync execution timeout (seconds) | `300` |
134+
| `EXECUTION_TIMEOUT_ASYNC_FREE` | Async execution timeout (seconds) | `5400` |
135+
| `FREE_TABLES_LIMIT` | Max user tables per workspace | `5` |
136+
| `FREE_TABLE_ROWS_LIMIT` | Max rows per user table | `50000` |
137+
| `FREE_STORAGE_LIMIT_GB` | File storage quota (GB) | `5` |
138+
139+
<Callout type="info">
140+
Without billing, every account resolves to the free tier, so only the free-tier variables apply. Setting one variable enforces only that limit — the rest stay unlimited.
141+
</Callout>
142+
143+
<Callout type="warn">
144+
Deployments installed with the Helm chart ship these variables preset in `app.envDefaults`, so chart-based installs keep enforcement unless those keys are removed or overridden.
145+
</Callout>
146+
124147
## Example .env
125148

126149
```bash

apps/sim/.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,15 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
112112
# Admin API (Optional - for self-hosted GitOps)
113113
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
114114
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces
115+
116+
# Limits (Optional - self-hosted). With billing disabled (BILLING_ENABLED unset), no plan
117+
# limits are enforced. Explicitly setting a free-tier variable below opts that specific
118+
# limit back in at the configured value.
119+
# RATE_LIMIT_FREE_SYNC=50 # Sync executions per minute
120+
# RATE_LIMIT_FREE_ASYNC=200 # Async executions per minute
121+
# RATE_LIMIT_FREE_API_ENDPOINT=30 # v1 API endpoint requests per minute
122+
# EXECUTION_TIMEOUT_FREE=300 # Sync execution timeout in seconds
123+
# EXECUTION_TIMEOUT_ASYNC_FREE=5400 # Async execution timeout in seconds
124+
# FREE_TABLES_LIMIT=5 # Max user tables per workspace
125+
# FREE_TABLE_ROWS_LIMIT=50000 # Max rows per user table
126+
# FREE_STORAGE_LIMIT_GB=5 # File storage quota in GB

apps/sim/app/api/mcp/tools/execute/route.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -213,15 +213,26 @@ export const POST = withRouteHandler(
213213
}
214214

215215
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
216-
const result = await Promise.race([
217-
mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
218-
new Promise<never>((_, reject) => {
219-
timeoutHandle = setTimeout(
220-
() => reject(new Error('Tool execution timeout')),
221-
executionTimeout
222-
)
223-
}),
224-
]).finally(() => {
216+
const executePromise = mcpService.executeTool(
217+
userId,
218+
serverId,
219+
toolCall,
220+
workspaceId,
221+
extraHeaders
222+
)
223+
// A zero timeout means "no timeout" (billing-disabled deployments).
224+
const result = await (executionTimeout > 0
225+
? Promise.race([
226+
executePromise,
227+
new Promise<never>((_, reject) => {
228+
timeoutHandle = setTimeout(
229+
() => reject(new Error('Tool execution timeout')),
230+
executionTimeout
231+
)
232+
}),
233+
])
234+
: executePromise
235+
).finally(() => {
225236
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
226237
})
227238

apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DEFAULT_BILLING_CONCURRENCY_LIMITS } from '@/lib/billing/concurrency-defaults'
12
import {
23
CREDIT_TIERS,
34
CREDITS_PER_DOLLAR,
@@ -116,6 +117,20 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [
116117
},
117118
],
118119
},
120+
{
121+
title: 'Execution concurrency',
122+
rows: [
123+
{
124+
label: 'Concurrent executions',
125+
values: [
126+
DEFAULT_BILLING_CONCURRENCY_LIMITS.free.toLocaleString('en-US'),
127+
DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US'),
128+
DEFAULT_BILLING_CONCURRENCY_LIMITS.team.toLocaleString('en-US'),
129+
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.enterprise.toLocaleString('en-US')} (customizable)`,
130+
],
131+
},
132+
],
133+
},
119134
{
120135
title: 'Rate limits (runs/min)',
121136
rows: [

apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { DEFAULT_BILLING_CONCURRENCY_LIMITS } from '@/lib/billing/concurrency-defaults'
2+
13
/**
24
* Config for a plan's top-level credit stats.
35
* When `credits` is omitted the credits/refresh block is not rendered.
@@ -25,20 +27,23 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = {
2527
}
2628

2729
export const PRO_PLAN_FEATURES: readonly string[] = [
30+
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US')} concurrent executions`,
2831
'Invite teammates',
2932
'Deploy workflows as APIs',
3033
'Extended run timeouts',
3134
'More storage & tables',
3235
]
3336

3437
export const MAX_PLAN_FEATURES: readonly string[] = [
38+
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.team.toLocaleString('en-US')} concurrent executions`,
3539
'Invite teammates',
3640
'Sim Mailer & KB Live Sync',
3741
'Highest rate limits',
3842
'Expanded storage & tables',
3943
]
4044

4145
export const ENTERPRISE_PLAN_FEATURES: readonly string[] = [
46+
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.enterprise.toLocaleString('en-US')} concurrent executions, customizable`,
4247
'Custom limits & infrastructure',
4348
'SSO & SOC2 compliance',
4449
'Access control & self-hosting',

apps/sim/lib/admin/dashboard.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
getOrganizationUsageLimitFallbackDollars,
3030
getTeamOrganizationEconomics,
3131
} from '@/lib/admin/organization-economics'
32+
import { parseBillingConcurrencyLimit } from '@/lib/billing/concurrency-defaults'
33+
import { getBillingConcurrencyLimit } from '@/lib/billing/concurrency-limits'
3234
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
3335
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
3436
import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion'
@@ -190,6 +192,13 @@ function buildDashboardOrganizationSummary({
190192
latestSubscription?.plan === 'enterprise'
191193
? Math.max(0, Math.round(metadataNumber(metadata, 'seats') ?? 0))
192194
: memberCount
195+
const concurrencyLimit =
196+
latestSubscription?.plan === 'enterprise'
197+
? getBillingConcurrencyLimit(
198+
latestSubscription.plan,
199+
parseBillingConcurrencyLimit(metadata.concurrencyLimit)
200+
)
201+
: null
193202

194203
return {
195204
id: org.id,
@@ -202,6 +211,7 @@ function buildDashboardOrganizationSummary({
202211
memberCount,
203212
externalCollaboratorCount,
204213
seats,
214+
concurrencyLimit,
205215
includedMonthlyDollars,
206216
usageLimitDollars,
207217
effectiveUsageLimitDollars,
@@ -547,7 +557,11 @@ export async function updateDashboardEnterpriseSeats(
547557

548558
export async function updateDashboardOrganizationLimits(
549559
organizationId: string,
550-
values: { includedMonthlyDollars?: number; usageLimitDollars?: number },
560+
values: {
561+
includedMonthlyDollars?: number
562+
usageLimitDollars?: number
563+
concurrencyLimit?: number | null
564+
},
551565
actor: AdminMutationActor
552566
) {
553567
await db.transaction(async (tx) => {
@@ -574,6 +588,9 @@ export async function updateDashboardOrganizationLimits(
574588
if (values.includedMonthlyDollars !== undefined && subscriptionRow?.plan !== 'enterprise') {
575589
throw new Error('Included allowance is editable only for Enterprise organizations')
576590
}
591+
if (values.concurrencyLimit !== undefined && subscriptionRow?.plan !== 'enterprise') {
592+
throw new Error('Concurrency is editable only for Enterprise organizations')
593+
}
577594

578595
if (subscriptionRow?.plan === 'enterprise') {
579596
if (!hasPaidSubscriptionStatus(subscriptionRow.status)) {
@@ -598,6 +615,9 @@ export async function updateDashboardOrganizationLimits(
598615
...current,
599616
includedMonthlyCredits: included,
600617
usageLimitCredits: configuredUsageLimit,
618+
...(values.concurrencyLimit !== undefined
619+
? { concurrencyLimit: values.concurrencyLimit }
620+
: {}),
601621
}
602622
},
603623
})
@@ -650,7 +670,7 @@ export async function updateDashboardOrganizationLimits(
650670
action: AuditAction.ORGANIZATION_UPDATED,
651671
resourceType: AuditResourceType.ORGANIZATION,
652672
resourceId: organizationId,
653-
description: 'Admin updated organization credit limits',
673+
description: 'Admin updated organization limits',
654674
metadata: values,
655675
})
656676
}

apps/sim/lib/api/contracts/v1/admin/dashboard.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { describe, expect, it } from 'vitest'
44
import {
55
adminDashboardBalanceGrantBodySchema,
6+
adminDashboardIssueEnterpriseBodySchema,
7+
adminDashboardLimitsBodySchema,
68
adminDashboardOrganizationSummarySchema,
79
adminDashboardUpdateMemberBodySchema,
810
} from '@/lib/api/contracts/v1/admin/dashboard'
@@ -55,6 +57,7 @@ describe('admin dashboard credit grant contract', () => {
5557
memberCount: 0,
5658
externalCollaboratorCount: 0,
5759
seats: 0,
60+
concurrencyLimit: null,
5861
includedMonthlyDollars: 0,
5962
usageLimitDollars: 0.001,
6063
effectiveUsageLimitDollars: 0.001,
@@ -64,4 +67,33 @@ describe('admin dashboard credit grant contract', () => {
6467
}).success
6568
).toBe(true)
6669
})
70+
71+
it('accepts positive integer Enterprise concurrency limits', () => {
72+
expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: 1250 }).success).toBe(true)
73+
expect(
74+
adminDashboardIssueEnterpriseBodySchema.safeParse({
75+
ownerUserId: 'owner-1',
76+
monthlyInvoiceAmountUsd: 500,
77+
includedMonthlyDollars: 500,
78+
seats: 10,
79+
concurrencyLimit: 1250,
80+
pausePaymentCollection: true,
81+
}).success
82+
).toBe(true)
83+
expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: 0 }).success).toBe(false)
84+
expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: 1.5 }).success).toBe(false)
85+
})
86+
87+
it('accepts null to restore the deployment-wide Enterprise concurrency default', () => {
88+
expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: null }).success).toBe(true)
89+
expect(
90+
adminDashboardIssueEnterpriseBodySchema.safeParse({
91+
ownerUserId: 'owner-1',
92+
monthlyInvoiceAmountUsd: 500,
93+
includedMonthlyDollars: 500,
94+
seats: 10,
95+
concurrencyLimit: null,
96+
}).success
97+
).toBe(false)
98+
})
6799
})

apps/sim/lib/api/contracts/v1/admin/dashboard.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
adminV1QueryStringSchema,
88
adminV1SingleResponseSchema,
99
} from '@/lib/api/contracts/v1/admin/shared'
10+
import { MAX_BILLING_CONCURRENCY_LIMIT } from '@/lib/billing/concurrency-defaults'
1011

1112
const dollarAmountSchema = z
1213
.number()
@@ -40,6 +41,8 @@ export const adminDashboardProvisioningSchema = z.object({
4041
includedMonthlyDollars: creditAlignedDollarAmountSchema,
4142
usageLimitDollars: creditAlignedDollarAmountSchema,
4243
seats: z.number().int().positive(),
44+
concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT),
45+
pausePaymentCollection: z.boolean(),
4346
stripeSubscriptionId: z.string().nullable(),
4447
error: z.string().nullable(),
4548
createdAt: z.string(),
@@ -57,6 +60,7 @@ export const adminDashboardOrganizationSummarySchema = z.object({
5760
memberCount: z.number().int().min(0),
5861
externalCollaboratorCount: z.number().int().min(0),
5962
seats: z.number().int().min(0),
63+
concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT).nullable(),
6064
includedMonthlyDollars: dollarAmountSchema,
6165
usageLimitDollars: dollarAmountSchema,
6266
effectiveUsageLimitDollars: dollarAmountSchema,
@@ -111,6 +115,8 @@ export const adminDashboardIssueEnterpriseBodySchema = z.object({
111115
includedMonthlyDollars: creditAlignedDollarAmountSchema,
112116
usageLimitDollars: creditAlignedDollarAmountSchema.optional(),
113117
seats: z.number().int().positive().max(100_000),
118+
concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT).optional(),
119+
pausePaymentCollection: z.boolean().optional(),
114120
})
115121

116122
export const adminDashboardSeatsBodySchema = z.object({
@@ -121,9 +127,19 @@ export const adminDashboardLimitsBodySchema = z
121127
.object({
122128
includedMonthlyDollars: creditAlignedDollarAmountSchema.optional(),
123129
usageLimitDollars: creditAlignedDollarAmountSchema.optional(),
130+
concurrencyLimit: z
131+
.number()
132+
.int()
133+
.positive()
134+
.max(MAX_BILLING_CONCURRENCY_LIMIT)
135+
.nullable()
136+
.optional(),
124137
})
125138
.refine(
126-
(value) => value.includedMonthlyDollars !== undefined || value.usageLimitDollars !== undefined,
139+
(value) =>
140+
value.includedMonthlyDollars !== undefined ||
141+
value.usageLimitDollars !== undefined ||
142+
value.concurrencyLimit !== undefined,
127143
{ error: 'At least one limit must be provided' }
128144
)
129145

0 commit comments

Comments
 (0)