Skip to content

Commit c0ade1c

Browse files
feat(table): per-plan table dispatch concurrency with env overrides
1 parent 1da346b commit c0ade1c

11 files changed

Lines changed: 17585 additions & 14 deletions

File tree

apps/sim/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,5 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
133133
# EXECUTION_TIMEOUT_ASYNC_FREE=5400 # Async execution timeout in seconds
134134
# FREE_TABLES_LIMIT=5 # Max user tables per workspace
135135
# FREE_TABLE_ROWS_LIMIT=50000 # Max rows per user table
136+
# TABLE_DISPATCH_CONCURRENCY_FREE=20 # Rows one table run executes in parallel (also _PRO/_TEAM/_ENTERPRISE, default 50; billing disabled uses the highest configured tier)
136137
# FREE_STORAGE_LIMIT_GB=5 # File storage quota in GB

apps/sim/background/workflow-column-execution.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { retryTableAdmission } from '@/lib/table/admission-retry'
2222
import { withCascadeLock } from '@/lib/table/cascade-lock'
2323
import { getColumnId } from '@/lib/table/column-keys'
2424
import { isExecCancelled } from '@/lib/table/deps'
25+
import { getMaxTableDispatchConcurrency } from '@/lib/table/dispatch-concurrency'
2526
import { appendTableEvent } from '@/lib/table/events'
2627
import type {
2728
RowData,
@@ -852,11 +853,15 @@ export const workflowGroupCellTask = task({
852853
id: 'workflow-group-cell',
853854
machine: 'medium-1x',
854855
retry: { maxAttempts: 1 },
855-
// Combined with `concurrencyKey: tableId`, caps each table's sub-queue to
856-
// 20 in-flight cell jobs while letting different tables run in parallel.
856+
// Combined with `concurrencyKey: tableId`, caps each table's sub-queue of
857+
// in-flight cell jobs while letting different tables run in parallel. The
858+
// cap is the highest per-plan dispatch window so the queue never throttles
859+
// below a plan's window — the dispatcher window is the real per-run limiter.
860+
// Read at trigger.dev deploy time: raising a TABLE_DISPATCH_CONCURRENCY_*
861+
// env var above the current max needs a trigger.dev redeploy to take effect.
857862
queue: {
858863
name: 'workflow-group-cell',
859-
concurrencyLimit: 20,
864+
concurrencyLimit: getMaxTableDispatchConcurrency(),
860865
},
861866
run: (payload: QueuedWorkflowGroupCellPayload, { signal }) =>
862867
executeWorkflowGroupCellJob(payload, signal),

apps/sim/lib/core/config/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ export const env = createEnv({
103103
ENTERPRISE_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on enterprise tier (default: 1000000)
104104
TABLE_MAX_ROW_SIZE_BYTES: z.number().optional(), // Max serialized size in bytes of a single user-table row (default: 409600)
105105
TABLE_MAX_PAGE_BYTES: z.number().optional(), // Dev-preview: byte budget per row-page read; pages cut early past it (unset = disabled)
106+
TABLE_DISPATCH_CONCURRENCY_FREE: z.number().optional(), // Rows one table run executes in parallel on free tier (default: 20)
107+
TABLE_DISPATCH_CONCURRENCY_PRO: z.number().optional(), // Rows one table run executes in parallel on Pro tier (default: 50)
108+
TABLE_DISPATCH_CONCURRENCY_TEAM: z.number().optional(), // Rows one table run executes in parallel on Max/Team tier (default: 50)
109+
TABLE_DISPATCH_CONCURRENCY_ENTERPRISE: z.number().optional(), // Rows one table run executes in parallel on Enterprise tier (default: 50)
106110

107111
// Credit-tier Stripe prices (monthly)
108112
STRIPE_PRICE_TIER_25_MO: z.string().min(1).optional(), // Pro: $25/mo (6,000 credits)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockEnv, mockFlags } = vi.hoisted(() => ({
7+
mockEnv: {} as Record<string, string | undefined>,
8+
mockFlags: { isBillingEnabled: true },
9+
}))
10+
11+
vi.mock('@/lib/core/config/env', () => ({
12+
env: mockEnv,
13+
envNumber: (
14+
value: number | string | undefined | null,
15+
fallback: number,
16+
options: { min?: number; integer?: boolean } = {}
17+
) => {
18+
const parsed = Number(value)
19+
const min = options.min ?? 0
20+
return Number.isFinite(parsed) &&
21+
parsed >= min &&
22+
(!options.integer || Number.isInteger(parsed))
23+
? parsed
24+
: fallback
25+
},
26+
}))
27+
28+
vi.mock('@/lib/core/config/env-flags', () => ({
29+
get isBillingEnabled() {
30+
return mockFlags.isBillingEnabled
31+
},
32+
}))
33+
34+
import {
35+
getMaxTableDispatchConcurrency,
36+
getTableDispatchConcurrency,
37+
} from '@/lib/table/dispatch-concurrency'
38+
39+
describe('getTableDispatchConcurrency', () => {
40+
beforeEach(() => {
41+
for (const key of Object.keys(mockEnv)) delete mockEnv[key]
42+
mockFlags.isBillingEnabled = true
43+
})
44+
45+
it('resolves plan-bucketed defaults', () => {
46+
expect(getTableDispatchConcurrency(null)).toBe(20)
47+
expect(getTableDispatchConcurrency('free')).toBe(20)
48+
expect(getTableDispatchConcurrency('pro_6000')).toBe(50)
49+
expect(getTableDispatchConcurrency('team_25000')).toBe(50)
50+
expect(getTableDispatchConcurrency('enterprise')).toBe(50)
51+
})
52+
53+
it('applies env overrides per plan', () => {
54+
mockEnv.TABLE_DISPATCH_CONCURRENCY_FREE = '5'
55+
mockEnv.TABLE_DISPATCH_CONCURRENCY_ENTERPRISE = '200'
56+
57+
expect(getTableDispatchConcurrency('free')).toBe(5)
58+
expect(getTableDispatchConcurrency('pro_6000')).toBe(50)
59+
expect(getTableDispatchConcurrency('enterprise')).toBe(200)
60+
})
61+
62+
it('uses the highest configured tier when billing is disabled', () => {
63+
mockFlags.isBillingEnabled = false
64+
expect(getTableDispatchConcurrency(null)).toBe(50)
65+
66+
mockEnv.TABLE_DISPATCH_CONCURRENCY_TEAM = '120'
67+
expect(getTableDispatchConcurrency(null)).toBe(120)
68+
})
69+
})
70+
71+
describe('getMaxTableDispatchConcurrency', () => {
72+
beforeEach(() => {
73+
for (const key of Object.keys(mockEnv)) delete mockEnv[key]
74+
})
75+
76+
it('returns the highest per-plan value', () => {
77+
expect(getMaxTableDispatchConcurrency()).toBe(50)
78+
79+
mockEnv.TABLE_DISPATCH_CONCURRENCY_PRO = '80'
80+
expect(getMaxTableDispatchConcurrency()).toBe(80)
81+
})
82+
})
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { getPlanTypeForLimits, type PlanCategory } from '@/lib/billing/plan-helpers'
2+
import { env, envNumber } from '@/lib/core/config/env'
3+
import { isBillingEnabled } from '@/lib/core/config/env-flags'
4+
5+
/**
6+
* Default per-plan table dispatch concurrency — how many rows one table run
7+
* executes in parallel (the dispatcher window size). Overridable via the
8+
* `TABLE_DISPATCH_CONCURRENCY_{FREE,PRO,TEAM,ENTERPRISE}` env vars.
9+
*/
10+
export const DEFAULT_TABLE_DISPATCH_CONCURRENCY = {
11+
free: 20,
12+
pro: 50,
13+
team: 50,
14+
enterprise: 50,
15+
} as const satisfies Record<PlanCategory, number>
16+
17+
/**
18+
* Resolves per-plan dispatch concurrency, applying env overrides on top of
19+
* the defaults.
20+
*/
21+
export function getTableDispatchConcurrencyLimits(): Record<PlanCategory, number> {
22+
return {
23+
free: envNumber(env.TABLE_DISPATCH_CONCURRENCY_FREE, DEFAULT_TABLE_DISPATCH_CONCURRENCY.free, {
24+
min: 1,
25+
integer: true,
26+
}),
27+
pro: envNumber(env.TABLE_DISPATCH_CONCURRENCY_PRO, DEFAULT_TABLE_DISPATCH_CONCURRENCY.pro, {
28+
min: 1,
29+
integer: true,
30+
}),
31+
team: envNumber(env.TABLE_DISPATCH_CONCURRENCY_TEAM, DEFAULT_TABLE_DISPATCH_CONCURRENCY.team, {
32+
min: 1,
33+
integer: true,
34+
}),
35+
enterprise: envNumber(
36+
env.TABLE_DISPATCH_CONCURRENCY_ENTERPRISE,
37+
DEFAULT_TABLE_DISPATCH_CONCURRENCY.enterprise,
38+
{ min: 1, integer: true }
39+
),
40+
}
41+
}
42+
43+
/**
44+
* Dispatch concurrency for one payer plan. Billing-disabled deployments get
45+
* the highest configured tier.
46+
*/
47+
export function getTableDispatchConcurrency(plan: string | null | undefined): number {
48+
if (!isBillingEnabled) return getMaxTableDispatchConcurrency()
49+
return getTableDispatchConcurrencyLimits()[getPlanTypeForLimits(plan)]
50+
}
51+
52+
/**
53+
* Highest configured dispatch concurrency across plans. The
54+
* `workflow-group-cell` trigger.dev queue cap derives from this so the
55+
* server-side per-table ceiling never throttles below a plan's window.
56+
*/
57+
export function getMaxTableDispatchConcurrency(): number {
58+
return Math.max(...Object.values(getTableDispatchConcurrencyLimits()))
59+
}
60+
61+
/**
62+
* Resolves the workspace payer's plan and returns its dispatch concurrency.
63+
* Uses the same billing attribution the cells are billed under, so the window
64+
* follows whoever pays for the run.
65+
*/
66+
export async function resolveTableDispatchConcurrency(input: {
67+
workspaceId: string
68+
actorUserId?: string | null
69+
}): Promise<number> {
70+
if (!isBillingEnabled) return getMaxTableDispatchConcurrency()
71+
const { resolveBillingAttribution, resolveSystemBillingAttribution } = await import(
72+
'@/lib/billing/core/billing-attribution'
73+
)
74+
const attribution = input.actorUserId
75+
? await resolveBillingAttribution({
76+
actorUserId: input.actorUserId,
77+
workspaceId: input.workspaceId,
78+
})
79+
: await resolveSystemBillingAttribution(input.workspaceId)
80+
return getTableDispatchConcurrency(attribution.payerSubscription?.plan)
81+
}

apps/sim/lib/table/dispatcher.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,6 @@ import {
4040

4141
const logger = createLogger('TableRunDispatcher')
4242

43-
/** Window size matches the cell-execution concurrency cap so one window
44-
* saturates the pool before the next is loaded — yields a row-major
45-
* scan-line crawl (rows 1-20 finish before 21-40 start). */
46-
const WINDOW_SIZE = TABLE_CONCURRENCY_LIMIT
47-
4843
const ACTIVE_DISPATCH_STATUSES = ['pending', 'dispatching'] as const
4944

5045
export type DispatchStatus = 'pending' | 'dispatching' | 'complete' | 'cancelled'
@@ -85,6 +80,9 @@ export interface DispatchRow {
8580
limit: DispatchLimit | null
8681
/** Units of `limit.type` already consumed (eligible rows dispatched). */
8782
processedCount: number
83+
/** Rows executed in parallel per window, resolved from the payer's plan at
84+
* creation. Null on pre-column rows → legacy cap of 20. */
85+
concurrency: number | null
8886
isManualRun: boolean
8987
/** User who triggered the run (for usage attribution); null for auto-fire. */
9088
triggeredByUserId: string | null
@@ -190,6 +188,8 @@ export async function insertDispatch(input: {
190188
mode: DispatchMode
191189
scope: DispatchScope
192190
limit?: DispatchLimit | null
191+
/** Per-window parallelism from the payer's plan (see `resolveTableDispatchConcurrency`). */
192+
concurrency: number
193193
isManualRun: boolean
194194
triggeredByUserId?: string | null
195195
}): Promise<string> {
@@ -202,6 +202,7 @@ export async function insertDispatch(input: {
202202
mode: input.mode,
203203
scope: input.scope,
204204
limit: input.limit ?? null,
205+
concurrency: input.concurrency,
205206
status: 'pending',
206207
// -1 = "haven't started." First window's filter `position > -1` matches
207208
// position 0; subsequent iterations advance to `lastPosition` which then
@@ -291,6 +292,7 @@ export async function listActiveDispatches(tableId: string): Promise<DispatchRow
291292
cursor: row.cursor,
292293
limit: (row.limit as DispatchLimit | null) ?? null,
293294
processedCount: row.processedCount,
295+
concurrency: row.concurrency,
294296
isManualRun: row.isManualRun,
295297
triggeredByUserId: row.triggeredByUserId,
296298
requestedAt: row.requestedAt,
@@ -315,6 +317,7 @@ export async function readDispatch(dispatchId: string): Promise<DispatchRow | nu
315317
cursor: row.cursor,
316318
limit: (row.limit as DispatchLimit | null) ?? null,
317319
processedCount: row.processedCount,
320+
concurrency: row.concurrency,
318321
isManualRun: row.isManualRun,
319322
triggeredByUserId: row.triggeredByUserId,
320323
requestedAt: row.requestedAt,
@@ -380,6 +383,11 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
380383
})
381384
}
382385

386+
// Window size = the dispatch's plan-resolved parallelism, so one window
387+
// saturates the cell pool before the next is loaded — yields a row-major
388+
// scan-line crawl. Pre-column dispatches fall back to the legacy cap.
389+
const windowSize = dispatch.concurrency ?? TABLE_CONCURRENCY_LIMIT
390+
383391
const filters = [
384392
eq(userTableRows.tableId, dispatch.tableId),
385393
gt(userTableRows.position, dispatch.cursor),
@@ -429,7 +437,7 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
429437
.from(userTableRows)
430438
.where(and(...filters))
431439
.orderBy(asc(userTableRows.position))
432-
.limit(WINDOW_SIZE)
440+
.limit(windowSize)
433441
// Filtered scopes carry a jsonb predicate the planner can't estimate — left alone it
434442
// seq-scans the whole shared relation per window; keep it on the tenant's position index.
435443
const chunk = hasJsonbFilter
@@ -533,8 +541,8 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
533541
// (CRIU-checkpointed wait); database backend calls the cell-task runner
534542
// directly via Promise.all (skips async_jobs since we're awaiting in-
535543
// process anyway). Either way the parent dispatcher blocks until every
536-
// cell in the window terminates — bounds queue depth at WINDOW_SIZE.
537-
const items = await buildEnqueueItems(windowRuns)
544+
// cell in the window terminates — bounds queue depth at the window size.
545+
const items = await buildEnqueueItems(windowRuns, windowSize)
538546
const queue = await getJobQueue()
539547
try {
540548
await queue.batchEnqueueAndWait('workflow-group-cell', items)
@@ -792,6 +800,7 @@ export async function markActiveDispatchesCancelled(
792800
cursor: row.cursor,
793801
limit: (row.limit as DispatchLimit | null) ?? null,
794802
processedCount: row.processedCount,
803+
concurrency: row.concurrency,
795804
isManualRun: row.isManualRun,
796805
triggeredByUserId: row.triggeredByUserId,
797806
requestedAt: row.requestedAt,

apps/sim/lib/table/workflow-columns.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const logger = createLogger('WorkflowGroupScheduler')
3535
import { getColumnId } from '@/lib/table/column-keys'
3636
import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
3737
import { areGroupDepsSatisfied, areOutputsFilled, isExecInFlight } from '@/lib/table/deps'
38+
import { resolveTableDispatchConcurrency } from '@/lib/table/dispatch-concurrency'
3839
import type { DispatchLimit, DispatchMode } from '@/lib/table/dispatcher'
3940
import { buildFilterClause } from '@/lib/table/sql'
4041

@@ -237,7 +238,8 @@ export function buildPendingRuns(
237238
* id. The cell-job import pulls in the executor + blocks stack, so skip it on
238239
* trigger.dev to avoid a multi-second dispatcher cold-start. */
239240
export async function buildEnqueueItems(
240-
pendingRuns: WorkflowGroupCellPayload[]
241+
pendingRuns: WorkflowGroupCellPayload[],
242+
concurrencyLimit: number = TABLE_CONCURRENCY_LIMIT
241243
): Promise<Array<{ payload: QueuedWorkflowGroupCellPayload; options: EnqueueOptions }>> {
242244
if (pendingRuns.length === 0) return []
243245

@@ -292,7 +294,7 @@ export async function buildEnqueueItems(
292294
},
293295
},
294296
concurrencyKey: runOpts.tableId,
295-
concurrencyLimit: TABLE_CONCURRENCY_LIMIT,
297+
concurrencyLimit,
296298
tags: cellTagsFor(runOpts),
297299
...(runner ? { runner } : {}),
298300
cancelKey: cellCancelKey(runOpts.tableId, runOpts.rowId, runOpts.groupId),
@@ -391,7 +393,9 @@ export type QueuedWorkflowGroupCellPayload = Omit<
391393
billingAttribution: BillingAttributionSnapshot
392394
}
393395

394-
/** Per-table concurrency cap. Mirrors trigger.dev's `concurrencyLimit: 20`. */
396+
/** Legacy per-table concurrency cap. The live cap is per-plan (see
397+
* `getTableDispatchConcurrency`); this remains the fallback for dispatch rows
398+
* that predate the `concurrency` column and for non-dispatch cell enqueues. */
395399
export const TABLE_CONCURRENCY_LIMIT = 20
396400

397401
/**
@@ -745,6 +749,14 @@ export async function runWorkflowColumn(opts: {
745749
runDispatcherToCompletion,
746750
} = await import('./dispatcher')
747751

752+
// Per-window parallelism follows the payer's plan, resolved once here and
753+
// persisted on the dispatch row so the dispatcher loop reads a stable value
754+
// across checkpointed waits and task retries.
755+
const concurrency = await resolveTableDispatchConcurrency({
756+
workspaceId,
757+
actorUserId: triggeredByUserId,
758+
})
759+
748760
// Always insert a `table_run_dispatches` row, and insert it FIRST — before
749761
// the prior-run cancel and the bulk clear below, which can take seconds on
750762
// a large table. The client shows its Stop control optimistically from the
@@ -768,6 +780,7 @@ export async function runWorkflowColumn(opts: {
768780
: {}),
769781
},
770782
limit,
783+
concurrency,
771784
isManualRun,
772785
triggeredByUserId,
773786
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "table_run_dispatches" ADD COLUMN "concurrency" integer;

0 commit comments

Comments
 (0)