Skip to content

Commit 9d5ed38

Browse files
feat(table): per-plan table dispatch concurrency with env overrides (#5720)
* feat(table): per-plan table dispatch concurrency with env overrides * fix(table): enforce shared concurrencyKey cap on database batchEnqueueAndWait * refactor(table): thread dispatch concurrency via invocation instead of persisting it * improvement(table): collapse dispatch concurrency env vars to FREE/PAID
1 parent 1da346b commit 9d5ed38

10 files changed

Lines changed: 259 additions & 23 deletions

File tree

apps/sim/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,6 @@ 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 on free tier
137+
# TABLE_DISPATCH_CONCURRENCY_PAID=50 # Rows one table run executes in parallel on paid tiers (billing disabled uses this)
136138
# FREE_STORAGE_LIMIT_GB=5 # File storage quota in GB

apps/sim/background/table-run-dispatcher.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const logger = createLogger('TableRunDispatcherTask')
77

88
export interface TableRunDispatcherPayload {
99
dispatchId: string
10+
/** Invoker's plan-resolved window size. Absent on payloads from before the
11+
* field existed → dispatcher falls back to the legacy cap. */
12+
concurrency?: number
1013
}
1114

1215
/**
@@ -26,9 +29,9 @@ export const tableRunDispatcherTask = task({
2629
concurrencyLimit: 8,
2730
},
2831
run: async (payload: TableRunDispatcherPayload) => {
29-
const { dispatchId } = payload
32+
const { dispatchId, concurrency } = payload
3033
try {
31-
await runDispatcherToCompletion(dispatchId)
34+
await runDispatcherToCompletion(dispatchId, concurrency)
3235
} catch (err) {
3336
logger.error(`[${dispatchId}] dispatcher loop failed`, { error: toError(err).message })
3437
throw err

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/async-jobs/backends/database.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
5+
import { sleep } from '@sim/utils/helpers'
56
import { beforeEach, describe, expect, it, vi } from 'vitest'
67

78
vi.mock('@sim/db', () => ({
@@ -78,3 +79,36 @@ describe('DatabaseJobQueue enqueue', () => {
7879
})
7980
})
8081
})
82+
83+
describe('DatabaseJobQueue batchEnqueueAndWait', () => {
84+
beforeEach(() => {
85+
vi.clearAllMocks()
86+
resetDbChainMock()
87+
})
88+
89+
it('caps overlapping batches sharing a concurrencyKey at the shared limit', async () => {
90+
const queue = new DatabaseJobQueue()
91+
let inFlight = 0
92+
let maxInFlight = 0
93+
const makeItem = () => ({
94+
payload: {},
95+
options: {
96+
concurrencyKey: 'table-1',
97+
concurrencyLimit: 2,
98+
runner: async () => {
99+
inFlight += 1
100+
maxInFlight = Math.max(maxInFlight, inFlight)
101+
await sleep(1)
102+
inFlight -= 1
103+
},
104+
},
105+
})
106+
107+
await Promise.all([
108+
queue.batchEnqueueAndWait('workflow-group-cell', [makeItem(), makeItem()]),
109+
queue.batchEnqueueAndWait('workflow-group-cell', [makeItem(), makeItem()]),
110+
])
111+
112+
expect(maxInFlight).toBe(2)
113+
})
114+
})

apps/sim/lib/core/async-jobs/backends/database.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,23 @@ export class DatabaseJobQueue implements JobQueueBackend {
207207
inlineCancelKeyControllers.set(cancelKey, controller)
208208
tracked.push({ key: cancelKey, controller })
209209
}
210-
return runner(item.payload, controller.signal).catch((err) => {
210+
// Same shared-key semaphore as `runInline`: without it, overlapping
211+
// batches on one concurrencyKey (e.g. two dispatches on one table) would
212+
// each run their full window concurrently instead of sharing the cap.
213+
const { concurrencyKey, concurrencyLimit } = item.options ?? {}
214+
const run = async () => {
215+
if (concurrencyKey && concurrencyLimit && concurrencyLimit > 0) {
216+
await acquireSlot(concurrencyKey, concurrencyLimit)
217+
try {
218+
await runner(item.payload, controller.signal)
219+
} finally {
220+
releaseSlot(concurrencyKey)
221+
}
222+
return
223+
}
224+
await runner(item.payload, controller.signal)
225+
}
226+
return run().catch((err) => {
211227
logger.error(`[${type}] Inline run failed`, {
212228
cancelKey,
213229
error: toError(err).message,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ 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_PAID: z.number().optional(), // Rows one table run executes in parallel on paid tiers (default: 50)
106108

107109
// Credit-tier Stripe prices (monthly)
108110
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 free vs paid 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', () => {
54+
mockEnv.TABLE_DISPATCH_CONCURRENCY_FREE = '5'
55+
mockEnv.TABLE_DISPATCH_CONCURRENCY_PAID = '200'
56+
57+
expect(getTableDispatchConcurrency('free')).toBe(5)
58+
expect(getTableDispatchConcurrency('pro_6000')).toBe(200)
59+
expect(getTableDispatchConcurrency('enterprise')).toBe(200)
60+
})
61+
62+
it('uses the paid value when billing is disabled', () => {
63+
mockFlags.isBillingEnabled = false
64+
expect(getTableDispatchConcurrency(null)).toBe(50)
65+
66+
mockEnv.TABLE_DISPATCH_CONCURRENCY_PAID = '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 configured value', () => {
77+
expect(getMaxTableDispatchConcurrency()).toBe(50)
78+
79+
mockEnv.TABLE_DISPATCH_CONCURRENCY_FREE = '80'
80+
expect(getMaxTableDispatchConcurrency()).toBe(80)
81+
})
82+
})
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { getPlanTypeForLimits } 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 table dispatch concurrency — how many rows one table run executes
7+
* in parallel (the dispatcher window size). Free vs paid (Pro, Max,
8+
* Enterprise); overridable via `TABLE_DISPATCH_CONCURRENCY_{FREE,PAID}`.
9+
*/
10+
export const DEFAULT_TABLE_DISPATCH_CONCURRENCY = {
11+
free: 20,
12+
paid: 50,
13+
} as const
14+
15+
/**
16+
* Resolves dispatch concurrency limits, applying env overrides on top of the
17+
* defaults.
18+
*/
19+
export function getTableDispatchConcurrencyLimits(): { free: number; paid: number } {
20+
return {
21+
free: envNumber(env.TABLE_DISPATCH_CONCURRENCY_FREE, DEFAULT_TABLE_DISPATCH_CONCURRENCY.free, {
22+
min: 1,
23+
integer: true,
24+
}),
25+
paid: envNumber(env.TABLE_DISPATCH_CONCURRENCY_PAID, DEFAULT_TABLE_DISPATCH_CONCURRENCY.paid, {
26+
min: 1,
27+
integer: true,
28+
}),
29+
}
30+
}
31+
32+
/**
33+
* Dispatch concurrency for one payer plan. Billing-disabled deployments get
34+
* the paid value.
35+
*/
36+
export function getTableDispatchConcurrency(plan: string | null | undefined): number {
37+
const limits = getTableDispatchConcurrencyLimits()
38+
if (!isBillingEnabled) return limits.paid
39+
return getPlanTypeForLimits(plan) === 'free' ? limits.free : limits.paid
40+
}
41+
42+
/**
43+
* Highest configured dispatch concurrency. The `workflow-group-cell`
44+
* trigger.dev queue cap derives from this so the server-side per-table
45+
* ceiling never throttles below a plan's window.
46+
*/
47+
export function getMaxTableDispatchConcurrency(): number {
48+
const limits = getTableDispatchConcurrencyLimits()
49+
return Math.max(limits.free, limits.paid)
50+
}
51+
52+
/**
53+
* Resolves the workspace payer's plan and returns its dispatch concurrency.
54+
* Uses the same billing attribution the cells are billed under, so the window
55+
* follows whoever pays for the run.
56+
*/
57+
export async function resolveTableDispatchConcurrency(input: {
58+
workspaceId: string
59+
actorUserId?: string | null
60+
}): Promise<number> {
61+
if (!isBillingEnabled) return getTableDispatchConcurrencyLimits().paid
62+
const { resolveBillingAttribution, resolveSystemBillingAttribution } = await import(
63+
'@/lib/billing/core/billing-attribution'
64+
)
65+
const attribution = input.actorUserId
66+
? await resolveBillingAttribution({
67+
actorUserId: input.actorUserId,
68+
workspaceId: input.workspaceId,
69+
})
70+
: await resolveSystemBillingAttribution(input.workspaceId)
71+
return getTableDispatchConcurrency(attribution.payerSubscription?.plan)
72+
}

apps/sim/lib/table/dispatcher.ts

Lines changed: 21 additions & 12 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'
@@ -323,14 +318,23 @@ export async function readDispatch(dispatchId: string): Promise<DispatchRow | nu
323318

324319
/** Drive `dispatcherStep` to completion. Shared between the trigger.dev task
325320
* wrapper (`tableRunDispatcherTask`) and the in-process inline path so both
326-
* runtimes use identical loop semantics + error logging. */
327-
export async function runDispatcherToCompletion(dispatchId: string): Promise<void> {
328-
while ((await dispatcherStep(dispatchId)) === 'continue') {}
321+
* runtimes use identical loop semantics + error logging. `concurrency` is the
322+
* invoker's plan-resolved window size (see `resolveTableDispatchConcurrency`),
323+
* threaded via the task payload; absent on payloads from before the field
324+
* existed → legacy cap. */
325+
export async function runDispatcherToCompletion(
326+
dispatchId: string,
327+
concurrency?: number
328+
): Promise<void> {
329+
while ((await dispatcherStep(dispatchId, concurrency)) === 'continue') {}
329330
}
330331

331332
/** Run one window of the dispatcher state machine. Caller re-invokes (via the
332333
* trigger.dev task wrapper) until the returned status is `'done'`. */
333-
export async function dispatcherStep(dispatchId: string): Promise<DispatcherStepResult> {
334+
export async function dispatcherStep(
335+
dispatchId: string,
336+
concurrency?: number
337+
): Promise<DispatcherStepResult> {
334338
const dispatch = await readDispatch(dispatchId)
335339
if (!dispatch) {
336340
logger.warn(`[${dispatchId}] dispatch row missing — aborting`)
@@ -380,6 +384,11 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
380384
})
381385
}
382386

387+
// Window size = the invoker's plan-resolved parallelism, so one window
388+
// saturates the cell pool before the next is loaded — yields a row-major
389+
// scan-line crawl. Payloads without the field fall back to the legacy cap.
390+
const windowSize = concurrency ?? TABLE_CONCURRENCY_LIMIT
391+
383392
const filters = [
384393
eq(userTableRows.tableId, dispatch.tableId),
385394
gt(userTableRows.position, dispatch.cursor),
@@ -429,7 +438,7 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
429438
.from(userTableRows)
430439
.where(and(...filters))
431440
.orderBy(asc(userTableRows.position))
432-
.limit(WINDOW_SIZE)
441+
.limit(windowSize)
433442
// Filtered scopes carry a jsonb predicate the planner can't estimate — left alone it
434443
// seq-scans the whole shared relation per window; keep it on the tenant's position index.
435444
const chunk = hasJsonbFilter
@@ -533,8 +542,8 @@ export async function dispatcherStep(dispatchId: string): Promise<DispatcherStep
533542
// (CRIU-checkpointed wait); database backend calls the cell-task runner
534543
// directly via Promise.all (skips async_jobs since we're awaiting in-
535544
// 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)
545+
// cell in the window terminates — bounds queue depth at the window size.
546+
const items = await buildEnqueueItems(windowRuns, windowSize)
538547
const queue = await getJobQueue()
539548
try {
540549
await queue.batchEnqueueAndWait('workflow-group-cell', items)

0 commit comments

Comments
 (0)